import z from 'zod'; import { UserData } from '../db/schemas.js'; import { config } from '../config/index.js'; import { SyncManager, type SyncOverride, type FetchResult, parseSyncedUrl, } from './sync.js'; import { extractNamesFromExpression } from '../parser/streamExpression.js'; import { createLogger } from '../logging/logger.js'; const logger = createLogger('core'); /** * Schema for a stream expression item fetched from a sync URL. */ const StreamExpressionSchema = z.object({ expression: z.string().min(1), name: z.string().optional(), score: z.number().optional(), enabled: z.boolean().optional(), }); export type StreamExpressionItem = z.infer; /** * Manages stream expression (SEL) URL syncing and access control. * * Access model (controls which sync URLs can be used, NOT which expressions can be entered): * - `SEL_SYNC_ACCESS = 'all'` → anyone can sync from any URL * - `SEL_SYNC_ACCESS = 'trusted'` → trusted users can sync from any URL; * others can only sync from WHITELISTED_SEL_URLS * * Users can always enter any SEL expression locally - access control only applies to sync URLs. */ export class SelAccess { private static _instance: SyncManager; /** * Get or create the singleton SyncManager instance. */ private static get manager(): SyncManager { if (!this._instance) { const configuredUrls = config.userLimits.sel.urls; const refreshInterval = config.userLimits.sync.refreshInterval; this._instance = new SyncManager({ cacheKey: 'sel-expressions', maxCacheSize: 100, refreshInterval, configuredUrls, itemSchema: StreamExpressionSchema, itemKey: (item) => item.expression, convertValue: (v) => ({ expression: v }), }); } return this._instance; } /** * Initialise the SEL access service. */ public static initialise(): Promise { return this.manager.initialise(); } /** * Clean up resources. Safe to call before `initialise()`: the `manager` * getter would otherwise read from `config.userLimits.sel` and trip the * settings-store guard if shutdown runs before `initialiseConfig()` has * resolved (e.g. SIGTERM during startup). */ public static cleanup(): void { if (this._instance) this._instance.cleanup(); } /** * Validate sync URLs based on access level and user trust. * - `all` → any URL allowed * - `trusted` → trusted users can use any URL; others limited to whitelisted SEL URLs */ public static validateUrls(urls: string[], userData?: UserData): string[] { const access = config.userLimits.sel.access; const isUnrestricted = access === 'all' || (access === 'trusted' && userData?.trusted); if (isUnrestricted) return urls; // Non-trusted users can only use whitelisted SEL URLs return urls.filter((url) => this.manager.allowedUrls.includes(url)); } /** * Fetch expressions from a single URL. */ public static async getExpressionsForUrl( url: string ): Promise { return this.manager.fetchFromUrl(url); } /** * Resolve expressions from URLs with validation. */ public static async resolveExpressions( urls: string[] | undefined, userData?: UserData ): Promise { if (!urls?.length) return []; const validUrls = this.validateUrls(urls, userData); if (!validUrls.length) return []; const results = await Promise.all( validUrls.map((url) => this.getExpressionsForUrl(url)) ); return results.flat(); } /** * Resolve expressions from URLs, returning per-URL results with errors. * Used by the API route to forward errors to the frontend. */ public static async resolveExpressionsWithErrors( urls: string[] | undefined, userData?: UserData ): Promise[]> { if (!urls?.length) return []; const validUrls = new Set(this.validateUrls(urls, userData)); const results = await Promise.all( urls.map((url) => { if (!validUrls.has(url)) { return { url, items: [] as StreamExpressionItem[], error: config.userLimits.sel.access === 'trusted' && !userData?.trusted ? 'This URL is not in the allowed list. Contact the instance owner to whitelist it, or ask to be marked as a trusted user.' : 'This URL is not allowed by the server configuration.', } satisfies FetchResult; } return this.manager.fetchFromUrlWithError(url); }) ); return results; } /** * Sync stream expressions from URLs into the user's existing expressions. * This is the main method called by the middleware. * * Override logic for SEL: * - **disabled**: skip the expression entirely * - **score override (ranked only)**: override the score value * * Override matching: * - By exact expression string match (`override.expression === item.expression`) * - By extracted names match: names extracted from expression comments vs `override.exprNames` * * Resolves `` inline placeholders in-place; unplaced URLs * are appended at the end. Dangling placeholders are stripped. */ public static async syncStreamExpressions( urls: string[] | undefined, existing: U[], userData: UserData, transform: (item: StreamExpressionItem) => U, getField: (item: U) => string ): Promise { const validUrls = urls?.length ? this.validateUrls(urls, userData) : []; if (validUrls.length === 0) { const cleaned = existing.filter( (item) => !parseSyncedUrl(getField(item)) ); return cleaned.length === existing.length ? existing : cleaned; } const validUrlSet = new Set(validUrls); const urlExprMap = new Map(); await Promise.all( validUrls.map(async (url) => { const exprs = await this.getExpressionsForUrl(url); urlExprMap.set(url, exprs); }) ); const overrides: SyncOverride[] = userData.selOverrides || []; const result: U[] = []; const resolvedInlineUrls = new Set(); const pushExpressions = (expressions: StreamExpressionItem[]) => { for (const expr of expressions) { const override = this._findSelOverride(expr, overrides); if (override?.disabled) continue; const overriddenExpr = override ? this._applySelOverride(expr, override) : expr; result.push(transform(overriddenExpr)); } }; for (const item of existing) { const placeholderUrl = parseSyncedUrl(getField(item)); if (placeholderUrl) { if (validUrlSet.has(placeholderUrl)) { resolvedInlineUrls.add(placeholderUrl); pushExpressions(urlExprMap.get(placeholderUrl) ?? []); } continue; } result.push(item); } for (const url of validUrls) { if (resolvedInlineUrls.has(url)) continue; pushExpressions(urlExprMap.get(url) ?? []); } return result; } /** * Add URLs to the allowed list for SEL syncing. * URLs added this way are considered trusted and can be used for syncing. */ public static addAllowedUrls(urls: string[]): void { this.manager.addAllowedUrls(urls); } /** * Get all allowed URLs for SEL syncing. */ public static getAllowedUrls(): string[] { return this.manager.allowedUrls; } /** * Find a matching SEL override for an expression. * Matches by exact expression string or by comparing extracted names * from the expression against the override's stored `exprNames` array. */ private static _findSelOverride( expr: StreamExpressionItem, overrides: SyncOverride[] ): SyncOverride | undefined { if (!overrides.length) return undefined; return overrides.find((o) => { // Match by exact expression if (o.expression && o.expression === expr.expression) return true; // Match by extracted names vs stored exprNames if (o.exprNames && o.exprNames.length > 0) { const names = extractNamesFromExpression(expr.expression, false); const matches = (list?: string[]) => !!list && list.length === o.exprNames!.length && list.every((n, i) => n === o.exprNames![i]); if (matches(names)) { return true; } } return false; }); } /** * Apply an SEL override to an expression item. * Supports score overrides and enabled state overrides. */ private static _applySelOverride( expr: StreamExpressionItem, override: SyncOverride ): StreamExpressionItem { const result = { ...expr }; if (override.score !== undefined) { result.score = override.score; } // If the user explicitly set disabled=false, override enabled to true if (override.disabled === false && expr.enabled === false) { result.enabled = true; } return result; } /** * Helper method to resolve all synced stream expressions from URLs for temporary validation. * Returns expressions without modifying the userData config. * Used by config validation to merge synced expressions temporarily. */ public static async resolveSyncedExpressionsForValidation( userData: UserData ): Promise<{ included: { expression: string; enabled: boolean }[]; excluded: { expression: string; enabled: boolean }[]; required: { expression: string; enabled: boolean }[]; preferred: { expression: string; enabled: boolean }[]; ranked: { expression: string; score: number; enabled: boolean }[]; }> { try { const [included, excluded, required, preferred, ranked] = await Promise.all([ this.syncStreamExpressions( userData.syncedIncludedStreamExpressionUrls, [], userData, (item) => ({ expression: item.expression, enabled: item.enabled ?? true, }), (item) => item.expression ), this.syncStreamExpressions( userData.syncedExcludedStreamExpressionUrls, [], userData, (item) => ({ expression: item.expression, enabled: item.enabled ?? true, }), (item) => item.expression ), this.syncStreamExpressions( userData.syncedRequiredStreamExpressionUrls, [], userData, (item) => ({ expression: item.expression, enabled: item.enabled ?? true, }), (item) => item.expression ), this.syncStreamExpressions( userData.syncedPreferredStreamExpressionUrls, [], userData, (item) => ({ expression: item.expression, enabled: item.enabled ?? true, }), (item) => item.expression ), this.syncStreamExpressions( userData.syncedRankedStreamExpressionUrls, [], userData, (item) => ({ expression: item.expression, score: item.score || 0, enabled: item.enabled ?? true, }), (item) => item.expression ), ]); return { included, excluded, required, preferred, ranked }; } catch (err) { throw new Error( err instanceof Error ? `Failed to resolve one or more synced stream expressions: ${err.message}` : 'Failed to resolve one or more synced stream expressions' ); } } }