diff --git a/.env.example b/.env.example index 33ab0cf..ff70192 100644 --- a/.env.example +++ b/.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://.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= diff --git a/.gitignore b/.gitignore index c651633..f61fc05 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ build/ .env.*.local !.env.example +# Backup output on the VPS +backups/ + # Python __pycache__/ *.pyc diff --git a/apps/api/app/lib/storage.py b/apps/api/app/lib/storage.py index 73d6248..354d998 100644 --- a/apps/api/app/lib/storage.py +++ b/apps/api/app/lib/storage.py @@ -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() diff --git a/apps/api/app/lib/transcode.py b/apps/api/app/lib/transcode.py new file mode 100644 index 0000000..e17ce4e --- /dev/null +++ b/apps/api/app/lib/transcode.py @@ -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" diff --git a/apps/api/app/routes/uploads.py b/apps/api/app/routes/uploads.py index a28f9ad..9851edc 100644 --- a/apps/api/app/routes/uploads.py +++ b/apps/api/app/routes/uploads.py @@ -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 diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 3f37659..d336dea 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -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] diff --git a/docker-compose.yml b/docker-compose.yml index d3a5959..95a805c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/infra/backup/media-backup-entrypoint.sh b/infra/backup/media-backup-entrypoint.sh new file mode 100755 index 0000000..11bc0e7 --- /dev/null +++ b/infra/backup/media-backup-entrypoint.sh @@ -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 diff --git a/infra/backup/media-backup-run.sh b/infra/backup/media-backup-run.sh new file mode 100755 index 0000000..212b453 --- /dev/null +++ b/infra/backup/media-backup-run.sh @@ -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:-?})"