Files
gerbeur/README.md
khannurien 708d113013 v4: a /community page shows the site's week and month, from the same figures as the digests, and every recurring server task is a job of one scheduler — the digests' top dumps and community section (who joined, the new public playlists, the chat and the totals) existed only in emails. community-service.ts now gathers them for a period from listTopDumps, listNewUsers and listNewPublicPlaylists, each in its own service and returning the API's Dump, PublicUser and Playlist; the digest builds its sections from the same call, its email-only DumpRow and DUMP_COLS replaced by one Dump mapper that networkOf uses too, and links its community section to the page. The page, /community for the last week and /community/monthly for the month, signed in only and linked from the avatar menu, reads GET /api/community?period=, which serves figures kept in memory and recomputed every GERBEUR_COMMUNITY_REFRESH_MINUTES (15 by default). It shows the four figures, the top dumps and the new playlists as live cards, and the new members as UserCards, the follow tab's user row made shared. Its words are the emails' own: the messages both use are renamed from email.digest.* to digest.*, as digest.weekly.off was. api/lib/scheduler.ts's startJobs runs the digests, the community refresh, the WebSocket ping, the typing expiry and the rate-limit sweep, which main.ts starts, so none of them runs on import any more. PERIOD_MS moves to shared/settings.ts. community is reserved as a category slug, and migration 0022 renames a category that had it, with renameReservedCategories moved out of 0021 into reserved-slugs.ts. A group's dump or playlist cards now fill its column. 262 backend tests and 678 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in screenshots in Smooth light at 1280 and 390px, Smooth dark with mocked playlists, Brutalist dark at 390px and Kanagawa dark at 1280px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:42:01 +00:00

16 KiB

gerbeur

A small invite-only social platform for sharing links and files. Users can post URLs and media (YouTube, SoundCloud, Bandcamp, images, audio, video, …), vote, comment, follow each other, build playlists, and search content. A real-time WebSocket layer handles live presence, vote counts, and notifications, and weekly and monthly email digests sum up what happened. The UI is localized (English and French) and ships multiple visual themes.

Stack

Layer Technology
Runtime Deno 2.x
API Oak (HTTP + WebSocket)
Database SQLite via node:sqlite
Frontend React 19 + Vite 8

Development

Prerequisites

  • Deno 2.x

Setup

cp .env.example .env
# Edit .env: set GERBEUR_JWT_SECRET to the output of: openssl rand -hex 32

Run

deno task dev

This starts both the API server (port 8000, with file watching) and the Vite dev server (port 3000) concurrently.

Open http://localhost:3000. On first run a default admin / admin account is created — change the password immediately.

Environment variables

See .env.example for the full list with descriptions. Key variables:

Variable Description Default
GERBEUR_JWT_SECRET JWT signing secret — required, generate with openssl rand -hex 32 —
GERBEUR_PUBLIC_URL Public-facing URL of the server (no trailing slash) — used for CORS, WebSocket origin, email links, OG URLs http://localhost:GERBEUR_PORT
GERBEUR_PORT Internal port Oak listens on 8000
GERBEUR_LISTEN_HOST Network interface Oak binds to; use 127.0.0.1 to restrict to loopback 0.0.0.0
GERBEUR_FRONTEND_URL Frontend base URL for email links and CORS; auto-added to allowed origins — the only variable needed when the frontend runs on a separate host GERBEUR_PUBLIC_URL
GERBEUR_ALLOWED_ORIGINS Comma-separated extra origins for CORS/WebSocket; PUBLIC_URL and FRONTEND_URL are always included — typically only needed in dev for the Vite server "" (empty)
GERBEUR_SITE_NAME Site name used in the browser tab, app header, OG meta tags, and emails gerbeur
GERBEUR_SMTPS_URL SMTPS connection URL for outgoing email (smtps://user:pass@host:465); unset sends no email unset
GERBEUR_FROM_EMAIL Sender of every email — required when GERBEUR_SMTPS_URL is set, or the server won't start unset
GERBEUR_EMAIL_LOCALE Language of every email: en or fr en
GERBEUR_DEFAULT_STYLE The instance's style (smooth, brutalist, geocities, nyt, kanagawa, melange): the site's until one is picked, and the welcome and reset emails' smooth
GERBEUR_WELCOME_EMAIL_BODY Markdown body for the account-creation welcome email; supports {{username}} and {{site_name}} built-in template
GERBEUR_COMMUNITY_REFRESH_MINUTES How often, in minutes, the /community page's figures are recomputed 15
VITE_API_PROTOCOL API protocol baked into the frontend bundle (see Production) http
VITE_API_HOSTNAME API hostname baked into the frontend bundle localhost
VITE_API_PORT API port baked into the frontend bundle 8000

Production

The standard deployment runs API and frontend in a single container. The API server (Oak) serves the compiled frontend as static files, so both share the same origin — no VITE_API_* build args needed. Set GERBEUR_PUBLIC_URL to the externally-visible URL; it is automatically allowed for HTTP/WebSocket requests.

docker build -t gerbeur .

docker build -t gerbeur .

docker run -d \
  -p 8000:8000 \
  -v gerbeur-db:/app/api/sql \
  -v gerbeur-uploads:/app/api/uploads \
  -e GERBEUR_JWT_SECRET=$(openssl rand -hex 32) \
  -e GERBEUR_PUBLIC_URL=https://example.com \
  -e GERBEUR_SITE_NAME=mysite \
  --name gerbeur \
  gerbeur

The two volumes are required for persistence:

  • gerbeur-db — SQLite database (api/sql/gerbeur.db), initialized automatically on first run
  • gerbeur-uploads — user-uploaded files (api/uploads/)

Separate API and frontend (optional)

If you need to run the API on a different host than the frontend, pass the API location as build args so it gets baked into the frontend bundle. In that setup, add the frontend origin to GERBEUR_ALLOWED_ORIGINS so cross-origin HTTP/WebSocket requests are accepted:

docker build \
  --build-arg VITE_API_PROTOCOL=https \
  --build-arg VITE_API_HOSTNAME=api.example.com \
  --build-arg VITE_API_PORT=443 \
  -t gerbeur .

Reverse proxy

Put a reverse proxy (nginx, Caddy, …) in front of the container to handle TLS. Forward everything to port 8000. Example Caddyfile:

example.com {
    reverse_proxy localhost:8000
}

Project structure

shared/            # Imported by both halves: API and socket types, limits, permissions
  test/            # Tests of shared/ (Deno): test/<module>.test.ts
locales/           # Lingui catalogues (en, fr): the interface's and the emails' messages
api/
  main.ts          # Entry point — Oak application, middleware, routes
  config.ts        # Environment variables
  middleware/      # Oak middleware: errors, auth, OG tags
  routes/          # One router per API resource: HTTP + WebSocket, no SQL
  services/        # Business logic and every SQL query
  remote/          # Everything that fetches from other sites
  mail/            # One template per email, and the layout they share
    providers/     # Link metadata providers (YouTube, SoundCloud, Bandcamp, …)
  model/           # Per table: row type, row guard, row ↔ API mappers
  validators/      # Per domain: request body guards
  lib/             # Helpers with no SQL and no outbound fetch: JWT, slugs, files, …
  db/
    connection.ts  # The database handle and transaction(); creates a new database
    startup.ts     # Housekeeping run when the database opens
    schema.sql     # Database schema
    migrate.ts     # Migration runner
    migrations/    # Versioned schema migrations
  test/            # Backend tests (Deno): test/<layer>/<module>.test.ts
  sql/
    gerbeur.db     # SQLite database (not committed)
  uploads/         # User-uploaded files — avatars, dumps, thumbnails, … (not committed)
src/               # React frontend (Vite)
  config/          # API base URL, default feed tab, player mode, instance settings
  pages/           # One component per route, nothing else
  components/      # React components only (.tsx)
  contexts/        # FooContext.ts + FooProvider.tsx pairs
  hooks/           # Every hook (useFoo.ts)
  utils/           # Every non-React module: pure helpers, storage, API glue
  test/            # Frontend tests (Vitest): test/<layer>/<domain>/<Module>.test.tsx
  themes/          # Per-theme CSS files
  i18n.ts          # Lingui runtime setup
public/            # Static assets (favicon, manifest, service worker)

URLs

Every page and API path follows one of two rules, which API.md states, along with every page, the slug and username rules and every endpoint. Pages are a hierarchy under @name for users and plain words for categories and app pages (/@bob/dumps/never-gonna, /music/hot), built and parsed by shared/paths.ts. API resources are /api/<plural>/<key>, the caller's own under /api/me. A route change updates its router's module doc and API.md together.

Whatever crosses the wire is defined once, in shared/: the API envelope (shared/api.ts), each domain's types and request bodies (shared/dump.ts, shared/user.ts, …), the socket protocol (shared/ws.ts), the validation limits and the role permissions. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. shared/ imports nothing from either half.

The backend is split by layer, one kind of module per folder, and each file is named after its domain: the dumps are routes/dumps.ts, services/dump-service.ts, model/dump.ts and validators/dump.ts. Routes parse and answer, services hold the logic and the SQL, remote/ is the only place that fetches from other sites, and mail/ renders each email that services/email-service.ts sends. Tests mirror the source one file per module, so the tests of services/chat-service.ts are in test/services/chat-service.test.ts.

Comments

A comment says what the code can't. If deleting it would lose nothing, it is the wrong comment: on an export, rewrite it; anywhere else, delete it.

Every module and every export has a JSDoc (/** */). Each gives at least one of:

  1. Context: who uses it and where (a page and its route, a caller, a WebSocket message).
  2. Usage: what a caller must know to use it correctly, such as a precondition, an invariant, what it leaves to the caller, or a side effect. Add an example only when the call isn't obvious.
  3. Place in the chain: where its input comes from and where its output goes.

A doc opens with the role, then the mechanism, then the details. Its first sentence says what part of the app the thing serves or what problem it solves, so a reader who has never seen it knows why it exists. How it does that (what feeds it, what reads it, how it's split) comes next, and the details last. Each sentence makes sense given only the ones before it. Not "Declares the three contexts WSProvider fills from the WebSocket. They are split so that a feed doesn't re-render with every chat message", which leaves the reason for last, but "Live updates from the server reach the pages through three contexts, split so that a feed doesn't re-render with every chat message. WSProvider fills them from its WebSocket."

Once the role is said, a type's doc is phrased from the value's side, naming the producer and the consumer: "Returned to Y by X when Z", "Passed to Y by X". It is never "What X returns to Y" or "What X passes to Y".

Don't pack a doc into a label followed by a colon and a list ("X: one thing, plus some other thing"). Write sentences with a subject and a verb.

When the obvious comment is useless, write the useful one: the context is always there. A field (of an interface, or a parameter) gets a doc only when it adds something its parent's doc doesn't. Context that concerns the whole type goes on the type, not on one of its fields. The same goes for private functions and locals. None of these count as a doc:

  • a restatement of the name or type (/** The dump. */, "Whether it is loading.", "The props of X.", "Renders the X.");
  • a description of what the body, the JSX or the SQL plainly does;
  • a list that goes stale (every caller of a widely used component): name the kind of caller instead.

When a comment is there, it is as short as it can be, often one sentence, and it is written in full sentences. Labels and fragments ("Upvotes on dumps.") don't count. A module doc is a few lines at most. It opens with the module's role in the app, then says how it plays it, who calls it and what it hands back, and it doesn't repeat what its exports' docs say. @throws stays on whatever throws to its caller, with the statuses of an APIException and when each happens. A router's module doc lists its endpoints (method, path, who may call it), since the code states them nowhere in one place. A summary never ends in a colon: the reference shows it alone.

// comments go wherever the code isn't obvious. They explain why, not what: a workaround, an ordering constraint, a surprising limitation, a security reason, an event handler that must not bubble.

Every claim is checked against the code (grep the callers). A wrong comment is worse than none.

Reference

In the frontend, every exported function states its return type, so the reference can show it: a component returns React.JSX.Element (or | null), a hook returning several things returns a documented Use<Name>Result interface. A component that takes props takes an exported <Name>Props interface. React's types are used through the global React namespace rather than a default import of react, which deno doc can't follow.

deno task doc builds the HTML reference into docs/ from every module of api/, shared/ and src/ but the tests. Its index page is the module doc of mod.ts at the root, which introduces the repository's three parts and their layers in prose; deno doc builds the list of every module itself, grouped by path. The docs workflow publishes it on every push to main that touches them. deno task doc:lint checks the rules the linter can see, and that no summary ends in a colon, through doc-lint.ts. Two kinds of diagnostic are let through. A field without a doc passes, since a field gets one only when it adds to its type's. deno doc doesn't document npm packages, so it takes a type imported from one (react-hook-form's FieldValues, say) for a private one, and those references pass too.

Each of the frontend's components/, contexts/, hooks/ and utils/ is split into the same domain folders — auth, category, chat, comment, dump, editor, feed, form, layout, player, playlist, profile, social, theme, ws — plus shared/ for what belongs to no single domain. So the chat lives in components/chat/, contexts/chat/, hooks/chat/ and utils/chat/, and its tests in test/<same path>. Files are imported directly (no index.ts barrels), and everything uses named exports.