|
|
@@ -0,0 +1,190 @@
|
|
|
+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;
|
|
|
+
|
|
|
+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);
|
|
|
+ // Remove trailing slashes (but keep "/" as-is)
|
|
|
+ if (p.length > 1) p = p.replace(/\/+$/, "");
|
|
|
+ return p;
|
|
|
+}
|
|
|
+
|
|
|
+function containsDotDotSegment(p) {
|
|
|
+ // Reject "/../", "/..", "../", etc.
|
|
|
+ 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;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Validates and normalizes environment variables.
|
|
|
+ * This function is pure: pass in an env object (e.g. process.env) and it returns config or throws.
|
|
|
+ *
|
|
|
+ * @param {Record<string, any>} env
|
|
|
+ * @returns {{
|
|
|
+ * mongodbUri: string,
|
|
|
+ * sessionSecret: string,
|
|
|
+ * nasRootPath: string,
|
|
|
+ * nodeEnv: "development" | "test" | "production",
|
|
|
+ * port?: number
|
|
|
+ * }}
|
|
|
+ */
|
|
|
+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"',
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** @type {any} */
|
|
|
+ const cfg = {
|
|
|
+ mongodbUri,
|
|
|
+ sessionSecret,
|
|
|
+ nasRootPath,
|
|
|
+ nodeEnv: nodeEnvRaw,
|
|
|
+ };
|
|
|
+
|
|
|
+ if (port !== undefined) cfg.port = port;
|
|
|
+
|
|
|
+ return cfg;
|
|
|
+}
|