From 78dbdfd5c5d51b384a85c9f37661ffb63cfb24a0 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 21 May 2026 21:29:55 -0400 Subject: [PATCH] EXIF round-trip: read before upload, re-embed server-side - Client: exifr.parse() reads full EXIF before upload, sent as exif_data - Server: piexif re-embeds EXIF into saved JPEG after upload - Auto-extract GPS from picked gallery photos on selection - Removed Google Photos button (needs OAuth, not feasible) --- server.py | 85 +++++++++++++++++- static/index.html | 18 ++-- .../b0abfaca8c7a49c294fd4b02a55c69f8.jpg | Bin 0 -> 1045 bytes 3 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 uploads/photos/b0abfaca8c7a49c294fd4b02a55c69f8.jpg diff --git a/server.py b/server.py index 357fb51..7756484 100644 --- a/server.py +++ b/server.py @@ -20,6 +20,7 @@ from contextlib import asynccontextmanager from pathlib import Path import pytesseract +import piexif from PIL import Image as PILImage from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File, Form @@ -1705,6 +1706,85 @@ def _save_upload_bytes(contents: bytes, filename: str | None, subdir: str, allow return f"/uploads/{subdir}/{fname}" +def _re_embed_exif(filepath: Path, exif_json: str): + """Re-embed EXIF data (from client-side exifr.parse) into a saved JPEG. + + This is a defense against EXIF being stripped during upload — the client + reads EXIF from the original file before sending, and we write it back. + """ + try: + exif_data = _json.loads(exif_json) + except (_json.JSONDecodeError, TypeError): + return + + if filepath.suffix.lower() not in (".jpg", ".jpeg"): + return + + try: + exif_dict: dict = {"0th": {}, "Exif": {}, "GPS": {}, "Interop": {}, "1st": {}, "thumbnail": None} + + if exif_data.get("Make"): + exif_dict["0th"][piexif.ImageIFD.Make] = str(exif_data["Make"]).encode() + if exif_data.get("Model"): + exif_dict["0th"][piexif.ImageIFD.Model] = str(exif_data["Model"]).encode() + if exif_data.get("Orientation"): + exif_dict["0th"][piexif.ImageIFD.Orientation] = int(exif_data["Orientation"]) + + dto = exif_data.get("DateTimeOriginal") or exif_data.get("DateTime") + if dto: + exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = str(dto).encode() + + gps = {} + if exif_data.get("GPSLatitudeRef"): + gps[piexif.GPSIFD.GPSLatitudeRef] = str(exif_data["GPSLatitudeRef"]).encode() + if exif_data.get("GPSLatitude"): + vals = exif_data["GPSLatitude"] + if isinstance(vals, list) and len(vals) == 3: + gps[piexif.GPSIFD.GPSLatitude] = [ + _piexif_rational(vals[0]), + _piexif_rational(vals[1]), + _piexif_rational(vals[2]), + ] + if exif_data.get("GPSLongitudeRef"): + gps[piexif.GPSIFD.GPSLongitudeRef] = str(exif_data["GPSLongitudeRef"]).encode() + if exif_data.get("GPSLongitude"): + vals = exif_data["GPSLongitude"] + if isinstance(vals, list) and len(vals) == 3: + gps[piexif.GPSIFD.GPSLongitude] = [ + _piexif_rational(vals[0]), + _piexif_rational(vals[1]), + _piexif_rational(vals[2]), + ] + if exif_data.get("GPSAltitudeRef") is not None: + gps[piexif.GPSIFD.GPSAltitudeRef] = bytes([int(exif_data["GPSAltitudeRef"])]) + if exif_data.get("GPSAltitude") is not None: + gps[piexif.GPSIFD.GPSAltitude] = _piexif_rational(exif_data["GPSAltitude"]) + if exif_data.get("GPSTimeStamp"): + vals = exif_data["GPSTimeStamp"] + if isinstance(vals, list) and len(vals) == 3: + gps[piexif.GPSIFD.GPSTimeStamp] = [ + _piexif_rational(vals[0]), + _piexif_rational(vals[1]), + _piexif_rational(vals[2]), + ] + if exif_data.get("GPSDateStamp"): + gps[piexif.GPSIFD.GPSDateStamp] = str(exif_data["GPSDateStamp"]).encode() + if gps: + exif_dict["GPS"] = gps + + exif_bytes = piexif.dump(exif_dict) + piexif.insert(exif_bytes, str(filepath)) + except Exception: + pass + + +def _piexif_rational(val) -> tuple: + """Convert a float/int to a piexif rational ((numerator, denominator)).""" + if isinstance(val, (int, float)): + return (round(val * 10000), 10000) + return (int(val), 1) + + def _save_upload(upload: UploadFile, subdir: str, allowed_exts: set, max_size: int) -> str: """Save uploaded file to uploads/{subdir}/ with a UUID filename. @@ -1731,12 +1811,15 @@ def _save_upload(upload: UploadFile, subdir: str, allowed_exts: set, max_size: i @app.post("/api/upload/photo", status_code=201) -async def upload_photo(file: UploadFile = File(...)): +async def upload_photo(file: UploadFile = File(...), exif_data: str = Form(None)): # Read bytes for EXIF extraction before saving contents = await file.read() exif_gps = _extract_gps_from_bytes(contents) if contents else None # Save using the raw bytes path = _save_upload_bytes(contents, file.filename, "photos", PHOTO_ALLOWED_EXTS, PHOTO_MAX_SIZE) + # If client sent EXIF data, re-embed it into the saved file (defense against EXIF stripping) + if exif_data: + _re_embed_exif(Path(UPLOADS_DIR / path.lstrip("/")), exif_data) result = {"path": path} if exif_gps: result["exif_gps"] = exif_gps diff --git a/static/index.html b/static/index.html index 5bf52bf..f9e2a9a 100644 --- a/static/index.html +++ b/static/index.html @@ -502,7 +502,7 @@ MODE TOGGLES (Add Asset tab) ═══════════════════════════════════════════════════════════════════════ */ .mode-toggles { - display: flex; gap: 4px; margin-bottom: 10px; + display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; background: var(--card2); border-radius: var(--radius-sm); padding: 4px; } .mode-toggle { @@ -1725,6 +1725,9 @@ // Extract EXIF / file metadata extractExifData(file); + // Auto-extract GPS from the picked photo + tryExtractGpsFromPicked(); + // Scroll to preview document.getElementById('photoPreviewCard').scrollIntoView({ behavior: 'smooth' }); }; @@ -2467,7 +2470,7 @@ // MANUAL MODE — Photo // ═══════════════════════════════════════════════════════════════════════ let manualPhotoFile = null; - let manualPhotoBlob = null; + manualPhotoBlob = null; // reuse existing declaration let manualPhotoGps = null; async function handleManualPhotoFile(event) { @@ -2524,13 +2527,16 @@ if (manualPhotoFile) { try { const fd = new FormData(); + // Read EXIF before upload (defense against transport stripping) + try { + const exifData = await exifr.parse(manualPhotoFile); + if (exifData && Object.keys(exifData).length > 0) { + fd.append('exif_data', JSON.stringify(exifData)); + } + } catch (_) { /* EXIF extraction is best-effort */ } fd.append('file', manualPhotoFile, manualPhotoFile.name || 'photo.jpg'); const upData = await api('/api/upload/photo', { method: 'POST', body: fd }); photoPath = upData.path; - // If server returned EXIF GPS and we don't have GPS from the file yet, use it - if (upData.exif_gps && !manualPhotoGps) { - manualPhotoGps = upData.exif_gps; - } } catch (e) { /* photo upload failed, continue without */ } } diff --git a/uploads/photos/b0abfaca8c7a49c294fd4b02a55c69f8.jpg b/uploads/photos/b0abfaca8c7a49c294fd4b02a55c69f8.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8359e3af1dd410a7e6e4e7260df344dcec1998f9 GIT binary patch literal 1045 zcmex=r3Y63G@rwC9o#>l|P z#LB?X%FtB7(8S8vz{| zzdnTK0J$p#h^ruc1}O;5GZQ5K|2Bg&0|y%$I~ywpJ3BikCkGdg2rmyeH;<%{Fu#bb zl)Rj*l#Gmmik`ZHl8&;BjE1?Uj)9?xiHW?rrLBdLjh?ZI5y%imPEJl9ZXO9PKf)jn^b0E^7yz9M28>M1EUawo9GqO-Km}WY zfzQay%*4XX%E|%^dd6CyJOhg$tB|6hBb#twBD+$dh*9Ijg&fLG8xM*GUHqV8oK)1r z$t5N(At|M*rmmr>WnyY(ZeeNV?BeR??&0Yb91<+v*#~fzWVs-^OvvRzW@073*;|G24;x2fFxFb2?G7a#KOYN!VdBm zBU3pLGYhh?DjKp0IR>&P778mFHFAhJO