feat: rewrite backend in Python (FastAPI + SQLAlchemy + boto3)
Same routes, same Docker topology, same env vars. Frontend untouched.
Backend swap:
- Hono on Node -> FastAPI on Python 3.12, served by uvicorn behind Caddy.
- Drizzle on Node -> SQLAlchemy 2.0 async (asyncpg driver) with declarative
models that mirror the previous schema 1:1. Migrations are still plain
SQL files (apps/api/app/migrations/) run idempotently at startup by
app/db/migrate.py via asyncpg, tracking each file's sha256 in a
schema_migrations table.
- aws4fetch -> boto3 wrapped in asyncio.to_thread so the async routes
do not block on MinIO/S3 calls. The presign_put / init_multipart /
complete_multipart / head / delete contract is unchanged so the React
client sees the same /api/uploads/* payloads.
- Cookie+JWT admin auth -> pyjwt + FastAPI Cookie() dependency.
constant-time password compare. Same 7-day HttpOnly cookie shape.
- QR code: qrcode lib + reportlab. /api/qrcode keeps format=png|svg|pdf;
the PDF is still A6 with the couple's names + 'Aponte a câmera' CTA.
- Pydantic-settings replaces the zod env loader and still fails fast
on missing required vars.
Routes ported with identical paths and JSON shapes:
- public: /api/event, /api/gallery, /api/qrcode, /api/stats
- uploads: /api/uploads/init, /{id}/confirm, /{id}/abort
- admin: /login, /logout, /me, /event (GET+PATCH), /uploads (GET with
?status, ?kind=photo|video, ?q= for search), /uploads/{id} (PATCH
for author/message edit), /uploads/{id}/{approve,reject,cover},
/uploads/{id} (DELETE), /event/cover (DELETE), /uploads/bulk, /stats.
Build pipeline:
- Dockerfile: stage 1 builds the React/Vite SPA with pnpm; stage 2 is
python:3.12-slim that installs deps via uv (fast, reproducible),
copies the app/, and copies the SPA dist from stage 1 into /app/public
so the FastAPI process serves both /api/* and the SPA assets.
- docker-compose.yml unchanged: postgres + minio + minio-init + app +
caddy. Same env vars (DATABASE_URL, S3_*, ADMIN_PASSWORD, SESSION_SECRET,
ALLOWED_ADMIN_EMAILS, AUTO_MIGRATE).
- Removed apps/api from the pnpm workspace; root package.json now only
scripts the web. Makefile centralizes dev/build/docker commands so
the Python side has the same DX as the Node side did.
This commit is contained in:
parent
00baf9bd89
commit
2fa17e5feb
@ -14,3 +14,11 @@ dist
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
|
__pycache__
|
||||||
|
**/__pycache__
|
||||||
|
*.pyc
|
||||||
|
.venv
|
||||||
|
**/.venv
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
.pytest_cache
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@ -11,5 +11,18 @@ build/
|
|||||||
.env.*.local
|
.env.*.local
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.python-version
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
49
Dockerfile
49
Dockerfile
@ -1,35 +1,44 @@
|
|||||||
FROM node:22-alpine AS base
|
# ---- Stage 1: build the React/Vite SPA with pnpm ----
|
||||||
|
FROM node:22-alpine AS web-build
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||||
|
|
||||||
FROM base AS deps
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml* ./
|
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
|
||||||
COPY packages/shared/package.json packages/shared/
|
COPY packages/shared/package.json packages/shared/
|
||||||
COPY apps/api/package.json apps/api/
|
|
||||||
COPY apps/web/package.json apps/web/
|
COPY apps/web/package.json apps/web/
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM deps AS build
|
|
||||||
WORKDIR /app
|
|
||||||
COPY tsconfig.base.json ./
|
COPY tsconfig.base.json ./
|
||||||
COPY packages packages
|
COPY packages/shared packages/shared
|
||||||
COPY apps apps
|
COPY apps/web apps/web
|
||||||
RUN pnpm -F @leetete/web build
|
RUN pnpm -F @leetete/web build
|
||||||
|
|
||||||
FROM base AS runtime
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3000
|
|
||||||
ENV STATIC_DIR=/app/public
|
|
||||||
|
|
||||||
COPY --from=build /app/node_modules ./node_modules
|
# ---- Stage 2: Python runtime ----
|
||||||
COPY --from=build /app/pnpm-workspace.yaml /app/package.json /app/pnpm-lock.yaml* ./
|
FROM python:3.12-slim AS runtime
|
||||||
COPY --from=build /app/tsconfig.base.json ./
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
COPY --from=build /app/packages packages
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
COPY --from=build /app/apps/api apps/api
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
COPY --from=build /app/apps/web/dist public
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PORT=3000 \
|
||||||
|
STATIC_DIR=/app/public
|
||||||
|
|
||||||
|
# Install uv (faster than pip for resolution + install)
|
||||||
|
RUN pip install --no-cache-dir uv==0.5.4
|
||||||
|
|
||||||
|
WORKDIR /app/apps/api
|
||||||
|
|
||||||
|
# Install Python dependencies into the system interpreter
|
||||||
|
COPY apps/api/pyproject.toml ./
|
||||||
|
RUN uv pip install --system --no-cache .
|
||||||
|
|
||||||
|
# Copy app source
|
||||||
|
COPY apps/api/app ./app
|
||||||
|
|
||||||
|
# Copy built web static assets from stage 1
|
||||||
|
COPY --from=web-build /app/apps/web/dist /app/public
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["pnpm", "--filter", "@leetete/api", "start"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000", "--proxy-headers", "--forwarded-allow-ips=*"]
|
||||||
|
|||||||
32
Makefile
Normal file
32
Makefile
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
.PHONY: install dev-web dev-api migrate build docker-up docker-down docker-logs lint typecheck
|
||||||
|
|
||||||
|
install:
|
||||||
|
pnpm install
|
||||||
|
cd apps/api && uv sync
|
||||||
|
|
||||||
|
dev-web:
|
||||||
|
pnpm -F @leetete/web dev
|
||||||
|
|
||||||
|
dev-api:
|
||||||
|
cd apps/api && uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 3000
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
cd apps/api && uv run python -m app.db.migrate
|
||||||
|
|
||||||
|
build:
|
||||||
|
pnpm -F @leetete/web build
|
||||||
|
|
||||||
|
typecheck:
|
||||||
|
pnpm -F @leetete/web typecheck
|
||||||
|
|
||||||
|
lint:
|
||||||
|
cd apps/api && uv run ruff check app
|
||||||
|
|
||||||
|
docker-up:
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
docker-down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
docker-logs:
|
||||||
|
docker compose logs -f app
|
||||||
0
apps/api/app/__init__.py
Normal file
0
apps/api/app/__init__.py
Normal file
40
apps/api/app/config.py
Normal file
40
apps/api/app/config.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False)
|
||||||
|
|
||||||
|
DATABASE_URL: str
|
||||||
|
|
||||||
|
S3_ENDPOINT: str
|
||||||
|
S3_BUCKET: str
|
||||||
|
S3_REGION: str = "us-east-1"
|
||||||
|
S3_ACCESS_KEY_ID: str
|
||||||
|
S3_SECRET_ACCESS_KEY: str
|
||||||
|
S3_PUBLIC_BASE_URL: str
|
||||||
|
S3_FORCE_PATH_STYLE: bool = True
|
||||||
|
|
||||||
|
COUPLE_NAMES: str = "Stefanie & Leandro"
|
||||||
|
EVENT_DATE: str | None = None
|
||||||
|
PUBLIC_BASE_URL: str | None = None
|
||||||
|
|
||||||
|
ADMIN_PASSWORD: str
|
||||||
|
SESSION_SECRET: str = Field(..., min_length=16)
|
||||||
|
ALLOWED_ADMIN_EMAILS: str
|
||||||
|
|
||||||
|
PORT: int = 3000
|
||||||
|
STATIC_DIR: str = "./public"
|
||||||
|
NODE_ENV: str = "production"
|
||||||
|
AUTO_MIGRATE: bool = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def admin_emails_list(self) -> list[str]:
|
||||||
|
return [e.strip().lower() for e in self.ALLOWED_ADMIN_EMAILS.split(",") if e.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
return self.NODE_ENV == "production"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings() # type: ignore[call-arg]
|
||||||
0
apps/api/app/db/__init__.py
Normal file
0
apps/api/app/db/__init__.py
Normal file
33
apps/api/app/db/base.py
Normal file
33
apps/api/app/db/base.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_url(url: str) -> str:
|
||||||
|
"""Convert any postgres URL flavor to the asyncpg driver form."""
|
||||||
|
if url.startswith("postgres://"):
|
||||||
|
url = "postgresql://" + url[len("postgres://") :]
|
||||||
|
if url.startswith("postgresql://") and "+asyncpg" not in url:
|
||||||
|
url = "postgresql+asyncpg://" + url[len("postgresql://") :]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
_normalize_url(settings.DATABASE_URL),
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncIterator[AsyncSession]:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
yield session
|
||||||
70
apps/api/app/db/migrate.py
Normal file
70
apps/api/app/db/migrate.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
|
|
||||||
|
def _to_pg_url(url: str) -> str:
|
||||||
|
if url.startswith("postgres://"):
|
||||||
|
url = "postgresql://" + url[len("postgres://") :]
|
||||||
|
if url.startswith("postgresql+asyncpg://"):
|
||||||
|
url = "postgresql://" + url[len("postgresql+asyncpg://") :]
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations(database_url: str) -> None:
|
||||||
|
conn = await asyncpg.connect(_to_pg_url(database_url))
|
||||||
|
try:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
migrations_dir = Path(__file__).parent.parent / "migrations"
|
||||||
|
files = sorted(f for f in migrations_dir.iterdir() if f.suffix == ".sql")
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
content = f.read_text(encoding="utf-8")
|
||||||
|
sha = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
"SELECT sha256 FROM schema_migrations WHERE name = $1", f.name
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing is not None:
|
||||||
|
if existing["sha256"] != sha:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Migration {f.name} has been modified after being applied. "
|
||||||
|
"Add a new migration file instead."
|
||||||
|
)
|
||||||
|
print(f"[migrate] skip {f.name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"[migrate] apply {f.name}")
|
||||||
|
async with conn.transaction():
|
||||||
|
await conn.execute(content)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO schema_migrations (name, sha256) VALUES ($1, $2)",
|
||||||
|
f.name,
|
||||||
|
sha,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[migrate] done")
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
asyncio.run(run_migrations(settings.DATABASE_URL))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
78
apps/api/app/db/models.py
Normal file
78
apps/api/app/db/models.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
from sqlalchemy import BigInteger, Boolean, Index, Integer, Text, text
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_NOW_MS_DEFAULT = text("(EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT")
|
||||||
|
|
||||||
|
|
||||||
|
class EventConfig(Base):
|
||||||
|
__tablename__ = "event_config"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, server_default=text("1"))
|
||||||
|
couple_names: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
event_date: Mapped[str | None] = mapped_column(Text)
|
||||||
|
cover_key: Mapped[str | None] = mapped_column(Text)
|
||||||
|
gallery_visibility: Mapped[str] = mapped_column(
|
||||||
|
Text, nullable=False, server_default=text("'public'")
|
||||||
|
)
|
||||||
|
moderation: Mapped[str] = mapped_column(
|
||||||
|
Text, nullable=False, server_default=text("'post'")
|
||||||
|
)
|
||||||
|
max_file_mb: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, server_default=text("500")
|
||||||
|
)
|
||||||
|
allow_video: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, server_default=text("TRUE")
|
||||||
|
)
|
||||||
|
max_video_seconds: Mapped[int] = mapped_column(
|
||||||
|
Integer, nullable=False, server_default=text("300")
|
||||||
|
)
|
||||||
|
welcome_message: Mapped[str | None] = mapped_column(Text)
|
||||||
|
updated_at: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, nullable=False, server_default=_NOW_MS_DEFAULT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Upload(Base):
|
||||||
|
__tablename__ = "uploads"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_uploads_status_created", "status", "created_at"),
|
||||||
|
Index("idx_uploads_source", "source"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||||
|
storage_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
thumbnail_key: Mapped[str | None] = mapped_column(Text)
|
||||||
|
mime_type: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
|
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
author_name: Mapped[str | None] = mapped_column(Text)
|
||||||
|
message: Mapped[str | None] = mapped_column(Text)
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
Text, nullable=False, server_default=text("'approved'")
|
||||||
|
)
|
||||||
|
source: Mapped[str] = mapped_column(
|
||||||
|
Text, nullable=False, server_default=text("'guest'")
|
||||||
|
)
|
||||||
|
ip_hash: Mapped[str | None] = mapped_column(Text)
|
||||||
|
provider_upload_id: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, nullable=False, server_default=_NOW_MS_DEFAULT
|
||||||
|
)
|
||||||
|
approved_at: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||||
|
action: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
actor: Mapped[str | None] = mapped_column(Text)
|
||||||
|
payload: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, nullable=False, server_default=_NOW_MS_DEFAULT
|
||||||
|
)
|
||||||
0
apps/api/app/lib/__init__.py
Normal file
0
apps/api/app/lib/__init__.py
Normal file
57
apps/api/app/lib/auth.py
Normal file
57
apps/api/app/lib/auth.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import Cookie, HTTPException, Response
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
COOKIE_NAME = "wedding_admin"
|
||||||
|
SESSION_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||||
|
|
||||||
|
|
||||||
|
def constant_time_eq(a: str, b: str) -> bool:
|
||||||
|
return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_token(email: str) -> str:
|
||||||
|
payload = {
|
||||||
|
"email": email,
|
||||||
|
"exp": int(time.time()) + SESSION_TTL,
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.SESSION_SECRET, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def set_session_cookie(response: Response, email: str) -> None:
|
||||||
|
token = _create_token(email)
|
||||||
|
response.set_cookie(
|
||||||
|
key=COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
max_age=SESSION_TTL,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.is_production,
|
||||||
|
samesite="lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session_cookie(response: Response) -> None:
|
||||||
|
response.delete_cookie(COOKIE_NAME, path="/")
|
||||||
|
|
||||||
|
|
||||||
|
def get_admin_email(
|
||||||
|
wedding_admin: Annotated[str | None, Cookie(alias=COOKIE_NAME)] = None,
|
||||||
|
) -> str:
|
||||||
|
if not wedding_admin:
|
||||||
|
raise HTTPException(status_code=401, detail="unauthorized")
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
wedding_admin, settings.SESSION_SECRET, algorithms=["HS256"]
|
||||||
|
)
|
||||||
|
except jwt.PyJWTError as e:
|
||||||
|
raise HTTPException(status_code=401, detail="unauthorized") from e
|
||||||
|
email = payload.get("email")
|
||||||
|
if not email or not isinstance(email, str):
|
||||||
|
raise HTTPException(status_code=401, detail="unauthorized")
|
||||||
|
return email
|
||||||
22
apps/api/app/lib/ids.py
Normal file
22
apps/api/app/lib/ids.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||||
|
|
||||||
|
|
||||||
|
def _nanoid(size: int = 16) -> str:
|
||||||
|
n = len(_ALPHABET)
|
||||||
|
return "".join(_ALPHABET[secrets.randbelow(n)] for _ in range(size))
|
||||||
|
|
||||||
|
|
||||||
|
def upload_id() -> str:
|
||||||
|
return f"up_{_nanoid()}"
|
||||||
|
|
||||||
|
|
||||||
|
def audit_id() -> str:
|
||||||
|
return f"au_{_nanoid()}"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_ip(ip: str, salt: str) -> str:
|
||||||
|
h = hashlib.sha256(f"{salt}:{ip}".encode("utf-8")).hexdigest()
|
||||||
|
return h[:32]
|
||||||
60
apps/api/app/lib/pdf.py
Normal file
60
apps/api/app/lib/pdf.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
from reportlab.lib.colors import Color
|
||||||
|
from reportlab.lib.pagesizes import A6
|
||||||
|
from reportlab.lib.units import mm
|
||||||
|
from reportlab.lib.utils import ImageReader
|
||||||
|
from reportlab.pdfgen.canvas import Canvas
|
||||||
|
|
||||||
|
from .qrcode_gen import generate_qr_png
|
||||||
|
|
||||||
|
|
||||||
|
def generate_qr_pdf(
|
||||||
|
url: str, couple_names: str, event_date: str | None = None
|
||||||
|
) -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
width, height = A6
|
||||||
|
c = Canvas(buf, pagesize=A6)
|
||||||
|
|
||||||
|
ink = Color(0.18, 0.16, 0.14)
|
||||||
|
muted = Color(0.45, 0.42, 0.38)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
title_size = 28
|
||||||
|
c.setFillColor(ink)
|
||||||
|
c.setFont("Times-Italic", title_size)
|
||||||
|
title_y = height - 28 * mm
|
||||||
|
title_w = c.stringWidth(couple_names, "Times-Italic", title_size)
|
||||||
|
c.drawString((width - title_w) / 2, title_y, couple_names)
|
||||||
|
|
||||||
|
# Date
|
||||||
|
if event_date:
|
||||||
|
c.setFillColor(muted)
|
||||||
|
c.setFont("Helvetica", 11)
|
||||||
|
dw = c.stringWidth(event_date, "Helvetica", 11)
|
||||||
|
c.drawString((width - dw) / 2, title_y - 7 * mm, event_date)
|
||||||
|
|
||||||
|
# QR
|
||||||
|
qr_png = generate_qr_png(url, 800)
|
||||||
|
qr_img = ImageReader(io.BytesIO(qr_png))
|
||||||
|
qr_size = 70 * mm
|
||||||
|
qr_x = (width - qr_size) / 2
|
||||||
|
qr_y = (height - qr_size) / 2 - 8 * mm
|
||||||
|
c.drawImage(qr_img, qr_x, qr_y, qr_size, qr_size, preserveAspectRatio=True)
|
||||||
|
|
||||||
|
# CTA
|
||||||
|
cta = "Aponte a câmera do celular"
|
||||||
|
c.setFillColor(ink)
|
||||||
|
c.setFont("Helvetica-Bold", 12)
|
||||||
|
cw = c.stringWidth(cta, "Helvetica-Bold", 12)
|
||||||
|
c.drawString((width - cw) / 2, qr_y - 9 * mm, cta)
|
||||||
|
|
||||||
|
sub = "para enviar fotos e mensagem"
|
||||||
|
c.setFillColor(muted)
|
||||||
|
c.setFont("Helvetica", 10)
|
||||||
|
sw = c.stringWidth(sub, "Helvetica", 10)
|
||||||
|
c.drawString((width - sw) / 2, qr_y - 14 * mm, sub)
|
||||||
|
|
||||||
|
c.showPage()
|
||||||
|
c.save()
|
||||||
|
return buf.getvalue()
|
||||||
37
apps/api/app/lib/qrcode_gen.py
Normal file
37
apps/api/app/lib/qrcode_gen.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
from qrcode.constants import ERROR_CORRECT_M
|
||||||
|
from qrcode.image.svg import SvgPathImage
|
||||||
|
|
||||||
|
|
||||||
|
def _make(text: str, box_size: int = 10) -> qrcode.QRCode:
|
||||||
|
qr = qrcode.QRCode(
|
||||||
|
version=None,
|
||||||
|
error_correction=ERROR_CORRECT_M,
|
||||||
|
box_size=box_size,
|
||||||
|
border=2,
|
||||||
|
)
|
||||||
|
qr.add_data(text)
|
||||||
|
qr.make(fit=True)
|
||||||
|
return qr
|
||||||
|
|
||||||
|
|
||||||
|
def generate_qr_png(text: str, size: int = 800) -> bytes:
|
||||||
|
# box_size is per-module pixels; estimate to hit target output size
|
||||||
|
qr = _make(text, box_size=1)
|
||||||
|
modules = qr.modules_count + qr.border * 2
|
||||||
|
box_size = max(4, size // modules)
|
||||||
|
qr = _make(text, box_size=box_size)
|
||||||
|
img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_qr_svg(text: str) -> str:
|
||||||
|
qr = _make(text, box_size=10)
|
||||||
|
img = qr.make_image(image_factory=SvgPathImage)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf)
|
||||||
|
return buf.getvalue().decode("utf-8")
|
||||||
158
apps/api/app/lib/storage.py
Normal file
158
apps/api/app/lib/storage.py
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
DEFAULT_PART_SIZE = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
class S3Storage:
|
||||||
|
"""Thin wrapper around boto3 that fits the same shape used by routes.
|
||||||
|
|
||||||
|
All network methods are async-wrapped via asyncio.to_thread so they
|
||||||
|
can be awaited from FastAPI handlers without blocking the event loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.S3_ENDPOINT,
|
||||||
|
region_name=settings.S3_REGION,
|
||||||
|
aws_access_key_id=settings.S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.S3_SECRET_ACCESS_KEY,
|
||||||
|
config=Config(
|
||||||
|
signature_version="s3v4",
|
||||||
|
s3={
|
||||||
|
"addressing_style": "path"
|
||||||
|
if settings.S3_FORCE_PATH_STYLE
|
||||||
|
else "virtual",
|
||||||
|
},
|
||||||
|
retries={"max_attempts": 3, "mode": "standard"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._bucket = settings.S3_BUCKET
|
||||||
|
self._public_base = settings.S3_PUBLIC_BASE_URL.rstrip("/")
|
||||||
|
|
||||||
|
def presign_put(
|
||||||
|
self, key: str, content_type: str, expires_in: int = 600
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
url = self._client.generate_presigned_url(
|
||||||
|
"put_object",
|
||||||
|
Params={
|
||||||
|
"Bucket": self._bucket,
|
||||||
|
"Key": key,
|
||||||
|
"ContentType": content_type,
|
||||||
|
},
|
||||||
|
ExpiresIn=expires_in,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"url": url,
|
||||||
|
"method": "PUT",
|
||||||
|
"headers": {"content-type": content_type},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def init_multipart(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
content_type: str,
|
||||||
|
part_count: int,
|
||||||
|
expires_in: int = 3600,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
def _init() -> dict[str, Any]:
|
||||||
|
res = self._client.create_multipart_upload(
|
||||||
|
Bucket=self._bucket, Key=key, ContentType=content_type
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
res = await asyncio.to_thread(_init)
|
||||||
|
upload_id: str = res["UploadId"]
|
||||||
|
|
||||||
|
def _sign_parts() -> list[str]:
|
||||||
|
urls: list[str] = []
|
||||||
|
for i in range(1, part_count + 1):
|
||||||
|
u = self._client.generate_presigned_url(
|
||||||
|
"upload_part",
|
||||||
|
Params={
|
||||||
|
"Bucket": self._bucket,
|
||||||
|
"Key": key,
|
||||||
|
"PartNumber": i,
|
||||||
|
"UploadId": upload_id,
|
||||||
|
},
|
||||||
|
ExpiresIn=expires_in,
|
||||||
|
)
|
||||||
|
urls.append(u)
|
||||||
|
return urls
|
||||||
|
|
||||||
|
part_urls = await asyncio.to_thread(_sign_parts)
|
||||||
|
return {
|
||||||
|
"upload_id": upload_id,
|
||||||
|
"part_size": DEFAULT_PART_SIZE,
|
||||||
|
"part_urls": part_urls,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def complete_multipart(
|
||||||
|
self,
|
||||||
|
key: str,
|
||||||
|
upload_id: str,
|
||||||
|
parts: list[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
sorted_parts = sorted(parts, key=lambda p: p["partNumber"])
|
||||||
|
boto_parts = [
|
||||||
|
{"PartNumber": p["partNumber"], "ETag": p["etag"]} for p in sorted_parts
|
||||||
|
]
|
||||||
|
|
||||||
|
def _complete() -> None:
|
||||||
|
self._client.complete_multipart_upload(
|
||||||
|
Bucket=self._bucket,
|
||||||
|
Key=key,
|
||||||
|
UploadId=upload_id,
|
||||||
|
MultipartUpload={"Parts": boto_parts},
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_complete)
|
||||||
|
|
||||||
|
async def abort_multipart(self, key: str, upload_id: str) -> None:
|
||||||
|
def _abort() -> None:
|
||||||
|
try:
|
||||||
|
self._client.abort_multipart_upload(
|
||||||
|
Bucket=self._bucket, Key=key, UploadId=upload_id
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.to_thread(_abort)
|
||||||
|
|
||||||
|
def public_url(self, key: str) -> str:
|
||||||
|
return f"{self._public_base}/{key}"
|
||||||
|
|
||||||
|
async def head(self, key: str) -> dict[str, Any] | None:
|
||||||
|
def _head() -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
res = self._client.head_object(Bucket=self._bucket, Key=key)
|
||||||
|
return {
|
||||||
|
"contentType": res.get("ContentType", "application/octet-stream"),
|
||||||
|
"contentLength": int(res.get("ContentLength", 0)),
|
||||||
|
"etag": (res.get("ETag", "") or "").strip('"'),
|
||||||
|
}
|
||||||
|
except self._client.exceptions.ClientError as e:
|
||||||
|
code = e.response.get("Error", {}).get("Code", "")
|
||||||
|
if code in {"404", "NoSuchKey", "NotFound"}:
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_head)
|
||||||
|
|
||||||
|
async def delete(self, key: str) -> None:
|
||||||
|
def _delete() -> None:
|
||||||
|
try:
|
||||||
|
self._client.delete_object(Bucket=self._bucket, Key=key)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
await asyncio.to_thread(_delete)
|
||||||
|
|
||||||
|
|
||||||
|
storage = S3Storage()
|
||||||
71
apps/api/app/main.py
Normal file
71
apps/api/app/main.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .db.base import engine
|
||||||
|
from .db.migrate import run_migrations
|
||||||
|
from .routes import admin, public, uploads
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
if settings.AUTO_MIGRATE:
|
||||||
|
print("[startup] running migrations")
|
||||||
|
await run_migrations(settings.DATABASE_URL)
|
||||||
|
print(f"[startup] static dir: {settings.STATIC_DIR}")
|
||||||
|
yield
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
lifespan=lifespan,
|
||||||
|
title="Wedding Photos API",
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
docs_url="/api/docs",
|
||||||
|
redoc_url=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"ok": True, "ts": int(time.time() * 1000)}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(public.router, prefix="/api", tags=["public"])
|
||||||
|
app.include_router(uploads.router, prefix="/api/uploads", tags=["uploads"])
|
||||||
|
app.include_router(admin.router, prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
# ----- SPA static asset serving -----
|
||||||
|
static_dir = Path(settings.STATIC_DIR)
|
||||||
|
assets_dir = static_dir / "assets"
|
||||||
|
if assets_dir.exists():
|
||||||
|
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/{full_path:path}", include_in_schema=False)
|
||||||
|
async def spa_fallback(full_path: str):
|
||||||
|
if full_path.startswith("api/") or full_path == "api":
|
||||||
|
raise HTTPException(404, detail="not_found")
|
||||||
|
if full_path in {"favicon.ico", "robots.txt"}:
|
||||||
|
candidate = static_dir / full_path
|
||||||
|
if candidate.is_file():
|
||||||
|
return FileResponse(candidate)
|
||||||
|
index = static_dir / "index.html"
|
||||||
|
if index.is_file():
|
||||||
|
return FileResponse(index)
|
||||||
|
raise HTTPException(404, detail="static_not_found")
|
||||||
0
apps/api/app/routes/__init__.py
Normal file
0
apps/api/app/routes/__init__.py
Normal file
352
apps/api/app/routes/admin.py
Normal file
352
apps/api/app/routes/admin.py
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
import time
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||||
|
from sqlalchemy import delete, or_, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..db.base import get_db
|
||||||
|
from ..db.models import EventConfig, Upload
|
||||||
|
from ..lib.auth import (
|
||||||
|
clear_session_cookie,
|
||||||
|
constant_time_eq,
|
||||||
|
get_admin_email,
|
||||||
|
set_session_cookie,
|
||||||
|
)
|
||||||
|
from ..lib.storage import storage
|
||||||
|
from ..schemas.api import (
|
||||||
|
AdminBulk,
|
||||||
|
AdminUploadUpdate,
|
||||||
|
EventConfigUpdate,
|
||||||
|
LoginIn,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
ADMIN_PAGE_SIZE = 30
|
||||||
|
|
||||||
|
Db = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
Admin = Annotated[str, Depends(get_admin_email)]
|
||||||
|
|
||||||
|
|
||||||
|
def _now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Auth (unauthenticated) ----------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(body: LoginIn, response: Response):
|
||||||
|
email = body.email.lower()
|
||||||
|
if email not in settings.admin_emails_list:
|
||||||
|
raise HTTPException(401, detail="unauthorized")
|
||||||
|
if not constant_time_eq(body.password, settings.ADMIN_PASSWORD):
|
||||||
|
raise HTTPException(401, detail="unauthorized")
|
||||||
|
set_session_cookie(response, email)
|
||||||
|
return {"ok": True, "email": email}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(response: Response):
|
||||||
|
clear_session_cookie(response)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Authenticated ----------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def me(email: Admin):
|
||||||
|
return {"email": email}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/event")
|
||||||
|
async def get_event(_: Admin, db: Db):
|
||||||
|
cfg = (
|
||||||
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(404, detail="not_configured")
|
||||||
|
return {
|
||||||
|
"coupleNames": cfg.couple_names,
|
||||||
|
"eventDate": cfg.event_date,
|
||||||
|
"coverKey": cfg.cover_key,
|
||||||
|
"coverUrl": storage.public_url(cfg.cover_key) if cfg.cover_key else None,
|
||||||
|
"galleryVisibility": cfg.gallery_visibility,
|
||||||
|
"moderation": cfg.moderation,
|
||||||
|
"maxFileMb": cfg.max_file_mb,
|
||||||
|
"allowVideo": cfg.allow_video,
|
||||||
|
"maxVideoSeconds": cfg.max_video_seconds,
|
||||||
|
"welcomeMessage": cfg.welcome_message,
|
||||||
|
"updatedAt": cfg.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_EVENT_COL_MAP = {
|
||||||
|
"coupleNames": "couple_names",
|
||||||
|
"eventDate": "event_date",
|
||||||
|
"coverKey": "cover_key",
|
||||||
|
"galleryVisibility": "gallery_visibility",
|
||||||
|
"moderation": "moderation",
|
||||||
|
"maxFileMb": "max_file_mb",
|
||||||
|
"allowVideo": "allow_video",
|
||||||
|
"maxVideoSeconds": "max_video_seconds",
|
||||||
|
"welcomeMessage": "welcome_message",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/event")
|
||||||
|
async def patch_event(body: EventConfigUpdate, _: Admin, db: Db):
|
||||||
|
incoming = body.model_dump(exclude_unset=True)
|
||||||
|
updates: dict[str, object] = {
|
||||||
|
_EVENT_COL_MAP[k]: v for k, v in incoming.items() if k in _EVENT_COL_MAP
|
||||||
|
}
|
||||||
|
if not updates:
|
||||||
|
return {"ok": True, "noop": True}
|
||||||
|
updates["updated_at"] = _now_ms()
|
||||||
|
await db.execute(
|
||||||
|
update(EventConfig).where(EventConfig.id == 1).values(**updates)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/uploads")
|
||||||
|
async def list_uploads(
|
||||||
|
_: Admin,
|
||||||
|
db: Db,
|
||||||
|
status: str | None = None,
|
||||||
|
kind: str | None = None,
|
||||||
|
q: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: Annotated[int, Query(ge=1, le=100)] = ADMIN_PAGE_SIZE,
|
||||||
|
):
|
||||||
|
stmt = select(Upload)
|
||||||
|
if status in {"pending", "approved", "rejected"}:
|
||||||
|
stmt = stmt.where(Upload.status == status)
|
||||||
|
if cursor and cursor.isdigit():
|
||||||
|
stmt = stmt.where(Upload.created_at < int(cursor))
|
||||||
|
if kind == "photo":
|
||||||
|
stmt = stmt.where(Upload.mime_type.like("image/%"))
|
||||||
|
elif kind == "video":
|
||||||
|
stmt = stmt.where(Upload.mime_type.like("video/%"))
|
||||||
|
if q and q.strip():
|
||||||
|
pattern = f"%{q.strip()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Upload.author_name.ilike(pattern),
|
||||||
|
Upload.message.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(Upload.created_at.desc()).limit(limit + 1)
|
||||||
|
|
||||||
|
rows = list((await db.execute(stmt)).scalars().all())
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
slice_rows = rows[:limit] if has_more else rows
|
||||||
|
|
||||||
|
cfg = (
|
||||||
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
cover_key = cfg.cover_key if cfg else None
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"url": storage.public_url(r.storage_key),
|
||||||
|
"thumbnailUrl": storage.public_url(r.thumbnail_key)
|
||||||
|
if r.thumbnail_key
|
||||||
|
else None,
|
||||||
|
"storageKey": r.storage_key,
|
||||||
|
"mimeType": r.mime_type,
|
||||||
|
"isVideo": r.mime_type.startswith("video/"),
|
||||||
|
"sizeBytes": r.size_bytes,
|
||||||
|
"durationSeconds": r.duration_seconds,
|
||||||
|
"authorName": r.author_name,
|
||||||
|
"message": r.message,
|
||||||
|
"status": r.status,
|
||||||
|
"source": r.source,
|
||||||
|
"createdAt": r.created_at,
|
||||||
|
"approvedAt": r.approved_at,
|
||||||
|
"isCover": cover_key == r.storage_key,
|
||||||
|
}
|
||||||
|
for r in slice_rows
|
||||||
|
]
|
||||||
|
next_cursor = str(slice_rows[-1].created_at) if has_more and slice_rows else None
|
||||||
|
return {"items": items, "nextCursor": next_cursor}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/uploads/{upload_id}")
|
||||||
|
async def patch_upload(
|
||||||
|
upload_id: str, body: AdminUploadUpdate, _: Admin, db: Db
|
||||||
|
):
|
||||||
|
incoming = body.model_dump(exclude_unset=True)
|
||||||
|
if not incoming:
|
||||||
|
return {"ok": True, "noop": True}
|
||||||
|
snake = {"authorName": "author_name", "message": "message"}
|
||||||
|
updates: dict[str, object | None] = {}
|
||||||
|
for k, v in incoming.items():
|
||||||
|
if k in snake:
|
||||||
|
updates[snake[k]] = v.strip() or None if isinstance(v, str) else v
|
||||||
|
if not updates:
|
||||||
|
return {"ok": True, "noop": True}
|
||||||
|
res = await db.execute(
|
||||||
|
update(Upload)
|
||||||
|
.where(Upload.id == upload_id)
|
||||||
|
.values(**updates)
|
||||||
|
.returning(Upload.id)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
if res.scalar_one_or_none() is None:
|
||||||
|
raise HTTPException(404, detail="not_found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/uploads/{upload_id}/approve")
|
||||||
|
async def approve(upload_id: str, _: Admin, db: Db):
|
||||||
|
res = await db.execute(
|
||||||
|
update(Upload)
|
||||||
|
.where(Upload.id == upload_id)
|
||||||
|
.values(status="approved", approved_at=_now_ms())
|
||||||
|
.returning(Upload.id)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
if res.scalar_one_or_none() is None:
|
||||||
|
raise HTTPException(404, detail="not_found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/uploads/{upload_id}/reject")
|
||||||
|
async def reject(upload_id: str, _: Admin, db: Db):
|
||||||
|
res = await db.execute(
|
||||||
|
update(Upload)
|
||||||
|
.where(Upload.id == upload_id)
|
||||||
|
.values(status="rejected", approved_at=None)
|
||||||
|
.returning(Upload.id)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
if res.scalar_one_or_none() is None:
|
||||||
|
raise HTTPException(404, detail="not_found")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/uploads/{upload_id}/cover")
|
||||||
|
async def set_cover(upload_id: str, _: Admin, 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")
|
||||||
|
await db.execute(
|
||||||
|
update(EventConfig)
|
||||||
|
.where(EventConfig.id == 1)
|
||||||
|
.values(cover_key=row.storage_key, updated_at=_now_ms())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/event/cover")
|
||||||
|
async def clear_cover(_: Admin, db: Db):
|
||||||
|
await db.execute(
|
||||||
|
update(EventConfig)
|
||||||
|
.where(EventConfig.id == 1)
|
||||||
|
.values(cover_key=None, updated_at=_now_ms())
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/uploads/{upload_id}")
|
||||||
|
async def delete_upload(upload_id: str, _: Admin, 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")
|
||||||
|
|
||||||
|
storage_key = row.storage_key
|
||||||
|
thumb_key = row.thumbnail_key
|
||||||
|
|
||||||
|
await storage.delete(storage_key)
|
||||||
|
if thumb_key:
|
||||||
|
await storage.delete(thumb_key)
|
||||||
|
await db.delete(row)
|
||||||
|
|
||||||
|
cfg = (
|
||||||
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if cfg and cfg.cover_key == storage_key:
|
||||||
|
await db.execute(
|
||||||
|
update(EventConfig).where(EventConfig.id == 1).values(cover_key=None)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/uploads/bulk")
|
||||||
|
async def bulk(body: AdminBulk, _: Admin, db: Db):
|
||||||
|
if body.action == "delete":
|
||||||
|
rows = list(
|
||||||
|
(await db.execute(select(Upload).where(Upload.id.in_(body.ids))))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
await storage.delete(r.storage_key)
|
||||||
|
await db.execute(delete(Upload).where(Upload.id.in_(body.ids)))
|
||||||
|
|
||||||
|
cfg = (
|
||||||
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if cfg and any(r.storage_key == cfg.cover_key for r in rows):
|
||||||
|
await db.execute(
|
||||||
|
update(EventConfig).where(EventConfig.id == 1).values(cover_key=None)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
new_status = "approved" if body.action == "approve" else "rejected"
|
||||||
|
approved_at = _now_ms() if new_status == "approved" else None
|
||||||
|
await db.execute(
|
||||||
|
update(Upload)
|
||||||
|
.where(Upload.id.in_(body.ids))
|
||||||
|
.values(status=new_status, approved_at=approved_at)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True, "count": len(body.ids)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def stats(_: Admin, db: Db):
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(Upload.status, Upload.mime_type, Upload.size_bytes)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
counts = {
|
||||||
|
"total": len(rows),
|
||||||
|
"approved": 0,
|
||||||
|
"pending": 0,
|
||||||
|
"rejected": 0,
|
||||||
|
"photos": 0,
|
||||||
|
"videos": 0,
|
||||||
|
"totalBytes": 0,
|
||||||
|
}
|
||||||
|
for r in rows:
|
||||||
|
counts[r.status] = counts.get(r.status, 0) + 1
|
||||||
|
if r.mime_type.startswith("image/"):
|
||||||
|
counts["photos"] += 1
|
||||||
|
elif r.mime_type.startswith("video/"):
|
||||||
|
counts["videos"] += 1
|
||||||
|
counts["totalBytes"] += r.size_bytes
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export.zip")
|
||||||
|
async def export_zip(_: Admin):
|
||||||
|
raise HTTPException(501, detail="not_implemented_yet")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/imports")
|
||||||
|
async def imports(_: Admin):
|
||||||
|
raise HTTPException(501, detail="not_implemented_yet")
|
||||||
143
apps/api/app/routes/public.py
Normal file
143
apps/api/app/routes/public.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..db.base import get_db
|
||||||
|
from ..db.models import EventConfig, Upload
|
||||||
|
from ..lib.pdf import generate_qr_pdf
|
||||||
|
from ..lib.qrcode_gen import generate_qr_png, generate_qr_svg
|
||||||
|
from ..lib.storage import storage
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
GALLERY_PAGE_SIZE = 24
|
||||||
|
|
||||||
|
Db = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/event")
|
||||||
|
async def get_event(db: Db):
|
||||||
|
cfg = (
|
||||||
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(404, detail="not_configured")
|
||||||
|
return {
|
||||||
|
"coupleNames": cfg.couple_names,
|
||||||
|
"eventDate": cfg.event_date,
|
||||||
|
"welcomeMessage": cfg.welcome_message,
|
||||||
|
"galleryVisibility": cfg.gallery_visibility,
|
||||||
|
"allowVideo": cfg.allow_video,
|
||||||
|
"maxFileMb": cfg.max_file_mb,
|
||||||
|
"maxVideoSeconds": cfg.max_video_seconds,
|
||||||
|
"coverUrl": storage.public_url(cfg.cover_key) if cfg.cover_key else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/gallery")
|
||||||
|
async def get_gallery(
|
||||||
|
db: Db,
|
||||||
|
cursor: str | None = None,
|
||||||
|
limit: Annotated[int, Query(le=60, ge=1)] = GALLERY_PAGE_SIZE,
|
||||||
|
):
|
||||||
|
cfg = (
|
||||||
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not cfg or cfg.gallery_visibility != "public":
|
||||||
|
return {"items": [], "nextCursor": None}
|
||||||
|
|
||||||
|
cursor_val: int | None = None
|
||||||
|
if cursor and cursor.isdigit():
|
||||||
|
cursor_val = int(cursor)
|
||||||
|
|
||||||
|
stmt = select(Upload).where(Upload.status == "approved")
|
||||||
|
if cursor_val is not None:
|
||||||
|
stmt = stmt.where(Upload.created_at < cursor_val)
|
||||||
|
stmt = stmt.order_by(Upload.created_at.desc()).limit(limit + 1)
|
||||||
|
|
||||||
|
rows = list((await db.execute(stmt)).scalars().all())
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
slice_rows = rows[:limit] if has_more else rows
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"url": storage.public_url(r.storage_key),
|
||||||
|
"thumbnailUrl": storage.public_url(r.thumbnail_key)
|
||||||
|
if r.thumbnail_key
|
||||||
|
else None,
|
||||||
|
"mimeType": r.mime_type,
|
||||||
|
"isVideo": r.mime_type.startswith("video/"),
|
||||||
|
"authorName": r.author_name,
|
||||||
|
"message": r.message,
|
||||||
|
"createdAt": r.created_at,
|
||||||
|
}
|
||||||
|
for r in slice_rows
|
||||||
|
]
|
||||||
|
next_cursor = str(slice_rows[-1].created_at) if has_more and slice_rows else None
|
||||||
|
return {"items": items, "nextCursor": next_cursor}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/qrcode")
|
||||||
|
async def get_qrcode(
|
||||||
|
request: Request,
|
||||||
|
db: Db,
|
||||||
|
format: str = "png",
|
||||||
|
url: str | None = None,
|
||||||
|
):
|
||||||
|
fmt = format.lower()
|
||||||
|
if url:
|
||||||
|
base = url
|
||||||
|
elif settings.PUBLIC_BASE_URL:
|
||||||
|
base = settings.PUBLIC_BASE_URL
|
||||||
|
else:
|
||||||
|
base = str(request.base_url).rstrip("/")
|
||||||
|
target = base.rstrip("/") + "/enviar"
|
||||||
|
|
||||||
|
if fmt == "svg":
|
||||||
|
svg = generate_qr_svg(target)
|
||||||
|
return Response(
|
||||||
|
content=svg,
|
||||||
|
media_type="image/svg+xml; charset=utf-8",
|
||||||
|
headers={"cache-control": "public, max-age=300"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if fmt == "pdf":
|
||||||
|
cfg = (
|
||||||
|
await db.execute(select(EventConfig).where(EventConfig.id == 1))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
pdf_bytes = generate_qr_pdf(
|
||||||
|
target,
|
||||||
|
cfg.couple_names if cfg else settings.COUPLE_NAMES,
|
||||||
|
cfg.event_date if cfg else settings.EVENT_DATE,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={
|
||||||
|
"content-disposition": 'inline; filename="qr-mesa.pdf"',
|
||||||
|
"cache-control": "no-store",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
png = generate_qr_png(target, 800)
|
||||||
|
return Response(
|
||||||
|
content=png,
|
||||||
|
media_type="image/png",
|
||||||
|
headers={"cache-control": "public, max-age=300"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_stats(db: Db):
|
||||||
|
rows = (
|
||||||
|
await db.execute(
|
||||||
|
select(Upload.id, Upload.mime_type).where(Upload.status == "approved")
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
photos = sum(1 for r in rows if r.mime_type.startswith("image/"))
|
||||||
|
videos = sum(1 for r in rows if r.mime_type.startswith("video/"))
|
||||||
|
return {"photos": photos, "videos": videos, "total": len(rows)}
|
||||||
208
apps/api/app/routes/uploads.py
Normal file
208
apps/api/app/routes/uploads.py
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
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 ..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")
|
||||||
|
|
||||||
|
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}
|
||||||
0
apps/api/app/schemas/__init__.py
Normal file
0
apps/api/app/schemas/__init__.py
Normal file
53
apps/api/app/schemas/api.py
Normal file
53
apps/api/app/schemas/api.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UploadInitIn(BaseModel):
|
||||||
|
filename: str = Field(min_length=1, max_length=255)
|
||||||
|
mimeType: str = Field(pattern=r"^(image|video)/[a-z0-9.+-]+$")
|
||||||
|
sizeBytes: int = Field(gt=0)
|
||||||
|
durationSeconds: int | None = Field(default=None, gt=0)
|
||||||
|
authorName: str | None = Field(default=None, max_length=80)
|
||||||
|
message: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class MultipartPart(BaseModel):
|
||||||
|
partNumber: int = Field(gt=0)
|
||||||
|
etag: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class MultipartConfirm(BaseModel):
|
||||||
|
providerUploadId: str
|
||||||
|
parts: list[MultipartPart]
|
||||||
|
|
||||||
|
|
||||||
|
class UploadConfirmIn(BaseModel):
|
||||||
|
multipart: MultipartConfirm | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LoginIn(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class EventConfigUpdate(BaseModel):
|
||||||
|
coupleNames: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
eventDate: str | None = None
|
||||||
|
coverKey: str | None = None
|
||||||
|
galleryVisibility: Literal["public", "private"] | None = None
|
||||||
|
moderation: Literal["pre", "post"] | None = None
|
||||||
|
maxFileMb: int | None = Field(default=None, gt=0)
|
||||||
|
allowVideo: bool | None = None
|
||||||
|
maxVideoSeconds: int | None = Field(default=None, gt=0)
|
||||||
|
welcomeMessage: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUploadUpdate(BaseModel):
|
||||||
|
authorName: str | None = Field(default=None, max_length=80)
|
||||||
|
message: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminBulk(BaseModel):
|
||||||
|
action: Literal["approve", "reject", "delete"]
|
||||||
|
ids: list[str] = Field(min_length=1, max_length=200)
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import type { Config } from 'drizzle-kit';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
schema: './src/db/schema.ts',
|
|
||||||
out: './src/db/migrations',
|
|
||||||
dialect: 'postgresql',
|
|
||||||
} satisfies Config;
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@leetete/api",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"main": "./src/app.ts",
|
|
||||||
"types": "./src/app.ts",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/app.ts",
|
|
||||||
"./env": "./src/env.ts",
|
|
||||||
"./db/schema": "./src/db/schema.ts"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"dev": "tsx watch src/server.ts",
|
|
||||||
"start": "tsx src/server.ts",
|
|
||||||
"migrate": "tsx src/db/migrate.ts",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"build": "tsc --noEmit",
|
|
||||||
"db:generate": "drizzle-kit generate"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@hono/node-server": "^1.13.7",
|
|
||||||
"@leetete/shared": "workspace:*",
|
|
||||||
"aws4fetch": "^1.0.20",
|
|
||||||
"drizzle-orm": "^0.36.4",
|
|
||||||
"hono": "^4.6.14",
|
|
||||||
"nanoid": "^5.0.9",
|
|
||||||
"pdf-lib": "^1.17.1",
|
|
||||||
"postgres": "^3.4.5",
|
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"zod": "^3.23.8"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.10.1",
|
|
||||||
"@types/qrcode": "^1.5.5",
|
|
||||||
"drizzle-kit": "^0.28.1",
|
|
||||||
"typescript": "^5.6.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
apps/api/pyproject.toml
Normal file
36
apps/api/pyproject.toml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
[project]
|
||||||
|
name = "wedding-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Backend for the wedding photos app"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi[standard]==0.115.5",
|
||||||
|
"uvicorn[standard]==0.32.1",
|
||||||
|
"sqlalchemy[asyncio]==2.0.36",
|
||||||
|
"asyncpg==0.30.0",
|
||||||
|
"boto3==1.35.66",
|
||||||
|
"qrcode[pil]==8.0",
|
||||||
|
"reportlab==4.2.5",
|
||||||
|
"pydantic-settings==2.6.1",
|
||||||
|
"pyjwt==2.10.0",
|
||||||
|
"email-validator==2.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"ruff==0.7.4",
|
||||||
|
"mypy==1.13.0",
|
||||||
|
"pytest==8.3.3",
|
||||||
|
"httpx==0.27.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B", "ASYNC"]
|
||||||
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import { Hono } from 'hono';
|
|
||||||
import { cors } from 'hono/cors';
|
|
||||||
import type { Bindings } from './env.js';
|
|
||||||
import { adminRoutes } from './routes/admin.js';
|
|
||||||
import { publicRoutes } from './routes/public.js';
|
|
||||||
import { uploadsRoutes } from './routes/uploads.js';
|
|
||||||
|
|
||||||
export function createApp() {
|
|
||||||
const app = new Hono<Bindings>().basePath('/api');
|
|
||||||
|
|
||||||
app.use('*', cors({ origin: (origin) => origin ?? '*', credentials: true }));
|
|
||||||
|
|
||||||
app.get('/health', (c) => c.json({ ok: true, ts: Date.now() }));
|
|
||||||
|
|
||||||
app.route('/', publicRoutes);
|
|
||||||
app.route('/uploads', uploadsRoutes);
|
|
||||||
app.route('/admin', adminRoutes);
|
|
||||||
|
|
||||||
app.notFound((c) => c.json({ error: 'not_found' }, 404));
|
|
||||||
app.onError((err, c) => {
|
|
||||||
console.error('unhandled', err);
|
|
||||||
return c.json({ error: 'internal_error' }, 500);
|
|
||||||
});
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type App = ReturnType<typeof createApp>;
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
import * as schema from './schema.js';
|
|
||||||
|
|
||||||
export type DB = ReturnType<typeof drizzle<typeof schema>>;
|
|
||||||
|
|
||||||
let dbInstance: DB | null = null;
|
|
||||||
let pgInstance: ReturnType<typeof postgres> | null = null;
|
|
||||||
|
|
||||||
export function initDb(databaseUrl: string): DB {
|
|
||||||
if (dbInstance) return dbInstance;
|
|
||||||
pgInstance = postgres(databaseUrl, { max: 10, idle_timeout: 30 });
|
|
||||||
dbInstance = drizzle(pgInstance, { schema });
|
|
||||||
return dbInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDb(): DB {
|
|
||||||
if (!dbInstance) {
|
|
||||||
throw new Error('DB not initialized. Call initDb() before getDb().');
|
|
||||||
}
|
|
||||||
return dbInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeDb(): Promise<void> {
|
|
||||||
if (pgInstance) {
|
|
||||||
await pgInstance.end({ timeout: 5 });
|
|
||||||
pgInstance = null;
|
|
||||||
dbInstance = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { readFileSync, readdirSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import postgres from 'postgres';
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
function sha256(content: string): string {
|
|
||||||
return createHash('sha256').update(content).digest('hex');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runMigrations(databaseUrl: string): Promise<void> {
|
|
||||||
const sql = postgres(databaseUrl, { max: 1 });
|
|
||||||
try {
|
|
||||||
await sql`
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
name TEXT PRIMARY KEY,
|
|
||||||
sha256 TEXT NOT NULL,
|
|
||||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
|
|
||||||
const dir = join(__dirname, 'migrations');
|
|
||||||
const files = readdirSync(dir)
|
|
||||||
.filter((f) => f.endsWith('.sql'))
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
const content = readFileSync(join(dir, file), 'utf-8');
|
|
||||||
const hash = sha256(content);
|
|
||||||
|
|
||||||
const existing = await sql<{ sha256: string }[]>`
|
|
||||||
SELECT sha256 FROM schema_migrations WHERE name = ${file}
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (existing.length) {
|
|
||||||
if (existing[0]!.sha256 !== hash) {
|
|
||||||
throw new Error(
|
|
||||||
`Migration ${file} has been modified after being applied. ` +
|
|
||||||
`Add a new migration file instead.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(`[migrate] skip ${file}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[migrate] apply ${file}`);
|
|
||||||
await sql.unsafe(content);
|
|
||||||
await sql`
|
|
||||||
INSERT INTO schema_migrations (name, sha256) VALUES (${file}, ${hash})
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[migrate] done');
|
|
||||||
} finally {
|
|
||||||
await sql.end({ timeout: 5 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
||||||
const url = process.env['DATABASE_URL'];
|
|
||||||
if (!url) {
|
|
||||||
console.error('DATABASE_URL is required');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
runMigrations(url).catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
|
||||||
import { bigint, boolean, index, integer, pgTable, text } from 'drizzle-orm/pg-core';
|
|
||||||
|
|
||||||
export const eventConfig = pgTable('event_config', {
|
|
||||||
id: integer('id').primaryKey().default(1),
|
|
||||||
coupleNames: text('couple_names').notNull(),
|
|
||||||
eventDate: text('event_date'),
|
|
||||||
coverKey: text('cover_key'),
|
|
||||||
galleryVisibility: text('gallery_visibility', { enum: ['public', 'private'] })
|
|
||||||
.notNull()
|
|
||||||
.default('public'),
|
|
||||||
moderation: text('moderation', { enum: ['pre', 'post'] }).notNull().default('post'),
|
|
||||||
maxFileMb: integer('max_file_mb').notNull().default(500),
|
|
||||||
allowVideo: boolean('allow_video').notNull().default(true),
|
|
||||||
maxVideoSeconds: integer('max_video_seconds').notNull().default(300),
|
|
||||||
welcomeMessage: text('welcome_message'),
|
|
||||||
updatedAt: bigint('updated_at', { mode: 'number' })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(extract(epoch from now()) * 1000)::bigint`),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const uploads = pgTable(
|
|
||||||
'uploads',
|
|
||||||
{
|
|
||||||
id: text('id').primaryKey(),
|
|
||||||
storageKey: text('storage_key').notNull(),
|
|
||||||
thumbnailKey: text('thumbnail_key'),
|
|
||||||
mimeType: text('mime_type').notNull(),
|
|
||||||
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
|
|
||||||
durationSeconds: integer('duration_seconds'),
|
|
||||||
authorName: text('author_name'),
|
|
||||||
message: text('message'),
|
|
||||||
status: text('status', { enum: ['pending', 'approved', 'rejected'] })
|
|
||||||
.notNull()
|
|
||||||
.default('approved'),
|
|
||||||
source: text('source', { enum: ['guest', 'import'] }).notNull().default('guest'),
|
|
||||||
ipHash: text('ip_hash'),
|
|
||||||
providerUploadId: text('provider_upload_id'),
|
|
||||||
createdAt: bigint('created_at', { mode: 'number' })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(extract(epoch from now()) * 1000)::bigint`),
|
|
||||||
approvedAt: bigint('approved_at', { mode: 'number' }),
|
|
||||||
},
|
|
||||||
(t) => ({
|
|
||||||
statusCreatedIdx: index('idx_uploads_status_created').on(t.status, t.createdAt),
|
|
||||||
sourceIdx: index('idx_uploads_source').on(t.source),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const auditLog = pgTable('audit_log', {
|
|
||||||
id: text('id').primaryKey(),
|
|
||||||
action: text('action').notNull(),
|
|
||||||
actor: text('actor'),
|
|
||||||
payload: text('payload'),
|
|
||||||
createdAt: bigint('created_at', { mode: 'number' })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(extract(epoch from now()) * 1000)::bigint`),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type EventConfigRow = typeof eventConfig.$inferSelect;
|
|
||||||
export type UploadRow = typeof uploads.$inferSelect;
|
|
||||||
export type NewUploadRow = typeof uploads.$inferInsert;
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const envSchema = z.object({
|
|
||||||
DATABASE_URL: z.string().min(1),
|
|
||||||
|
|
||||||
S3_ENDPOINT: z.string().min(1),
|
|
||||||
S3_BUCKET: z.string().min(1),
|
|
||||||
S3_REGION: z.string().default('us-east-1'),
|
|
||||||
S3_ACCESS_KEY_ID: z.string().min(1),
|
|
||||||
S3_SECRET_ACCESS_KEY: z.string().min(1),
|
|
||||||
S3_PUBLIC_BASE_URL: z.string().min(1),
|
|
||||||
S3_FORCE_PATH_STYLE: z.enum(['true', 'false']).default('true'),
|
|
||||||
|
|
||||||
COUPLE_NAMES: z.string().default('Stefanie & Leandro'),
|
|
||||||
EVENT_DATE: z.string().optional(),
|
|
||||||
PUBLIC_BASE_URL: z.string().optional(),
|
|
||||||
|
|
||||||
ADMIN_PASSWORD: z.string().min(1),
|
|
||||||
SESSION_SECRET: z.string().min(16),
|
|
||||||
ALLOWED_ADMIN_EMAILS: z.string().min(1),
|
|
||||||
|
|
||||||
PORT: z.coerce.number().int().positive().default(3000),
|
|
||||||
STATIC_DIR: z.string().default('./public'),
|
|
||||||
NODE_ENV: z.enum(['development', 'production']).default('production'),
|
|
||||||
AUTO_MIGRATE: z.enum(['true', 'false']).default('true'),
|
|
||||||
TRUST_PROXY: z.enum(['true', 'false']).default('true'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type AppEnv = z.infer<typeof envSchema>;
|
|
||||||
|
|
||||||
export interface AppVariables {
|
|
||||||
adminEmail: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Bindings = {
|
|
||||||
Bindings: AppEnv;
|
|
||||||
Variables: AppVariables;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function loadEnv(): AppEnv {
|
|
||||||
const result = envSchema.safeParse(process.env);
|
|
||||||
if (!result.success) {
|
|
||||||
console.error('Invalid environment variables:');
|
|
||||||
console.error(result.error.flatten().fieldErrors);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
import type { Context, Next } from 'hono';
|
|
||||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
|
|
||||||
import { sign, verify } from 'hono/jwt';
|
|
||||||
import type { Bindings } from '../env.js';
|
|
||||||
|
|
||||||
const COOKIE = 'wedding_admin';
|
|
||||||
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
|
||||||
|
|
||||||
interface SessionPayload {
|
|
||||||
email: string;
|
|
||||||
exp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function requireAdmin(c: Context<Bindings>, next: Next) {
|
|
||||||
const token = getCookie(c, COOKIE);
|
|
||||||
if (!token) return c.json({ error: 'unauthorized' }, 401);
|
|
||||||
try {
|
|
||||||
const payload = (await verify(
|
|
||||||
token,
|
|
||||||
c.env.SESSION_SECRET,
|
|
||||||
'HS256',
|
|
||||||
)) as unknown as SessionPayload;
|
|
||||||
if (!payload.email) return c.json({ error: 'unauthorized' }, 401);
|
|
||||||
c.set('adminEmail', payload.email);
|
|
||||||
await next();
|
|
||||||
} catch {
|
|
||||||
return c.json({ error: 'unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSession(c: Context<Bindings>, email: string) {
|
|
||||||
const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;
|
|
||||||
const token = await sign({ email, exp }, c.env.SESSION_SECRET, 'HS256');
|
|
||||||
const secure = c.env.NODE_ENV === 'production';
|
|
||||||
setCookie(c, COOKIE, token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure,
|
|
||||||
sameSite: 'Lax',
|
|
||||||
path: '/',
|
|
||||||
maxAge: SESSION_TTL_SECONDS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function destroySession(c: Context<Bindings>) {
|
|
||||||
deleteCookie(c, COOKIE, { path: '/' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
|
|
||||||
if (a.length !== b.length) return false;
|
|
||||||
const enc = new TextEncoder();
|
|
||||||
const aBytes = enc.encode(a);
|
|
||||||
const bBytes = enc.encode(b);
|
|
||||||
let result = 0;
|
|
||||||
for (let i = 0; i < aBytes.length; i++) result |= aBytes[i]! ^ bBytes[i]!;
|
|
||||||
return result === 0;
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import { customAlphabet } from 'nanoid';
|
|
||||||
|
|
||||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
||||||
const newId = customAlphabet(alphabet, 16);
|
|
||||||
|
|
||||||
export function uploadId(): string {
|
|
||||||
return `up_${newId()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function auditId(): string {
|
|
||||||
return `au_${newId()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function hashIp(ip: string, salt: string): Promise<string> {
|
|
||||||
const data = new TextEncoder().encode(`${salt}:${ip}`);
|
|
||||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
|
||||||
const bytes = new Uint8Array(digest);
|
|
||||||
let hex = '';
|
|
||||||
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
|
||||||
return hex.slice(0, 32);
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
|
||||||
import { generateQrPng } from './qrcode.js';
|
|
||||||
|
|
||||||
export interface QrPdfOptions {
|
|
||||||
url: string;
|
|
||||||
coupleNames: string;
|
|
||||||
eventDate?: string;
|
|
||||||
callToAction?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PT_PER_MM = 2.834645669;
|
|
||||||
const A6_WIDTH = 105 * PT_PER_MM;
|
|
||||||
const A6_HEIGHT = 148 * PT_PER_MM;
|
|
||||||
|
|
||||||
function centerX(
|
|
||||||
text: string,
|
|
||||||
fontSize: number,
|
|
||||||
font: import('pdf-lib').PDFFont,
|
|
||||||
): number {
|
|
||||||
const w = font.widthOfTextAtSize(text, fontSize);
|
|
||||||
return (A6_WIDTH - w) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateQrPdf(opts: QrPdfOptions): Promise<Uint8Array> {
|
|
||||||
const pdf = await PDFDocument.create();
|
|
||||||
const page = pdf.addPage([A6_WIDTH, A6_HEIGHT]);
|
|
||||||
|
|
||||||
const titleFont = await pdf.embedFont(StandardFonts.TimesRomanItalic);
|
|
||||||
const bodyFont = await pdf.embedFont(StandardFonts.Helvetica);
|
|
||||||
const boldFont = await pdf.embedFont(StandardFonts.HelveticaBold);
|
|
||||||
|
|
||||||
const ink = rgb(0.18, 0.16, 0.14);
|
|
||||||
const muted = rgb(0.45, 0.42, 0.38);
|
|
||||||
|
|
||||||
const titleSize = 28;
|
|
||||||
const titleY = A6_HEIGHT - 28 * PT_PER_MM;
|
|
||||||
page.drawText(opts.coupleNames, {
|
|
||||||
x: centerX(opts.coupleNames, titleSize, titleFont),
|
|
||||||
y: titleY,
|
|
||||||
size: titleSize,
|
|
||||||
font: titleFont,
|
|
||||||
color: ink,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (opts.eventDate) {
|
|
||||||
const dateSize = 11;
|
|
||||||
page.drawText(opts.eventDate, {
|
|
||||||
x: centerX(opts.eventDate, dateSize, bodyFont),
|
|
||||||
y: titleY - 7 * PT_PER_MM,
|
|
||||||
size: dateSize,
|
|
||||||
font: bodyFont,
|
|
||||||
color: muted,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const qrPng = await generateQrPng(opts.url, 600);
|
|
||||||
const qrImage = await pdf.embedPng(qrPng);
|
|
||||||
const qrSize = 70 * PT_PER_MM;
|
|
||||||
const qrX = (A6_WIDTH - qrSize) / 2;
|
|
||||||
const qrY = (A6_HEIGHT - qrSize) / 2 - 8 * PT_PER_MM;
|
|
||||||
page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
|
|
||||||
|
|
||||||
const cta = opts.callToAction ?? 'Aponte a câmera do celular';
|
|
||||||
const ctaSize = 12;
|
|
||||||
page.drawText(cta, {
|
|
||||||
x: centerX(cta, ctaSize, boldFont),
|
|
||||||
y: qrY - 9 * PT_PER_MM,
|
|
||||||
size: ctaSize,
|
|
||||||
font: boldFont,
|
|
||||||
color: ink,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sub = 'para enviar fotos e mensagem';
|
|
||||||
const subSize = 10;
|
|
||||||
page.drawText(sub, {
|
|
||||||
x: centerX(sub, subSize, bodyFont),
|
|
||||||
y: qrY - 14 * PT_PER_MM,
|
|
||||||
size: subSize,
|
|
||||||
font: bodyFont,
|
|
||||||
color: muted,
|
|
||||||
});
|
|
||||||
|
|
||||||
return await pdf.save();
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import QRCode from 'qrcode';
|
|
||||||
|
|
||||||
export async function generateQrPng(text: string, size = 512): Promise<Uint8Array> {
|
|
||||||
const dataUrl = await QRCode.toDataURL(text, {
|
|
||||||
errorCorrectionLevel: 'M',
|
|
||||||
margin: 2,
|
|
||||||
width: size,
|
|
||||||
});
|
|
||||||
const base64 = dataUrl.split(',')[1] ?? '';
|
|
||||||
return Uint8Array.from(Buffer.from(base64, 'base64'));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateQrSvg(text: string): Promise<string> {
|
|
||||||
return QRCode.toString(text, {
|
|
||||||
type: 'svg',
|
|
||||||
errorCorrectionLevel: 'M',
|
|
||||||
margin: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
import type { AppEnv } from '../env.js';
|
|
||||||
import { S3Storage } from './storage-s3.js';
|
|
||||||
import type { StorageProvider } from './storage.js';
|
|
||||||
|
|
||||||
let storageInstance: StorageProvider | null = null;
|
|
||||||
|
|
||||||
export function initStorage(env: AppEnv): StorageProvider {
|
|
||||||
if (storageInstance) return storageInstance;
|
|
||||||
storageInstance = new S3Storage({
|
|
||||||
endpoint: env.S3_ENDPOINT,
|
|
||||||
bucket: env.S3_BUCKET,
|
|
||||||
region: env.S3_REGION,
|
|
||||||
accessKeyId: env.S3_ACCESS_KEY_ID,
|
|
||||||
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
|
||||||
publicBaseUrl: env.S3_PUBLIC_BASE_URL,
|
|
||||||
forcePathStyle: env.S3_FORCE_PATH_STYLE === 'true',
|
|
||||||
});
|
|
||||||
return storageInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStorage(): StorageProvider {
|
|
||||||
if (!storageInstance) {
|
|
||||||
throw new Error('Storage not initialized. Call initStorage() before getStorage().');
|
|
||||||
}
|
|
||||||
return storageInstance;
|
|
||||||
}
|
|
||||||
@ -1,164 +0,0 @@
|
|||||||
import { AwsClient } from 'aws4fetch';
|
|
||||||
import type {
|
|
||||||
CompleteMultipartInput,
|
|
||||||
InitMultipartInput,
|
|
||||||
InitMultipartResult,
|
|
||||||
ObjectMeta,
|
|
||||||
PresignPutInput,
|
|
||||||
PresignPutResult,
|
|
||||||
StorageProvider,
|
|
||||||
} from './storage.js';
|
|
||||||
|
|
||||||
export interface S3StorageConfig {
|
|
||||||
endpoint: string;
|
|
||||||
bucket: string;
|
|
||||||
region: string;
|
|
||||||
accessKeyId: string;
|
|
||||||
secretAccessKey: string;
|
|
||||||
publicBaseUrl: string;
|
|
||||||
forcePathStyle?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PART_SIZE = 10 * 1024 * 1024;
|
|
||||||
|
|
||||||
export class S3Storage implements StorageProvider {
|
|
||||||
private aws: AwsClient;
|
|
||||||
|
|
||||||
constructor(private config: S3StorageConfig) {
|
|
||||||
this.aws = new AwsClient({
|
|
||||||
accessKeyId: config.accessKeyId,
|
|
||||||
secretAccessKey: config.secretAccessKey,
|
|
||||||
service: 's3',
|
|
||||||
region: config.region,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private objectUrl(key: string, query?: string): string {
|
|
||||||
const base = this.config.endpoint.replace(/\/$/, '');
|
|
||||||
const path = this.config.forcePathStyle
|
|
||||||
? `${base}/${this.config.bucket}/${encodeKey(key)}`
|
|
||||||
: `${base}/${encodeKey(key)}`;
|
|
||||||
return query ? `${path}?${query}` : path;
|
|
||||||
}
|
|
||||||
|
|
||||||
async presignPut(input: PresignPutInput): Promise<PresignPutResult> {
|
|
||||||
const expires = input.expiresInSec ?? 600;
|
|
||||||
const url = this.objectUrl(input.key, `X-Amz-Expires=${expires}`);
|
|
||||||
const signed = await this.aws.sign(
|
|
||||||
new Request(url, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'content-type': input.contentType },
|
|
||||||
}),
|
|
||||||
{ aws: { signQuery: true } },
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
url: signed.url,
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'content-type': input.contentType },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async initMultipart(input: InitMultipartInput): Promise<InitMultipartResult> {
|
|
||||||
const initUrl = this.objectUrl(input.key, 'uploads=');
|
|
||||||
const init = await this.aws.fetch(initUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': input.contentType },
|
|
||||||
});
|
|
||||||
if (!init.ok) {
|
|
||||||
throw new Error(`initMultipart failed: ${init.status} ${await init.text()}`);
|
|
||||||
}
|
|
||||||
const xml = await init.text();
|
|
||||||
const uploadId = matchXml(xml, 'UploadId');
|
|
||||||
if (!uploadId) throw new Error('multipart upload init: missing UploadId');
|
|
||||||
|
|
||||||
const expires = input.expiresInSec ?? 3600;
|
|
||||||
const partUrls: string[] = [];
|
|
||||||
for (let i = 1; i <= input.partCount; i++) {
|
|
||||||
const url = this.objectUrl(
|
|
||||||
input.key,
|
|
||||||
`partNumber=${i}&uploadId=${encodeURIComponent(uploadId)}&X-Amz-Expires=${expires}`,
|
|
||||||
);
|
|
||||||
const signed = await this.aws.sign(new Request(url, { method: 'PUT' }), {
|
|
||||||
aws: { signQuery: true },
|
|
||||||
});
|
|
||||||
partUrls.push(signed.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { uploadId, partSize: DEFAULT_PART_SIZE, partUrls };
|
|
||||||
}
|
|
||||||
|
|
||||||
async completeMultipart(input: CompleteMultipartInput): Promise<void> {
|
|
||||||
const sorted = [...input.parts].sort((a, b) => a.partNumber - b.partNumber);
|
|
||||||
const body =
|
|
||||||
`<CompleteMultipartUpload>` +
|
|
||||||
sorted
|
|
||||||
.map(
|
|
||||||
(p) =>
|
|
||||||
`<Part><PartNumber>${p.partNumber}</PartNumber><ETag>${escapeXml(p.etag)}</ETag></Part>`,
|
|
||||||
)
|
|
||||||
.join('') +
|
|
||||||
`</CompleteMultipartUpload>`;
|
|
||||||
const url = this.objectUrl(input.key, `uploadId=${encodeURIComponent(input.uploadId)}`);
|
|
||||||
const res = await this.aws.fetch(url, { method: 'POST', body });
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`completeMultipart failed: ${res.status} ${await res.text()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async abortMultipart(input: { key: string; uploadId: string }): Promise<void> {
|
|
||||||
const url = this.objectUrl(input.key, `uploadId=${encodeURIComponent(input.uploadId)}`);
|
|
||||||
await this.aws.fetch(url, { method: 'DELETE' });
|
|
||||||
}
|
|
||||||
|
|
||||||
publicUrl(key: string): string {
|
|
||||||
return `${this.config.publicBaseUrl.replace(/\/$/, '')}/${encodeKey(key)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async presignGet(key: string, expiresInSec = 3600): Promise<string> {
|
|
||||||
const url = this.objectUrl(key, `X-Amz-Expires=${expiresInSec}`);
|
|
||||||
const signed = await this.aws.sign(new Request(url, { method: 'GET' }), {
|
|
||||||
aws: { signQuery: true },
|
|
||||||
});
|
|
||||||
return signed.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
async exists(key: string): Promise<boolean> {
|
|
||||||
const res = await this.aws.fetch(this.objectUrl(key), { method: 'HEAD' });
|
|
||||||
return res.ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(key: string): Promise<void> {
|
|
||||||
const res = await this.aws.fetch(this.objectUrl(key), { method: 'DELETE' });
|
|
||||||
if (!res.ok && res.status !== 404) {
|
|
||||||
throw new Error(`delete failed: ${res.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async head(key: string): Promise<ObjectMeta | null> {
|
|
||||||
const res = await this.aws.fetch(this.objectUrl(key), { method: 'HEAD' });
|
|
||||||
if (res.status === 404) return null;
|
|
||||||
if (!res.ok) throw new Error(`head failed: ${res.status}`);
|
|
||||||
return {
|
|
||||||
contentType: res.headers.get('content-type') ?? 'application/octet-stream',
|
|
||||||
contentLength: Number(res.headers.get('content-length') ?? 0),
|
|
||||||
etag: res.headers.get('etag') ?? '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeKey(key: string): string {
|
|
||||||
return key.split('/').map(encodeURIComponent).join('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchXml(xml: string, tag: string): string | null {
|
|
||||||
const m = xml.match(new RegExp(`<${tag}>([^<]+)</${tag}>`));
|
|
||||||
return m?.[1] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeXml(s: string): string {
|
|
||||||
return s
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
export interface PresignPutInput {
|
|
||||||
key: string;
|
|
||||||
contentType: string;
|
|
||||||
contentLength?: number;
|
|
||||||
expiresInSec?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PresignPutResult {
|
|
||||||
url: string;
|
|
||||||
method: 'PUT';
|
|
||||||
headers: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InitMultipartInput {
|
|
||||||
key: string;
|
|
||||||
contentType: string;
|
|
||||||
partCount: number;
|
|
||||||
expiresInSec?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InitMultipartResult {
|
|
||||||
uploadId: string;
|
|
||||||
partSize: number;
|
|
||||||
partUrls: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompleteMultipartInput {
|
|
||||||
key: string;
|
|
||||||
uploadId: string;
|
|
||||||
parts: { partNumber: number; etag: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ObjectMeta {
|
|
||||||
contentType: string;
|
|
||||||
contentLength: number;
|
|
||||||
etag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageProvider {
|
|
||||||
presignPut(input: PresignPutInput): Promise<PresignPutResult>;
|
|
||||||
initMultipart(input: InitMultipartInput): Promise<InitMultipartResult>;
|
|
||||||
completeMultipart(input: CompleteMultipartInput): Promise<void>;
|
|
||||||
abortMultipart(input: { key: string; uploadId: string }): Promise<void>;
|
|
||||||
publicUrl(key: string): string;
|
|
||||||
presignGet(key: string, expiresInSec?: number): Promise<string>;
|
|
||||||
exists(key: string): Promise<boolean>;
|
|
||||||
delete(key: string): Promise<void>;
|
|
||||||
head(key: string): Promise<ObjectMeta | null>;
|
|
||||||
}
|
|
||||||
@ -1,322 +0,0 @@
|
|||||||
import { and, desc, eq, ilike, inArray, like, lt, or } from 'drizzle-orm';
|
|
||||||
import { Hono } from 'hono';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import {
|
|
||||||
adminBulkSchema,
|
|
||||||
adminUploadUpdateSchema,
|
|
||||||
eventConfigUpdateSchema,
|
|
||||||
type AdminStats,
|
|
||||||
type AdminUploadsResponse,
|
|
||||||
} from '@leetete/shared';
|
|
||||||
import { getDb } from '../db/client.js';
|
|
||||||
import { eventConfig, uploads } from '../db/schema.js';
|
|
||||||
import type { Bindings } from '../env.js';
|
|
||||||
import {
|
|
||||||
createSession,
|
|
||||||
destroySession,
|
|
||||||
requireAdmin,
|
|
||||||
timingSafeEqual,
|
|
||||||
} from '../lib/auth.js';
|
|
||||||
import { getStorage } from '../lib/storage-factory.js';
|
|
||||||
|
|
||||||
export const adminRoutes = new Hono<Bindings>();
|
|
||||||
|
|
||||||
const ADMIN_PAGE_SIZE = 30;
|
|
||||||
|
|
||||||
const loginSchema = z.object({
|
|
||||||
email: z.string().email().toLowerCase(),
|
|
||||||
password: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.post('/login', async (c) => {
|
|
||||||
const body = await c.req.json().catch(() => null);
|
|
||||||
const parsed = loginSchema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return c.json({ error: 'invalid_body' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = c.env.ALLOWED_ADMIN_EMAILS.split(',').map((e) => e.trim().toLowerCase());
|
|
||||||
const emailOk = allowed.includes(parsed.data.email);
|
|
||||||
const passwordOk = await timingSafeEqual(parsed.data.password, c.env.ADMIN_PASSWORD);
|
|
||||||
if (!emailOk || !passwordOk) {
|
|
||||||
return c.json({ error: 'unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
await createSession(c, parsed.data.email);
|
|
||||||
return c.json({ ok: true, email: parsed.data.email });
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.post('/logout', async (c) => {
|
|
||||||
destroySession(c);
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.use('*', requireAdmin);
|
|
||||||
|
|
||||||
adminRoutes.get('/me', (c) => c.json({ email: c.get('adminEmail') }));
|
|
||||||
|
|
||||||
adminRoutes.get('/event', async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
|
|
||||||
const row = rows[0];
|
|
||||||
if (!row) return c.json({ error: 'not_configured' }, 404);
|
|
||||||
const storage = getStorage();
|
|
||||||
return c.json({
|
|
||||||
coupleNames: row.coupleNames,
|
|
||||||
eventDate: row.eventDate,
|
|
||||||
coverKey: row.coverKey,
|
|
||||||
coverUrl: row.coverKey ? storage.publicUrl(row.coverKey) : null,
|
|
||||||
galleryVisibility: row.galleryVisibility,
|
|
||||||
moderation: row.moderation,
|
|
||||||
maxFileMb: row.maxFileMb,
|
|
||||||
allowVideo: row.allowVideo,
|
|
||||||
maxVideoSeconds: row.maxVideoSeconds,
|
|
||||||
welcomeMessage: row.welcomeMessage,
|
|
||||||
updatedAt: row.updatedAt,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.patch('/event', async (c) => {
|
|
||||||
const body = await c.req.json().catch(() => null);
|
|
||||||
const parsed = eventConfigUpdateSchema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
|
|
||||||
}
|
|
||||||
const db = getDb();
|
|
||||||
await db
|
|
||||||
.update(eventConfig)
|
|
||||||
.set({ ...parsed.data, updatedAt: Date.now() })
|
|
||||||
.where(eq(eventConfig.id, 1));
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.get('/uploads', async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const status = c.req.query('status') as 'pending' | 'approved' | 'rejected' | undefined;
|
|
||||||
const kind = c.req.query('kind') as 'photo' | 'video' | undefined;
|
|
||||||
const q = c.req.query('q')?.trim();
|
|
||||||
const cursorParam = c.req.query('cursor');
|
|
||||||
const cursor = cursorParam ? Number(cursorParam) : null;
|
|
||||||
const limit = Math.min(Number(c.req.query('limit') ?? ADMIN_PAGE_SIZE), 100);
|
|
||||||
|
|
||||||
const filters = [];
|
|
||||||
if (status) filters.push(eq(uploads.status, status));
|
|
||||||
if (cursor !== null && Number.isFinite(cursor)) {
|
|
||||||
filters.push(lt(uploads.createdAt, cursor));
|
|
||||||
}
|
|
||||||
if (kind === 'photo') filters.push(like(uploads.mimeType, 'image/%'));
|
|
||||||
if (kind === 'video') filters.push(like(uploads.mimeType, 'video/%'));
|
|
||||||
if (q && q.length > 0) {
|
|
||||||
const pattern = `%${q.replace(/[%_]/g, (m) => `\\${m}`)}%`;
|
|
||||||
const cond = or(
|
|
||||||
ilike(uploads.authorName, pattern),
|
|
||||||
ilike(uploads.message, pattern),
|
|
||||||
);
|
|
||||||
if (cond) filters.push(cond);
|
|
||||||
}
|
|
||||||
const where = filters.length ? and(...filters) : undefined;
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(uploads)
|
|
||||||
.where(where)
|
|
||||||
.orderBy(desc(uploads.createdAt))
|
|
||||||
.limit(limit + 1);
|
|
||||||
|
|
||||||
const hasMore = rows.length > limit;
|
|
||||||
const slice = hasMore ? rows.slice(0, limit) : rows;
|
|
||||||
const storage = getStorage();
|
|
||||||
|
|
||||||
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
|
|
||||||
const coverKey = cfg[0]?.coverKey ?? null;
|
|
||||||
|
|
||||||
const items = slice.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
url: storage.publicUrl(r.storageKey),
|
|
||||||
thumbnailUrl: r.thumbnailKey ? storage.publicUrl(r.thumbnailKey) : null,
|
|
||||||
storageKey: r.storageKey,
|
|
||||||
mimeType: r.mimeType,
|
|
||||||
isVideo: r.mimeType.startsWith('video/'),
|
|
||||||
sizeBytes: r.sizeBytes,
|
|
||||||
durationSeconds: r.durationSeconds,
|
|
||||||
authorName: r.authorName,
|
|
||||||
message: r.message,
|
|
||||||
status: r.status,
|
|
||||||
source: r.source,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
approvedAt: r.approvedAt,
|
|
||||||
isCover: coverKey === r.storageKey,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const nextCursor = hasMore ? String(slice[slice.length - 1]!.createdAt) : null;
|
|
||||||
return c.json({ items, nextCursor } satisfies AdminUploadsResponse);
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.patch('/uploads/:id', async (c) => {
|
|
||||||
const id = c.req.param('id');
|
|
||||||
const body = await c.req.json().catch(() => null);
|
|
||||||
const parsed = adminUploadUpdateSchema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
|
|
||||||
}
|
|
||||||
const updates: Record<string, string | null> = {};
|
|
||||||
if (parsed.data.authorName !== undefined) {
|
|
||||||
updates['authorName'] = parsed.data.authorName?.trim() || null;
|
|
||||||
}
|
|
||||||
if (parsed.data.message !== undefined) {
|
|
||||||
updates['message'] = parsed.data.message?.trim() || null;
|
|
||||||
}
|
|
||||||
if (Object.keys(updates).length === 0) {
|
|
||||||
return c.json({ ok: true, noop: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const result = await db
|
|
||||||
.update(uploads)
|
|
||||||
.set(updates)
|
|
||||||
.where(eq(uploads.id, id))
|
|
||||||
.returning({ id: uploads.id });
|
|
||||||
if (!result.length) return c.json({ error: 'not_found' }, 404);
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.post('/uploads/:id/approve', async (c) => {
|
|
||||||
const id = c.req.param('id');
|
|
||||||
const db = getDb();
|
|
||||||
const result = await db
|
|
||||||
.update(uploads)
|
|
||||||
.set({ status: 'approved', approvedAt: Date.now() })
|
|
||||||
.where(eq(uploads.id, id))
|
|
||||||
.returning({ id: uploads.id });
|
|
||||||
if (!result.length) return c.json({ error: 'not_found' }, 404);
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.post('/uploads/:id/reject', async (c) => {
|
|
||||||
const id = c.req.param('id');
|
|
||||||
const db = getDb();
|
|
||||||
const result = await db
|
|
||||||
.update(uploads)
|
|
||||||
.set({ status: 'rejected', approvedAt: null })
|
|
||||||
.where(eq(uploads.id, id))
|
|
||||||
.returning({ id: uploads.id });
|
|
||||||
if (!result.length) return c.json({ error: 'not_found' }, 404);
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.post('/uploads/:id/cover', async (c) => {
|
|
||||||
const id = c.req.param('id');
|
|
||||||
const db = getDb();
|
|
||||||
const rows = await db.select().from(uploads).where(eq(uploads.id, id));
|
|
||||||
const row = rows[0];
|
|
||||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
|
||||||
await db
|
|
||||||
.update(eventConfig)
|
|
||||||
.set({ coverKey: row.storageKey, updatedAt: Date.now() })
|
|
||||||
.where(eq(eventConfig.id, 1));
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.delete('/event/cover', async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
await db
|
|
||||||
.update(eventConfig)
|
|
||||||
.set({ coverKey: null, updatedAt: Date.now() })
|
|
||||||
.where(eq(eventConfig.id, 1));
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.delete('/uploads/:id', async (c) => {
|
|
||||||
const id = c.req.param('id');
|
|
||||||
const db = getDb();
|
|
||||||
const rows = await db.select().from(uploads).where(eq(uploads.id, id));
|
|
||||||
const row = rows[0];
|
|
||||||
if (!row) return c.json({ error: 'not_found' }, 404);
|
|
||||||
|
|
||||||
const storage = getStorage();
|
|
||||||
await storage.delete(row.storageKey).catch((err) => {
|
|
||||||
console.warn('failed to delete from storage', row.storageKey, err);
|
|
||||||
});
|
|
||||||
if (row.thumbnailKey) {
|
|
||||||
await storage.delete(row.thumbnailKey).catch(() => {});
|
|
||||||
}
|
|
||||||
await db.delete(uploads).where(eq(uploads.id, id));
|
|
||||||
|
|
||||||
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
|
|
||||||
if (cfg[0]?.coverKey === row.storageKey) {
|
|
||||||
await db.update(eventConfig).set({ coverKey: null }).where(eq(eventConfig.id, 1));
|
|
||||||
}
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.post('/uploads/bulk', async (c) => {
|
|
||||||
const body = await c.req.json().catch(() => null);
|
|
||||||
const parsed = adminBulkSchema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const storage = getStorage();
|
|
||||||
|
|
||||||
if (parsed.data.action === 'delete') {
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(uploads)
|
|
||||||
.where(inArray(uploads.id, parsed.data.ids));
|
|
||||||
await Promise.all(
|
|
||||||
rows.map((r) => storage.delete(r.storageKey).catch(() => {})),
|
|
||||||
);
|
|
||||||
await db.delete(uploads).where(inArray(uploads.id, parsed.data.ids));
|
|
||||||
|
|
||||||
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
|
|
||||||
if (cfg[0]?.coverKey && rows.some((r) => r.storageKey === cfg[0]!.coverKey)) {
|
|
||||||
await db.update(eventConfig).set({ coverKey: null }).where(eq(eventConfig.id, 1));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const status = parsed.data.action === 'approve' ? 'approved' : 'rejected';
|
|
||||||
const approvedAt = status === 'approved' ? Date.now() : null;
|
|
||||||
await db
|
|
||||||
.update(uploads)
|
|
||||||
.set({ status, approvedAt })
|
|
||||||
.where(inArray(uploads.id, parsed.data.ids));
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json({ ok: true, count: parsed.data.ids.length });
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.get('/stats', async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const all = await db
|
|
||||||
.select({
|
|
||||||
status: uploads.status,
|
|
||||||
mimeType: uploads.mimeType,
|
|
||||||
sizeBytes: uploads.sizeBytes,
|
|
||||||
})
|
|
||||||
.from(uploads);
|
|
||||||
|
|
||||||
const stats: AdminStats = {
|
|
||||||
total: all.length,
|
|
||||||
approved: 0,
|
|
||||||
pending: 0,
|
|
||||||
rejected: 0,
|
|
||||||
photos: 0,
|
|
||||||
videos: 0,
|
|
||||||
totalBytes: 0,
|
|
||||||
};
|
|
||||||
for (const r of all) {
|
|
||||||
stats[r.status]++;
|
|
||||||
if (r.mimeType.startsWith('image/')) stats.photos++;
|
|
||||||
else if (r.mimeType.startsWith('video/')) stats.videos++;
|
|
||||||
stats.totalBytes += r.sizeBytes;
|
|
||||||
}
|
|
||||||
return c.json(stats);
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.get('/export.zip', async (c) => {
|
|
||||||
return c.json({ error: 'not_implemented_yet' }, 501);
|
|
||||||
});
|
|
||||||
|
|
||||||
adminRoutes.post('/imports', async (c) => {
|
|
||||||
return c.json({ error: 'not_implemented_yet' }, 501);
|
|
||||||
});
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
import { and, desc, eq, lt } from 'drizzle-orm';
|
|
||||||
import { Hono } from 'hono';
|
|
||||||
import type { GalleryResponse } from '@leetete/shared';
|
|
||||||
import { getDb } from '../db/client.js';
|
|
||||||
import { eventConfig, uploads } from '../db/schema.js';
|
|
||||||
import type { Bindings } from '../env.js';
|
|
||||||
import { generateQrPdf } from '../lib/qr-pdf.js';
|
|
||||||
import { generateQrPng, generateQrSvg } from '../lib/qrcode.js';
|
|
||||||
import { getStorage } from '../lib/storage-factory.js';
|
|
||||||
|
|
||||||
export const publicRoutes = new Hono<Bindings>();
|
|
||||||
|
|
||||||
const GALLERY_PAGE_SIZE = 24;
|
|
||||||
|
|
||||||
publicRoutes.get('/event', async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
|
|
||||||
const row = cfg[0];
|
|
||||||
if (!row) return c.json({ error: 'not_configured' }, 404);
|
|
||||||
const storage = getStorage();
|
|
||||||
return c.json({
|
|
||||||
coupleNames: row.coupleNames,
|
|
||||||
eventDate: row.eventDate,
|
|
||||||
welcomeMessage: row.welcomeMessage,
|
|
||||||
galleryVisibility: row.galleryVisibility,
|
|
||||||
allowVideo: row.allowVideo,
|
|
||||||
maxFileMb: row.maxFileMb,
|
|
||||||
maxVideoSeconds: row.maxVideoSeconds,
|
|
||||||
coverUrl: row.coverKey ? storage.publicUrl(row.coverKey) : null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
publicRoutes.get('/gallery', async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
|
|
||||||
const row = cfg[0];
|
|
||||||
if (!row || row.galleryVisibility !== 'public') {
|
|
||||||
return c.json({ items: [], nextCursor: null } satisfies GalleryResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cursorParam = c.req.query('cursor');
|
|
||||||
const cursor = cursorParam ? Number(cursorParam) : null;
|
|
||||||
const limit = Math.min(Number(c.req.query('limit') ?? GALLERY_PAGE_SIZE), 60);
|
|
||||||
|
|
||||||
const baseCondition = eq(uploads.status, 'approved');
|
|
||||||
const where =
|
|
||||||
cursor !== null && Number.isFinite(cursor)
|
|
||||||
? and(baseCondition, lt(uploads.createdAt, cursor))
|
|
||||||
: baseCondition;
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(uploads)
|
|
||||||
.where(where)
|
|
||||||
.orderBy(desc(uploads.createdAt))
|
|
||||||
.limit(limit + 1);
|
|
||||||
|
|
||||||
const hasMore = rows.length > limit;
|
|
||||||
const slice = hasMore ? rows.slice(0, limit) : rows;
|
|
||||||
const storage = getStorage();
|
|
||||||
|
|
||||||
const items = slice.map((r) => ({
|
|
||||||
id: r.id,
|
|
||||||
url: storage.publicUrl(r.storageKey),
|
|
||||||
thumbnailUrl: r.thumbnailKey ? storage.publicUrl(r.thumbnailKey) : null,
|
|
||||||
mimeType: r.mimeType,
|
|
||||||
isVideo: r.mimeType.startsWith('video/'),
|
|
||||||
authorName: r.authorName,
|
|
||||||
message: r.message,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const nextCursor = hasMore ? String(slice[slice.length - 1]!.createdAt) : null;
|
|
||||||
return c.json({ items, nextCursor } satisfies GalleryResponse);
|
|
||||||
});
|
|
||||||
|
|
||||||
publicRoutes.get('/qrcode', async (c) => {
|
|
||||||
const format = (c.req.query('format') ?? 'png').toLowerCase();
|
|
||||||
const overrideUrl = c.req.query('url');
|
|
||||||
|
|
||||||
const reqUrl = new URL(c.req.url);
|
|
||||||
const base = overrideUrl ?? c.env.PUBLIC_BASE_URL ?? `${reqUrl.protocol}//${reqUrl.host}`;
|
|
||||||
const target = base.replace(/\/$/, '') + '/enviar';
|
|
||||||
|
|
||||||
if (format === 'svg') {
|
|
||||||
const svg = await generateQrSvg(target);
|
|
||||||
return new Response(svg, {
|
|
||||||
headers: {
|
|
||||||
'content-type': 'image/svg+xml; charset=utf-8',
|
|
||||||
'cache-control': 'public, max-age=300',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (format === 'pdf') {
|
|
||||||
const db = getDb();
|
|
||||||
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
|
|
||||||
const row = cfg[0];
|
|
||||||
const pdf = await generateQrPdf({
|
|
||||||
url: target,
|
|
||||||
coupleNames: row?.coupleNames ?? c.env.COUPLE_NAMES,
|
|
||||||
eventDate: row?.eventDate ?? c.env.EVENT_DATE,
|
|
||||||
});
|
|
||||||
return new Response(pdf, {
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/pdf',
|
|
||||||
'content-disposition': 'inline; filename="qr-mesa.pdf"',
|
|
||||||
'cache-control': 'no-store',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const png = await generateQrPng(target, 800);
|
|
||||||
return new Response(png, {
|
|
||||||
headers: {
|
|
||||||
'content-type': 'image/png',
|
|
||||||
'cache-control': 'public, max-age=300',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
publicRoutes.get('/stats', async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const all = await db
|
|
||||||
.select({ id: uploads.id, mimeType: uploads.mimeType })
|
|
||||||
.from(uploads)
|
|
||||||
.where(eq(uploads.status, 'approved'));
|
|
||||||
const photos = all.filter((u) => u.mimeType.startsWith('image/')).length;
|
|
||||||
const videos = all.filter((u) => u.mimeType.startsWith('video/')).length;
|
|
||||||
return c.json({ photos, videos, total: all.length });
|
|
||||||
});
|
|
||||||
@ -1,220 +0,0 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
|
||||||
import { Hono, type Context } from 'hono';
|
|
||||||
import {
|
|
||||||
uploadConfirmSchema,
|
|
||||||
uploadInitSchema,
|
|
||||||
type UploadInitResponse,
|
|
||||||
} from '@leetete/shared';
|
|
||||||
import { getDb } from '../db/client.js';
|
|
||||||
import { eventConfig, uploads } from '../db/schema.js';
|
|
||||||
import type { Bindings } from '../env.js';
|
|
||||||
import { hashIp, uploadId } from '../lib/ids.js';
|
|
||||||
import { getStorage } from '../lib/storage-factory.js';
|
|
||||||
|
|
||||||
export const uploadsRoutes = new Hono<Bindings>();
|
|
||||||
|
|
||||||
const SINGLE_PUT_MAX = 50 * 1024 * 1024;
|
|
||||||
const PART_SIZE = 10 * 1024 * 1024;
|
|
||||||
const KEY_PREFIX = 'uploads';
|
|
||||||
const IP_HASH_SALT = 'wedding-uploads-v1';
|
|
||||||
|
|
||||||
function extFromFilename(filename: string, mimeType: string): string {
|
|
||||||
const dot = filename.lastIndexOf('.');
|
|
||||||
if (dot >= 0 && dot < filename.length - 1) {
|
|
||||||
const ext = filename.slice(dot + 1).toLowerCase();
|
|
||||||
if (/^[a-z0-9]{1,6}$/.test(ext)) return ext;
|
|
||||||
}
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
'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',
|
|
||||||
};
|
|
||||||
return map[mimeType.toLowerCase()] ?? 'bin';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildKey(id: string, filename: string, mimeType: string): string {
|
|
||||||
const now = new Date();
|
|
||||||
const yyyy = now.getUTCFullYear();
|
|
||||||
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
||||||
const ext = extFromFilename(filename, mimeType);
|
|
||||||
return `${KEY_PREFIX}/${yyyy}/${mm}/${id}.${ext}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClientIp(c: Context<Bindings>): string {
|
|
||||||
return (
|
|
||||||
c.req.header('cf-connecting-ip') ??
|
|
||||||
c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ??
|
|
||||||
c.req.header('x-real-ip') ??
|
|
||||||
'unknown'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadsRoutes.post('/init', async (c) => {
|
|
||||||
const body = await c.req.json().catch(() => null);
|
|
||||||
const parsed = uploadInitSchema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
|
|
||||||
}
|
|
||||||
const input = parsed.data;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
|
|
||||||
const event = cfg[0];
|
|
||||||
if (!event) return c.json({ error: 'not_configured' }, 500);
|
|
||||||
|
|
||||||
const isVideo = input.mimeType.startsWith('video/');
|
|
||||||
if (isVideo && !event.allowVideo) {
|
|
||||||
return c.json({ error: 'video_not_allowed' }, 400);
|
|
||||||
}
|
|
||||||
if (input.sizeBytes > event.maxFileMb * 1024 * 1024) {
|
|
||||||
return c.json({ error: 'file_too_large', maxFileMb: event.maxFileMb }, 400);
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
isVideo &&
|
|
||||||
input.durationSeconds !== undefined &&
|
|
||||||
input.durationSeconds > event.maxVideoSeconds
|
|
||||||
) {
|
|
||||||
return c.json({ error: 'video_too_long', maxVideoSeconds: event.maxVideoSeconds }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = uploadId();
|
|
||||||
const storageKey = buildKey(id, input.filename, input.mimeType);
|
|
||||||
const storage = getStorage();
|
|
||||||
const ipHashValue = await hashIp(getClientIp(c), IP_HASH_SALT);
|
|
||||||
|
|
||||||
if (input.sizeBytes <= SINGLE_PUT_MAX) {
|
|
||||||
const presigned = await storage.presignPut({
|
|
||||||
key: storageKey,
|
|
||||||
contentType: input.mimeType,
|
|
||||||
contentLength: input.sizeBytes,
|
|
||||||
expiresInSec: 900,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(uploads).values({
|
|
||||||
id,
|
|
||||||
storageKey,
|
|
||||||
mimeType: input.mimeType,
|
|
||||||
sizeBytes: input.sizeBytes,
|
|
||||||
durationSeconds: input.durationSeconds ?? null,
|
|
||||||
authorName: input.authorName?.trim() || null,
|
|
||||||
message: input.message?.trim() || null,
|
|
||||||
status: 'pending',
|
|
||||||
source: 'guest',
|
|
||||||
ipHash: ipHashValue,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: UploadInitResponse = {
|
|
||||||
uploadId: id,
|
|
||||||
storageKey,
|
|
||||||
mode: 'single',
|
|
||||||
putUrl: presigned.url,
|
|
||||||
putHeaders: presigned.headers,
|
|
||||||
};
|
|
||||||
return c.json(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
const partCount = Math.ceil(input.sizeBytes / PART_SIZE);
|
|
||||||
const multipart = await storage.initMultipart({
|
|
||||||
key: storageKey,
|
|
||||||
contentType: input.mimeType,
|
|
||||||
partCount,
|
|
||||||
expiresInSec: 3600 * 6,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.insert(uploads).values({
|
|
||||||
id,
|
|
||||||
storageKey,
|
|
||||||
mimeType: input.mimeType,
|
|
||||||
sizeBytes: input.sizeBytes,
|
|
||||||
durationSeconds: input.durationSeconds ?? null,
|
|
||||||
authorName: input.authorName?.trim() || null,
|
|
||||||
message: input.message?.trim() || null,
|
|
||||||
status: 'pending',
|
|
||||||
source: 'guest',
|
|
||||||
ipHash: ipHashValue,
|
|
||||||
providerUploadId: multipart.uploadId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response: UploadInitResponse = {
|
|
||||||
uploadId: id,
|
|
||||||
storageKey,
|
|
||||||
mode: 'multipart',
|
|
||||||
multipart: {
|
|
||||||
providerUploadId: multipart.uploadId,
|
|
||||||
partSize: multipart.partSize,
|
|
||||||
partUrls: multipart.partUrls,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return c.json(response);
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadsRoutes.post('/:id/confirm', async (c) => {
|
|
||||||
const id = c.req.param('id');
|
|
||||||
const body = await c.req.json().catch(() => ({}));
|
|
||||||
const parsed = uploadConfirmSchema.safeParse(body);
|
|
||||||
if (!parsed.success) {
|
|
||||||
return c.json({ error: 'invalid_body', details: parsed.error.flatten() }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const rows = await db.select().from(uploads).where(eq(uploads.id, id));
|
|
||||||
const upload = rows[0];
|
|
||||||
if (!upload) return c.json({ error: 'not_found' }, 404);
|
|
||||||
if (upload.status === 'approved') return c.json({ ok: true, alreadyApproved: true });
|
|
||||||
|
|
||||||
const cfg = await db.select().from(eventConfig).where(eq(eventConfig.id, 1));
|
|
||||||
const event = cfg[0];
|
|
||||||
if (!event) return c.json({ error: 'not_configured' }, 500);
|
|
||||||
|
|
||||||
const storage = getStorage();
|
|
||||||
|
|
||||||
if (parsed.data.multipart) {
|
|
||||||
if (parsed.data.multipart.providerUploadId !== upload.providerUploadId) {
|
|
||||||
return c.json({ error: 'multipart_mismatch' }, 400);
|
|
||||||
}
|
|
||||||
await storage.completeMultipart({
|
|
||||||
key: upload.storageKey,
|
|
||||||
uploadId: parsed.data.multipart.providerUploadId,
|
|
||||||
parts: parsed.data.multipart.parts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const head = await storage.head(upload.storageKey);
|
|
||||||
if (!head) {
|
|
||||||
return c.json({ error: 'not_uploaded' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalStatus = event.moderation === 'pre' ? 'pending' : 'approved';
|
|
||||||
await db
|
|
||||||
.update(uploads)
|
|
||||||
.set({
|
|
||||||
status: finalStatus,
|
|
||||||
approvedAt: finalStatus === 'approved' ? Date.now() : null,
|
|
||||||
})
|
|
||||||
.where(eq(uploads.id, id));
|
|
||||||
|
|
||||||
return c.json({ ok: true, status: finalStatus });
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadsRoutes.post('/:id/abort', async (c) => {
|
|
||||||
const id = c.req.param('id');
|
|
||||||
const db = getDb();
|
|
||||||
const rows = await db.select().from(uploads).where(eq(uploads.id, id));
|
|
||||||
const upload = rows[0];
|
|
||||||
if (!upload) return c.json({ error: 'not_found' }, 404);
|
|
||||||
|
|
||||||
if (upload.providerUploadId) {
|
|
||||||
const storage = getStorage();
|
|
||||||
await storage
|
|
||||||
.abortMultipart({ key: upload.storageKey, uploadId: upload.providerUploadId })
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
await db.delete(uploads).where(eq(uploads.id, id));
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { serve } from '@hono/node-server';
|
|
||||||
import { serveStatic } from '@hono/node-server/serve-static';
|
|
||||||
import { Hono } from 'hono';
|
|
||||||
import { createApp } from './app.js';
|
|
||||||
import { closeDb, initDb } from './db/client.js';
|
|
||||||
import { runMigrations } from './db/migrate.js';
|
|
||||||
import type { Bindings } from './env.js';
|
|
||||||
import { loadEnv } from './env.js';
|
|
||||||
import { initStorage } from './lib/storage-factory.js';
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const env = loadEnv();
|
|
||||||
|
|
||||||
if (env.AUTO_MIGRATE === 'true') {
|
|
||||||
console.log('[startup] running migrations');
|
|
||||||
await runMigrations(env.DATABASE_URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
initDb(env.DATABASE_URL);
|
|
||||||
initStorage(env);
|
|
||||||
|
|
||||||
const apiApp = createApp();
|
|
||||||
|
|
||||||
const app = new Hono<Bindings>();
|
|
||||||
app.route('/', apiApp);
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
'/assets/*',
|
|
||||||
serveStatic({
|
|
||||||
root: env.STATIC_DIR,
|
|
||||||
rewriteRequestPath: (path) => path,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const f of ['/favicon.ico', '/robots.txt']) {
|
|
||||||
app.get(f, serveStatic({ path: `${env.STATIC_DIR}${f}` }));
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('*', serveStatic({ path: `${env.STATIC_DIR}/index.html` }));
|
|
||||||
|
|
||||||
const server = serve(
|
|
||||||
{
|
|
||||||
fetch: (req) => app.fetch(req, env),
|
|
||||||
port: env.PORT,
|
|
||||||
hostname: '0.0.0.0',
|
|
||||||
},
|
|
||||||
(info) => {
|
|
||||||
console.log(`[startup] listening on http://0.0.0.0:${info.port}`);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const shutdown = async (signal: string) => {
|
|
||||||
console.log(`[shutdown] received ${signal}`);
|
|
||||||
server.close();
|
|
||||||
await closeDb();
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
||||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error('[startup] failed', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"types": ["node"],
|
|
||||||
"noEmit": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"]
|
|
||||||
}
|
|
||||||
@ -4,11 +4,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"packageManager": "pnpm@9.12.0",
|
"packageManager": "pnpm@9.12.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:api": "pnpm -F @leetete/api dev",
|
|
||||||
"dev:web": "pnpm -F @leetete/web dev",
|
"dev:web": "pnpm -F @leetete/web dev",
|
||||||
"build": "pnpm -r build",
|
"build": "pnpm -F @leetete/web build",
|
||||||
"typecheck": "pnpm -r typecheck",
|
"typecheck": "pnpm -r typecheck"
|
||||||
"migrate": "pnpm -F @leetete/api migrate"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.6.3"
|
"typescript": "^5.6.3"
|
||||||
|
|||||||
986
pnpm-lock.yaml
generated
986
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,3 @@
|
|||||||
packages:
|
packages:
|
||||||
- "apps/*"
|
- "apps/web"
|
||||||
- "packages/*"
|
- "packages/shared"
|
||||||
|
|||||||
Reference in New Issue
Block a user