You are reading the documentation of Guestroll v0.4.0. The latest version is v0.5.0. Latest docs · What changed · Changelog
Skip to content

Architecture ​

One Next.js app, one process, one SQLite file, one folder of photos.

                    ┌────────────────────── Guestroll (Node.js) ───────────────────────┐
 phone ──QR code──▶ │ /e/[slug]          guest page (server component + client app)   │
                    │ /api/e/[slug]/*    join, unlock, name, upload, own photos       │
                    │ /api/media/[id]    the only way a photo leaves the server       │
 laptop ──────────▶ │ /admin/*           admin pages                                  │
                    │ /api/admin/*       events, moderation, QR, archives, stats      │
                    │                                                                  │
                    │ lib/policy.ts ◀── every access decision, pure and tested        │
                    │ lib/*        ──▶ SQLite (Drizzle)   ──▶ DATA_DIR/guestroll.db   │
                    │              ──▶ files               ──▶ DATA_DIR/uploads/       │
                    └──────────────────────────────────────────────────────────────────┘

Request flow: a guest takes a photo ​

  1. The QR code opens /e/<slug>. The server component looks the event up; if the browser hasn't joined it yet, it renders AutoJoin (no code) or UnlockScreen (code). Joining writes the event id into the encrypted gr_guest cookie.
  2. The guest page renders the capture component chosen for the event (components/capture/registry.ts) inside CaptureHost.
  3. The capture component calls onFiles(files). useUploadQueue compresses each file in the browser, then uploads it with progress, one at a time, retrying on network errors and waiting while offline.
  4. POST /api/e/<slug>/photos checks the origin, the join, the upload state and quota (lib/policy.ts), takes a per-guest lock, reads the body with a size cap, sniffs and re-encodes the image (lib/images/process.ts) into three variants, writes them under DATA_DIR/uploads/<event>/ and inserts the row.
  5. The photo comes back as a PhotoDto: ids and dimensions, never a path. Thumbnails load from /api/media/<id>?v=thumb, which re-checks access.

Code layout ​

src/
├── app/                      routes (Next.js App Router)
│   ├── e/[slug]/             guest page and its social card
│   ├── admin/                login, (panel)/ pages, poster, live wall
│   └── api/                  route handlers, thin: validate, ask policy, call lib
├── components/
│   ├── ui/                   design-system primitives (Button, Field, SegmentedControl…)
│   ├── capture/              swappable capture components and their contract
│   ├── upload/               CaptureHost, upload queue, exposure counter, prints tray
│   ├── guest/                guest page pieces
│   └── admin/                admin pieces (event-form/, moderation/, qr/…)
└── lib/
    ├── policy.ts             all access rules, no I/O
    ├── validation.ts         request body parsing, no I/O
    ├── db/                   Drizzle schema and client (migrations in /drizzle)
    ├── auth/                 admin password, sessions, page guard
    ├── security/             rate limiter, request helpers, ids
    ├── images/               upload and logo processing (sharp)
    ├── storage/              file paths and I/O
    ├── qr/                   style parsing, the SVG renderer, server export
    ├── i18n/                 messages (en, fr, it, es), locale resolution
    └── events.ts, photos.ts, stats.ts, archive.ts   data access

Two rules keep this maintainable:

  • Access rules live in lib/policy.ts only. Routes ask it; they never re-derive a rule inline. The SQL filter in listPhotos mirrors canViewPhoto and is the one exception, commented as such.
  • Browser code never imports server modules. Anything shared with client components lives in modules without Node imports (lib/constants.ts, lib/qr/render.ts, lib/i18n/messages.ts). Server-only modules import server-only so a mistake fails the build.

Updater ​

updater/ is a separate, dependency-free Node program shipped in the same image (docker-compose profile updater). docker.mjs speaks the Docker Engine API over the socket, update.mjs replaces a container with the same one on a new image (keeping its own settings, not its old image's defaults) and rolls back when the health check fails, server.mjs takes the app's token-authenticated requests. The app side is src/lib/updates.ts (GitHub checks, update history) and src/lib/updater.ts (the client).

Data ​

TableHolds
eventsSettings, access-code hash, QR style (JSON), logo version
photosOne row per photo: event, guest id, optional name, size, status, download count
activityAppend-only log behind every stat (view, upload, download, failed code…)
accountsAdmin accounts: username, password hash, role (owner / organizer), session version
account_eventsWhich events each organizer manages
settingsKey/value: SMTP (password sealed), alert choices, instance email
share_linksGallery links: token, password hash, expiry, downloads switch, views, revocation
guest_contactsOpt-in guest emails, one per guest and event, deleted with the event
messagesGuestbook: text, voice note container and size, status. The audio file is <event-id>/messages/<id>.<ext> in storage
webhook_deliveriesWebhook queue: the notice as JSON, formatted for the service when sent, retried like emails
outboxEmail queue: rendered message, state, attempts, next retry, dedupe key

Guests have no table: a guest is a random id in their cookie. Their name is copied onto each photo they send.

Files: <event-id>/<photo-id>.{original.jpg,display.webp,thumb.webp} and <event-id>/logo.png, in DATA_DIR/uploads or in an S3 bucket. Both drivers implement StorageDriver (src/lib/storage/driver.ts) and pass the same contract test (tests/integration/storage.test.ts); the S3 one signs plain fetch calls (aws4fetch) instead of pulling in the AWS SDK.

Choices worth knowing ​

  • Heavy media work happens in the browser. Frames and filters are drawn on the guest's phone before upload, and the slideshow video is encoded by the admin's browser (WebCodecs, muxed by mediabunny). The image never ships ffmpeg, its size or its CVEs.

  • Email goes through a queue in SQLite, drained by an in-process scheduler (src/lib/scheduler.ts, started from instrumentation.ts). An SMTP outage never fails a request, and each email has a dedupe key so a retried job can't send it twice. Ending an event is claimed atomically on ended_at.

  • SQLite in WAL mode handles a wedding's burst of uploads comfortably and makes backups a file copy. The flip side: one process, no horizontal scaling. That's also why the rate limiter is in memory.

  • Re-encoding every upload costs CPU but removes GPS metadata and any payload hidden in the file.

  • One upload at a time per guest, enforced on both sides, keeps quotas exact and phones responsive.

  • The QR renderer is pure and shared by the live preview and the server export, so the downloaded file matches the preview.

Released under the MIT License.