Files
gerbeur/api/config.ts
khannurien c0d03e9aaa v4: users sign in to one gerbeur instance with their account on another — topic instances, each with its own admins, domain and data, had no way to share accounts. Sign-in is federated over a subset of OpenID Connect, the authorization code flow with PKCE: each instance is an identity provider for its own users and a client for the others', and signs what it tells them with an Ed25519 key kept in federation_keys and published at /.well-known/jwks.json, beside its metadata at /.well-known/openid-configuration. Only sign-in is federated: an account from another instance is a local users row linked to (issuer, subject) in user_identities, with no password (password_hash ''), so sessions, roles, permissions, the socket and every foreign key work as before.
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>
2026-10-09 14:23:00 +00:00

235 lines
9.0 KiB
TypeScript

/**
* Lets a deployment configure the server through the `GERBEUR_*` environment
* variables, which it reads once at import, and holds the fixed limits the
* backend applies. The deno tasks load `.env` with `--env-file`, so changing a
* setting needs a restart.
*
* @module
*/
import { BASE_STYLE, isStyleName, type StyleName } from "../shared/theme.ts";
import { isLocale, type Locale, SOURCE_LOCALE } from "../shared/locale.ts";
/** The port the server listens on (`GERBEUR_PORT`, default 8000). */
export const PORT: number = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
/**
* The `smtps://` URL of the server `email-service.ts` sends mail through
* (`GERBEUR_SMTPS_URL`). When it is empty, no email is sent: sign-up sends no
* welcome, a password reset request does nothing, and there are no digests.
*/
export const SMTPS_URL: string = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() ||
"";
/**
* Whether the instance sends email, which is whether {@link SMTPS_URL} is
* set. `email-service.ts` opens its transport by it, and index.html tells the
* client, whose settings show the digest switches disabled without it.
*/
export const EMAIL_ENABLED: boolean = SMTPS_URL.length > 0;
/**
* The `From` address of every email (`GERBEUR_FROM_EMAIL`), as an address or
* as `Name <address>`. `email-service.ts` refuses to load without it when
* {@link SMTPS_URL} is set.
*/
export const FROM_EMAIL: string = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() ||
"";
/**
* The Markdown of the welcome email sent on registration
* (`GERBEUR_WELCOME_EMAIL_BODY`), or null for the built-in one in the
* {@link EMAIL_LOCALE}. `api/mail/welcome.ts` replaces `{{site_name}}` and
* `{{username}}` in it.
*/
export const WELCOME_EMAIL_BODY: string | null =
Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() || null;
/**
* The language of every email (`GERBEUR_EMAIL_LOCALE`). The interface picks
* each visitor's language in their browser, which the server never learns, so
* the emails take the instance's. A value that isn't a locale means the
* source one, English.
*/
export const EMAIL_LOCALE: Locale = (() => {
const locale = Deno.env.get("GERBEUR_EMAIL_LOCALE")?.trim();
return isLocale(locale) ? locale : SOURCE_LOCALE;
})();
/**
* The instance's style (`GERBEUR_DEFAULT_STYLE`). The site shows it until a
* visitor or their account picks one, through index.html, and the emails that
* aren't a user's own (the welcome and the password reset) are drawn in it. A
* value that isn't a style means {@link BASE_STYLE}.
*/
export const DEFAULT_STYLE: StyleName = (() => {
const style = Deno.env.get("GERBEUR_DEFAULT_STYLE")?.trim();
return isStyleName(style) ? style : BASE_STYLE;
})();
/**
* The key lib/jwt.ts signs and verifies tokens with (`GERBEUR_JWT_SECRET`).
* The server refuses to start without it.
*/
export const JWT_SECRET: string = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() ||
"";
/**
* The interface the server binds to (`GERBEUR_LISTEN_HOST`). The default,
* 0.0.0.0, lets Docker forward the port; 127.0.0.1 keeps it to loopback.
*/
export const LISTEN_HOST: string = Deno.env.get("GERBEUR_LISTEN_HOST") ||
"0.0.0.0";
/**
* The URL visitors reach the server at (`GERBEUR_PUBLIC_URL`), without a
* trailing slash. Behind a reverse proxy it must be the outside URL: it is an
* allowed origin, it tells links to this site's own pages from other links,
* and it prefixes the image URLs in their previews.
*/
export const PUBLIC_URL: string =
Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") ||
`http://localhost:${PORT}`;
/**
* Points password reset links and the Open Graph tags' page URLs at the
* frontend when it isn't served from {@link PUBLIC_URL}, as with a separate
* Vite server, and allows it as an origin. It is `GERBEUR_FRONTEND_URL`,
* without a trailing slash.
*/
export const FRONTEND_URL: string =
Deno.env.get("GERBEUR_FRONTEND_URL")?.trim().replace(/\/$/, "") ||
PUBLIC_URL;
/**
* The SQLite database file (`GERBEUR_DB_PATH`). api/test/setup.ts points it at
* a throwaway file before anything opens the database.
*/
export const DB_PATH: string = Deno.env.get("GERBEUR_DB_PATH") ||
"api/sql/gerbeur.db";
/**
* The directory uploads are stored under (`GERBEUR_UPLOADS_DIR`). Each kind has
* its subdirectory below, created by the first upload, and each file is named
* after the id of what it belongs to.
*/
export const UPLOADS_DIR: string = Deno.env.get("GERBEUR_UPLOADS_DIR") ||
"api/uploads";
/** Holds the file of each file dump. */
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
/** Holds each user's avatar. */
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
/** Holds each playlist's cover. */
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
/**
* Holds the images pasted into chat messages, comments, dump notes and
* descriptions. The startup tasks purge those nothing links to.
*/
export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
/**
* Holds the thumbnails a poster uploads (`<id>-custom`) and those the
* thumbnails route generates from file dumps.
*/
export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`;
/**
* Caps the size of an image upload, in bytes, for avatars, covers, thumbnails
* and attachments. lib/upload.ts states it in megabytes in its error.
*/
export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024;
/**
* The MIME type prefixes a file dump may have. dump-service also accepts the
* exact types of {@link DUMP_ALLOWED_MIME_TYPES}.
*/
export const DUMP_ALLOWED_MIME_PREFIXES: string[] = [
"text/",
"image/",
"video/",
"audio/",
];
/** The documents and archives a file dump may be, beyond the prefixes. */
export const DUMP_ALLOWED_MIME_TYPES: Set<string> = new Set([
"application/pdf",
"application/json",
"application/zip",
"application/x-zip-compressed",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/msword",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
]);
/** The page size when a request names none. */
export const PAGINATION_DEFAULT_LIMIT = 20;
/** The largest page size a request may ask for. */
export const PAGINATION_MAX_LIMIT = 100;
/** How long an attachment nothing links to is kept before startup purges it. */
export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1;
/**
* How long an invite stays usable. invite-service refuses an older one, and
* the startup tasks purge it.
*/
export const UNUSED_INVITES_RETENTION_DAYS = 7;
/**
* How often, in minutes, the `/community` page's figures are recomputed
* (`GERBEUR_COMMUNITY_REFRESH_MINUTES`), 15 by default.
*/
export const COMMUNITY_REFRESH_MINUTES: number =
Number(Deno.env.get("GERBEUR_COMMUNITY_REFRESH_MINUTES")) || 15;
/**
* The site's name (`GERBEUR_SITE_NAME`). It is filled into index.html, the OG
* tags, the icons' manifest and the emails.
*/
export const OG_SITE_NAME: string = Deno.env.get("GERBEUR_SITE_NAME") ||
"gerbeur";
/**
* The emoji that serves as the site's logo (`GERBEUR_SITE_EMOJI`). The icons
* script draws the favicons from it, and index.html carries it to the app's
* header, so changing it needs a restart but no rebuild.
*/
export const SITE_EMOJI: string = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚";
/**
* How the frontend plays Bandcamp. `"embed"` uses Bandcamp's iframe, which
* can't autoplay. `"native"` resolves the page's streams through
* `GET /api/bandcamp/tracks` and plays them in the app's player, which
* autoplays and queues an album's tracks.
*/
export type BandcampPlayer = "embed" | "native";
/**
* How the frontend plays Bandcamp (`GERBEUR_BANDCAMP_PLAYER`), passed to it in
* index.html. Any value but `"native"` means `"embed"`, so a typo falls back to
* the player that always works.
*/
export const BANDCAMP_PLAYER: BandcampPlayer =
Deno.env.get("GERBEUR_BANDCAMP_PLAYER")?.trim() === "native"
? "native"
: "embed";
/**
* The background of the generated Apple touch icon, the manifest's colors, and
* the accent of previews of this site's own pages. index.html's `theme-color`
* tags don't follow it.
*/
export const THEME_COLOR = "#111827";
const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? "";
/**
* The origins that CORS in main.ts and the WebSocket route accept. They are
* {@link PUBLIC_URL}, {@link FRONTEND_URL} and the comma-separated
* `GERBEUR_ALLOWED_ORIGINS`.
*/
export const ALLOWED_ORIGINS: string[] = Array.from(
new Set([
PUBLIC_URL,
FRONTEND_URL,
...rawOrigins.split(",").map((o) => o.trim()).filter(Boolean),
]),
);
/**
* Lets federated sign-in reach instances on private addresses, `localhost`
* among them, and over plain http (`GERBEUR_FEDERATION_ALLOW_PRIVATE=1`), so
* that two instances on one machine can trust each other in development.
* Off, `remote/gerbeur-instance.ts` reaches only public https instances, since
* an identity is only as safe as the connection it came over.
*/
export const FEDERATION_ALLOW_PRIVATE: boolean =
Deno.env.get("GERBEUR_FEDERATION_ALLOW_PRIVATE")?.trim() === "1";