From 6c87bf2f7a5c23815aad83c77e4478701c6d3a10 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 13 Jul 2026 23:11:59 +0200 Subject: [PATCH] feat(help): serve the in-app wiki from disk instead of fetching GitHub The /help pages fetched their markdown from raw.githubusercontent.com at runtime, so a self-hosted install was served docs from main rather than the version it was actually running, and help was unusable without network access. The wiki/ directory was in the repo the whole time; the bundled-snapshot fallback the code reached for was gitignored and never populated by any build step, so it was dead code. Read wiki/ straight from disk instead. server/{src,dist}/services both sit three levels under the repo root, so a single __dirname anchor resolves in dev, a built source install, vitest and Docker with no copy or build step. GitHub is kept strictly as a fallback for when the directory cannot be resolved, decided once at load by probing for _Sidebar.md: a page missing from a present wiki is a genuine 404, since falling back per-file would reintroduce the version skew this removes. Ship wiki/ in the image: .dockerignore excluded it outright, so the COPY alone would have produced an image with no wiki, and the GitHub fallback would have masked that at runtime. Add a real path-containment check on assets now that the path becomes a filesystem read rather than a URL, and document TREK_WIKI_DIR as an off-by-default escape hatch across the deployment configs. --- .dockerignore | 1 - .gitignore | 2 - Dockerfile | 4 + README.md | 1 + charts/trek/templates/configmap.yaml | 3 + charts/trek/values.yaml | 6 + docker-compose.yml | 1 + server/.env.example | 1 + server/src/nest/help/help.controller.ts | 13 +- server/src/nest/help/help.module.ts | 2 +- server/src/services/wikiService.ts | 93 +++++++---- server/tests/fixtures/wiki/Home.md | 3 + server/tests/fixtures/wiki/Sample.md | 9 ++ server/tests/fixtures/wiki/_Sidebar.md | 9 ++ server/tests/fixtures/wiki/assets/notes.txt | 1 + server/tests/fixtures/wiki/assets/pic.png | Bin 0 -> 68 bytes server/tests/integration/help.test.ts | 93 +++++++++++ .../tests/unit/services/wikiService.test.ts | 151 ++++++++++++++++++ unraid-template.xml | 1 + wiki/Environment-Variables.md | 1 + wiki/Install-Helm.md | 1 + wiki/Install-Unraid.md | 2 +- 22 files changed, 359 insertions(+), 39 deletions(-) create mode 100644 server/tests/fixtures/wiki/Home.md create mode 100644 server/tests/fixtures/wiki/Sample.md create mode 100644 server/tests/fixtures/wiki/_Sidebar.md create mode 100644 server/tests/fixtures/wiki/assets/notes.txt create mode 100644 server/tests/fixtures/wiki/assets/pic.png create mode 100644 server/tests/integration/help.test.ts create mode 100644 server/tests/unit/services/wikiService.test.ts diff --git a/.dockerignore b/.dockerignore index 5b1aea136..25672b03f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -32,6 +32,5 @@ server/tests/ server/vitest.config.ts **/*.test.ts **/*.spec.ts -wiki/ scripts/ charts/ diff --git a/.gitignore b/.gitignore index fef272b87..a83917a19 100644 --- a/.gitignore +++ b/.gitignore @@ -66,5 +66,3 @@ test-data .run .full-review -# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/) -server/assets/wiki/ diff --git a/Dockerfile b/Dockerfile index c05da667a..ba5a9c4c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,6 +71,10 @@ COPY --from=server-builder /app/server/dist ./server/dist # only emits dist, so these must be copied explicitly or the features silently # degrade to empty in the image. COPY --from=server-builder /app/server/assets ./server/assets +# The in-app help pages (/help) read this straight from disk at runtime, so the +# docs always match the version running. Without it, wikiService falls back to +# fetching the GitHub wiki, which tracks main and needs network access. +COPY wiki ./wiki # tsconfig-paths/register reads this at runtime to resolve MCP SDK paths. COPY server/tsconfig.json ./server/ # Encryption-key rotation is run on demand via tsx (a prod dep) straight from the diff --git a/README.md b/README.md index 70bc79377..0b0920e14 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,7 @@ Caddy handles TLS and WebSockets automatically. | `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto | | `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | | `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | +| `TREK_WIKI_DIR` | Where the in-app Help pages (`/help`) read their content from. TREK ships its wiki and serves it from disk, so Help always matches the version you are running — you should not need to set this. Point it at your own directory to serve custom docs. If the path does not exist, Help falls back to fetching the public GitHub wiki (needs outbound network, and tracks the latest release). | bundled `wiki/` | | `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | | `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` | diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml index 5cecc9d86..d6d6e8c86 100644 --- a/charts/trek/templates/configmap.yaml +++ b/charts/trek/templates/configmap.yaml @@ -13,6 +13,9 @@ data: {{- if .Values.env.LOG_LEVEL }} LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }} {{- end }} + {{- if .Values.env.TREK_WIKI_DIR }} + TREK_WIKI_DIR: {{ .Values.env.TREK_WIKI_DIR | quote }} + {{- end }} {{- if .Values.env.ALLOWED_ORIGINS }} ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }} {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 150e89851..4af6bd9ba 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -24,6 +24,12 @@ env: # Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin). # LOG_LEVEL: "info" # "info" = concise user actions, "debug" = verbose details. + # TREK_WIKI_DIR: "/app/wiki" + # Where the in-app Help pages (/help) read their content from. Leave unset: the + # image ships the wiki at /app/wiki and finds it automatically, so Help matches + # the version you are running. Only set this to serve your own docs from a mounted + # volume. If the path does not exist, Help falls back to fetching the public GitHub + # wiki, which needs egress and tracks the latest release rather than your version. # DEFAULT_LANGUAGE: "en" # Default language on the login page for users with no saved preference. # Browser/OS language is auto-detected first; this is the fallback when no match is found. diff --git a/docker-compose.yml b/docker-compose.yml index 2ba3340d5..e59ccbc2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs). - TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) - LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details +# - TREK_WIKI_DIR=/app/wiki # Where the in-app Help pages (/help) read their content from. Leave unset: the image ships the wiki at /app/wiki and finds it automatically. Only set this to serve your own docs, mounted into the container. If the path does not exist, Help falls back to fetching the public GitHub wiki (needs outbound network, and tracks the latest release rather than your version). # - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar # - SESSION_DURATION=30d # How long users stay logged in (trek_session JWT + cookie maxAge). Accepts: 1h | 12h | 7d | 30d | 90d. Default: 24h # - SESSION_DURATION_REMEMBER=30d # Session length when "Remember me" is ticked at login: longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Default: 30d diff --git a/server/.env.example b/server/.env.example index 47ad25f1d..7be9f4cb3 100644 --- a/server/.env.example +++ b/server/.env.example @@ -13,6 +13,7 @@ TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin) # Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar # Note: browser/OS language is detected automatically first; this is the fallback when no match is found. LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details +# TREK_WIKI_DIR=../wiki # Where the in-app Help pages (/help) read their content from. Leave unset: the repo's wiki/ directory is found automatically, so Help always matches the version you are running. Only set this to point at your own docs. If the path does not exist, Help falls back to fetching the public GitHub wiki (needs outbound network). ALLOWED_ORIGINS=https://trek.example.com # Comma-separated origins for CORS and email links FORCE_HTTPS=false # Optional. When true: HTTPS redirect + HSTS + CSP upgrade-insecure-requests + secure cookies. Only behind a TLS proxy. diff --git a/server/src/nest/help/help.controller.ts b/server/src/nest/help/help.controller.ts index 0ea16f94e..ace688be3 100644 --- a/server/src/nest/help/help.controller.ts +++ b/server/src/nest/help/help.controller.ts @@ -4,16 +4,17 @@ import { getWikiIndex, getWikiPage, getWikiAsset, + isLocalWiki, WikiNotFound, type WikiPage, type WikiNavSection, } from '../../services/wikiService'; /** - * /api/help — embedded TREK wiki. Content is public docs (the wiki is public on - * GitHub), so these endpoints are unauthenticated; that also lets tags load - * the proxied assets without sending credentials. All upstream calls go to the - * fixed TREK wiki path and are cached server-side. + * /api/help — embedded TREK wiki, served from the `wiki/` directory that ships + * with the app (see wikiService for the GitHub fallback). Content is public docs, + * so these endpoints are unauthenticated; that also lets tags load the + * proxied assets without sending credentials. */ @Controller('api/help') export class HelpController { @@ -41,7 +42,9 @@ export class HelpController { try { const { buf, type } = await getWikiAsset(assetPath); res.setHeader('Content-Type', type); - res.setHeader('Cache-Control', 'public, max-age=3600'); + // Bundled assets are pinned to this build, so they can be cached hard; the + // GitHub fallback refreshes hourly, so match that TTL instead. + res.setHeader('Cache-Control', isLocalWiki() ? 'public, max-age=86400' : 'public, max-age=3600'); res.end(buf); } catch { res.status(404).end(); diff --git a/server/src/nest/help/help.module.ts b/server/src/nest/help/help.module.ts index 13910eb07..7a9ce9271 100644 --- a/server/src/nest/help/help.module.ts +++ b/server/src/nest/help/help.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { HelpController } from './help.controller'; -/** /api/help — embedded GitHub wiki (fetched + cached in wikiService). */ +/** /api/help — the bundled `wiki/` directory, read via wikiService. */ @Module({ controllers: [HelpController], }) diff --git a/server/src/services/wikiService.ts b/server/src/services/wikiService.ts index 501633100..09b4f53ce 100644 --- a/server/src/services/wikiService.ts +++ b/server/src/services/wikiService.ts @@ -1,21 +1,41 @@ import path from 'path'; -import { promises as fs } from 'fs'; +import { existsSync, promises as fs } from 'fs'; /** - * In-app Help/Wiki content, sourced from the TREK GitHub wiki (kept in the repo - * under `wiki/**` and mirrored to the GitHub wiki on push to main). The server - * fetches the markdown from GitHub and caches it, so the embedded help stays in - * sync with wiki edits without a redeploy — and the client never talks to GitHub - * directly (images are proxied too). A bundled snapshot under server/assets/wiki - * is the cold-start / offline fallback. + * In-app Help/Wiki content, sourced from the `wiki/**` directory that ships with + * the app — the same content that CI mirrors to the public GitHub wiki. Reading + * from disk keeps the help pages pinned to the running version (a v1.2 install + * shows v1.2 docs, not whatever `main` says) and works offline. + * + * If that directory can't be resolved — an unusual layout, an image built without + * it — we fall back to fetching from the GitHub wiki over the network and caching + * hourly, so help degrades instead of disappearing. The client never talks to + * GitHub directly either way; images are proxied through /api/help/asset. */ const REPO = 'mauriceboe/TREK'; const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main/wiki`; -const TTL_MS = 60 * 60 * 1000; // refresh from GitHub at most hourly -const SNAPSHOT_DIR = path.join(__dirname, '..', '..', 'assets', 'wiki'); +const TTL_MS = 60 * 60 * 1000; // remote fallback only: refresh from GitHub at most hourly const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +/** + * `server/{src,dist}/services` both sit three levels under the repo root, so this + * one anchor resolves in dev, a built source install, vitest, and Docker (where + * the Dockerfile copies `wiki/` to /app/wiki). `process.cwd()` would not — Docker + * runs the server from /app/server. + */ +const WIKI_DIR = process.env.TREK_WIKI_DIR ?? path.join(__dirname, '..', '..', '..', 'wiki'); + +/** + * Probe for the sidebar rather than the bare directory: an empty or half-copied + * `wiki/` should fall back to GitHub, not serve an empty table of contents. + */ +const useLocalWiki = existsSync(path.join(WIKI_DIR, '_Sidebar.md')); + +if (!useLocalWiki) { + console.warn(`[help] wiki not found at ${WIKI_DIR} — falling back to the GitHub wiki (help may not match this version)`); +} + export class WikiNotFound extends Error { status = 404; } @@ -29,16 +49,26 @@ const assetCache = new Map(); const fresh = (ts: number): boolean => Date.now() - ts < TTL_MS; -async function readSnapshot(file: string): Promise { - try { - return await fs.readFile(path.join(SNAPSHOT_DIR, file), 'utf8'); - } catch { - return null; - } +/** Resolve a path inside the wiki dir, refusing anything that escapes it. */ +function resolveInWiki(rel: string): string { + const root = path.resolve(WIKI_DIR); + const full = path.resolve(root, rel); + if (full !== root && !full.startsWith(root + path.sep)) throw new WikiNotFound(rel); + return full; } -/** Fetch a wiki text file with cache → stale-cache → bundled-snapshot fallback. */ +/** Fetch a wiki text file: local disk, or GitHub with cache → stale-cache fallback. */ async function fetchText(file: string): Promise { + if (useLocalWiki) { + try { + return await fs.readFile(resolveInWiki(file), 'utf8'); + } catch (err) { + if (err instanceof WikiNotFound) throw err; + if ((err as NodeJS.ErrnoException).code === 'ENOENT') throw new WikiNotFound(file); + throw err; + } + } + const cached = textCache.get(file); if (cached && fresh(cached.ts)) return cached.data; try { @@ -53,14 +83,9 @@ async function fetchText(file: string): Promise { if (res.status === 404) throw new WikiNotFound(file); } catch (err) { if (err instanceof WikiNotFound) throw err; - // network/parse error — fall through to stale cache or snapshot + // network/parse error — fall through to stale cache } if (cached) return cached.data; // serve stale rather than fail - const snap = await readSnapshot(file); - if (snap != null) { - textCache.set(file, { data: snap, ts: Date.now() }); - return snap; - } throw new WikiNotFound(file); } @@ -128,6 +153,9 @@ export interface WikiPage { markdown: string; } +/** True when help is served from the bundled wiki rather than fetched from GitHub. */ +export const isLocalWiki = (): boolean => useLocalWiki; + export async function getWikiIndex(): Promise<{ sections: WikiNavSection[] }> { const md = await fetchText('_Sidebar.md'); return { sections: parseSidebar(md) }; @@ -148,7 +176,7 @@ const ASSET_TYPES: Record = { '.svg': 'image/svg+xml', }; -/** Proxy a wiki image so the browser never calls GitHub directly. */ +/** Read a wiki image from disk, or proxy it from GitHub so the browser never calls it directly. */ export async function getWikiAsset(assetPath: string): Promise<{ buf: Buffer; type: string }> { // Defend against traversal; allow nested image folders. if (assetPath.includes('..') || !/^[A-Za-z0-9/._-]+$/.test(assetPath)) throw new WikiNotFound(assetPath); @@ -156,6 +184,18 @@ export async function getWikiAsset(assetPath: string): Promise<{ buf: Buffer; ty const type = ASSET_TYPES[ext]; if (!type) throw new WikiNotFound(assetPath); + if (useLocalWiki) { + try { + // resolveInWiki re-checks containment: the regex above is a filter, this is the boundary. + const buf = await fs.readFile(resolveInWiki(assetPath)); + return { buf, type }; + } catch (err) { + if (err instanceof WikiNotFound) throw err; + if ((err as NodeJS.ErrnoException).code === 'ENOENT') throw new WikiNotFound(assetPath); + throw err; + } + } + const cached = assetCache.get(assetPath); if (cached && fresh(cached.ts)) return { buf: cached.buf, type: cached.type }; try { @@ -171,10 +211,5 @@ export async function getWikiAsset(assetPath: string): Promise<{ buf: Buffer; ty /* fall through */ } if (cached) return { buf: cached.buf, type: cached.type }; - try { - const buf = await fs.readFile(path.join(SNAPSHOT_DIR, assetPath)); - return { buf, type }; - } catch { - throw new WikiNotFound(assetPath); - } + throw new WikiNotFound(assetPath); } diff --git a/server/tests/fixtures/wiki/Home.md b/server/tests/fixtures/wiki/Home.md new file mode 100644 index 000000000..7cfd4348f --- /dev/null +++ b/server/tests/fixtures/wiki/Home.md @@ -0,0 +1,3 @@ +# Fixture Home + +Landing page for the wikiService fixtures. diff --git a/server/tests/fixtures/wiki/Sample.md b/server/tests/fixtures/wiki/Sample.md new file mode 100644 index 000000000..09bb68b09 --- /dev/null +++ b/server/tests/fixtures/wiki/Sample.md @@ -0,0 +1,9 @@ +# Sample Page + + + +Intro text linking to [[Quick Start|Quick-Start]] and [[Home]]. + +![A picture](assets/pic.png) + +![External](https://example.com/x.png) diff --git a/server/tests/fixtures/wiki/_Sidebar.md b/server/tests/fixtures/wiki/_Sidebar.md new file mode 100644 index 000000000..8233efd29 --- /dev/null +++ b/server/tests/fixtures/wiki/_Sidebar.md @@ -0,0 +1,9 @@ +## Getting Started +- [[Home]] +- [[Quick Start|Quick-Start]] + +## Planning +- [[Sample Page|Sample]] +- [[Bad Slug|_private]] + +## Empty Section diff --git a/server/tests/fixtures/wiki/assets/notes.txt b/server/tests/fixtures/wiki/assets/notes.txt new file mode 100644 index 000000000..f5edb7d33 --- /dev/null +++ b/server/tests/fixtures/wiki/assets/notes.txt @@ -0,0 +1 @@ +not an image \ No newline at end of file diff --git a/server/tests/fixtures/wiki/assets/pic.png b/server/tests/fixtures/wiki/assets/pic.png new file mode 100644 index 0000000000000000000000000000000000000000..91740ca76159c90c7d904255c56f0733e3ae6c1c GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k8}blZci7-kcwN$DG5Lh8v~O;;{|;n Oi^0>?&t;ucLK6URFbux{ literal 0 HcmV?d00001 diff --git a/server/tests/integration/help.test.ts b/server/tests/integration/help.test.ts new file mode 100644 index 000000000..3de97ba2f --- /dev/null +++ b/server/tests/integration/help.test.ts @@ -0,0 +1,93 @@ +/** + * Help integration tests — /api/help served from the bundled `wiki/` directory. + * + * These run against the real repo wiki (no TREK_WIKI_DIR override) on purpose: the + * point is to prove the shipped docs are reachable through the HTTP layer, which is + * what a broken path or a wiki missing from the image would silently cost us. + */ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA foreign_keys = ON'); + return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {} } }; +}); + +vi.mock('../../src/db/database', () => dbMock); +vi.mock('../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, + SESSION_DURATION: '24h', + SESSION_DURATION_MS: 86400000, + SESSION_DURATION_SECONDS: 86400, + DEFAULT_LANGUAGE: 'en', +})); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); + +import { buildApp } from '../../src/bootstrap'; +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; + +let nestApp: INestApplication; +let app: Application; +const fetchSpy = vi.fn(async () => new Response('{}', { status: 200 })); + +beforeAll(async () => { + // Any outbound fetch here is a bug: help must be served from disk. + vi.stubGlobal('fetch', fetchSpy); + createTables(testDb); + runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); +}); + +afterAll(async () => { + vi.unstubAllGlobals(); + await nestApp?.close(); +}); + +describe('GET /api/help', () => { + it('returns the sidebar without authentication', async () => { + const res = await request(app).get('/api/help/index').expect(200); + + expect(res.body.sections.length).toBeGreaterThan(0); + expect(res.body.sections[0].pages[0]).toEqual({ title: expect.any(String), slug: expect.any(String) }); + }); + + it('renders a real wiki page', async () => { + const res = await request(app).get('/api/help/page/Home').expect(200); + + expect(res.body.slug).toBe('Home'); + expect(res.body.title.length).toBeGreaterThan(0); + expect(res.body.markdown.length).toBeGreaterThan(0); + }); + + it('404s an unknown page', async () => { + await request(app).get('/api/help/page/DefinitelyNotAPage').expect(404); + }); + + it('serves a wiki image from disk', async () => { + const res = await request(app).get('/api/help/asset/assets/TripPlaner.png').expect(200); + + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toContain('max-age=86400'); + expect(res.body.length).toBeGreaterThan(0); + }); + + it('refuses to serve files outside the wiki directory', async () => { + // Percent-encoded, because Express normalises a literal `../` away before the + // controller sees it — this is the form that actually reaches the guard, since + // the handler decodes the path itself off req.originalUrl. + await request(app).get('/api/help/asset/assets/%2e%2e%2f%2e%2e%2fpackage.json').expect(404); + await request(app).get('/api/help/asset/%2e%2e%2fserver%2f.env').expect(404); + }); + + it('never calls out to GitHub', () => { + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/services/wikiService.test.ts b/server/tests/unit/services/wikiService.test.ts new file mode 100644 index 000000000..ab074e8a1 --- /dev/null +++ b/server/tests/unit/services/wikiService.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'path'; + +// wikiService picks its source (local disk vs GitHub) once at module load, so each +// mode needs a fresh import with TREK_WIKI_DIR already set. +const FIXTURE_WIKI = path.join(__dirname, '..', '..', 'fixtures', 'wiki'); +const MISSING_WIKI = path.join(__dirname, '..', '..', 'fixtures', 'no-such-wiki'); + +type WikiModule = typeof import('../../../src/services/wikiService'); + +async function loadWiki(dir: string | undefined): Promise { + vi.resetModules(); + if (dir === undefined) delete process.env.TREK_WIKI_DIR; + else process.env.TREK_WIKI_DIR = dir; + return import('../../../src/services/wikiService'); +} + +const fetchSpy = vi.fn(); + +beforeEach(() => { + fetchSpy.mockReset(); + vi.stubGlobal('fetch', fetchSpy); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.TREK_WIKI_DIR; +}); + +describe('wikiService — local wiki on disk', () => { + it('serves the sidebar from disk and never touches the network', async () => { + const wiki = await loadWiki(FIXTURE_WIKI); + expect(wiki.isLocalWiki()).toBe(true); + + const { sections } = await wiki.getWikiIndex(); + + expect(sections.map((s) => s.title)).toEqual(['Getting Started', 'Planning']); // empty section dropped + expect(sections[0].pages).toEqual([ + { title: 'Home', slug: 'Home' }, + { title: 'Quick Start', slug: 'Quick-Start' }, + ]); + // `_private` fails SLUG_RE, so it is filtered out of the nav. + expect(sections[1].pages).toEqual([{ title: 'Sample Page', slug: 'Sample' }]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('renders a page, rewriting wikilinks and relative images', async () => { + const wiki = await loadWiki(FIXTURE_WIKI); + const page = await wiki.getWikiPage('Sample'); + + expect(page.title).toBe('Sample Page'); + expect(page.markdown).toContain('[Quick Start](/help/Quick-Start)'); + expect(page.markdown).toContain('[Home](/help/Home)'); + expect(page.markdown).toContain('![A picture](/api/help/asset/assets/pic.png)'); + expect(page.markdown).toContain('![External](https://example.com/x.png)'); // absolute URLs left alone + expect(page.markdown).not.toContain('TODO: screenshot'); // HTML comments stripped + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('404s an unknown page instead of falling back to GitHub', async () => { + const wiki = await loadWiki(FIXTURE_WIKI); + + // A page missing from disk does not exist in *this* version — falling back to + // GitHub here is what would reintroduce version skew. + await expect(wiki.getWikiPage('Nope')).rejects.toBeInstanceOf(wiki.WikiNotFound); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it.each([['_Sidebar'], ['../Home'], ['a/b'], ['']])('rejects the invalid slug %j', async (slug) => { + const wiki = await loadWiki(FIXTURE_WIKI); + await expect(wiki.getWikiPage(slug)).rejects.toBeInstanceOf(wiki.WikiNotFound); + }); + + it('serves an asset from disk with the right content type', async () => { + const wiki = await loadWiki(FIXTURE_WIKI); + const { buf, type } = await wiki.getWikiAsset('assets/pic.png'); + + expect(type).toBe('image/png'); + expect(buf.subarray(1, 4).toString()).toBe('PNG'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it.each([ + ['../../src/config.ts'], + ['../../../.env'], + ['assets/../../package.json'], + ['/etc/passwd'], + ['assets/notes.txt'], + ])('refuses the asset path %j', async (assetPath) => { + const wiki = await loadWiki(FIXTURE_WIKI); + await expect(wiki.getWikiAsset(assetPath)).rejects.toBeInstanceOf(wiki.WikiNotFound); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('404s a missing asset', async () => { + const wiki = await loadWiki(FIXTURE_WIKI); + await expect(wiki.getWikiAsset('assets/ghost.png')).rejects.toBeInstanceOf(wiki.WikiNotFound); + }); +}); + +describe('wikiService — GitHub fallback when no wiki is on disk', () => { + const ok = (body: string): Response => + ({ ok: true, status: 200, text: async () => body, arrayBuffer: async () => new ArrayBuffer(4) }) as unknown as Response; + + it('fetches from GitHub and caches the result', async () => { + const wiki = await loadWiki(MISSING_WIKI); + expect(wiki.isLocalWiki()).toBe(false); + + fetchSpy.mockResolvedValue(ok('## Docs\n- [[Home]]\n')); + + const first = await wiki.getWikiIndex(); + const second = await wiki.getWikiIndex(); + + expect(first.sections[0].pages).toEqual([{ title: 'Home', slug: 'Home' }]); + expect(second).toEqual(first); + expect(fetchSpy).toHaveBeenCalledTimes(1); // second read served from cache + expect(fetchSpy.mock.calls[0][0]).toContain('raw.githubusercontent.com'); + }); + + it('serves a stale cache entry when GitHub is unreachable', async () => { + const wiki = await loadWiki(MISSING_WIKI); + + fetchSpy.mockResolvedValueOnce(ok('# Cached\n')); + const before = await wiki.getWikiPage('Home'); + + fetchSpy.mockRejectedValue(new Error('network down')); + const after = await wiki.getWikiPage('Home'); + + expect(after.title).toBe(before.title); + }); + + it('404s when GitHub is unreachable and nothing is cached', async () => { + const wiki = await loadWiki(MISSING_WIKI); + fetchSpy.mockRejectedValue(new Error('network down')); + + await expect(wiki.getWikiPage('Home')).rejects.toBeInstanceOf(wiki.WikiNotFound); + }); +}); + +describe('wikiService — default path resolution', () => { + it("resolves the repo's wiki/ with no override, so a release never ships without docs", async () => { + // Guards the __dirname anchor and the Dockerfile COPY: if either breaks, help + // silently degrades to the GitHub fallback and nobody notices. + const wiki = await loadWiki(undefined); + + expect(wiki.isLocalWiki()).toBe(true); + const page = await wiki.getWikiPage('Home'); + expect(page.title.length).toBeGreaterThan(0); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/unraid-template.xml b/unraid-template.xml index 7126cf2c8..c3613f68a 100644 --- a/unraid-template.xml +++ b/unraid-template.xml @@ -33,6 +33,7 @@ production UTC info + en diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md index 1fa405b06..a3a0eb99c 100644 --- a/wiki/Environment-Variables.md +++ b/wiki/Environment-Variables.md @@ -28,6 +28,7 @@ Complete reference for all environment variables TREK reads. | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email notification links | same-origin | | `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` | | `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links and subscribable calendar feed URLs (the `webcal://`/`https://` links the Subscribe dialog hands to Google/Apple/Outlook). | — | +| `TREK_WIKI_DIR` | Where the in-app Help pages (`/help`) read their content from. TREK ships this wiki and serves it from disk, so the docs always match the version you are running. You should not need to set this — it is an escape hatch for unusual layouts. If the directory cannot be found, Help falls back to fetching the public GitHub wiki (which tracks the latest release and needs outbound network access). | the bundled `wiki/` directory | ### `HOST` — Source and Proxmox installs only diff --git a/wiki/Install-Helm.md b/wiki/Install-Helm.md index 27d13b600..28119e479 100644 --- a/wiki/Install-Helm.md +++ b/wiki/Install-Helm.md @@ -97,6 +97,7 @@ env: PORT: 3000 # TZ: "Europe/Berlin" # timezone for logs, reminders, cron jobs # LOG_LEVEL: "info" # "info" = concise, "debug" = verbose + # TREK_WIKI_DIR: "/app/wiki" # where /help reads its docs from; leave unset (the image ships them) # DEFAULT_LANGUAGE: "en" # fallback language on login page; supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, tr, ar, id, ja, ko, uk, gr # ALLOWED_ORIGINS: "https://trek.example.com" # APP_URL: "https://trek.example.com" diff --git a/wiki/Install-Unraid.md b/wiki/Install-Unraid.md index 8f142ec19..e98a699eb 100644 --- a/wiki/Install-Unraid.md +++ b/wiki/Install-Unraid.md @@ -45,7 +45,7 @@ The Unraid template exposes the following fields in the container UI: ### Advanced Variables -Additional variables (`PORT`, `NODE_ENV`, `LOG_LEVEL`, `DEFAULT_LANGUAGE`, `FORCE_HTTPS`, `TRUST_PROXY`, `COOKIE_SECURE`, `ALLOW_INTERNAL_NETWORK`, `SESSION_DURATION`, `SESSION_DURATION_REMEMBER`, all OIDC variables, `MCP_RATE_LIMIT`, `MCP_MAX_SESSION_PER_USER`, `DEMO_MODE`) are available under **Advanced View** in the template editor. +Additional variables (`PORT`, `NODE_ENV`, `LOG_LEVEL`, `TREK_WIKI_DIR`, `DEFAULT_LANGUAGE`, `FORCE_HTTPS`, `TRUST_PROXY`, `COOKIE_SECURE`, `ALLOW_INTERNAL_NETWORK`, `SESSION_DURATION`, `SESSION_DURATION_REMEMBER`, all OIDC variables, `MCP_RATE_LIMIT`, `MCP_MAX_SESSION_PER_USER`, `DEMO_MODE`) are available under **Advanced View** in the template editor. ## Setting the Encryption Key