282 lines
16 KiB
Markdown
282 lines
16 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_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
|
|
}
|
|
```
|
|
|
|
## 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.
|