wrongmove/api/poi_routes.py
Viktor Barzin 0a9a83507e
Harden backend security: IDOR fix, error sanitization, rate limiter fallback, security headers
- 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
2026-02-08 19:42:30 +00:00

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
]