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
|
||||
.vscode
|
||||
.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.example
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.venv/
|
||||
venv/
|
||||
.python-version
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.egg-info/
|
||||
|
||||
.vscode/
|
||||
.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 corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
|
||||
FROM base AS deps
|
||||
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 apps/api/package.json apps/api/
|
||||
COPY apps/web/package.json apps/web/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM deps AS build
|
||||
WORKDIR /app
|
||||
COPY tsconfig.base.json ./
|
||||
COPY packages packages
|
||||
COPY apps apps
|
||||
COPY packages/shared packages/shared
|
||||
COPY apps/web apps/web
|
||||
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
|
||||
COPY --from=build /app/pnpm-workspace.yaml /app/package.json /app/pnpm-lock.yaml* ./
|
||||
COPY --from=build /app/tsconfig.base.json ./
|
||||
COPY --from=build /app/packages packages
|
||||
COPY --from=build /app/apps/api apps/api
|
||||
COPY --from=build /app/apps/web/dist public
|
||||
# ---- Stage 2: Python runtime ----
|
||||
FROM python:3.12-slim AS runtime
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
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
|
||||
|
||||
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",
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"scripts": {
|
||||
"dev:api": "pnpm -F @leetete/api dev",
|
||||
"dev:web": "pnpm -F @leetete/web dev",
|
||||
"build": "pnpm -r build",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"migrate": "pnpm -F @leetete/api migrate"
|
||||
"build": "pnpm -F @leetete/web build",
|
||||
"typecheck": "pnpm -r typecheck"
|
||||
},
|
||||
"devDependencies": {
|
||||
"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:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
- "apps/web"
|
||||
- "packages/shared"
|
||||
|
||||
Reference in New Issue
Block a user