This repository has been archived on 2026-06-09. You can view files and clone it, but cannot push or open issues or pull requests.
wedding-app/apps/api/app/routes/uploads.py
Claude f882f8a1c8
feat: HEIC->JPEG transcoding and Postgres + MinIO backup sidecars
HEIC handling
- pillow-heif joins the deps; app/lib/transcode.py registers the
  HEIF opener and exposes heic_to_jpeg(bytes, quality=88) with EXIF
  rotation applied so portrait iPhone photos do not show sideways.
- Storage gains get_bytes / put_bytes so the server can read the
  uploaded HEIC out of MinIO, decode it, and put a JPEG back.
- /api/uploads/{id}/confirm now runs the transcode after the HEAD
  check passes when mime_type is image/heic or image/heif: writes
  the JPEG under a sibling key, deletes the HEIC, and updates the
  upload row's storage_key / mime_type / size_bytes. Failures fall
  back to keeping the HEIC so an upload is never lost in transit;
  the admin can re-upload if a particular file is unrenderable.

Backups (two sidecars in docker-compose.yml)
- postgres-backup uses prodrigestivill/postgres-backup-local:16-alpine
  with BACKUP_SCHEDULE_DB (default @daily) and layered retention
  (days/weeks/months) writing to ./backups/postgres on the host.
- media-backup is an alpine container that pulls mc on boot, sets up
  an alias for the in-cluster MinIO, optionally adds a remote alias
  (R2/B2/S3 via BACKUP_REMOTE_*), and runs mc mirror on a configurable
  cron schedule. Local mirror always; remote mirror only when
  BACKUP_REMOTE_* are set.
- Both write to ./backups/ on the host (bind mount), so the operator
  can rsync the directory off-box without touching containers.
- .env.example documents every new variable, including a R2 example
  for the remote target, and TZ for cron alignment.

Local backups directory is .gitignore'd so accidental commits do not
ship someone else's wedding photos to GitHub.
2026-06-07 22:50:29 +00:00

228 lines
7.2 KiB
Python

import asyncio
import time
from datetime import UTC, datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.base import get_db
from ..db.models import EventConfig, Upload
from ..lib.ids import hash_ip, upload_id
from ..lib.storage import storage
from ..lib.transcode import heic_to_jpeg, is_heic, swap_extension_to_jpg
from ..schemas.api import UploadConfirmIn, UploadInitIn
router = APIRouter()
SINGLE_PUT_MAX = 50 * 1024 * 1024
PART_SIZE = 10 * 1024 * 1024
KEY_PREFIX = "uploads"
IP_HASH_SALT = "wedding-uploads-v1"
Db = Annotated[AsyncSession, Depends(get_db)]
def _ext_from(filename: str, mime_type: str) -> str:
dot = filename.rfind(".")
if dot >= 0 and dot < len(filename) - 1:
ext = filename[dot + 1 :].lower()
if len(ext) <= 6 and ext.isalnum():
return ext
return {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/heic": "heic",
"image/heif": "heif",
"image/gif": "gif",
"video/mp4": "mp4",
"video/quicktime": "mov",
"video/webm": "webm",
}.get(mime_type.lower(), "bin")
def _build_key(uid: str, filename: str, mime: str) -> str:
now = datetime.now(UTC)
ext = _ext_from(filename, mime)
return f"{KEY_PREFIX}/{now.year}/{now.month:02d}/{uid}.{ext}"
def _client_ip(request: Request) -> str:
h = request.headers
if cf := h.get("cf-connecting-ip"):
return cf
xff = h.get("x-forwarded-for")
if xff:
first = xff.split(",")[0].strip()
if first:
return first
if real := h.get("x-real-ip"):
return real
return "unknown"
def _now_ms() -> int:
return int(time.time() * 1000)
@router.post("/init")
async def init_upload(body: UploadInitIn, request: Request, db: Db):
cfg = (
await db.execute(select(EventConfig).where(EventConfig.id == 1))
).scalar_one_or_none()
if not cfg:
raise HTTPException(500, detail="not_configured")
is_video = body.mimeType.startswith("video/")
if is_video and not cfg.allow_video:
raise HTTPException(400, detail="video_not_allowed")
if body.sizeBytes > cfg.max_file_mb * 1024 * 1024:
raise HTTPException(
400, detail={"error": "file_too_large", "maxFileMb": cfg.max_file_mb}
)
if (
is_video
and body.durationSeconds is not None
and body.durationSeconds > cfg.max_video_seconds
):
raise HTTPException(
400,
detail={
"error": "video_too_long",
"maxVideoSeconds": cfg.max_video_seconds,
},
)
uid = upload_id()
key = _build_key(uid, body.filename, body.mimeType)
ip_h = hash_ip(_client_ip(request), IP_HASH_SALT)
if body.sizeBytes <= SINGLE_PUT_MAX:
presigned = storage.presign_put(key, body.mimeType, expires_in=900)
db.add(
Upload(
id=uid,
storage_key=key,
mime_type=body.mimeType,
size_bytes=body.sizeBytes,
duration_seconds=body.durationSeconds,
author_name=(body.authorName or "").strip() or None,
message=(body.message or "").strip() or None,
status="pending",
source="guest",
ip_hash=ip_h,
created_at=_now_ms(),
)
)
await db.commit()
return {
"uploadId": uid,
"storageKey": key,
"mode": "single",
"putUrl": presigned["url"],
"putHeaders": presigned["headers"],
}
part_count = (body.sizeBytes + PART_SIZE - 1) // PART_SIZE
multipart = await storage.init_multipart(
key, body.mimeType, part_count, expires_in=3600 * 6
)
db.add(
Upload(
id=uid,
storage_key=key,
mime_type=body.mimeType,
size_bytes=body.sizeBytes,
duration_seconds=body.durationSeconds,
author_name=(body.authorName or "").strip() or None,
message=(body.message or "").strip() or None,
status="pending",
source="guest",
ip_hash=ip_h,
provider_upload_id=multipart["upload_id"],
created_at=_now_ms(),
)
)
await db.commit()
return {
"uploadId": uid,
"storageKey": key,
"mode": "multipart",
"multipart": {
"providerUploadId": multipart["upload_id"],
"partSize": multipart["part_size"],
"partUrls": multipart["part_urls"],
},
}
@router.post("/{upload_id}/confirm")
async def confirm_upload(upload_id: str, body: UploadConfirmIn, db: Db):
row = (
await db.execute(select(Upload).where(Upload.id == upload_id))
).scalar_one_or_none()
if not row:
raise HTTPException(404, detail="not_found")
if row.status == "approved":
return {"ok": True, "alreadyApproved": True}
cfg = (
await db.execute(select(EventConfig).where(EventConfig.id == 1))
).scalar_one_or_none()
if not cfg:
raise HTTPException(500, detail="not_configured")
if body.multipart:
if body.multipart.providerUploadId != row.provider_upload_id:
raise HTTPException(400, detail="multipart_mismatch")
await storage.complete_multipart(
row.storage_key,
body.multipart.providerUploadId,
[{"partNumber": p.partNumber, "etag": p.etag} for p in body.multipart.parts],
)
head = await storage.head(row.storage_key)
if head is None:
raise HTTPException(400, detail="not_uploaded")
# Transparently transcode HEIC to JPEG so the gallery renders in any
# browser. Falls back to the original on any failure so an upload is
# never lost; admin can re-upload manually if needed.
if is_heic(row.mime_type):
try:
src_bytes = await storage.get_bytes(row.storage_key)
jpeg_bytes = await asyncio.to_thread(heic_to_jpeg, src_bytes)
new_key = swap_extension_to_jpg(row.storage_key)
await storage.put_bytes(new_key, jpeg_bytes, "image/jpeg")
if new_key != row.storage_key:
await storage.delete(row.storage_key)
row.storage_key = new_key
row.mime_type = "image/jpeg"
row.size_bytes = len(jpeg_bytes)
except Exception as e: # noqa: BLE001
print(f"[transcode] HEIC->JPEG failed for {row.storage_key}: {e}")
final_status = "pending" if cfg.moderation == "pre" else "approved"
row.status = final_status
row.approved_at = _now_ms() if final_status == "approved" else None
await db.commit()
return {"ok": True, "status": final_status}
@router.post("/{upload_id}/abort")
async def abort_upload(upload_id: str, db: Db):
row = (
await db.execute(select(Upload).where(Upload.id == upload_id))
).scalar_one_or_none()
if not row:
raise HTTPException(404, detail="not_found")
if row.provider_upload_id:
await storage.abort_multipart(row.storage_key, row.provider_upload_id)
await db.delete(row)
await db.commit()
return {"ok": True}