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:
@@ -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
@@ -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 |
Reference in New Issue
Block a user