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)
This commit is contained in:
2026-05-21 21:29:55 -04:00
parent 906b61143a
commit 78dbdfd5c5
3 changed files with 96 additions and 7 deletions
+84 -1
View File
@@ -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