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:
parent
2fa17e5feb
commit
f882f8a1c8
23
.env.example
23
.env.example
@ -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
3
.gitignore
vendored
@ -11,6 +11,9 @@ build/
|
||||
.env.*.local
|
||||
!.env.example
|
||||
|
||||
# Backup output on the VPS
|
||||
backups/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@ -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()
|
||||
|
||||
39
apps/api/app/lib/transcode.py
Normal file
39
apps/api/app/lib/transcode.py
Normal 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"
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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:
|
||||
|
||||
36
infra/backup/media-backup-entrypoint.sh
Executable file
36
infra/backup/media-backup-entrypoint.sh
Executable 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
|
||||
16
infra/backup/media-backup-run.sh
Executable file
16
infra/backup/media-backup-run.sh
Executable 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:-?})"
|
||||
Reference in New Issue
Block a user