terrology / web /app.py
Dav66's picture
Upload 2 files
7cc9e75 verified
Raw
History Blame Contribute Delete
10.6 kB
from __future__ import annotations
import asyncio
import io
import math
import os
import uuid
import zipfile
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, model_validator
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from web.generate import run_job
from web.jobs import JobStatus, store
_STATIC = Path(__file__).parent / "static"
def _rate_limit_key(request: Request) -> str:
"""
Rate limiting key: IP address for per-user quotas.
Honours X-Forwarded-For when TERROLOGY_TRUST_PROXY=1 (for reverse proxies).
By default, trusts only direct peer IP to prevent spoofing.
"""
if os.getenv("TERROLOGY_TRUST_PROXY") == "1":
xff = request.headers.get("X-Forwarded-For")
if xff:
# X-Forwarded-For may be a comma-separated list; take the first (originating client)
return xff.split(",")[0].strip()
return get_remote_address(request)
async def _cleanup_loop() -> None:
while True:
await asyncio.sleep(300) # every 5 minutes
try:
store.cleanup_expired()
except Exception as e:
print(f"Error in cleanup loop: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(_cleanup_loop())
yield
task.cancel()
_RATE_LIMIT = "5/hour" if not os.getenv("TERROLOGY_NO_RATE_LIMIT") else "10000/hour"
_MAX_JOBS = int(os.getenv("MAX_CONCURRENT_JOBS", "1"))
limiter = Limiter(key_func=_rate_limit_key)
app = FastAPI(title="Terrology", lifespan=lifespan)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
_MAX_ROUTE_SPAN_KM = 300.0
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Return great-circle distance in km between two WGS84 points."""
R = 6371.0
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (
math.sin(dlat / 2) ** 2
+ math.cos(math.radians(lat1))
* math.cos(math.radians(lat2))
* math.sin(dlon / 2) ** 2
)
return R * 2 * math.asin(math.sqrt(a))
class JobParams(BaseModel):
lat: float | None = Field(None, ge=-90, le=90)
lon: float | None = Field(None, ge=-180, le=180)
# Upper bound: 800 colour-grid cells × 10 m major-road half-width = 8000 m,
# the radius at which even primary roads (20 m wide) are exactly one colour cell.
# Beyond this only area features (water, parks) and terrain shape are visible.
radius: float = Field(500, ge=100, le=8000)
shape: str = Field("square", pattern="^(square|circle|hexagon)$")
polygon: list[list[float]] | None = None # [[lng, lat], ...] GeoJSON ring
terrain_exag: float = Field(2.0, ge=1.0, le=4.0)
colors: int = Field(4, ge=1, le=7)
no_buildings: bool = False
roof_shapes: bool = False
raceway: bool = False
waterways: bool = False
contour_interval: float | None = Field(None, ge=1)
border_width_mm: float = Field(0.0, ge=0.0, le=20.0)
water_depth_mm: float = Field(0.8, ge=0.0, le=5.0)
road_depth_mm: float = Field(0.15, ge=0.0, le=5.0)
building_exag: float | None = Field(None, ge=0.5, le=5.0)
dem_source: str = Field("glo30", pattern="^(glo30|srtm|aw3d30)$")
# New fields
route: list[list[float]] | None = None # [[lon, lat], ...] client-parsed GPX
route_width: float = Field(1.5, ge=0.5, le=5.0)
to_lat: float | None = Field(None, ge=-90, le=90)
to_lon: float | None = Field(None, ge=-180, le=180)
span_buffer: float = Field(0.05, ge=0.0, le=0.5)
size: float = Field(190.0, ge=100.0, le=256.0)
scale: float | None = Field(None, ge=1000.0, le=250000.0)
raceway_width: float = Field(1.5, ge=0.5, le=5.0)
waterway_width: float = Field(1.0, ge=0.2, le=5.0)
color_depth_mm: float = Field(1.5, ge=0.5, le=3.0)
min_building_area: float | None = Field(None, ge=0.0, le=500.0) # None = auto
smooth_boundary: int = Field(0, ge=0, le=6)
no_terrain: bool = False
no_terrain_base: bool = False
@model_validator(mode="after")
def check_location(self):
# --- Mutual exclusions ---
if self.route is not None and (
self.to_lat is not None or self.to_lon is not None
):
raise ValueError("route and to_lat/to_lon are mutually exclusive")
if (
self.to_lat is not None or self.to_lon is not None
) and self.polygon is not None:
raise ValueError("to_lat/to_lon and polygon are mutually exclusive")
if (self.to_lat is None) != (self.to_lon is None):
raise ValueError("to_lat and to_lon must both be present or both absent")
# Two-point mode requires a starting lat+lon
if self.to_lat is not None and (self.lat is None or self.lon is None):
raise ValueError("to_lat/to_lon (two-point mode) requires lat and lon")
# --- no_terrain conflicts ---
if self.no_terrain and self.no_buildings:
raise ValueError(
"no_terrain and no_buildings together produce an empty model"
)
if self.no_terrain and self.route is not None:
raise ValueError(
"no_terrain and route are incompatible (route is painted onto the terrain surface)"
)
# --- Validate route points ---
if self.route is not None:
if len(self.route) < 2:
raise ValueError("route must have at least 2 points")
if len(self.route) > 5000:
raise ValueError("route must have at most 5000 points")
for pt in self.route:
if len(pt) != 2:
raise ValueError(
"Each route point must have exactly 2 elements [lon, lat]"
)
lon_, lat_ = pt
if not (-180 <= lon_ <= 180):
raise ValueError(
f"Route point longitude {lon_} out of range [-180, 180]"
)
if not (-90 <= lat_ <= 90):
raise ValueError(
f"Route point latitude {lat_} out of range [-90, 90]"
)
# Span check: approximate bounding box diagonal
lats = [pt[1] for pt in self.route]
lons = [pt[0] for pt in self.route]
span_km = _haversine_km(min(lats), min(lons), max(lats), max(lons))
if span_km > _MAX_ROUTE_SPAN_KM:
raise ValueError(
f"Route spans approximately {span_km:.0f} km, which exceeds the "
f"{_MAX_ROUTE_SPAN_KM:.0f} km limit for free hosting"
)
# --- Two-point span check ---
if self.to_lat is not None and self.lat is not None and self.lon is not None:
span_km = _haversine_km(self.lat, self.lon, self.to_lat, self.to_lon)
if span_km > _MAX_ROUTE_SPAN_KM:
raise ValueError(
f"Two-point span is approximately {span_km:.0f} km, which exceeds the "
f"{_MAX_ROUTE_SPAN_KM:.0f} km limit for free hosting"
)
# --- Location requirement ---
has_location = (
self.polygon is not None
or self.route is not None
or (self.lat is not None and self.lon is not None)
)
if not has_location:
raise ValueError("Provide either polygon, route, or lat+lon")
# --- Polygon validation ---
if self.polygon is not None and len(self.polygon) < 3:
raise ValueError("Polygon must have at least 3 points")
return self
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/api/dem-sources")
async def dem_sources():
from terrology.fetcher import DEM_SOURCES, ot_key_configured
key_ok = ot_key_configured()
return {
"key_configured": key_ok,
"sources": [
{"id": s, "available": s == "glo30" or key_ok} for s in DEM_SOURCES
],
}
@app.get("/")
async def index():
return FileResponse(_STATIC / "index.html")
@app.post("/api/jobs")
@limiter.limit(_RATE_LIMIT)
async def create_job(
request: Request,
params: JobParams,
background_tasks: BackgroundTasks,
):
job_id = str(uuid.uuid4())
job = store.try_create(job_id, _MAX_JOBS)
if job is None:
raise HTTPException(status_code=503, detail="Server busy – try again shortly")
background_tasks.add_task(run_job, job_id, params.model_dump())
return {"job_id": job_id}
@app.get("/api/jobs/{job_id}")
async def get_job(job_id: str):
job = store.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
return job.as_response()
@app.get("/api/jobs/{job_id}/download")
async def download_job(job_id: str, background_tasks: BackgroundTasks):
job = store.get(job_id)
if job is None or job.status != JobStatus.READY:
raise HTTPException(status_code=404, detail="Job not ready or not found")
out_dir = job.output_dir
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
for fname in ["model.obj", "model.mtl", "model.3mf"]:
p = out_dir / fname
if p.exists():
zf.write(p, fname)
buf.seek(0)
background_tasks.add_task(store.delete, job_id)
return StreamingResponse(
buf,
media_type="application/zip",
headers={
"Content-Disposition": f"attachment; filename=terrology_{job_id[:8]}.zip"
},
)
@app.get("/api/jobs/{job_id}/files/{filename}")
async def get_job_file(job_id: str, filename: str):
if filename not in ("model.obj", "model.mtl"):
raise HTTPException(status_code=404, detail="File not found")
job = store.get(job_id)
if job is None or job.status != JobStatus.READY:
raise HTTPException(status_code=404, detail="Job not ready or not found")
p = job.output_dir / filename
if not p.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(p)
app.mount("/static", StaticFiles(directory=str(_STATIC)), name="static")