mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -32,6 +32,5 @@ server/tests/
|
||||
server/vitest.config.ts
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
wiki/
|
||||
scripts/
|
||||
charts/
|
||||
|
||||
@@ -66,5 +66,3 @@ test-data
|
||||
|
||||
.run
|
||||
.full-review
|
||||
# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/)
|
||||
server/assets/wiki/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 <img> 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 <img> 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();
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<string, { buf: Buffer; type: string; ts: number }>();
|
||||
|
||||
const fresh = (ts: number): boolean => Date.now() - ts < TTL_MS;
|
||||
|
||||
async function readSnapshot(file: string): Promise<string | null> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string, string> = {
|
||||
'.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);
|
||||
}
|
||||
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# Fixture Home
|
||||
|
||||
Landing page for the wikiService fixtures.
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
# Sample Page
|
||||
|
||||
<!-- TODO: screenshot goes here -->
|
||||
|
||||
Intro text linking to [[Quick Start|Quick-Start]] and [[Home]].
|
||||
|
||||

|
||||
|
||||

|
||||
+9
@@ -0,0 +1,9 @@
|
||||
## Getting Started
|
||||
- [[Home]]
|
||||
- [[Quick Start|Quick-Start]]
|
||||
|
||||
## Planning
|
||||
- [[Sample Page|Sample]]
|
||||
- [[Bad Slug|_private]]
|
||||
|
||||
## Empty Section
|
||||
+1
@@ -0,0 +1 @@
|
||||
not an image
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<WikiModule> {
|
||||
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('');
|
||||
expect(page.markdown).toContain(''); // 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();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@
|
||||
<Config Name="NODE_ENV" Target="NODE_ENV" Default="production" Mode="" Description="Node environment (production / development)." Type="Variable" Display="advanced" Required="false" Mask="false">production</Config>
|
||||
<Config Name="TZ" Target="TZ" Default="UTC" Mode="" Description="Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)." Type="Variable" Display="always" Required="false" Mask="false">UTC</Config>
|
||||
<Config Name="LOG_LEVEL" Target="LOG_LEVEL" Default="info" Mode="" Description="Log verbosity: info = concise user actions, debug = verbose admin-level details." Type="Variable" Display="advanced" Required="false" Mask="false">info</Config>
|
||||
<Config Name="TREK_WIKI_DIR" Target="TREK_WIKI_DIR" Default="" Mode="" Description="Where the in-app Help pages (/help) read their content from. Leave empty: the container ships the wiki at /app/wiki and finds it automatically. Only set this to serve your own docs from a mounted path. 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)." Type="Variable" Display="advanced" Required="false" Mask="false"/>
|
||||
<Config Name="DEFAULT_LANGUAGE" Target="DEFAULT_LANGUAGE" Default="en" Mode="" Description="Default language shown 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. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar." Type="Variable" Display="advanced" Required="false" Mask="false">en</Config>
|
||||
<Config Name="ALLOWED_ORIGINS" Target="ALLOWED_ORIGINS" Default="" Mode="" Description="Comma-separated origins allowed for CORS and used as base URL in email notification links (e.g. https://trek.example.com)." Type="Variable" Display="always" Required="false" Mask="false"/>
|
||||
<Config Name="APP_URL" Target="APP_URL" Default="" Mode="" Description="Public base URL of this instance (e.g. https://trek.example.com). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as base URL for email notification links." Type="Variable" Display="always" Required="false" Mask="false"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user