Tile-Server: eigene Range-fähige /tiles-Route (StaticFiles liefert hinter BaseHTTPMiddleware kein 206)

Spike-Befund: app-weit kommen nur 200 ohne Accept-Ranges zurück, FileResponse-Range
wird von der BaseHTTPMiddleware gebrochen. MapLibre/pmtiles braucht aber Byte-Ranges.
Route gibt 206 als normales Response (Byte-Slice) zurück. Produktion: nginx/NPM direkt.
This commit is contained in:
rene 2026-06-04 20:31:57 +02:00
parent d9ecdb15fb
commit bdadde8b98

View file

@ -372,11 +372,50 @@ app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons")
app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img")
# Selbst-gehostete Vektor-Tiles (.pmtiles) — liegen im data-Volume, NICHT im Image.
# Starlette FileResponse beherrscht Range-Requests (206) → MapLibre/pmtiles-Protokoll.
# Guard: Mount nur wenn das Verzeichnis existiert (sonst No-Op, z. B. lokal/Prod ohne Tiles).
# WICHTIG: Starlettes StaticFiles/FileResponse liefert hinter unserer BaseHTTPMiddleware
# KEINE Range-Requests (206) — app-weit kommt nur 200 ohne Accept-Ranges zurück.
# MapLibre/pmtiles BRAUCHT aber Byte-Ranges (liest einzelne Tiles aus dem Single-File).
# Daher eine eigene Route, die 206 als normales Response (Byte-Slice) zurückgibt — das
# überlebt die Middleware. Für Produktion/Skalierung gehört das hinter nginx/NPM direkt
# (Range nativ, keine App-CPU) — siehe docs/TILE_SERVER_HANDOVER.md, Entscheidung #2.
_TILES_DIR = os.getenv("TILES_DIR", "/data/tiles")
if os.path.isdir(_TILES_DIR):
app.mount("/tiles", StaticFiles(directory=_TILES_DIR), name="tiles")
@app.get("/tiles/{filename}")
async def serve_tile(filename: str, request: Request):
# Kein Path-Traversal
if "/" in filename or "\\" in filename or ".." in filename:
return Response(status_code=404)
path = os.path.join(_TILES_DIR, filename)
if not os.path.isfile(path):
return Response(status_code=404)
file_size = os.path.getsize(path)
base_headers = {"Accept-Ranges": "bytes", "Cache-Control": "public, max-age=86400"}
range_header = request.headers.get("range")
if range_header and range_header.startswith("bytes="):
rng = range_header[6:].split(",")[0] # nur erster Range (pmtiles nutzt single-range)
start_s, _, end_s = rng.partition("-")
try:
if start_s == "": # Suffix-Range "bytes=-N"
length = int(end_s)
start = max(0, file_size - length)
end = file_size - 1
else:
start = int(start_s)
end = int(end_s) if end_s else file_size - 1
except ValueError:
return Response(status_code=416, headers={**base_headers, "Content-Range": f"bytes */{file_size}"})
end = min(end, file_size - 1)
if start > end or start >= file_size:
return Response(status_code=416, headers={**base_headers, "Content-Range": f"bytes */{file_size}"})
with open(path, "rb") as f:
f.seek(start)
data = f.read(end - start + 1)
return Response(
data, status_code=206, media_type="application/octet-stream",
headers={**base_headers, "Content-Range": f"bytes {start}-{end}/{file_size}"},
)
# Kein Range → ganze Datei streamen (pmtiles macht das normalerweise nicht).
return FileResponse(path, media_type="application/octet-stream", headers=base_headers)
# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")