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.
This commit is contained in:
Claude 2026-06-07 22:50:29 +00:00
parent 2fa17e5feb
commit f882f8a1c8
No known key found for this signature in database
9 changed files with 196 additions and 0 deletions

View File

@ -32,3 +32,26 @@ EVENT_DATE=07 de junho de 2026
ADMIN_PASSWORD=senha-forte-compartilhada-entre-os-dois
SESSION_SECRET=gere-um-uuid-longo-aqui-min-32-chars
ALLOWED_ADMIN_EMAILS=voce@exemplo.com,outro@exemplo.com
# ----- Backup (vai pra ./backups/ no host por padrão)
TZ=America/Sao_Paulo
# Cron schedule para o Postgres: aceita formato cron OR @daily/@hourly/etc
BACKUP_SCHEDULE_DB=@daily
# Cron para o MinIO (mc mirror é incremental, pode rodar mais vezes)
BACKUP_SCHEDULE_MEDIA=0 3 * * *
# Retenção (camadas: dias / semanas / meses) — só pro Postgres
BACKUP_KEEP_DAYS=14
BACKUP_KEEP_WEEKS=4
BACKUP_KEEP_MONTHS=6
# Backup remoto OPCIONAL — espelha o bucket pra outro provedor S3 (R2,
# B2, AWS S3, outro MinIO). Deixe vazio pra ficar só com backup local.
# Exemplo com Cloudflare R2:
# BACKUP_REMOTE_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
# BACKUP_REMOTE_ACCESS_KEY=...
# BACKUP_REMOTE_SECRET_KEY=...
# BACKUP_REMOTE_BUCKET=wedding-media-backup
BACKUP_REMOTE_ENDPOINT=
BACKUP_REMOTE_ACCESS_KEY=
BACKUP_REMOTE_SECRET_KEY=
BACKUP_REMOTE_BUCKET=

3
.gitignore vendored
View File

@ -11,6 +11,9 @@ build/
.env.*.local
!.env.example
# Backup output on the VPS
backups/
# Python
__pycache__/
*.pyc

View File

@ -154,5 +154,23 @@ class S3Storage:
await asyncio.to_thread(_delete)
async def get_bytes(self, key: str) -> bytes:
def _get() -> bytes:
res = self._client.get_object(Bucket=self._bucket, Key=key)
return res["Body"].read()
return await asyncio.to_thread(_get)
async def put_bytes(self, key: str, data: bytes, content_type: str) -> None:
def _put() -> None:
self._client.put_object(
Bucket=self._bucket,
Key=key,
Body=data,
ContentType=content_type,
)
await asyncio.to_thread(_put)
storage = S3Storage()

View File

@ -0,0 +1,39 @@
import io
import logging
import pillow_heif
from PIL import Image, ImageOps
pillow_heif.register_heif_opener()
logger = logging.getLogger(__name__)
HEIC_MIMES = {"image/heic", "image/heif"}
def is_heic(mime_type: str) -> bool:
return mime_type.lower() in HEIC_MIMES
def heic_to_jpeg(heic_bytes: bytes, quality: int = 88) -> bytes:
"""Decode HEIC, apply EXIF rotation, re-encode as JPEG.
Raises on any decode/encode failure so the caller can decide whether
to keep the HEIC fallback.
"""
img = Image.open(io.BytesIO(heic_bytes))
img = ImageOps.exif_transpose(img)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
out = io.BytesIO()
img.save(out, format="JPEG", quality=quality, optimize=True, progressive=True)
return out.getvalue()
def swap_extension_to_jpg(key: str) -> str:
lower = key.lower()
if lower.endswith(".heic"):
return key[: -len(".heic")] + ".jpg"
if lower.endswith(".heif"):
return key[: -len(".heif")] + ".jpg"
return key + ".jpg"

View File

@ -1,3 +1,4 @@
import asyncio
import time
from datetime import UTC, datetime
from typing import Annotated
@ -10,6 +11,7 @@ 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()
@ -187,6 +189,23 @@ async def confirm_upload(upload_id: str, body: UploadConfirmIn, db: Db):
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

View File

@ -14,6 +14,7 @@ dependencies = [
"pydantic-settings==2.6.1",
"pyjwt==2.10.0",
"email-validator==2.2.0",
"pillow-heif==0.21.0",
]
[project.optional-dependencies]

View File

@ -102,6 +102,47 @@ services:
- app
- minio
postgres-backup:
image: prodrigestivill/postgres-backup-local:16-alpine
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
POSTGRES_HOST: postgres
POSTGRES_DB: ${POSTGRES_DB:-wedding}
POSTGRES_USER: ${POSTGRES_USER:-wedding}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_EXTRA_OPTS: "--clean --if-exists"
SCHEDULE: ${BACKUP_SCHEDULE_DB:-@daily}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
BACKUP_KEEP_WEEKS: ${BACKUP_KEEP_WEEKS:-4}
BACKUP_KEEP_MONTHS: ${BACKUP_KEEP_MONTHS:-6}
TZ: ${TZ:-America/Sao_Paulo}
volumes:
- ./backups/postgres:/backups
media-backup:
image: alpine:3.20
restart: unless-stopped
depends_on:
minio:
condition: service_healthy
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
S3_BUCKET: ${S3_BUCKET:-wedding-media}
BACKUP_SCHEDULE_MEDIA: ${BACKUP_SCHEDULE_MEDIA:-0 3 * * *}
BACKUP_REMOTE_ENDPOINT: ${BACKUP_REMOTE_ENDPOINT:-}
BACKUP_REMOTE_ACCESS_KEY: ${BACKUP_REMOTE_ACCESS_KEY:-}
BACKUP_REMOTE_SECRET_KEY: ${BACKUP_REMOTE_SECRET_KEY:-}
BACKUP_REMOTE_BUCKET: ${BACKUP_REMOTE_BUCKET:-}
TZ: ${TZ:-America/Sao_Paulo}
volumes:
- ./backups/media:/backups
- ./infra/backup:/scripts:ro
entrypoint: ["/bin/sh", "/scripts/media-backup-entrypoint.sh"]
volumes:
postgres_data:
minio_data:

View File

@ -0,0 +1,36 @@
#!/bin/sh
set -e
echo "[backup] installing mc + ca-certs + tzdata"
apk add --no-cache --quiet curl ca-certificates tzdata >/dev/null
curl -sSLo /usr/local/bin/mc https://dl.min.io/client/mc/release/linux-amd64/mc
chmod +x /usr/local/bin/mc
if [ -n "$TZ" ] && [ -f "/usr/share/zoneinfo/$TZ" ]; then
cp "/usr/share/zoneinfo/$TZ" /etc/localtime
echo "$TZ" > /etc/timezone
fi
echo "[backup] configuring mc aliases"
mc alias set --quiet local http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
if [ -n "$BACKUP_REMOTE_ENDPOINT" ] && [ -n "$BACKUP_REMOTE_ACCESS_KEY" ]; then
echo "[backup] remote target configured: $BACKUP_REMOTE_ENDPOINT"
mc alias set --quiet remote "$BACKUP_REMOTE_ENDPOINT" "$BACKUP_REMOTE_ACCESS_KEY" "$BACKUP_REMOTE_SECRET_KEY"
else
echo "[backup] no remote target — local-only mirror"
fi
mkdir -p /backups /var/log
SCHEDULE="${BACKUP_SCHEDULE_MEDIA:-0 3 * * *}"
echo "[backup] schedule: $SCHEDULE"
mkdir -p /etc/crontabs
echo "$SCHEDULE /scripts/media-backup-run.sh >> /var/log/backup.log 2>&1" > /etc/crontabs/root
echo "" >> /etc/crontabs/root
echo "[backup] running initial mirror"
/scripts/media-backup-run.sh || echo "[backup] initial mirror failed — will retry on schedule"
echo "[backup] starting crond"
exec crond -f -l 6

View File

@ -0,0 +1,16 @@
#!/bin/sh
set -e
TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "[$TS] media-backup: start"
mc mirror --overwrite --remove --quiet "local/$S3_BUCKET" "/backups/$S3_BUCKET" \
|| echo "[$TS] media-backup: local mirror reported errors"
if [ -n "$BACKUP_REMOTE_ENDPOINT" ] && [ -n "$BACKUP_REMOTE_BUCKET" ]; then
mc mirror --overwrite --quiet "local/$S3_BUCKET" "remote/$BACKUP_REMOTE_BUCKET/$S3_BUCKET" \
|| echo "[$TS] media-backup: remote mirror reported errors"
fi
LOCAL_SIZE="$(du -sh "/backups/$S3_BUCKET" 2>/dev/null | awk '{print $1}')"
echo "[$TS] media-backup: done (local: ${LOCAL_SIZE:-?})"