Files
gerbeur/README.md
khannurien 14e3ab125b v3: fix the privacy and security findings of the comment pass — private dumps' broadcasts, mentions and attachments reached people who can't see them, and a few smaller holes. A dump's every write now goes through ws-service's broadcastDumpChange(before, after), which decides the audience: a private dump's creation, edits and deletion reach its owner's sockets alone (its deletion id went to everyone before), going private is dump_deleted for everyone else and dump_updated for the owner (whose other tabs dropped it), going public dump_created for everyone else. Mentions in a private dump, a comment on one, or a private playlist's description notify no one until it goes public (mentions are deduplicated per context, so going public notifies them once), and a moderator's edit of a comment, chat message or playlist mentions in the author's name. A link preview of a private playlist is refused, as one of a private dump already was: the preview is stored in the linking dump, which others may see. Attachments record the type of the resource that links them (migration 0018 backfills it); one linked by a private dump or playlist, or a comment on a private dump, is served only with its owner's media token, which Markdown now adds to attachment URLs, and one whose resource is gone answers 404 and is purged at startup. The Bandcamp provider claimed bandcamp.com.evil.example (the pattern wasn't anchored) and now reuses bandcamp-tralbum's anchored one; the YouTube provider took any site's ?list= for a YouTube playlist and missed m., music., youtube-nocookie and /live/ links, and now shares canonical-url's rules. Sign-in does the same scrypt work for an unknown username as for a wrong password, so timing no longer tells which exist. Usernames and emails are unique regardless of case (migration 0017, which refuses to run over existing clashes and names them): "Bob" and "bob" could both sign up and a mention picked one, emails had no constraint, and a reset request in another case silently sent nothing. The Open Graph tags escape the site name and build their URLs on PUBLIC_URL and FRONTEND_URL instead of the request's origin, which a proxy may rewrite.
Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email.

Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:19:07 +00:00

255 lines
13 KiB
Markdown

# 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. The UI is
localized (English and French) and ships multiple visual themes.
## Stack
| Layer | Technology |
| -------- | ------------------------------------------------- |
| Runtime | [Deno](https://deno.com) 2.x |
| API | [Oak](https://jsr.io/@oak/oak) (HTTP + WebSocket) |
| Database | SQLite via `node:sqlite` |
| Frontend | React 19 + Vite 8 |
## Development
### Prerequisites
- Deno 2.x
### Setup
```sh
cp .env.example .env
# Edit .env: set GERBEUR_JWT_SECRET to the output of: openssl rand -hex 32
```
### Run
```sh
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](http://localhost:3000). On first run a default
`admin` / `admin` account is created — change the password immediately.
### Environment variables
See [`.env.example`](.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 |
| `GERBEUR_FROM_EMAIL` | Sender address for outgoing emails — required when `GERBEUR_SMTPS_URL` is set | unset |
| `GERBEUR_WELCOME_EMAIL_BODY` | Markdown body for the account-creation welcome email; supports `{{username}}` and `{{site_name}}` | built-in template |
| `VITE_API_PROTOCOL` | API protocol baked into the frontend bundle (see [Production](#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
### Docker (recommended)
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.
```sh
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:
```sh
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
api/
main.ts # Entry point — Oak application, middleware, routes
config.ts # Environment variables
middleware/ # Oak middleware: errors, auth, OG tags
routes/ # One router per URL prefix: HTTP + WebSocket, no SQL
services/ # Business logic and every SQL query
remote/ # Everything that fetches from other sites
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, feed tabs, player mode
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
locales/ # Lingui message catalogues (en, fr)
themes/ # Per-theme CSS files
i18n.ts # Lingui runtime setup
public/ # Static assets (favicon, manifest, service worker)
```
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. 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 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 what the module does,
then says 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 (and the generated `src/locales/`).
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.