201 lines
12 KiB
Markdown
201 lines
12 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()
|
|
startup.ts # Housekeeping run when the database opens
|
|
schema.sql # Database schema
|
|
init.ts # First-run database initialisation
|
|
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`.
|
|
|
|
All three parts — `api/`, `shared/` and `src/` — are documented for `deno doc`,
|
|
the same way in every file. Each module opens with a `/** … @module */` block
|
|
saying what it is for. Every export has a JSDoc comment, and so does every field
|
|
of an exported interface; a comment that describes a declaration is always `/**
|
|
*/`, and `//` is only for notes on the code inside. Functions open with a verb
|
|
("Returns…", "Checks…", "Renders…" for a component), everything else with a noun
|
|
phrase. Parameters are named in the prose, in backticks. `{@link}` points at
|
|
symbols the file already has in scope. A function that can throw to its caller
|
|
says so with `@throws`: for an `APIException`, the statuses and when. A router's
|
|
module doc lists its endpoints, one line each: method and path, who may call it,
|
|
what it does; a page's JSDoc names its route and who may see it. The first
|
|
paragraph of a doc is its summary, which the reference shows alone in lists, so
|
|
it is a sentence that stands on its own: it never ends in a colon introducing
|
|
what follows.
|
|
|
|
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, each
|
|
prop documented. 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`.
|
|
`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
|
|
alone are let through.
|
|
|
|
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.
|