export const REQUIRED_ENV_VARS = [ "MONGODB_URI", "SESSION_SECRET", "NAS_ROOT_PATH", ]; export const ALLOWED_NODE_ENVS = new Set(["development", "test", "production"]); export const MIN_SESSION_SECRET_LENGTH = 32; const ALLOWED_SEARCH_PROVIDERS = new Set(["fs", "qsirch"]); const ALLOWED_QSIRCH_DATE_FIELDS = new Set(["modified", "created"]); const ALLOWED_QSIRCH_MODES = new Set(["sync", "async", "auto"]); function isBlank(value) { return value === undefined || value === null || String(value).trim() === ""; } function normalizeString(value) { return String(value).trim(); } function normalizeUnixPath(value) { let p = normalizeString(value); if (p.length > 1) p = p.replace(/\/+$/, ""); return p; } function containsDotDotSegment(p) { return /(^|\/)\.\.(\/|$)/.test(p); } function looksLikePlaceholderSecret(value) { const s = normalizeString(value).toLowerCase(); return ( s.includes("change-me") || s.includes("changeme") || s.includes("replace-me") || s.includes("replace_this") || s === "secret" || s === "password" ); } function validateMongoUri(uri) { return uri.startsWith("mongodb://") || uri.startsWith("mongodb+srv://"); } function parsePort(value) { const raw = normalizeString(value); if (!/^\d+$/.test(raw)) return { ok: false, value: null }; const n = Number(raw); if (!Number.isInteger(n) || n < 1 || n > 65535) return { ok: false, value: null }; return { ok: true, value: n }; } function buildEnvError(missing, invalid) { const lines = []; lines.push("Invalid environment configuration."); if (missing.length > 0) { lines.push(""); lines.push("Missing required environment variables:"); for (const key of missing) lines.push(`- ${key}`); } if (invalid.length > 0) { lines.push(""); lines.push("Invalid environment variables:"); for (const item of invalid) lines.push(`- ${item.key}: ${item.message}`); } lines.push(""); lines.push( "Tip: Copy and adjust the example env files (.env.local.example / .env.docker.example)." ); const err = new Error(lines.join("\n")); err.code = "ENV_INVALID"; err.missing = missing; err.invalid = invalid; return err; } function isValidHttpUrl(value) { try { const u = new URL(value); return u.protocol === "http:" || u.protocol === "https:"; } catch { return false; } } export function validateEnv(env) { const e = env ?? {}; const missing = []; const invalid = []; for (const key of REQUIRED_ENV_VARS) { if (isBlank(e[key])) missing.push(key); } const mongodbUri = !isBlank(e.MONGODB_URI) ? normalizeString(e.MONGODB_URI) : ""; if (mongodbUri && !validateMongoUri(mongodbUri)) { invalid.push({ key: "MONGODB_URI", message: 'must start with "mongodb://" or "mongodb+srv://"', }); } const sessionSecret = !isBlank(e.SESSION_SECRET) ? normalizeString(e.SESSION_SECRET) : ""; if (sessionSecret) { if (sessionSecret.length < MIN_SESSION_SECRET_LENGTH) { invalid.push({ key: "SESSION_SECRET", message: `must be at least ${MIN_SESSION_SECRET_LENGTH} characters long`, }); } if (looksLikePlaceholderSecret(sessionSecret)) { invalid.push({ key: "SESSION_SECRET", message: "looks like a placeholder (replace it with a strong random secret)", }); } } const nasRootPath = !isBlank(e.NAS_ROOT_PATH) ? normalizeUnixPath(e.NAS_ROOT_PATH) : ""; if (nasRootPath) { if (!nasRootPath.startsWith("/")) { invalid.push({ key: "NAS_ROOT_PATH", message: 'must be an absolute Unix path (starts with "/")', }); } if (containsDotDotSegment(nasRootPath)) { invalid.push({ key: "NAS_ROOT_PATH", message: 'must not contain ".." path segments', }); } } const nodeEnvRaw = !isBlank(e.NODE_ENV) ? normalizeString(e.NODE_ENV) : "development"; if (nodeEnvRaw && !ALLOWED_NODE_ENVS.has(nodeEnvRaw)) { invalid.push({ key: "NODE_ENV", message: 'must be one of "development", "test", "production"', }); } const cookieSecureRaw = !isBlank(e.SESSION_COOKIE_SECURE) ? normalizeString(e.SESSION_COOKIE_SECURE).toLowerCase() : ""; if ( cookieSecureRaw && cookieSecureRaw !== "true" && cookieSecureRaw !== "false" ) { invalid.push({ key: "SESSION_COOKIE_SECURE", message: 'must be "true" or "false" if provided', }); } // ------------------------------------------------------------------------- // Search provider configuration (RHL-016) // ------------------------------------------------------------------------- const searchProvider = !isBlank(e.SEARCH_PROVIDER) ? normalizeString(e.SEARCH_PROVIDER).toLowerCase() : "fs"; if (!ALLOWED_SEARCH_PROVIDERS.has(searchProvider)) { invalid.push({ key: "SEARCH_PROVIDER", message: 'must be one of "fs" or "qsirch"', }); } if (searchProvider === "qsirch") { if (isBlank(e.QSIRCH_BASE_URL)) { missing.push("QSIRCH_BASE_URL"); } else if (!isValidHttpUrl(normalizeString(e.QSIRCH_BASE_URL))) { invalid.push({ key: "QSIRCH_BASE_URL", message: 'must be a valid http(s) URL (e.g. "http://192.168.0.22:8080")', }); } if (isBlank(e.QSIRCH_ACCOUNT)) missing.push("QSIRCH_ACCOUNT"); if (isBlank(e.QSIRCH_PASSWORD)) missing.push("QSIRCH_PASSWORD"); if (isBlank(e.QSIRCH_PATH_PREFIX)) missing.push("QSIRCH_PATH_PREFIX"); const dateField = !isBlank(e.QSIRCH_DATE_FIELD) ? normalizeString(e.QSIRCH_DATE_FIELD).toLowerCase() : "modified"; if (!ALLOWED_QSIRCH_DATE_FIELDS.has(dateField)) { invalid.push({ key: "QSIRCH_DATE_FIELD", message: 'must be "modified" or "created"', }); } const mode = !isBlank(e.QSIRCH_MODE) ? normalizeString(e.QSIRCH_MODE).toLowerCase() : "sync"; if (!ALLOWED_QSIRCH_MODES.has(mode)) { invalid.push({ key: "QSIRCH_MODE", message: 'must be "sync", "async", or "auto"', }); } } let port; if (!isBlank(e.PORT)) { const parsed = parsePort(e.PORT); if (!parsed.ok) { invalid.push({ key: "PORT", message: "must be an integer between 1 and 65535", }); } else { port = parsed.value; } } if (missing.length > 0 || invalid.length > 0) { throw buildEnvError(missing, invalid); } const cfg = { mongodbUri, sessionSecret, nasRootPath, nodeEnv: nodeEnvRaw, searchProvider, }; if (port !== undefined) cfg.port = port; return cfg; }