- Fix task status IDOR by adding ownership check; suppress traceback/error in production - Passkey routes: return generic error messages for internal exceptions, keep ValueError for user-facing - JWT_SECRET and OIDC_CLIENT_ID: raise RuntimeError in production when using defaults - Rate limiter: add in-memory fallback counter when Redis is unavailable - Fix X-Forwarded-For IP spoofing with trusted_proxy_depth (rightmost-N selection) - Add SecurityHeadersMiddleware (X-Content-Type-Options, X-Frame-Options, CSP, conditional HSTS) - CORS: add PUT/DELETE methods for POI routes - POI input validation: field length and coordinate range constraints - QueryParameters: add min_sqm <= max_sqm validation
200 lines
5.8 KiB
Python
200 lines
5.8 KiB
Python
import logging
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from api.auth import User, get_current_user
|
|
from database import engine
|
|
from models.listing import ListingType
|
|
from repositories.poi_repository import POIRepository
|
|
from repositories.user_repository import UserRepository
|
|
from services import poi_service, task_service
|
|
|
|
logger = logging.getLogger("uvicorn")
|
|
|
|
poi_router = APIRouter(prefix="/api/poi", tags=["poi"])
|
|
|
|
|
|
class CreatePOIRequest(BaseModel):
|
|
name: str = Field(max_length=200)
|
|
address: str = Field(max_length=500)
|
|
latitude: float = Field(ge=-90, le=90)
|
|
longitude: float = Field(ge=-180, le=180)
|
|
|
|
|
|
class UpdatePOIRequest(BaseModel):
|
|
name: str | None = Field(default=None, max_length=200)
|
|
address: str | None = Field(default=None, max_length=500)
|
|
latitude: float | None = Field(default=None, ge=-90, le=90)
|
|
longitude: float | None = Field(default=None, ge=-180, le=180)
|
|
|
|
|
|
class POIResponse(BaseModel):
|
|
id: int
|
|
name: str
|
|
address: str
|
|
latitude: float
|
|
longitude: float
|
|
created_at: str
|
|
|
|
|
|
class CalculateRequest(BaseModel):
|
|
travel_modes: list[str] # WALK, BICYCLE, TRANSIT
|
|
listing_type: ListingType = ListingType.RENT
|
|
listing_ids: list[int] | None = None
|
|
|
|
|
|
class POIDistanceResponse(BaseModel):
|
|
poi_id: int
|
|
poi_name: str
|
|
travel_mode: str
|
|
duration_seconds: int
|
|
distance_meters: int
|
|
|
|
|
|
def _get_user_id(user: User) -> int:
|
|
"""Resolve auth User to database user ID."""
|
|
user_repo = UserRepository(engine)
|
|
db_user = user_repo.get_user_by_email(user.email)
|
|
if db_user is None:
|
|
# Auto-create user on first POI interaction
|
|
db_user = user_repo.create_user(user.email)
|
|
assert db_user.id is not None
|
|
return db_user.id
|
|
|
|
|
|
def _poi_to_response(poi: "poi_service.PointOfInterest") -> POIResponse:
|
|
return POIResponse(
|
|
id=poi.id, # type: ignore[arg-type]
|
|
name=poi.name,
|
|
address=poi.address,
|
|
latitude=poi.latitude,
|
|
longitude=poi.longitude,
|
|
created_at=poi.created_at.isoformat(),
|
|
)
|
|
|
|
|
|
@poi_router.get("", response_model=list[POIResponse])
|
|
async def list_pois(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
) -> list[POIResponse]:
|
|
"""List all POIs for the current user."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
pois = poi_service.get_user_pois(repo, user_id)
|
|
return [_poi_to_response(p) for p in pois]
|
|
|
|
|
|
@poi_router.post("", response_model=POIResponse)
|
|
async def create_poi(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
body: CreatePOIRequest,
|
|
) -> POIResponse:
|
|
"""Create a new POI."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
result = poi_service.create_poi(
|
|
repo,
|
|
user_id=user_id,
|
|
name=body.name,
|
|
address=body.address,
|
|
latitude=body.latitude,
|
|
longitude=body.longitude,
|
|
)
|
|
return _poi_to_response(result.poi)
|
|
|
|
|
|
@poi_router.put("/{poi_id}", response_model=POIResponse)
|
|
async def update_poi(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
poi_id: int,
|
|
body: UpdatePOIRequest,
|
|
) -> POIResponse:
|
|
"""Update an existing POI."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
result = poi_service.update_poi(
|
|
repo,
|
|
poi_id=poi_id,
|
|
user_id=user_id,
|
|
name=body.name,
|
|
address=body.address,
|
|
latitude=body.latitude,
|
|
longitude=body.longitude,
|
|
)
|
|
if result is None:
|
|
raise HTTPException(status_code=404, detail="POI not found")
|
|
return _poi_to_response(result.poi)
|
|
|
|
|
|
@poi_router.delete("/{poi_id}")
|
|
async def delete_poi(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
poi_id: int,
|
|
) -> dict[str, bool]:
|
|
"""Delete a POI and its associated distances."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
deleted = poi_service.delete_poi(repo, poi_id, user_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="POI not found")
|
|
return {"success": True}
|
|
|
|
|
|
@poi_router.post("/{poi_id}/calculate")
|
|
async def calculate_distances(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
poi_id: int,
|
|
body: CalculateRequest,
|
|
) -> dict[str, str]:
|
|
"""Trigger distance calculation for a POI."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
|
|
# Verify POI exists and belongs to user
|
|
poi = poi_service.get_poi(repo, poi_id)
|
|
if poi is None or poi.user_id != user_id:
|
|
raise HTTPException(status_code=404, detail="POI not found")
|
|
|
|
result = poi_service.trigger_calculation(
|
|
poi_id=poi_id,
|
|
travel_modes=body.travel_modes,
|
|
listing_type=body.listing_type,
|
|
user_email=user.email,
|
|
listing_ids=body.listing_ids,
|
|
)
|
|
|
|
if result.task_id:
|
|
task_service.add_task_for_user(user.email, result.task_id)
|
|
|
|
return {"task_id": result.task_id or "", "message": result.message}
|
|
|
|
|
|
@poi_router.get("/distances")
|
|
async def get_distances(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
listing_id: int,
|
|
listing_type: ListingType = ListingType.RENT,
|
|
) -> list[POIDistanceResponse]:
|
|
"""Get POI distances for a specific listing."""
|
|
user_id = _get_user_id(user)
|
|
repo = POIRepository(engine)
|
|
poi_repo_pois = {
|
|
p.id: p for p in poi_service.get_user_pois(repo, user_id)
|
|
}
|
|
|
|
distances = poi_service.get_distances_for_listing(
|
|
repo, listing_id, listing_type, user_id
|
|
)
|
|
|
|
return [
|
|
POIDistanceResponse(
|
|
poi_id=d.poi_id,
|
|
poi_name=poi_repo_pois[d.poi_id].name if d.poi_id in poi_repo_pois else "Unknown",
|
|
travel_mode=d.travel_mode,
|
|
duration_seconds=d.duration_seconds,
|
|
distance_meters=d.distance_meters,
|
|
)
|
|
for d in distances
|
|
]
|