validateEnv.js 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. export const REQUIRED_ENV_VARS = [
  2. "MONGODB_URI",
  3. "SESSION_SECRET",
  4. "NAS_ROOT_PATH",
  5. ];
  6. export const ALLOWED_NODE_ENVS = new Set(["development", "test", "production"]);
  7. export const MIN_SESSION_SECRET_LENGTH = 32;
  8. function isBlank(value) {
  9. return value === undefined || value === null || String(value).trim() === "";
  10. }
  11. function normalizeString(value) {
  12. return String(value).trim();
  13. }
  14. function normalizeUnixPath(value) {
  15. let p = normalizeString(value);
  16. // Remove trailing slashes (but keep "/" as-is)
  17. if (p.length > 1) p = p.replace(/\/+$/, "");
  18. return p;
  19. }
  20. function containsDotDotSegment(p) {
  21. // Reject "/../", "/..", "../", etc.
  22. return /(^|\/)\.\.(\/|$)/.test(p);
  23. }
  24. function looksLikePlaceholderSecret(value) {
  25. const s = normalizeString(value).toLowerCase();
  26. return (
  27. s.includes("change-me") ||
  28. s.includes("changeme") ||
  29. s.includes("replace-me") ||
  30. s.includes("replace_this") ||
  31. s === "secret" ||
  32. s === "password"
  33. );
  34. }
  35. function validateMongoUri(uri) {
  36. return uri.startsWith("mongodb://") || uri.startsWith("mongodb+srv://");
  37. }
  38. function parsePort(value) {
  39. const raw = normalizeString(value);
  40. if (!/^\d+$/.test(raw)) return { ok: false, value: null };
  41. const n = Number(raw);
  42. if (!Number.isInteger(n) || n < 1 || n > 65535)
  43. return { ok: false, value: null };
  44. return { ok: true, value: n };
  45. }
  46. function buildEnvError(missing, invalid) {
  47. const lines = [];
  48. lines.push("Invalid environment configuration.");
  49. if (missing.length > 0) {
  50. lines.push("");
  51. lines.push("Missing required environment variables:");
  52. for (const key of missing) lines.push(`- ${key}`);
  53. }
  54. if (invalid.length > 0) {
  55. lines.push("");
  56. lines.push("Invalid environment variables:");
  57. for (const item of invalid) lines.push(`- ${item.key}: ${item.message}`);
  58. }
  59. lines.push("");
  60. lines.push(
  61. "Tip: Copy and adjust the example env files (.env.local.example / .env.docker.example)."
  62. );
  63. const err = new Error(lines.join("\n"));
  64. err.code = "ENV_INVALID";
  65. err.missing = missing;
  66. err.invalid = invalid;
  67. return err;
  68. }
  69. /**
  70. * Validates and normalizes environment variables.
  71. * This function is pure: pass in an env object (e.g. process.env) and it returns config or throws.
  72. *
  73. * @param {Record<string, any>} env
  74. * @returns {{
  75. * mongodbUri: string,
  76. * sessionSecret: string,
  77. * nasRootPath: string,
  78. * nodeEnv: "development" | "test" | "production",
  79. * port?: number
  80. * }}
  81. */
  82. export function validateEnv(env) {
  83. const e = env ?? {};
  84. const missing = [];
  85. const invalid = [];
  86. for (const key of REQUIRED_ENV_VARS) {
  87. if (isBlank(e[key])) missing.push(key);
  88. }
  89. const mongodbUri = !isBlank(e.MONGODB_URI)
  90. ? normalizeString(e.MONGODB_URI)
  91. : "";
  92. if (mongodbUri && !validateMongoUri(mongodbUri)) {
  93. invalid.push({
  94. key: "MONGODB_URI",
  95. message: 'must start with "mongodb://" or "mongodb+srv://"',
  96. });
  97. }
  98. const sessionSecret = !isBlank(e.SESSION_SECRET)
  99. ? normalizeString(e.SESSION_SECRET)
  100. : "";
  101. if (sessionSecret) {
  102. if (sessionSecret.length < MIN_SESSION_SECRET_LENGTH) {
  103. invalid.push({
  104. key: "SESSION_SECRET",
  105. message: `must be at least ${MIN_SESSION_SECRET_LENGTH} characters long`,
  106. });
  107. }
  108. if (looksLikePlaceholderSecret(sessionSecret)) {
  109. invalid.push({
  110. key: "SESSION_SECRET",
  111. message:
  112. "looks like a placeholder (replace it with a strong random secret)",
  113. });
  114. }
  115. }
  116. const nasRootPath = !isBlank(e.NAS_ROOT_PATH)
  117. ? normalizeUnixPath(e.NAS_ROOT_PATH)
  118. : "";
  119. if (nasRootPath) {
  120. if (!nasRootPath.startsWith("/")) {
  121. invalid.push({
  122. key: "NAS_ROOT_PATH",
  123. message: 'must be an absolute Unix path (starts with "/")',
  124. });
  125. }
  126. if (containsDotDotSegment(nasRootPath)) {
  127. invalid.push({
  128. key: "NAS_ROOT_PATH",
  129. message: 'must not contain ".." path segments',
  130. });
  131. }
  132. }
  133. const nodeEnvRaw = !isBlank(e.NODE_ENV)
  134. ? normalizeString(e.NODE_ENV)
  135. : "development";
  136. if (nodeEnvRaw && !ALLOWED_NODE_ENVS.has(nodeEnvRaw)) {
  137. invalid.push({
  138. key: "NODE_ENV",
  139. message: 'must be one of "development", "test", "production"',
  140. });
  141. }
  142. let port;
  143. if (!isBlank(e.PORT)) {
  144. const parsed = parsePort(e.PORT);
  145. if (!parsed.ok) {
  146. invalid.push({
  147. key: "PORT",
  148. message: "must be an integer between 1 and 65535",
  149. });
  150. } else {
  151. port = parsed.value;
  152. }
  153. }
  154. if (missing.length > 0 || invalid.length > 0) {
  155. throw buildEnvError(missing, invalid);
  156. }
  157. /** @type {any} */
  158. const cfg = {
  159. mongodbUri,
  160. sessionSecret,
  161. nasRootPath,
  162. nodeEnv: nodeEnvRaw,
  163. };
  164. if (port !== undefined) cfg.port = port;
  165. return cfg;
  166. }