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 from pathlib import Path
import pytesseract import pytesseract
import piexif
from PIL import Image as PILImage from PIL import Image as PILImage
from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File, Form 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}" 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: def _save_upload(upload: UploadFile, subdir: str, allowed_exts: set, max_size: int) -> str:
"""Save uploaded file to uploads/{subdir}/ with a UUID filename. """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) @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 # Read bytes for EXIF extraction before saving
contents = await file.read() contents = await file.read()
exif_gps = _extract_gps_from_bytes(contents) if contents else None exif_gps = _extract_gps_from_bytes(contents) if contents else None
# Save using the raw bytes # Save using the raw bytes
path = _save_upload_bytes(contents, file.filename, "photos", PHOTO_ALLOWED_EXTS, PHOTO_MAX_SIZE) 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} result = {"path": path}
if exif_gps: if exif_gps:
result["exif_gps"] = exif_gps result["exif_gps"] = exif_gps
+12 -6
View File
@@ -502,7 +502,7 @@
MODE TOGGLES (Add Asset tab) MODE TOGGLES (Add Asset tab)
═══════════════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════════════ */
.mode-toggles { .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; background: var(--card2); border-radius: var(--radius-sm); padding: 4px;
} }
.mode-toggle { .mode-toggle {
@@ -1725,6 +1725,9 @@
// Extract EXIF / file metadata // Extract EXIF / file metadata
extractExifData(file); extractExifData(file);
// Auto-extract GPS from the picked photo
tryExtractGpsFromPicked();
// Scroll to preview // Scroll to preview
document.getElementById('photoPreviewCard').scrollIntoView({ behavior: 'smooth' }); document.getElementById('photoPreviewCard').scrollIntoView({ behavior: 'smooth' });
}; };
@@ -2467,7 +2470,7 @@
// MANUAL MODE — Photo // MANUAL MODE — Photo
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
let manualPhotoFile = null; let manualPhotoFile = null;
let manualPhotoBlob = null; manualPhotoBlob = null; // reuse existing declaration
let manualPhotoGps = null; let manualPhotoGps = null;
async function handleManualPhotoFile(event) { async function handleManualPhotoFile(event) {
@@ -2524,13 +2527,16 @@
if (manualPhotoFile) { if (manualPhotoFile) {
try { try {
const fd = new FormData(); 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'); fd.append('file', manualPhotoFile, manualPhotoFile.name || 'photo.jpg');
const upData = await api('/api/upload/photo', { method: 'POST', body: fd }); const upData = await api('/api/upload/photo', { method: 'POST', body: fd });
photoPath = upData.path; 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 */ } } catch (e) { /* photo upload failed, continue without */ }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB