diff --git a/.dockerignore b/.dockerignore index 9465ada..30f72fd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,3 +14,11 @@ dist .DS_Store .vscode .idea +__pycache__ +**/__pycache__ +*.pyc +.venv +**/.venv +.mypy_cache +.ruff_cache +.pytest_cache diff --git a/.gitignore b/.gitignore index a303ab8..c651633 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Dockerfile b/Dockerfile index 2cf2d0f..f3bf920 100644 --- a/Dockerfile +++ b/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=*"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1da9a36 --- /dev/null +++ b/Makefile @@ -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 diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/config.py b/apps/api/app/config.py new file mode 100644 index 0000000..3ee1dd1 --- /dev/null +++ b/apps/api/app/config.py @@ -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] diff --git a/apps/api/app/db/__init__.py b/apps/api/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/db/base.py b/apps/api/app/db/base.py new file mode 100644 index 0000000..b8c4a5b --- /dev/null +++ b/apps/api/app/db/base.py @@ -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 diff --git a/apps/api/app/db/migrate.py b/apps/api/app/db/migrate.py new file mode 100644 index 0000000..2e59bc0 --- /dev/null +++ b/apps/api/app/db/migrate.py @@ -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() diff --git a/apps/api/app/db/models.py b/apps/api/app/db/models.py new file mode 100644 index 0000000..eec294b --- /dev/null +++ b/apps/api/app/db/models.py @@ -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 + ) diff --git a/apps/api/app/lib/__init__.py b/apps/api/app/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/lib/auth.py b/apps/api/app/lib/auth.py new file mode 100644 index 0000000..69b455d --- /dev/null +++ b/apps/api/app/lib/auth.py @@ -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 diff --git a/apps/api/app/lib/ids.py b/apps/api/app/lib/ids.py new file mode 100644 index 0000000..4c69659 --- /dev/null +++ b/apps/api/app/lib/ids.py @@ -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] diff --git a/apps/api/app/lib/pdf.py b/apps/api/app/lib/pdf.py new file mode 100644 index 0000000..11d9547 --- /dev/null +++ b/apps/api/app/lib/pdf.py @@ -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() diff --git a/apps/api/app/lib/qrcode_gen.py b/apps/api/app/lib/qrcode_gen.py new file mode 100644 index 0000000..c8d1e41 --- /dev/null +++ b/apps/api/app/lib/qrcode_gen.py @@ -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") diff --git a/apps/api/app/lib/storage.py b/apps/api/app/lib/storage.py new file mode 100644 index 0000000..73d6248 --- /dev/null +++ b/apps/api/app/lib/storage.py @@ -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() diff --git a/apps/api/app/main.py b/apps/api/app/main.py new file mode 100644 index 0000000..c11ea21 --- /dev/null +++ b/apps/api/app/main.py @@ -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") diff --git a/apps/api/src/db/migrations/0001_initial.sql b/apps/api/app/migrations/0001_initial.sql similarity index 100% rename from apps/api/src/db/migrations/0001_initial.sql rename to apps/api/app/migrations/0001_initial.sql diff --git a/apps/api/app/routes/__init__.py b/apps/api/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/routes/admin.py b/apps/api/app/routes/admin.py new file mode 100644 index 0000000..c3787f7 --- /dev/null +++ b/apps/api/app/routes/admin.py @@ -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") diff --git a/apps/api/app/routes/public.py b/apps/api/app/routes/public.py new file mode 100644 index 0000000..290aa3e --- /dev/null +++ b/apps/api/app/routes/public.py @@ -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)} diff --git a/apps/api/app/routes/uploads.py b/apps/api/app/routes/uploads.py new file mode 100644 index 0000000..a28f9ad --- /dev/null +++ b/apps/api/app/routes/uploads.py @@ -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} diff --git a/apps/api/app/schemas/__init__.py b/apps/api/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/schemas/api.py b/apps/api/app/schemas/api.py new file mode 100644 index 0000000..6b2925c --- /dev/null +++ b/apps/api/app/schemas/api.py @@ -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) diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts deleted file mode 100644 index 1931818..0000000 --- a/apps/api/drizzle.config.ts +++ /dev/null @@ -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; diff --git a/apps/api/package.json b/apps/api/package.json deleted file mode 100644 index c9071ab..0000000 --- a/apps/api/package.json +++ /dev/null @@ -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" - } -} diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml new file mode 100644 index 0000000..3f37659 --- /dev/null +++ b/apps/api/pyproject.toml @@ -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 diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts deleted file mode 100644 index f960b76..0000000 --- a/apps/api/src/app.ts +++ /dev/null @@ -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().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; diff --git a/apps/api/src/db/client.ts b/apps/api/src/db/client.ts deleted file mode 100644 index 74b0867..0000000 --- a/apps/api/src/db/client.ts +++ /dev/null @@ -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>; - -let dbInstance: DB | null = null; -let pgInstance: ReturnType | 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 { - if (pgInstance) { - await pgInstance.end({ timeout: 5 }); - pgInstance = null; - dbInstance = null; - } -} diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts deleted file mode 100644 index 5a8ed32..0000000 --- a/apps/api/src/db/migrate.ts +++ /dev/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 { - 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); - }); -} diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts deleted file mode 100644 index 73a5702..0000000 --- a/apps/api/src/db/schema.ts +++ /dev/null @@ -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; diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts deleted file mode 100644 index 01c0433..0000000 --- a/apps/api/src/env.ts +++ /dev/null @@ -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; - -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; -} diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts deleted file mode 100644 index 454044a..0000000 --- a/apps/api/src/lib/auth.ts +++ /dev/null @@ -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, 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, 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) { - deleteCookie(c, COOKIE, { path: '/' }); -} - -export async function timingSafeEqual(a: string, b: string): Promise { - 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; -} diff --git a/apps/api/src/lib/ids.ts b/apps/api/src/lib/ids.ts deleted file mode 100644 index 5f74bf3..0000000 --- a/apps/api/src/lib/ids.ts +++ /dev/null @@ -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 { - 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); -} diff --git a/apps/api/src/lib/qr-pdf.ts b/apps/api/src/lib/qr-pdf.ts deleted file mode 100644 index 55b3361..0000000 --- a/apps/api/src/lib/qr-pdf.ts +++ /dev/null @@ -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 { - 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(); -} diff --git a/apps/api/src/lib/qrcode.ts b/apps/api/src/lib/qrcode.ts deleted file mode 100644 index 891968a..0000000 --- a/apps/api/src/lib/qrcode.ts +++ /dev/null @@ -1,19 +0,0 @@ -import QRCode from 'qrcode'; - -export async function generateQrPng(text: string, size = 512): Promise { - 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 { - return QRCode.toString(text, { - type: 'svg', - errorCorrectionLevel: 'M', - margin: 2, - }); -} diff --git a/apps/api/src/lib/storage-factory.ts b/apps/api/src/lib/storage-factory.ts deleted file mode 100644 index 268859c..0000000 --- a/apps/api/src/lib/storage-factory.ts +++ /dev/null @@ -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; -} diff --git a/apps/api/src/lib/storage-s3.ts b/apps/api/src/lib/storage-s3.ts deleted file mode 100644 index 011d132..0000000 --- a/apps/api/src/lib/storage-s3.ts +++ /dev/null @@ -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 { - 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 { - 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 { - const sorted = [...input.parts].sort((a, b) => a.partNumber - b.partNumber); - const body = - `` + - sorted - .map( - (p) => - `${p.partNumber}${escapeXml(p.etag)}`, - ) - .join('') + - ``; - 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 { - 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 { - 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 { - const res = await this.aws.fetch(this.objectUrl(key), { method: 'HEAD' }); - return res.ok; - } - - async delete(key: string): Promise { - 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 { - 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}>([^<]+)`)); - return m?.[1] ?? null; -} - -function escapeXml(s: string): string { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} diff --git a/apps/api/src/lib/storage.ts b/apps/api/src/lib/storage.ts deleted file mode 100644 index 9e2c828..0000000 --- a/apps/api/src/lib/storage.ts +++ /dev/null @@ -1,49 +0,0 @@ -export interface PresignPutInput { - key: string; - contentType: string; - contentLength?: number; - expiresInSec?: number; -} - -export interface PresignPutResult { - url: string; - method: 'PUT'; - headers: Record; -} - -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; - initMultipart(input: InitMultipartInput): Promise; - completeMultipart(input: CompleteMultipartInput): Promise; - abortMultipart(input: { key: string; uploadId: string }): Promise; - publicUrl(key: string): string; - presignGet(key: string, expiresInSec?: number): Promise; - exists(key: string): Promise; - delete(key: string): Promise; - head(key: string): Promise; -} diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts deleted file mode 100644 index e524a4d..0000000 --- a/apps/api/src/routes/admin.ts +++ /dev/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(); - -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 = {}; - 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); -}); diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts deleted file mode 100644 index bfdc448..0000000 --- a/apps/api/src/routes/public.ts +++ /dev/null @@ -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(); - -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 }); -}); diff --git a/apps/api/src/routes/uploads.ts b/apps/api/src/routes/uploads.ts deleted file mode 100644 index 5ff9381..0000000 --- a/apps/api/src/routes/uploads.ts +++ /dev/null @@ -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(); - -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 = { - '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): 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 }); -}); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts deleted file mode 100644 index 1e636c3..0000000 --- a/apps/api/src/server.ts +++ /dev/null @@ -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(); - 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); -}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json deleted file mode 100644 index 5552d15..0000000 --- a/apps/api/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "types": ["node"], - "noEmit": true - }, - "include": ["src/**/*"] -} diff --git a/package.json b/package.json index c9f2fa2..56f0fc6 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81ad3b5..3e71433 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,55 +12,6 @@ importers: specifier: ^5.6.3 version: 5.9.3 - apps/api: - dependencies: - '@hono/node-server': - specifier: ^1.13.7 - version: 1.19.14(hono@4.12.23) - '@leetete/shared': - specifier: workspace:* - version: link:../../packages/shared - aws4fetch: - specifier: ^1.0.20 - version: 1.0.20 - drizzle-orm: - specifier: ^0.36.4 - version: 0.36.4(@types/react@18.3.31)(postgres@3.4.9)(react@18.3.1) - hono: - specifier: ^4.6.14 - version: 4.12.23 - nanoid: - specifier: ^5.0.9 - version: 5.1.11 - pdf-lib: - specifier: ^1.17.1 - version: 1.17.1 - postgres: - specifier: ^3.4.5 - version: 3.4.9 - qrcode: - specifier: ^1.5.4 - version: 1.5.4 - tsx: - specifier: ^4.19.2 - version: 4.22.4 - zod: - specifier: ^3.23.8 - version: 3.25.76 - devDependencies: - '@types/node': - specifier: ^22.10.1 - version: 22.19.20 - '@types/qrcode': - specifier: ^1.5.5 - version: 1.5.6 - drizzle-kit: - specifier: ^0.28.1 - version: 0.28.1 - typescript: - specifier: ^5.6.3 - version: 5.9.3 - apps/web: dependencies: '@leetete/shared': @@ -203,23 +154,6 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@drizzle-team/brocli@0.10.2': - resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} - - '@esbuild-kit/core-utils@3.3.2': - resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' - - '@esbuild-kit/esm-loader@2.6.5': - resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' - - '@esbuild/aix-ppc64@0.19.12': - resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -232,18 +166,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.18.20': - resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.19.12': - resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} @@ -256,18 +178,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.18.20': - resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.19.12': - resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} @@ -280,18 +190,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.18.20': - resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.19.12': - resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} @@ -304,18 +202,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.18.20': - resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.19.12': - resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} @@ -328,18 +214,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.18.20': - resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.19.12': - resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} @@ -352,18 +226,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.18.20': - resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.19.12': - resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} @@ -376,18 +238,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.18.20': - resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.19.12': - resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} @@ -400,18 +250,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.18.20': - resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.19.12': - resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} @@ -424,18 +262,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.18.20': - resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.19.12': - resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} @@ -448,18 +274,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.18.20': - resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.19.12': - resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} @@ -472,18 +286,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.18.20': - resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.19.12': - resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} @@ -496,18 +298,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.18.20': - resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.19.12': - resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} @@ -520,18 +310,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.18.20': - resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.19.12': - resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} @@ -544,18 +322,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.18.20': - resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.19.12': - resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} @@ -568,18 +334,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.18.20': - resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.19.12': - resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} @@ -592,18 +346,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.18.20': - resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.19.12': - resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} @@ -622,18 +364,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.18.20': - resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.19.12': - resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} @@ -652,18 +382,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.18.20': - resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.19.12': - resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} @@ -682,18 +400,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.18.20': - resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.19.12': - resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} @@ -706,18 +412,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.18.20': - resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.19.12': - resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} @@ -730,18 +424,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.18.20': - resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.19.12': - resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} @@ -754,18 +436,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.18.20': - resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.19.12': - resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} @@ -778,12 +448,6 @@ packages: cpu: [x64] os: [win32] - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -812,12 +476,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@pdf-lib/standard-fonts@1.0.0': - resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} - - '@pdf-lib/upng@1.0.1': - resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} - '@remix-run/router@1.23.3': resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==} engines: {node: '>=14.0.0'} @@ -971,9 +629,6 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - '@types/qrcode@1.5.6': - resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} - '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -988,14 +643,6 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1013,9 +660,6 @@ packages: peerDependencies: postcss: ^8.1.0 - aws4fetch@1.0.20: - resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} - baseline-browser-mapping@2.10.34: resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==} engines: {node: '>=6.0.0'} @@ -1034,17 +678,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - caniuse-lite@1.0.30001797: resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} @@ -1052,16 +689,6 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - cliui@6.0.0: - resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1086,140 +713,19 @@ packages: supports-color: optional: true - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - dijkstrajs@1.0.3: - resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} - dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - drizzle-kit@0.28.1: - resolution: {integrity: sha512-JimOV+ystXTWMgZkLHYHf2w3oS28hxiH1FR0dkmJLc7GHzdGJoJAQtQS5DRppnabsRZwE2U1F6CuezVBgmsBBQ==} - hasBin: true - - drizzle-orm@0.36.4: - resolution: {integrity: sha512-1OZY3PXD7BR00Gl61UUOFihslDldfH4NFRH2MbP54Yxi0G/PKn4HfO65JYZ7c16DeP3SpM3Aw+VXVG9j6CRSXA==} - peerDependencies: - '@aws-sdk/client-rds-data': '>=3' - '@cloudflare/workers-types': '>=3' - '@electric-sql/pglite': '>=0.2.0' - '@libsql/client': '>=0.10.0' - '@libsql/client-wasm': '>=0.10.0' - '@neondatabase/serverless': '>=0.10.0' - '@op-engineering/op-sqlite': '>=2' - '@opentelemetry/api': ^1.4.1 - '@planetscale/database': '>=1' - '@prisma/client': '*' - '@tidbcloud/serverless': '*' - '@types/better-sqlite3': '*' - '@types/pg': '*' - '@types/react': '>=18' - '@types/sql.js': '*' - '@vercel/postgres': '>=0.8.0' - '@xata.io/client': '*' - better-sqlite3: '>=7' - bun-types: '*' - expo-sqlite: '>=14.0.0' - knex: '*' - kysely: '*' - mysql2: '>=2' - pg: '>=8' - postgres: '>=3' - prisma: '*' - react: '>=18' - sql.js: '>=1' - sqlite3: '>=5' - peerDependenciesMeta: - '@aws-sdk/client-rds-data': - optional: true - '@cloudflare/workers-types': - optional: true - '@electric-sql/pglite': - optional: true - '@libsql/client': - optional: true - '@libsql/client-wasm': - optional: true - '@neondatabase/serverless': - optional: true - '@op-engineering/op-sqlite': - optional: true - '@opentelemetry/api': - optional: true - '@planetscale/database': - optional: true - '@prisma/client': - optional: true - '@tidbcloud/serverless': - optional: true - '@types/better-sqlite3': - optional: true - '@types/pg': - optional: true - '@types/react': - optional: true - '@types/sql.js': - optional: true - '@vercel/postgres': - optional: true - '@xata.io/client': - optional: true - better-sqlite3: - optional: true - bun-types: - optional: true - expo-sqlite: - optional: true - knex: - optional: true - kysely: - optional: true - mysql2: - optional: true - pg: - optional: true - postgres: - optional: true - prisma: - optional: true - react: - optional: true - sql.js: - optional: true - sqlite3: - optional: true - electron-to-chromium@1.5.368: resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==} - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - esbuild-register@3.6.0: - resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} - peerDependencies: - esbuild: '>=0.12 <1' - - esbuild@0.18.20: - resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} - engines: {node: '>=12'} - hasBin: true - - esbuild@0.19.12: - resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -1254,10 +760,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -1273,13 +775,6 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1292,10 +787,6 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hono@4.12.23: - resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} - engines: {node: '>=16.9.0'} - is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -1308,10 +799,6 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1344,10 +831,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1374,11 +857,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@5.1.11: - resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} - engines: {node: ^18 || >=20} - hasBin: true - node-releases@2.0.47: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} @@ -1395,31 +873,9 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} - p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} - - p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} - - p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - - pako@1.0.11: - resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - pdf-lib@1.17.1: - resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1439,10 +895,6 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} - pngjs@5.0.0: - resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} - engines: {node: '>=10.13.0'} - postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -1490,15 +942,6 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} - postgres@3.4.9: - resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} - engines: {node: '>=12'} - - qrcode@1.5.4: - resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} - engines: {node: '>=10.13.0'} - hasBin: true - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -1535,16 +978,6 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - require-main-filename@2.0.0: - resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -1569,28 +1002,10 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - set-blocking@2.0.0: - resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -1623,9 +1038,6 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tsx@4.22.4: resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} engines: {node: '>=18.0.0'} @@ -1679,27 +1091,9 @@ packages: terser: optional: true - which-module@2.0.1: - resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - - y18n@4.0.3: - resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} - yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yargs-parser@18.1.3: - resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} - engines: {node: '>=6'} - - yargs@15.4.1: - resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} - engines: {node: '>=8'} - zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -1819,213 +1213,102 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@drizzle-team/brocli@0.10.2': {} - - '@esbuild-kit/core-utils@3.3.2': - dependencies: - esbuild: 0.18.20 - source-map-support: 0.5.21 - - '@esbuild-kit/esm-loader@2.6.5': - dependencies: - '@esbuild-kit/core-utils': 3.3.2 - get-tsconfig: 4.14.0 - - '@esbuild/aix-ppc64@0.19.12': - optional: true - '@esbuild/aix-ppc64@0.21.5': optional: true '@esbuild/aix-ppc64@0.28.0': optional: true - '@esbuild/android-arm64@0.18.20': - optional: true - - '@esbuild/android-arm64@0.19.12': - optional: true - '@esbuild/android-arm64@0.21.5': optional: true '@esbuild/android-arm64@0.28.0': optional: true - '@esbuild/android-arm@0.18.20': - optional: true - - '@esbuild/android-arm@0.19.12': - optional: true - '@esbuild/android-arm@0.21.5': optional: true '@esbuild/android-arm@0.28.0': optional: true - '@esbuild/android-x64@0.18.20': - optional: true - - '@esbuild/android-x64@0.19.12': - optional: true - '@esbuild/android-x64@0.21.5': optional: true '@esbuild/android-x64@0.28.0': optional: true - '@esbuild/darwin-arm64@0.18.20': - optional: true - - '@esbuild/darwin-arm64@0.19.12': - optional: true - '@esbuild/darwin-arm64@0.21.5': optional: true '@esbuild/darwin-arm64@0.28.0': optional: true - '@esbuild/darwin-x64@0.18.20': - optional: true - - '@esbuild/darwin-x64@0.19.12': - optional: true - '@esbuild/darwin-x64@0.21.5': optional: true '@esbuild/darwin-x64@0.28.0': optional: true - '@esbuild/freebsd-arm64@0.18.20': - optional: true - - '@esbuild/freebsd-arm64@0.19.12': - optional: true - '@esbuild/freebsd-arm64@0.21.5': optional: true '@esbuild/freebsd-arm64@0.28.0': optional: true - '@esbuild/freebsd-x64@0.18.20': - optional: true - - '@esbuild/freebsd-x64@0.19.12': - optional: true - '@esbuild/freebsd-x64@0.21.5': optional: true '@esbuild/freebsd-x64@0.28.0': optional: true - '@esbuild/linux-arm64@0.18.20': - optional: true - - '@esbuild/linux-arm64@0.19.12': - optional: true - '@esbuild/linux-arm64@0.21.5': optional: true '@esbuild/linux-arm64@0.28.0': optional: true - '@esbuild/linux-arm@0.18.20': - optional: true - - '@esbuild/linux-arm@0.19.12': - optional: true - '@esbuild/linux-arm@0.21.5': optional: true '@esbuild/linux-arm@0.28.0': optional: true - '@esbuild/linux-ia32@0.18.20': - optional: true - - '@esbuild/linux-ia32@0.19.12': - optional: true - '@esbuild/linux-ia32@0.21.5': optional: true '@esbuild/linux-ia32@0.28.0': optional: true - '@esbuild/linux-loong64@0.18.20': - optional: true - - '@esbuild/linux-loong64@0.19.12': - optional: true - '@esbuild/linux-loong64@0.21.5': optional: true '@esbuild/linux-loong64@0.28.0': optional: true - '@esbuild/linux-mips64el@0.18.20': - optional: true - - '@esbuild/linux-mips64el@0.19.12': - optional: true - '@esbuild/linux-mips64el@0.21.5': optional: true '@esbuild/linux-mips64el@0.28.0': optional: true - '@esbuild/linux-ppc64@0.18.20': - optional: true - - '@esbuild/linux-ppc64@0.19.12': - optional: true - '@esbuild/linux-ppc64@0.21.5': optional: true '@esbuild/linux-ppc64@0.28.0': optional: true - '@esbuild/linux-riscv64@0.18.20': - optional: true - - '@esbuild/linux-riscv64@0.19.12': - optional: true - '@esbuild/linux-riscv64@0.21.5': optional: true '@esbuild/linux-riscv64@0.28.0': optional: true - '@esbuild/linux-s390x@0.18.20': - optional: true - - '@esbuild/linux-s390x@0.19.12': - optional: true - '@esbuild/linux-s390x@0.21.5': optional: true '@esbuild/linux-s390x@0.28.0': optional: true - '@esbuild/linux-x64@0.18.20': - optional: true - - '@esbuild/linux-x64@0.19.12': - optional: true - '@esbuild/linux-x64@0.21.5': optional: true @@ -2035,12 +1318,6 @@ snapshots: '@esbuild/netbsd-arm64@0.28.0': optional: true - '@esbuild/netbsd-x64@0.18.20': - optional: true - - '@esbuild/netbsd-x64@0.19.12': - optional: true - '@esbuild/netbsd-x64@0.21.5': optional: true @@ -2050,12 +1327,6 @@ snapshots: '@esbuild/openbsd-arm64@0.28.0': optional: true - '@esbuild/openbsd-x64@0.18.20': - optional: true - - '@esbuild/openbsd-x64@0.19.12': - optional: true - '@esbuild/openbsd-x64@0.21.5': optional: true @@ -2065,58 +1336,30 @@ snapshots: '@esbuild/openharmony-arm64@0.28.0': optional: true - '@esbuild/sunos-x64@0.18.20': - optional: true - - '@esbuild/sunos-x64@0.19.12': - optional: true - '@esbuild/sunos-x64@0.21.5': optional: true '@esbuild/sunos-x64@0.28.0': optional: true - '@esbuild/win32-arm64@0.18.20': - optional: true - - '@esbuild/win32-arm64@0.19.12': - optional: true - '@esbuild/win32-arm64@0.21.5': optional: true '@esbuild/win32-arm64@0.28.0': optional: true - '@esbuild/win32-ia32@0.18.20': - optional: true - - '@esbuild/win32-ia32@0.19.12': - optional: true - '@esbuild/win32-ia32@0.21.5': optional: true '@esbuild/win32-ia32@0.28.0': optional: true - '@esbuild/win32-x64@0.18.20': - optional: true - - '@esbuild/win32-x64@0.19.12': - optional: true - '@esbuild/win32-x64@0.21.5': optional: true '@esbuild/win32-x64@0.28.0': optional: true - '@hono/node-server@1.19.14(hono@4.12.23)': - dependencies: - hono: 4.12.23 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2148,14 +1391,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@pdf-lib/standard-fonts@1.0.0': - dependencies: - pako: 1.0.11 - - '@pdf-lib/upng@1.0.1': - dependencies: - pako: 1.0.11 - '@remix-run/router@1.23.3': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -2261,13 +1496,10 @@ snapshots: '@types/node@22.19.20': dependencies: undici-types: 6.21.0 + optional: true '@types/prop-types@15.7.15': {} - '@types/qrcode@1.5.6': - dependencies: - '@types/node': 22.19.20 - '@types/react-dom@18.3.7(@types/react@18.3.31)': dependencies: '@types/react': 18.3.31 @@ -2289,12 +1521,6 @@ snapshots: transitivePeerDependencies: - supports-color - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - any-promise@1.3.0: {} anymatch@3.1.3: @@ -2313,8 +1539,6 @@ snapshots: postcss: 8.5.15 postcss-value-parser: 4.2.0 - aws4fetch@1.0.20: {} - baseline-browser-mapping@2.10.34: {} binary-extensions@2.3.0: {} @@ -2331,12 +1555,8 @@ snapshots: node-releases: 2.0.47 update-browserslist-db: 1.2.3(browserslist@4.28.2) - buffer-from@1.1.2: {} - camelcase-css@2.0.1: {} - camelcase@5.3.1: {} - caniuse-lite@1.0.30001797: {} chokidar@3.6.0: @@ -2351,18 +1571,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - cliui@6.0.0: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - commander@4.1.1: {} convert-source-map@2.0.0: {} @@ -2375,93 +1583,14 @@ snapshots: dependencies: ms: 2.1.3 - decamelize@1.2.0: {} - didyoumean@1.2.2: {} - dijkstrajs@1.0.3: {} - dlv@1.1.3: {} - drizzle-kit@0.28.1: - dependencies: - '@drizzle-team/brocli': 0.10.2 - '@esbuild-kit/esm-loader': 2.6.5 - esbuild: 0.19.12 - esbuild-register: 3.6.0(esbuild@0.19.12) - transitivePeerDependencies: - - supports-color - - drizzle-orm@0.36.4(@types/react@18.3.31)(postgres@3.4.9)(react@18.3.1): - optionalDependencies: - '@types/react': 18.3.31 - postgres: 3.4.9 - react: 18.3.1 - electron-to-chromium@1.5.368: {} - emoji-regex@8.0.0: {} - es-errors@1.3.0: {} - esbuild-register@3.6.0(esbuild@0.19.12): - dependencies: - debug: 4.4.3 - esbuild: 0.19.12 - transitivePeerDependencies: - - supports-color - - esbuild@0.18.20: - optionalDependencies: - '@esbuild/android-arm': 0.18.20 - '@esbuild/android-arm64': 0.18.20 - '@esbuild/android-x64': 0.18.20 - '@esbuild/darwin-arm64': 0.18.20 - '@esbuild/darwin-x64': 0.18.20 - '@esbuild/freebsd-arm64': 0.18.20 - '@esbuild/freebsd-x64': 0.18.20 - '@esbuild/linux-arm': 0.18.20 - '@esbuild/linux-arm64': 0.18.20 - '@esbuild/linux-ia32': 0.18.20 - '@esbuild/linux-loong64': 0.18.20 - '@esbuild/linux-mips64el': 0.18.20 - '@esbuild/linux-ppc64': 0.18.20 - '@esbuild/linux-riscv64': 0.18.20 - '@esbuild/linux-s390x': 0.18.20 - '@esbuild/linux-x64': 0.18.20 - '@esbuild/netbsd-x64': 0.18.20 - '@esbuild/openbsd-x64': 0.18.20 - '@esbuild/sunos-x64': 0.18.20 - '@esbuild/win32-arm64': 0.18.20 - '@esbuild/win32-ia32': 0.18.20 - '@esbuild/win32-x64': 0.18.20 - - esbuild@0.19.12: - optionalDependencies: - '@esbuild/aix-ppc64': 0.19.12 - '@esbuild/android-arm': 0.19.12 - '@esbuild/android-arm64': 0.19.12 - '@esbuild/android-x64': 0.19.12 - '@esbuild/darwin-arm64': 0.19.12 - '@esbuild/darwin-x64': 0.19.12 - '@esbuild/freebsd-arm64': 0.19.12 - '@esbuild/freebsd-x64': 0.19.12 - '@esbuild/linux-arm': 0.19.12 - '@esbuild/linux-arm64': 0.19.12 - '@esbuild/linux-ia32': 0.19.12 - '@esbuild/linux-loong64': 0.19.12 - '@esbuild/linux-mips64el': 0.19.12 - '@esbuild/linux-ppc64': 0.19.12 - '@esbuild/linux-riscv64': 0.19.12 - '@esbuild/linux-s390x': 0.19.12 - '@esbuild/linux-x64': 0.19.12 - '@esbuild/netbsd-x64': 0.19.12 - '@esbuild/openbsd-x64': 0.19.12 - '@esbuild/sunos-x64': 0.19.12 - '@esbuild/win32-arm64': 0.19.12 - '@esbuild/win32-ia32': 0.19.12 - '@esbuild/win32-x64': 0.19.12 - esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -2516,6 +1645,7 @@ snapshots: '@esbuild/win32-arm64': 0.28.0 '@esbuild/win32-ia32': 0.28.0 '@esbuild/win32-x64': 0.28.0 + optional: true escalade@3.2.0: {} @@ -2539,11 +1669,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - find-up@4.1.0: - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - fraction.js@5.3.4: {} fsevents@2.3.3: @@ -2553,12 +1678,6 @@ snapshots: gensync@1.0.0-beta.2: {} - get-caller-file@2.0.5: {} - - get-tsconfig@4.14.0: - dependencies: - resolve-pkg-maps: 1.0.0 - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2571,8 +1690,6 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.12.23: {} - is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 @@ -2583,8 +1700,6 @@ snapshots: is-extglob@2.1.1: {} - is-fullwidth-code-point@3.0.0: {} - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -2603,10 +1718,6 @@ snapshots: lines-and-columns@1.2.4: {} - locate-path@5.0.0: - dependencies: - p-locate: 4.1.0 - loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -2632,8 +1743,6 @@ snapshots: nanoid@3.3.12: {} - nanoid@5.1.11: {} - node-releases@2.0.47: {} normalize-path@3.0.0: {} @@ -2642,29 +1751,8 @@ snapshots: object-hash@3.0.0: {} - p-limit@2.3.0: - dependencies: - p-try: 2.2.0 - - p-locate@4.1.0: - dependencies: - p-limit: 2.3.0 - - p-try@2.2.0: {} - - pako@1.0.11: {} - - path-exists@4.0.0: {} - path-parse@1.0.7: {} - pdf-lib@1.17.1: - dependencies: - '@pdf-lib/standard-fonts': 1.0.0 - '@pdf-lib/upng': 1.0.1 - pako: 1.0.11 - tslib: 1.14.1 - picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -2675,8 +1763,6 @@ snapshots: pirates@4.0.7: {} - pngjs@5.0.0: {} - postcss-import@15.1.0(postcss@8.5.15): dependencies: postcss: 8.5.15 @@ -2715,14 +1801,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postgres@3.4.9: {} - - qrcode@1.5.4: - dependencies: - dijkstrajs: 1.0.3 - pngjs: 5.0.0 - yargs: 15.4.1 - queue-microtask@1.2.3: {} react-dom@18.3.1(react@18.3.1): @@ -2757,12 +1835,6 @@ snapshots: dependencies: picomatch: 2.3.2 - require-directory@2.1.1: {} - - require-main-filename@2.0.0: {} - - resolve-pkg-maps@1.0.0: {} - resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -2813,27 +1885,8 @@ snapshots: semver@6.3.1: {} - set-blocking@2.0.0: {} - source-map-js@1.2.1: {} - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -2893,17 +1946,17 @@ snapshots: ts-interface-checker@0.1.13: {} - tslib@1.14.1: {} - tsx@4.22.4: dependencies: esbuild: 0.28.0 optionalDependencies: fsevents: 2.3.3 + optional: true typescript@5.9.3: {} - undici-types@6.21.0: {} + undici-types@6.21.0: + optional: true update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: @@ -2922,35 +1975,6 @@ snapshots: '@types/node': 22.19.20 fsevents: 2.3.3 - which-module@2.0.1: {} - - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - y18n@4.0.3: {} - yallist@3.1.1: {} - yargs-parser@18.1.3: - dependencies: - camelcase: 5.3.1 - decamelize: 1.2.0 - - yargs@15.4.1: - dependencies: - cliui: 6.0.0 - decamelize: 1.2.0 - find-up: 4.1.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - require-main-filename: 2.0.0 - set-blocking: 2.0.0 - string-width: 4.2.3 - which-module: 2.0.1 - y18n: 4.0.3 - yargs-parser: 18.1.3 - zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ff5faa..cedb9be 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,3 @@ packages: - - "apps/*" - - "packages/*" + - "apps/web" + - "packages/shared"