Files
gerbeur/README.md
khannurien 90fc281e90 v4: every image the API serves comes scaled to the size it is shown at — a first visit to the feed loaded 4.8 MB of images out of 5.1 MB in all: an avatar shown at 32 pixels was the upload itself, a 1.4 MB GIF among them, and so were a cover, a custom thumbnail, an attachment and a proxied image, only an image dump having a preview, 960 pixels wide. Every route that answers an image now takes ?w= and sends a WebP copy at most that wide, of the widths shared/image.ts lists, 128, 320, 640 and 960, a width between two taking the one above, and never wider than the image; without it the upload goes out as before, a dump's thumbnail alone being scaled to the widest by default, the custom ones included. serveImage (lib/upload.ts) is where they all end, and the image proxy scales what it fetched the same way, keeping each width in its cache. lib/ffmpeg.ts makes the copies as it made the previews, which they replace: on first request, under uploads/scaled/ at the upload's own path, each given its upload's date and made again when the two differ, so a new avatar or cover needs nobody to delete the old ones. WebP keeps a PNG's transparency and a GIF's animation. A JPEG is turned by its EXIF orientation here, ffmpeg's own rotation of images depending on its version. An animated WebP, which ffmpeg can't read, and an image it fails on go out as they are, a failure being remembered until the upload changes. ?f=compat asks for the copy as a JPEG, or a PNG for what may be transparent: the digest's emails take their thumbnails that way, at 640 pixels, not every mail client reading WebP. On the client, imageProps (utils/shared/urls.ts) gives an image its srcset of those copies and the width it is laid out at, which config/imageSizes.ts states for each place from App.css: Avatar, Thumbnail, which scales an API image and leaves another site's as that site sends it, and the playlists' covers use it. The images of notes and comments still load as uploaded. The copies go with the dump, the custom thumbnail or the attachment they were made from, and generatedMediaSweepJob, a minute after the start then daily, takes those whose upload is gone, the previews under their former name and the temporary files a generation left unfinished.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:01:36 +00:00

332 lines
19 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
In the devcontainer, the dev instances listed in
[`dev/instances.ts`](dev/instances.ts) start with the container, each a service
of [`.devcontainer/compose.yaml`](.devcontainer/compose.yaml): `gerbeur` at
[http://localhost:3000](http://localhost:3000), `musique` at
[http://localhost:3001](http://localhost:3001). They run side by side so that
signing in from one instance to another can be tried on one machine, and from
the workspace's terminal:
```sh
docker compose -p gerbeur-dev logs -f musique # follow an instance
docker compose -p gerbeur-dev restart gerbeur # restart one
```
Outside the devcontainer, or with those services stopped, `deno task dev` starts
them all, and `deno task dev gerbeur` only those named.
Each instance has an API server, restarted on change, and a Vite server, and its
output is prefixed with its name. They follow one rule
([`dev/start.ts`](dev/start.ts)): instance `<name>` keeps its database in
`api/sql/<name>.db` and its uploads in `api/uploads/<name>/`, reads `.env`, and
takes what sets it apart (ports, site name, emoji, style) from its entry. A new
instance is an entry in `dev/instances.ts` and a service in `compose.yaml`.
On first run each instance creates a default `admin` / `admin` account — change
the password immediately. To let one instance's users sign in to another, trust
it on the other's `/manage`.
### 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_INCOMING` | `0` keeps users of other instances from signing in here, and signs out those already in | `1` |
| `GERBEUR_FEDERATION_OUTGOING` | `0` keeps this instance's users from signing in to other instances, and signs them out there | `1` |
| `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. Users and admins may name an instance by the address of
its API or of its pages: when the frontend runs on a host of its own
(`GERBEUR_FRONTEND_URL`), that host must pass `/.well-known/` on to the API, as
the Vite dev server does. A handle carries the API's host. Either way can be
turned off: `GERBEUR_FEDERATION_INCOMING=0` takes no users of other instances,
and `GERBEUR_FEDERATION_OUTGOING=0` lets no user sign in elsewhere. 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.
`deno task dev` runs several instances on one machine (see [Run](#run)), with
`GERBEUR_FEDERATION_ALLOW_PRIVATE=1`, which lets them reach each other over
plain http on localhost. Never set it in production. The first, `gerbeur`, takes
no users of the others, whose users sign in to each other.
## Project structure
```
dev/ # The dev instances (instances.ts) and their launcher (start.ts), for `deno task dev`
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); <name>.db per dev instance
uploads/ # User-uploaded files — avatars, dumps, thumbnails, … and scaled/, their copies made on request (not committed); <name>/ per dev instance
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.