From 856511777c03b53f16a9cf3a09d0e70c52211ff3 Mon Sep 17 00:00:00 2001 From: khannurien Date: Wed, 8 Apr 2026 19:43:19 +0000 Subject: [PATCH] chore: smaller docker image, simplification pass on environment variables, fix direct navigation without vite --- .env.example | 54 ++++++++++++++++++---------------- Dockerfile | 5 ++-- README.md | 37 +++++++++++------------ api/config.ts | 30 ++++++++++--------- api/lib/static.ts | 2 ++ api/main.ts | 4 +-- api/services/providers/self.ts | 8 ++--- scripts/lingui-compile.ts | 8 ++++- scripts/lingui-extract.ts | 7 ++++- 9 files changed, 85 insertions(+), 70 deletions(-) diff --git a/.env.example b/.env.example index 8bc17d8..a4bf622 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,36 @@ # ── API server ──────────────────────────────────────────────────────────────── -# Protocol the API server listens on (http or https) -GERBEUR_PROTOCOL=http +# Public-facing URL of the server. Used for CORS/WebSocket origin checks, +# email links, and OG image URLs. Behind a reverse proxy, set this to the +# externally-visible URL with no trailing slash (e.g. https://example.com). +# Defaults to http://localhost:GERBEUR_PORT for local development. +# GERBEUR_PUBLIC_URL=https://example.com -# Public hostname for the API (used in generated URLs, e.g. OG image tags) -GERBEUR_HOSTNAME=localhost +# Site name used in OG meta tags +GERBEUR_SITE_NAME=gerbeur + +# Port the API server listens on (the container's internal port) +GERBEUR_PORT=8000 # Network interface Oak binds to. Default: 0.0.0.0 (all interfaces, required for Docker). # Set to 127.0.0.1 to restrict to loopback only. GERBEUR_LISTEN_HOST=0.0.0.0 -# Port the API server listens on -GERBEUR_PORT=8000 +# Base URL of the frontend, used in email links (e.g. password reset) and +# automatically added to the CORS/WebSocket allowed-origins list. +# Defaults to GERBEUR_PUBLIC_URL — correct for single-container deployments. +# In prod with a separate frontend host, set this to the frontend origin; that +# is the only variable you need for that case (no GERBEUR_ALLOWED_ORIGINS entry required). +# Example: https://app.example.com +# GERBEUR_FRONTEND_URL= -# Comma-separated list of extra origins allowed to reach the API/WS cross-origin. -# The server's own BASE_URL is always allowed automatically. -# In dev: add Vite's dev server URL (check actual host/port in Vite output). -# In prod with a separate frontend host: add that public frontend origin here. +# Comma-separated list of *extra* origins allowed to reach the API/WS. +# GERBEUR_PUBLIC_URL and GERBEUR_FRONTEND_URL are always included automatically. +# Typically only needed in dev to whitelist the Vite dev server. +# Defaults to empty (no extra origins). # Example: http://localhost:3000,http://127.0.0.1:3000 GERBEUR_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 -# Base URL of the frontend, used in email links (e.g. password reset). -# Defaults to the API's own BASE_URL — correct when the API serves the frontend -# (standard single-container deployment). Override when running the frontend on -# a separate host or port (e.g. Vite dev server or a CDN origin). -# Example: http://localhost:3000 -# GERBEUR_FRONTEND_URL= - # Secret key used to sign JWTs. Generate with: openssl rand -hex 32 GERBEUR_JWT_SECRET= @@ -44,13 +48,13 @@ GERBEUR_FROM_EMAIL= # Use \n for line breaks in single-line .env values, or use a quoted multiline block. GERBEUR_WELCOME_EMAIL_BODY="# Welcome to {{site_name}}!\n\nHi **{{username}}**,\n\nYour account has been created successfully. Welcome aboard!" -# Site name used in OG meta tags -GERBEUR_SITE_NAME=gerbeur - # ── Frontend (Vite) ─────────────────────────────────────────────────────────── # These must be prefixed with VITE_ to be exposed to the client bundle. -# They tell the frontend where the API server is reachable from the browser. - -VITE_API_PROTOCOL=http -VITE_API_HOSTNAME=localhost -VITE_API_PORT=8000 +# Only needed when the API runs on a different host/port than the frontend. +# In standard dev (API on :8000, Vite on :3000) these are not required — +# the frontend automatically uses http://localhost:8000 in dev mode. +# In prod (single container) they are not used — the frontend uses relative URLs. +# Only set these when deploying the API and frontend on separate hosts. +# VITE_API_PROTOCOL=https +# VITE_API_HOSTNAME=api.example.com +# VITE_API_PORT=443 diff --git a/Dockerfile b/Dockerfile index 7332a5d..8cc9120 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,9 +25,9 @@ ENV VITE_API_PROTOCOL=$VITE_API_PROTOCOL \ RUN deno task build # ── Stage 2: runtime ────────────────────────────────────────────────────────── -FROM denoland/deno:2.7.11 +FROM denoland/deno:alpine-2.7.11 -RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* +RUN apk add --no-cache ffmpeg WORKDIR /app @@ -36,7 +36,6 @@ RUN deno install COPY api/ ./api/ COPY --from=builder /app/dist/ ./dist/ -COPY public/ ./public/ # Persistent data: database and uploaded/generated files must be mounted as volumes. VOLUME ["/app/api/sql", "/app/api/uploads"] diff --git a/README.md b/README.md index 9dad5e6..c92f86f 100644 --- a/README.md +++ b/README.md @@ -38,28 +38,27 @@ Open [http://localhost:3000](http://localhost:3000). On first run a default `adm 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_PROTOCOL` | Protocol the API server listens on (`http` or `https`) | `http` | -| `GERBEUR_HOSTNAME` | Public hostname used in generated URLs (e.g. OG image tags) | `localhost` | -| `GERBEUR_PORT` | API server port | `8000` | -| `GERBEUR_LISTEN_HOST` | Network interface Oak binds to; use `127.0.0.1` to restrict to loopback | `0.0.0.0` | -| `GERBEUR_ALLOWED_ORIGINS` | Comma-separated list of extra allowed frontend origins; the server's own `BASE_URL` is always allowed | `http://localhost:3000` | -| `GERBEUR_FRONTEND_URL` | Frontend base URL used in email links (e.g. password reset); defaults to the API's own `BASE_URL` | `BASE_URL` | -| `GERBEUR_SITE_NAME` | Site name used in 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` | +| 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 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. The server's own `BASE_URL` is always allowed for HTTP/WebSocket requests automatically. +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 . @@ -69,9 +68,7 @@ docker run -d \ -v gerbeur-db:/app/api/sql \ -v gerbeur-uploads:/app/api/uploads \ -e GERBEUR_JWT_SECRET=$(openssl rand -hex 32) \ - -e GERBEUR_PROTOCOL=https \ - -e GERBEUR_HOSTNAME=example.com \ - -e GERBEUR_PORT=8000 \ + -e GERBEUR_PUBLIC_URL=https://example.com \ --name gerbeur \ gerbeur ``` diff --git a/api/config.ts b/api/config.ts index b620f7f..945d530 100644 --- a/api/config.ts +++ b/api/config.ts @@ -1,5 +1,3 @@ -export const PROTOCOL = Deno.env.get("GERBEUR_PROTOCOL") || "http"; -export const HOSTNAME = Deno.env.get("GERBEUR_HOSTNAME") || "localhost"; export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000; export const SMTPS_URL = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || ""; export const FROM_EMAIL = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() || ""; @@ -18,12 +16,18 @@ export const JWT_SECRET = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || ""; // Defaults to 0.0.0.0 so Docker port-forwarding works out of the box. // Set to 127.0.0.1 to restrict to loopback only. export const LISTEN_HOST = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0"; -export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`; +// Public-facing URL of the server — used for CORS, WebSocket origin checks, +// email links, and OG image URLs. Behind a reverse proxy, set this to the +// externally-visible URL (e.g. https://example.com). No trailing slash. +// Defaults to http://localhost:PORT for local development. +export const PUBLIC_URL = + Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") || + `http://localhost:${PORT}`; // In single-container deployments the API serves the frontend, so FRONTEND_URL -// equals BASE_URL. Override with GERBEUR_FRONTEND_URL when running the frontend -// on a separate host/port (e.g. Vite dev server or a dedicated CDN origin). +// equals PUBLIC_URL. Override with GERBEUR_FRONTEND_URL when running the +// frontend on a separate host/port (e.g. Vite dev server or a CDN origin). export const FRONTEND_URL = Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() || - BASE_URL; + PUBLIC_URL; export const DB_PATH = "api/sql/gerbeur.db"; // Upload/files @@ -87,15 +91,13 @@ export const VALIDATION = { // SEO/OG export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur"; -const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? - "http://localhost:3000"; +const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? ""; export const ALLOWED_ORIGINS: string[] = Array.from( new Set([ - BASE_URL, - ...( - rawOrigins - ? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean) - : [] - ), + PUBLIC_URL, + // FRONTEND_URL is auto-included so the separate-frontend case only needs + // GERBEUR_FRONTEND_URL — no need to repeat it in GERBEUR_ALLOWED_ORIGINS. + FRONTEND_URL, + ...rawOrigins.split(",").map((o) => o.trim()).filter(Boolean), ]), ); diff --git a/api/lib/static.ts b/api/lib/static.ts index 7746e6d..6a3dab0 100644 --- a/api/lib/static.ts +++ b/api/lib/static.ts @@ -14,6 +14,8 @@ export function routeStaticFilesFrom(staticPaths: string[]) { } } + // SPA fallback: serve index.html so client-side routes work on direct navigation + await send(context, "/index.html", { root: staticPaths[0] }); await next(); }; } diff --git a/api/main.ts b/api/main.ts index 5cb0190..773d3dd 100644 --- a/api/main.ts +++ b/api/main.ts @@ -16,7 +16,7 @@ import notificationsRouter from "./routes/notifications.ts"; import invitesRouter from "./routes/invites.ts"; import searchRouter from "./routes/search.ts"; -import { ALLOWED_ORIGINS, BASE_URL, LISTEN_HOST, PORT } from "./config.ts"; +import { ALLOWED_ORIGINS, LISTEN_HOST, PORT, PUBLIC_URL } from "./config.ts"; import { errorMiddleware } from "./middleware/error.ts"; import { ogMiddleware } from "./middleware/og.ts"; import { routeStaticFilesFrom } from "./lib/static.ts"; @@ -100,7 +100,7 @@ app.use(routeStaticFilesFrom([ app.addEventListener( "listen", - () => console.log(`Server listening on ${BASE_URL}`), + () => console.log(`Server listening on ${PUBLIC_URL}`), ); app.addEventListener( diff --git a/api/services/providers/self.ts b/api/services/providers/self.ts index b5c5d5f..102779f 100644 --- a/api/services/providers/self.ts +++ b/api/services/providers/self.ts @@ -3,7 +3,7 @@ import type { RichContentProvider } from "../rich-content-service.ts"; import { getDump } from "../dump-service.ts"; import { getUserByUsername } from "../user-service.ts"; import { getPlaylistById } from "../playlist-service.ts"; -import { BASE_URL } from "../../config.ts"; +import { PUBLIC_URL } from "../../config.ts"; const DUMP_RE = /^\/dumps\/([^/]+)$/; const USER_RE = /^\/users\/([^/]+)$/; @@ -31,7 +31,7 @@ export const selfProvider: RichContentProvider = { const dump = getDump(dumpMatch[1]); let thumbnailUrl: string | undefined; if (dump.kind === "file" && dump.fileMime?.startsWith("image/")) { - thumbnailUrl = `${BASE_URL}/api/files/${dump.id}`; + thumbnailUrl = `${PUBLIC_URL}/api/files/${dump.id}`; } else if (dump.richContent?.thumbnailUrl) { thumbnailUrl = dump.richContent.thumbnailUrl; } @@ -49,7 +49,7 @@ export const selfProvider: RichContentProvider = { if (userMatch) { const user = getUserByUsername(userMatch[1]); const thumbnailUrl = user.avatarMime - ? `${BASE_URL}/api/avatars/${user.id}` + ? `${PUBLIC_URL}/api/avatars/${user.id}` : undefined; return Promise.resolve({ type: "generic", @@ -65,7 +65,7 @@ export const selfProvider: RichContentProvider = { if (playlistMatch) { const playlist = getPlaylistById(playlistMatch[1]); const thumbnailUrl = playlist.imageMime - ? `${BASE_URL}/api/playlists/${playlist.id}/image` + ? `${PUBLIC_URL}/api/playlists/${playlist.id}/image` : undefined; return Promise.resolve({ type: "generic", diff --git a/scripts/lingui-compile.ts b/scripts/lingui-compile.ts index 56912bf..f99f6f9 100644 --- a/scripts/lingui-compile.ts +++ b/scripts/lingui-compile.ts @@ -2,4 +2,10 @@ import { command as compile } from "../node_modules/@lingui/cli/dist/lingui-comp import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs"; const config = getConfig({ cwd: Deno.cwd() }); -await compile(config, { watch: false, namespace: undefined, typescript: false, allowEmpty: true, workersOptions: { poolSize: 0 } }); +await compile(config, { + watch: false, + namespace: undefined, + typescript: false, + allowEmpty: true, + workersOptions: { poolSize: 0 }, +}); diff --git a/scripts/lingui-extract.ts b/scripts/lingui-extract.ts index 55e3f8c..066dabe 100644 --- a/scripts/lingui-extract.ts +++ b/scripts/lingui-extract.ts @@ -2,4 +2,9 @@ import extract from "../node_modules/@lingui/cli/dist/lingui-extract.js"; import { getConfig } from "../node_modules/@lingui/conf/dist/index.mjs"; const config = getConfig({ cwd: Deno.cwd() }); -await extract(config, { verbose: false, watch: false, files: [], workersOptions: { poolSize: 0 } }); +await extract(config, { + verbose: false, + watch: false, + files: [], + workersOptions: { poolSize: 0 }, +});