From bdadde8b9814f61697257b88cb3b4809a5d7e7fb Mon Sep 17 00:00:00 2001 From: rene Date: Thu, 4 Jun 2026 20:31:57 +0200 Subject: [PATCH] =?UTF-8?q?Tile-Server:=20eigene=20Range-f=C3=A4hige=20/ti?= =?UTF-8?q?les-Route=20(StaticFiles=20liefert=20hinter=20BaseHTTPMiddlewar?= =?UTF-8?q?e=20kein=20206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/main.py | 47 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/backend/main.py b/backend/main.py index 453fb98..eb41d18 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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")