diff --git a/src/validators.py b/src/validators.py index eb1b75d..0ef0121 100644 --- a/src/validators.py +++ b/src/validators.py @@ -22,7 +22,9 @@ class DataValidator: DISCHARGE_MIN = 0.0 # cms DISCHARGE_MAX = 10000.0 # cms DISCHARGE_PERCENT_MIN = 0.0 - DISCHARGE_PERCENT_MAX = 200.0 # Allow some overflow + # % of channel capacity. Major floods genuinely exceed 200% (Oct 2024 peaked + # at 226%); values beyond 500% are treated as data errors. + DISCHARGE_PERCENT_MAX = 500.0 @classmethod def validate_measurement(cls, measurement: Dict[str, Any]) -> bool: @@ -59,7 +61,10 @@ class DataValidator: logger.warning(f"Discharge out of range: {discharge}") return False - # Validate discharge percent if present + # Validate discharge percent if present. This is an auxiliary field: + # an implausible value must not cost us the water level and discharge + # (rejecting rows here silently deleted the Oct 2024 flood peaks), so + # out-of-range percents are nulled and the measurement is kept. if measurement.get("discharge_percent") is not None: discharge_percent = float(measurement["discharge_percent"]) if not ( @@ -68,9 +73,10 @@ class DataValidator: <= cls.DISCHARGE_PERCENT_MAX ): logger.warning( - f"Discharge percent out of range: {discharge_percent}" + f"Discharge percent out of range ({discharge_percent}); " + "keeping measurement with discharge_percent=None" ) - return False + measurement["discharge_percent"] = None # Validate station ID station_id = measurement["station_id"] diff --git a/src/web_api.py b/src/web_api.py index d509cd9..44eb0f3 100644 --- a/src/web_api.py +++ b/src/web_api.py @@ -5,27 +5,38 @@ FastAPI web interface for water monitoring system import asyncio import os +import secrets import time from contextlib import asynccontextmanager from datetime import datetime, timedelta from threading import Lock -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional import requests -from fastapi import BackgroundTasks, FastAPI, HTTPException, Query +from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from .config import Config -from .health_check import (APIHealthCheck, DatabaseHealthCheck, - HealthCheckManager, MemoryHealthCheck) +from .health_check import ( + APIHealthCheck, + DatabaseHealthCheck, + HealthCheckManager, + MemoryHealthCheck, +) from .logging_config import get_logger, setup_logging from .metrics import get_metrics_collector, increment_counter, set_gauge from .postgres_history import PostgresHistory -from .schemas import (HealthResponse, MeasurementResponse, MetricsResponse, - ScrapingStatusResponse, StationCreateModel, - StationResponse, StationUpdateModel) +from .schemas import ( + HealthResponse, + MeasurementResponse, + MetricsResponse, + ScrapingStatusResponse, + StationCreateModel, + StationResponse, + StationUpdateModel, +) from .thaiwater import ThaiWaterClient from .water_scraper_v3 import EnhancedWaterMonitorScraper @@ -40,6 +51,23 @@ FORECAST_CACHE: Dict[str, tuple] = {} FORECAST_CACHE_LOCK = Lock() FORECAST_TTL = 900 # 15 minutes +# Admin API protection. Read/dashboard endpoints stay public; anything that +# mutates state or leaks configuration requires the X-API-Key header matching +# ADMIN_API_KEY. Secure by default: with no key configured, those endpoints +# are disabled entirely rather than open. +ADMIN_API_KEY = os.getenv("ADMIN_API_KEY") + + +def require_admin_key(x_api_key: Optional[str] = Header(None, alias="X-API-Key")): + if not ADMIN_API_KEY: + raise HTTPException( + status_code=503, + detail="Admin API disabled: ADMIN_API_KEY is not configured on the server", + ) + if not x_api_key or not secrets.compare_digest(x_api_key, ADMIN_API_KEY): + raise HTTPException(status_code=401, detail="Invalid or missing X-API-Key") + + # Dashboard HTML is loaded once at import from src/static/dashboard.html. _DASHBOARD_HTML_PATH = os.path.join( os.path.dirname(os.path.abspath(__file__)), "static", "dashboard.html" @@ -269,7 +297,11 @@ async def get_stations(): return stations -@app.post("/stations", response_model=StationResponse) +@app.post( + "/stations", + response_model=StationResponse, + dependencies=[Depends(require_admin_key)], +) async def create_station(station: StationCreateModel): """Create a new monitoring station""" increment_counter("api_requests", labels={"endpoint": "create_station"}) @@ -319,7 +351,11 @@ async def create_station(station: StationCreateModel): raise HTTPException(status_code=500, detail=str(e)) -@app.put("/stations/{station_id}", response_model=StationResponse) +@app.put( + "/stations/{station_id}", + response_model=StationResponse, + dependencies=[Depends(require_admin_key)], +) async def update_station(station_id: int, updates: StationUpdateModel): """Update an existing monitoring station""" increment_counter("api_requests", labels={"endpoint": "update_station"}) @@ -374,7 +410,7 @@ async def update_station(station_id: int, updates: StationUpdateModel): raise HTTPException(status_code=500, detail=str(e)) -@app.delete("/stations/{station_id}") +@app.delete("/stations/{station_id}", dependencies=[Depends(require_admin_key)]) async def delete_station(station_id: int): """Delete a monitoring station""" increment_counter("api_requests", labels={"endpoint": "delete_station"}) @@ -594,7 +630,7 @@ async def get_station_measurements( raise HTTPException(status_code=500, detail=str(e)) -@app.post("/scrape/trigger") +@app.post("/scrape/trigger", dependencies=[Depends(require_admin_key)]) async def trigger_scraping(background_tasks: BackgroundTasks): """Trigger manual data scraping""" increment_counter("api_requests", labels={"endpoint": "scrape_trigger"}) @@ -654,7 +690,7 @@ async def get_scraping_status(): ) -@app.get("/config") +@app.get("/config", dependencies=[Depends(require_admin_key)]) async def get_config(): """Get current configuration (sensitive data masked)""" increment_counter("api_requests", labels={"endpoint": "config"})