validateEnv.js 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  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. const ALLOWED_SEARCH_PROVIDERS = new Set(["fs", "qsirch"]);
  9. const ALLOWED_QSIRCH_DATE_FIELDS = new Set(["modified", "created"]);
  10. const ALLOWED_QSIRCH_MODES = new Set(["sync", "async", "auto"]);
  11. function isBlank(value) {
  12. return value === undefined || value === null || String(value).trim() === "";
  13. }
  14. function normalizeString(value) {
  15. return String(value).trim();
  16. }
  17. function normalizeUnixPath(value) {
  18. let p = normalizeString(value);
  19. if (p.length > 1) p = p.replace(/\/+$/, "");
  20. return p;
  21. }
  22. function containsDotDotSegment(p) {
  23. return /(^|\/)\.\.(\/|$)/.test(p);
  24. }
  25. function looksLikePlaceholderSecret(value) {
  26. const s = normalizeString(value).toLowerCase();
  27. return (
  28. s.includes("change-me") ||
  29. s.includes("changeme") ||
  30. s.includes("replace-me") ||
  31. s.includes("replace_this") ||
  32. s === "secret" ||
  33. s === "password"
  34. );
  35. }
  36. function validateMongoUri(uri) {
  37. return uri.startsWith("mongodb://") || uri.startsWith("mongodb+srv://");
  38. }
  39. function parsePort(value) {
  40. const raw = normalizeString(value);
  41. if (!/^\d+$/.test(raw)) return { ok: false, value: null };
  42. const n = Number(raw);
  43. if (!Number.isInteger(n) || n < 1 || n > 65535)
  44. return { ok: false, value: null };
  45. return { ok: true, value: n };
  46. }
  47. function buildEnvError(missing, invalid) {
  48. const lines = [];
  49. lines.push("Invalid environment configuration.");
  50. if (missing.length > 0) {
  51. lines.push("");
  52. lines.push("Missing required environment variables:");
  53. for (const key of missing) lines.push(`- ${key}`);
  54. }
  55. if (invalid.length > 0) {
  56. lines.push("");
  57. lines.push("Invalid environment variables:");
  58. for (const item of invalid) lines.push(`- ${item.key}: ${item.message}`);
  59. }
  60. lines.push("");
  61. lines.push(
  62. "Tip: Copy and adjust the example env files (.env.local.example / .env.docker.example)."
  63. );
  64. const err = new Error(lines.join("\n"));
  65. err.code = "ENV_INVALID";
  66. err.missing = missing;
  67. err.invalid = invalid;
  68. return err;
  69. }
  70. function isValidHttpUrl(value) {
  71. try {
  72. const u = new URL(value);
  73. return u.protocol === "http:" || u.protocol === "https:";
  74. } catch {
  75. return false;
  76. }
  77. }
  78. export function validateEnv(env) {
  79. const e = env ?? {};
  80. const missing = [];
  81. const invalid = [];
  82. for (const key of REQUIRED_ENV_VARS) {
  83. if (isBlank(e[key])) missing.push(key);
  84. }
  85. const mongodbUri = !isBlank(e.MONGODB_URI)
  86. ? normalizeString(e.MONGODB_URI)
  87. : "";
  88. if (mongodbUri && !validateMongoUri(mongodbUri)) {
  89. invalid.push({
  90. key: "MONGODB_URI",
  91. message: 'must start with "mongodb://" or "mongodb+srv://"',
  92. });
  93. }
  94. const sessionSecret = !isBlank(e.SESSION_SECRET)
  95. ? normalizeString(e.SESSION_SECRET)
  96. : "";
  97. if (sessionSecret) {
  98. if (sessionSecret.length < MIN_SESSION_SECRET_LENGTH) {
  99. invalid.push({
  100. key: "SESSION_SECRET",
  101. message: `must be at least ${MIN_SESSION_SECRET_LENGTH} characters long`,
  102. });
  103. }
  104. if (looksLikePlaceholderSecret(sessionSecret)) {
  105. invalid.push({
  106. key: "SESSION_SECRET",
  107. message:
  108. "looks like a placeholder (replace it with a strong random secret)",
  109. });
  110. }
  111. }
  112. const nasRootPath = !isBlank(e.NAS_ROOT_PATH)
  113. ? normalizeUnixPath(e.NAS_ROOT_PATH)
  114. : "";
  115. if (nasRootPath) {
  116. if (!nasRootPath.startsWith("/")) {
  117. invalid.push({
  118. key: "NAS_ROOT_PATH",
  119. message: 'must be an absolute Unix path (starts with "/")',
  120. });
  121. }
  122. if (containsDotDotSegment(nasRootPath)) {
  123. invalid.push({
  124. key: "NAS_ROOT_PATH",
  125. message: 'must not contain ".." path segments',
  126. });
  127. }
  128. }
  129. const nodeEnvRaw = !isBlank(e.NODE_ENV)
  130. ? normalizeString(e.NODE_ENV)
  131. : "development";
  132. if (nodeEnvRaw && !ALLOWED_NODE_ENVS.has(nodeEnvRaw)) {
  133. invalid.push({
  134. key: "NODE_ENV",
  135. message: 'must be one of "development", "test", "production"',
  136. });
  137. }
  138. const cookieSecureRaw = !isBlank(e.SESSION_COOKIE_SECURE)
  139. ? normalizeString(e.SESSION_COOKIE_SECURE).toLowerCase()
  140. : "";
  141. if (
  142. cookieSecureRaw &&
  143. cookieSecureRaw !== "true" &&
  144. cookieSecureRaw !== "false"
  145. ) {
  146. invalid.push({
  147. key: "SESSION_COOKIE_SECURE",
  148. message: 'must be "true" or "false" if provided',
  149. });
  150. }
  151. // -------------------------------------------------------------------------
  152. // Search provider configuration (RHL-016)
  153. // -------------------------------------------------------------------------
  154. const searchProvider = !isBlank(e.SEARCH_PROVIDER)
  155. ? normalizeString(e.SEARCH_PROVIDER).toLowerCase()
  156. : "fs";
  157. if (!ALLOWED_SEARCH_PROVIDERS.has(searchProvider)) {
  158. invalid.push({
  159. key: "SEARCH_PROVIDER",
  160. message: 'must be one of "fs" or "qsirch"',
  161. });
  162. }
  163. if (searchProvider === "qsirch") {
  164. if (isBlank(e.QSIRCH_BASE_URL)) {
  165. missing.push("QSIRCH_BASE_URL");
  166. } else if (!isValidHttpUrl(normalizeString(e.QSIRCH_BASE_URL))) {
  167. invalid.push({
  168. key: "QSIRCH_BASE_URL",
  169. message:
  170. 'must be a valid http(s) URL (e.g. "http://192.168.0.22:8080")',
  171. });
  172. }
  173. if (isBlank(e.QSIRCH_ACCOUNT)) missing.push("QSIRCH_ACCOUNT");
  174. if (isBlank(e.QSIRCH_PASSWORD)) missing.push("QSIRCH_PASSWORD");
  175. if (isBlank(e.QSIRCH_PATH_PREFIX)) missing.push("QSIRCH_PATH_PREFIX");
  176. const dateField = !isBlank(e.QSIRCH_DATE_FIELD)
  177. ? normalizeString(e.QSIRCH_DATE_FIELD).toLowerCase()
  178. : "modified";
  179. if (!ALLOWED_QSIRCH_DATE_FIELDS.has(dateField)) {
  180. invalid.push({
  181. key: "QSIRCH_DATE_FIELD",
  182. message: 'must be "modified" or "created"',
  183. });
  184. }
  185. const mode = !isBlank(e.QSIRCH_MODE)
  186. ? normalizeString(e.QSIRCH_MODE).toLowerCase()
  187. : "sync";
  188. if (!ALLOWED_QSIRCH_MODES.has(mode)) {
  189. invalid.push({
  190. key: "QSIRCH_MODE",
  191. message: 'must be "sync", "async", or "auto"',
  192. });
  193. }
  194. }
  195. let port;
  196. if (!isBlank(e.PORT)) {
  197. const parsed = parsePort(e.PORT);
  198. if (!parsed.ok) {
  199. invalid.push({
  200. key: "PORT",
  201. message: "must be an integer between 1 and 65535",
  202. });
  203. } else {
  204. port = parsed.value;
  205. }
  206. }
  207. if (missing.length > 0 || invalid.length > 0) {
  208. throw buildEnvError(missing, invalid);
  209. }
  210. const cfg = {
  211. mongodbUri,
  212. sessionSecret,
  213. nasRootPath,
  214. nodeEnv: nodeEnvRaw,
  215. searchProvider,
  216. };
  217. if (port !== undefined) cfg.port = port;
  218. return cfg;
  219. }