refactor(server): remaining nest domains read env via app-config

This commit is contained in:
jubnl
2026-07-25 15:29:13 +02:00
parent 9e87e09d03
commit d13758221e
15 changed files with 49 additions and 29 deletions
+2 -1
View File
@@ -17,6 +17,7 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import type { Request, Response } from 'express';
import fs from 'fs';
import { readEnv } from '../../app-config';
import type { User } from '../../types';
import { BackupService } from './backup.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -148,7 +149,7 @@ export class BackupController {
} catch (err) {
console.error('[backup] PUT auto-settings:', err);
const msg = err instanceof Error ? err.message : String(err);
throw new HttpException({ error: 'Could not save auto-backup settings', detail: process.env.NODE_ENV?.toLowerCase() !== 'production' ? msg : undefined }, 500);
throw new HttpException({ error: 'Could not save auto-backup settings', detail: !readEnv().app.isProduction ? msg : undefined }, 500);
}
}
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, writeFileSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, extname } from 'node:path';
import { randomUUID } from 'node:crypto';
import { readEnv } from '../../app-config';
import { execSync } from 'node:child_process';
import { promisify } from 'node:util';
import type { KiReservation } from './kitinerary.types';
@@ -78,7 +79,7 @@ export class KitineraryExtractorService implements OnModuleInit {
}
private findBinary(): string | null {
const envPath = process.env.KITINERARY_EXTRACTOR_PATH;
const envPath = readEnv().integrations.kitineraryExtractorPath;
if (envPath) {
if (existsSync(envPath)) return envPath;
console.warn(`[KItinerary] KITINERARY_EXTRACTOR_PATH="${envPath}" not found`);
@@ -20,6 +20,7 @@ import type { Request } from 'express';
import { diskStorage } from 'multer';
import path from 'path';
import fs from 'fs';
import { readEnv } from '../../app-config';
import { v4 as uuidv4 } from 'uuid';
import type { User } from '../../types';
import { CollectionsService } from './collections.service';
@@ -195,7 +196,7 @@ export class CollectionsController {
@UploadedFile() file: Express.Multer.File | undefined,
@Headers('x-socket-id') socketId?: string,
) {
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) {
if (readEnv().demo.enabled && isDemoEmail(user.email)) {
throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
}
if (!file) throw new HttpException({ error: 'No image uploaded' }, 400);
@@ -366,7 +367,7 @@ export class CollectionsController {
@Post(':id/cover')
@UseInterceptors(FileInterceptor('cover', COVER_UPLOAD))
uploadCover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) {
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) {
if (readEnv().demo.enabled && isDemoEmail(user.email)) {
throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
}
if (!file) throw new HttpException({ error: 'No image uploaded' }, 400);
+3 -1
View File
@@ -11,6 +11,7 @@ import {
UseGuards,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { readEnv } from '../../app-config';
import { FeedsService } from './feeds.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -23,7 +24,8 @@ import { db } from '../../db/database';
// the link is still absolute and copy-pasteable as webcal:// instead of a dead
// relative path.
function resolveFeedBase(req: Request): string {
const configured = (process.env.APP_URL || '').replace(/\/$/, '');
// Single trailing slash stripped on purpose (legacy parity; notifications strips all).
const configured = (readEnv().app.appUrl || '').replace(/\/$/, '');
if (configured) return configured;
const host = req.get('host');
return host ? `${req.protocol}://${host}` : '';
+2 -1
View File
@@ -20,6 +20,7 @@ import { diskStorage } from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import { readEnv } from '../../app-config';
import type { User } from '../../types';
import { FilesService } from './files.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -106,7 +107,7 @@ export class FilesController {
const cleanup = () => { if (file?.path) { try { fs.unlinkSync(file.path); } catch { /* best-effort */ } } };
try {
const trip = this.requireTrip(tripId, user);
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) {
if (readEnv().demo.enabled && isDemoEmail(user.email)) {
throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
}
if (!this.files.can('file_upload', trip, user)) {
+2 -1
View File
@@ -16,6 +16,7 @@ import {
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { readEnv } from '../../app-config';
import type { User } from '../../types';
import { PlacesService } from './places.service';
import { isUpdateConflict } from '../../services/conflictResult';
@@ -283,7 +284,7 @@ export class PlacesController {
) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) {
if (readEnv().demo.enabled && isDemoEmail(user.email)) {
throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
}
if (!file) {
@@ -1,6 +1,7 @@
import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from '@nestjs/common';
import type { Request, Response } from 'express';
import path from 'node:path';
import { readEnv } from '../../app-config';
import { PUBLIC_DIR } from './platform.routes';
/**
@@ -24,7 +25,8 @@ export class SpaFallbackFilter implements ExceptionFilter {
const req = ctx.getRequest<Request>();
const res = ctx.getResponse<Response>();
if (process.env.NODE_ENV === 'production' && req.method === 'GET') {
// Case-sensitive on purpose (legacy parity).
if (readEnv().app.nodeEnv === 'production' && req.method === 'GET') {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
return;
+5 -3
View File
@@ -11,11 +11,13 @@
* touches that gate — but the two properties above are why loading unsigned local
* code must be gated behind an explicit flag on top of the admin + kill-switch gates.
*
* Enable ONLY by setting `TREK_PLUGINS_DEV_LINK=1`. Off (absent/any other value)
* by default, so it can never be silently on in a shared or production deployment.
* Enable ONLY by setting `TREK_PLUGINS_DEV_LINK` to an explicit truthy value
* (1/true/on/yes). Off (absent/any other value) by default, so it can never be
* silently on in a shared or production deployment.
*/
import { readEnv } from '../../app-config';
export function devLinkEnabled(): boolean {
return process.env.TREK_PLUGINS_DEV_LINK === '1';
return readEnv().plugins.devLink;
}
/** Provenance marker stamped on a dev-linked plugin row (free-text `source_repo`). */
@@ -1,3 +1,4 @@
import { readEnv } from '../../../app-config';
import { db, canAccessTrip } from '../../../db/database';
import { broadcast, broadcastToUser } from '../../../websocket';
import { listBudgetItems } from '../../../services/budgetService';
@@ -279,7 +280,7 @@ export function createRealRpcHost(id: string, granted: ReadonlySet<string>, rout
// bytes to the shared demo instance, even through a plugin's db:write:files.
// Only resolve the email when demo mode is actually on — keeps the hot path
// (and the schema surface) untouched for self-hosted installs.
if (process.env.DEMO_MODE?.toLowerCase() === 'true') {
if (readEnv().demo.enabled) {
const uploader = db.prepare('SELECT email FROM users WHERE id = ?').get(actingUserId) as { email?: string } | undefined;
if (isDemoEmail(uploader?.email)) throw new ForbiddenResource('Uploads are disabled in demo mode.');
}
+8 -5
View File
@@ -11,6 +11,7 @@
*
* Pure + dependency-free (a clock is injected) so it unit-tests without timers.
*/
import { readEnv } from '../../../app-config';
/** A refilling token bucket: `capacity` tokens, refilled at `refillPerSec`. */
export class TokenBucket {
@@ -51,10 +52,12 @@ export interface RpcLimitConfig {
// Generous defaults: a legitimate plugin handling a request makes a handful of ctx
// calls; these only bite a runaway loop. Overridable via env for tuning.
// Frozen at import on purpose (legacy timing) — the limits are boot-stable.
const pluginTuning = readEnv().plugins;
export const DEFAULT_RPC_LIMIT: RpcLimitConfig = {
burst: Number(process.env.TREK_PLUGIN_RPC_BURST) || 60,
perSec: Number(process.env.TREK_PLUGIN_RPC_PER_SEC) || 20,
maxInFlight: Number(process.env.TREK_PLUGIN_RPC_INFLIGHT) || 16,
burst: pluginTuning.rpcBurst,
perSec: pluginTuning.rpcPerSec,
maxInFlight: pluginTuning.rpcInflight,
};
// Plugin log volume (ctx.log.*, stdout/stderr, unknown evt topics) reaches the
@@ -63,8 +66,8 @@ export const DEFAULT_RPC_LIMIT: RpcLimitConfig = {
// `while (true) ctx.log.error(...)` loop can't pin the host thread; a legitimate
// plugin logs far below this and never notices. Excess lines are dropped.
export const DEFAULT_LOG_LIMIT = {
burst: Number(process.env.TREK_PLUGIN_LOG_BURST) || 50,
perSec: Number(process.env.TREK_PLUGIN_LOG_PER_SEC) || 10,
burst: pluginTuning.logBurst,
perSec: pluginTuning.logPerSec,
};
/** Per-plugin limiter: a token bucket + an in-flight gauge. */
@@ -1,4 +1,5 @@
import semver from 'semver';
import { readEnv } from '../../../app-config';
/**
* Host-version compatibility for plugins (#plugins). A manifest declares the TREK
@@ -11,7 +12,7 @@ import semver from 'semver';
/** The running TREK version (same source as the rest of the app). */
export function hostVersion(): string {
return process.env.APP_VERSION || (require('../../../../package.json') as { version: string }).version;
return readEnv().app.appVersion || (require('../../../../package.json') as { version: string }).version;
}
let warnedUnparseable = false;
+4 -3
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { readEnv } from '../../app-config';
/**
* Filesystem layout for the plugin system (#plugins). Code and data are two
@@ -16,10 +17,10 @@ const DATA_ROOT = path.resolve(__dirname, '../../../data');
// Read lazily so a deployment (or a test) can point these at dedicated volumes
// via env without import-order surprises.
export function pluginsCodeRoot(): string {
return process.env.TREK_PLUGINS_DIR || path.join(DATA_ROOT, 'plugins');
return readEnv().plugins.dir || path.join(DATA_ROOT, 'plugins');
}
export function pluginsDataRoot(): string {
return process.env.TREK_PLUGINS_DATA_DIR || path.join(DATA_ROOT, 'plugins-data');
return readEnv().plugins.dataDir || path.join(DATA_ROOT, 'plugins-data');
}
/** A plugin's installed code directory (contains trek-plugin.json + server/index.js). */
@@ -112,7 +113,7 @@ export function serverCodeRoot(): string {
*/
let warnedPermissionsOff = false;
export function pluginPermissionArgs(pluginId: string): string[] {
if ((process.env.TREK_PLUGIN_PERMISSIONS ?? 'on').toLowerCase() === 'off') {
if (readEnv().plugins.permissionsOff) {
// The OS permission jail is the boundary that stops an installed plugin from
// reading trek.db / the secret files / shelling out. Turning it off makes plugin
// isolation crash-only — surface that loudly once so an operator can't leave it
@@ -1,3 +1,4 @@
import { readEnv } from '../../../app-config';
import { db } from '../../../db/database';
import { discoverPlugins } from '../install/discovery';
import { hostSatisfies, hostVersion, normalizedHost } from '../install/host-compat';
@@ -24,9 +25,8 @@ import semver from 'semver';
* (registers INACTIVE). Nothing executes on install; activation is separate.
*/
const REGISTRY_URL =
process.env.TREK_PLUGIN_REGISTRY_URL ||
'https://raw.githubusercontent.com/liketrek/TREK-Plugins/main/dist/index.json';
// Frozen at import on purpose (legacy timing) — the registry URL is boot-stable.
const REGISTRY_URL = readEnv().plugins.registryUrl;
const CACHE_TTL = 30 * 60 * 1000;
const MANIFEST_MAX_BYTES = 256 * 1024;
// Sideload upload ceiling — matches the SDK `pack` limit (50 MB) plus zip overhead.
@@ -1,6 +1,7 @@
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { fork, type ChildProcess } from 'node:child_process';
import { readEnv } from '../../../app-config';
import { resolveChildEntry, pluginCodeDir, pluginRealCodeDir, pluginPermissionArgs, ensurePluginModuleType } from '../paths';
import type { Envelope, RpcError, RpcRequest } from '../protocol/envelope';
import type { PluginRpcHost } from '../host/rpc-host';
@@ -89,7 +90,7 @@ const DEFAULTS: Required<SupervisorTuning> = {
// Hard RSS ceiling — the real memory cap. --max-old-space-size only bounds the
// V8 heap; Buffers/ArrayBuffers/native allocations sail past it, so a plugin
// could OOM the box while staying "under" the heap limit. Overridable via env.
maxRssBytes: (Number(process.env.TREK_PLUGIN_MAX_RSS_MB) || 300) * 1024 * 1024,
maxRssBytes: readEnv().plugins.maxRssMb * 1024 * 1024,
};
// A plugin may only act as a provider for a hook it BOTH implements (reported by
@@ -482,9 +483,10 @@ export class PluginSupervisor {
// code and a plugin can never reach a self-hoster's LAN service (a Gotify, an
// ntfy, an Ollama) no matter what the admin sets. Forwarded only when set, so
// the default stays the secure block-private policy.
...(process.env.TREK_PLUGIN_ALLOW_PRIVATE_EGRESS
? { TREK_PLUGIN_ALLOW_PRIVATE_EGRESS: process.env.TREK_PLUGIN_ALLOW_PRIVATE_EGRESS }
: {}),
// Normalized to the literal 'on': the child and plugin-sdk keep their
// `=== 'on'` check (they are exempt from app-config), so the host-side
// boolean-family coercion must collapse to the one literal they accept.
...(readEnv().plugins.allowPrivateEgress ? { TREK_PLUGIN_ALLOW_PRIVATE_EGRESS: 'on' } : {}),
},
});
sup.child = child;
+2 -1
View File
@@ -21,6 +21,7 @@ import type { Request, Response } from 'express';
import { diskStorage } from 'multer';
import path from 'path';
import fs from 'fs';
import { readEnv } from '../../app-config';
import { v4 as uuidv4 } from 'uuid';
import type { User } from '../../types';
import { TripsService } from './trips.service';
@@ -177,7 +178,7 @@ export class TripsController {
@Post(':id/cover')
@UseInterceptors(FileInterceptor('cover', COVER_UPLOAD))
cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined) {
if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(user.email)) {
if (readEnv().demo.enabled && isDemoEmail(user.email)) {
throw new HttpException({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' }, 403);
}
const access = this.trips.canAccessTrip(id, user.id);