A remote user is name@host here, in users.username, in paths (/@alice@music.example) and in mentions, through one handle rule in shared/validation.ts that paths, MENTION_RE and Markdown all build on; Markdown undoes GFM's email autolink of a handle. Their name, email and avatar come from their home at each sign-in and by its signed events (federation_outbox, delivered by the federation job with backoff): a rename at home leaves the old handle an alias here, and a password change or a withdrawn grant ends their sessions here. Their password, email, avatar and name can't change here (assertLocalUser), nor can they sign in elsewhere from here. An admin keeps the trusted and blocked instances on /manage (federation:manage): a trusted instance's users sign in from a button on the login page, a blocked one's can't and are signed out at once, and any other's join with an invite from the register page. The /authorize page asks the user once per instance, and /settings lists the instances their account signs in to, each disconnectable; /login?next= brings a signed-out visitor back to /authorize. Emails stay unique among local accounts only, and migration 0024 adds the tables, makes that index partial and reserves authorize and federation as category slugs. remote/gerbeur-instance.ts is the only way to other instances: https and public addresses only, no redirects, no certificate fallback, unless GERBEUR_FEDERATION_ALLOW_PRIVATE lets two instances on one machine talk. 298 backend tests and 702 frontend tests pass, and deno check, deno fmt, deno lint, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked end to end with two built instances on one machine: sign-in through the other's login and consent pages, consent skipped once granted and asked again after disconnecting, a rename and a password change at home reaching the other, and blocking; screenshots of the login, consent, profile, settings and manage pages at 1280 and 390px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
305 lines
17 KiB
Markdown
305 lines
17 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, 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](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 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_FEDERATION_ALLOW_PRIVATE` | `1` lets federated sign-in reach instances on private addresses and over http — for two instances on one machine, never in production | unset |
|
|
| `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](#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
|
|
}
|
|
```
|
|
|
|
### Several instances
|
|
|
|
Each instance is its own deployment, with its own database, uploads, admins and
|
|
domain, but its users can sign in to the others with their account, and the
|
|
other way round. A user from `music.example` is `@alice@music.example` on the
|
|
instances they sign in to. Their password, email, avatar and name stay on their
|
|
home instance, and the others follow its changes.
|
|
|
|
Every instance is ready for it: nothing to configure, as long as
|
|
`GERBEUR_PUBLIC_URL` is the instance's https origin (no path) and the instances
|
|
can reach each other. An admin decides who comes in, on `/manage`:
|
|
|
|
- users of a **trusted** instance sign in directly, from a button on the login
|
|
page;
|
|
- users of a **blocked** instance can't, and those already in are signed out;
|
|
- users of **any other** instance join with an invite, from the register page,
|
|
and sign in without one afterwards.
|
|
|
|
To try it on one machine, run two instances on different ports with
|
|
`GERBEUR_FEDERATION_ALLOW_PRIVATE=1`, which lets them reach each other over
|
|
plain http on localhost. Never set it in production.
|
|
|
|
## 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](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.
|