v0.3.0 Architecture - achedon12/guestroll GitHub Wiki
Note: Documentation of Guestroll v0.3.0. The latest release is v0.6.1: this page in v0.6.1 Β· what changed Β· other versions
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/ β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- The QR code opens
/e/<slug>. The server component looks the event up; if the browser hasn't joined it yet, it rendersAutoJoin(no code) orUnlockScreen(code). Joining writes the event id into the encryptedgr_guestcookie. - The guest page renders the capture component chosen for the event
(
components/capture/registry.ts) insideCaptureHost. - The capture component calls
onFiles(files).useUploadQueuecompresses each file in the browser, then uploads it with progress, one at a time, retrying on network errors and waiting while offline. -
POST /api/e/<slug>/photoschecks 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 underDATA_DIR/uploads/<event>/and inserts the row. - 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.
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.tsonly. Routes ask it; they never re-derive a rule inline. The SQL filter inlistPhotosmirrorscanViewPhotoand 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 importserver-onlyso a mistake fails the build.
| Table | Holds |
|---|---|
events |
Settings, access-code hash, QR style (JSON), logo version |
photos |
One row per photo: event, guest id, optional name, size, status, download count |
activity |
Append-only log behind every stat (view, upload, download, failed codeβ¦) |
accounts |
Admin accounts: username, password hash, role (owner / organizer), session version |
account_events |
Which events each organizer manages |
settings |
Key/value: SMTP (password sealed), alert choices, instance email |
share_links |
Gallery links: token, password hash, expiry, downloads switch, views, revocation |
guest_contacts |
Opt-in guest emails, one per guest and event, deleted with the event |
messages |
Guestbook: text, voice note container and size, status. The audio file is <event-id>/messages/<id>.<ext> in storage |
webhook_deliveries |
Webhook queue: the notice as JSON, formatted for the service when sent, retried like emails |
outbox |
Email 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.
-
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 frominstrumentation.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 onended_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.