mirror of
https://github.com/Dvorinka/Productier.git
synced 2026-06-03 20:13:01 +00:00
277 lines
8.6 KiB
JavaScript
277 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
const targetPath = path.resolve(process.cwd(), process.argv[2] || ".env.production");
|
|
|
|
const requiredKeys = [
|
|
"PUBLIC_DOMAIN",
|
|
"PUBLIC_URL",
|
|
"TLS_EMAIL",
|
|
"BETTER_AUTH_SECRET",
|
|
"MAIL_ENCRYPTION_KEY",
|
|
"CORS_ALLOW_ORIGINS",
|
|
"AUTH_MAGIC_LINK_PROVIDER",
|
|
"AUTH_MAIL_FROM",
|
|
"AUTH_SMTP_HOST",
|
|
"AUTH_SMTP_PORT",
|
|
"AUTH_SMTP_SECURE",
|
|
"AUTH_SMTP_USER",
|
|
"AUTH_SMTP_PASSWORD",
|
|
"POSTGRES_PASSWORD",
|
|
"S3_REGION",
|
|
"S3_BUCKET",
|
|
"S3_ACCESS_KEY",
|
|
"S3_SECRET_KEY"
|
|
];
|
|
|
|
const secretKeys = [
|
|
"BETTER_AUTH_SECRET",
|
|
"MAIL_ENCRYPTION_KEY",
|
|
"AUTH_SMTP_PASSWORD",
|
|
"POSTGRES_PASSWORD",
|
|
"S3_ACCESS_KEY",
|
|
"S3_SECRET_KEY"
|
|
];
|
|
|
|
const optionalSecretKeys = [
|
|
"METRICS_AUTH_TOKEN"
|
|
];
|
|
|
|
const insecureSecretValues = new Set([
|
|
"",
|
|
"changeme",
|
|
"change-me",
|
|
"replace-me",
|
|
"replace-with-a-long-random-secret",
|
|
"replace-with-a-different-long-random-secret",
|
|
"replace-with-smtp-password",
|
|
"replace-with-strong-password",
|
|
"replace-with-access-key",
|
|
"replace-with-secret-key",
|
|
"replace-with-metrics-token"
|
|
]);
|
|
|
|
function stripOptionalQuotes(value) {
|
|
const trimmed = value.trim();
|
|
if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
return trimmed.slice(1, -1);
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function parseEnv(raw) {
|
|
const result = new Map();
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#")) {
|
|
continue;
|
|
}
|
|
const normalized = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trim() : trimmed;
|
|
const equalsIndex = normalized.indexOf("=");
|
|
if (equalsIndex <= 0) {
|
|
continue;
|
|
}
|
|
const key = normalized.slice(0, equalsIndex).trim();
|
|
const value = stripOptionalQuotes(normalized.slice(equalsIndex + 1));
|
|
result.set(key, value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function isLocalHost(hostname) {
|
|
const normalized = String(hostname || "").toLowerCase();
|
|
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
|
}
|
|
|
|
function parseAbsoluteURL(value, envName, errors) {
|
|
try {
|
|
const parsed = new URL(value);
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
errors.push(`${envName} must use http or https`);
|
|
return null;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
errors.push(`${envName} must be a valid absolute URL`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isLikelyEmail(value) {
|
|
const normalized = String(value || "").trim();
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized);
|
|
}
|
|
|
|
function parseBoolean(value) {
|
|
const normalized = String(value || "").trim().toLowerCase();
|
|
if (normalized === "true" || normalized === "1" || normalized === "yes") {
|
|
return true;
|
|
}
|
|
if (normalized === "false" || normalized === "0" || normalized === "no") {
|
|
return false;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function normalizeOrigin(url) {
|
|
return `${url.protocol}//${url.host}`.toLowerCase();
|
|
}
|
|
|
|
async function main() {
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
let raw;
|
|
try {
|
|
raw = await readFile(targetPath, "utf8");
|
|
} catch (error) {
|
|
console.error(`[error] failed to read ${targetPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const env = parseEnv(raw);
|
|
|
|
for (const key of requiredKeys) {
|
|
const value = env.get(key);
|
|
if (!value || !value.trim()) {
|
|
errors.push(`${key} is required`);
|
|
}
|
|
}
|
|
|
|
for (const key of secretKeys) {
|
|
const value = String(env.get(key) || "").trim();
|
|
const normalized = value.toLowerCase();
|
|
if (insecureSecretValues.has(normalized) || value.length < 16) {
|
|
errors.push(`${key} must be a strong non-placeholder secret (minimum 16 characters)`);
|
|
}
|
|
}
|
|
|
|
for (const key of optionalSecretKeys) {
|
|
const value = String(env.get(key) || "").trim();
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
const normalized = value.toLowerCase();
|
|
if (insecureSecretValues.has(normalized) || value.length < 16) {
|
|
errors.push(`${key} must be a strong non-placeholder secret (minimum 16 characters) when set`);
|
|
}
|
|
}
|
|
|
|
const publicURL = env.get("PUBLIC_URL");
|
|
const publicDomain = String(env.get("PUBLIC_DOMAIN") || "").trim().toLowerCase();
|
|
let normalizedPublicOrigin = "";
|
|
if (publicURL) {
|
|
const parsedPublicURL = parseAbsoluteURL(publicURL, "PUBLIC_URL", errors);
|
|
if (parsedPublicURL) {
|
|
normalizedPublicOrigin = normalizeOrigin(parsedPublicURL);
|
|
if (!isLocalHost(parsedPublicURL.hostname) && parsedPublicURL.protocol !== "https:") {
|
|
errors.push("PUBLIC_URL must use https for non-local deployments");
|
|
}
|
|
if (publicDomain && parsedPublicURL.hostname.toLowerCase() !== publicDomain) {
|
|
errors.push(`PUBLIC_URL host (${parsedPublicURL.hostname}) must match PUBLIC_DOMAIN (${publicDomain})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const corsOrigins = env.get("CORS_ALLOW_ORIGINS");
|
|
if (corsOrigins) {
|
|
if (corsOrigins.includes("*")) {
|
|
errors.push("CORS_ALLOW_ORIGINS cannot include '*'");
|
|
}
|
|
const normalizedCorsOrigins = new Set();
|
|
for (const rawOrigin of corsOrigins.split(",")) {
|
|
const origin = rawOrigin.trim();
|
|
if (!origin) {
|
|
continue;
|
|
}
|
|
const parsedOrigin = parseAbsoluteURL(origin, "CORS_ALLOW_ORIGINS", errors);
|
|
if (!parsedOrigin) {
|
|
continue;
|
|
}
|
|
if ((parsedOrigin.pathname && parsedOrigin.pathname !== "/") || parsedOrigin.search || parsedOrigin.hash) {
|
|
errors.push(`CORS_ALLOW_ORIGINS origin must not include path/query/fragment: ${origin}`);
|
|
}
|
|
if (!isLocalHost(parsedOrigin.hostname) && parsedOrigin.protocol !== "https:") {
|
|
errors.push(`CORS_ALLOW_ORIGINS origin must use https for non-local deployments: ${origin}`);
|
|
}
|
|
if (publicDomain !== "localhost" && isLocalHost(parsedOrigin.hostname)) {
|
|
errors.push(`CORS_ALLOW_ORIGINS cannot include localhost in production: ${origin}`);
|
|
}
|
|
normalizedCorsOrigins.add(normalizeOrigin(parsedOrigin));
|
|
}
|
|
|
|
if (normalizedPublicOrigin && !normalizedCorsOrigins.has(normalizedPublicOrigin)) {
|
|
errors.push(`CORS_ALLOW_ORIGINS must include PUBLIC_URL origin (${normalizedPublicOrigin})`);
|
|
}
|
|
}
|
|
|
|
const magicLinkProvider = String(env.get("AUTH_MAGIC_LINK_PROVIDER") || "").trim().toLowerCase();
|
|
if (magicLinkProvider !== "smtp") {
|
|
errors.push("AUTH_MAGIC_LINK_PROVIDER must be smtp for production deployments");
|
|
}
|
|
|
|
const authDevMailboxEnabled = String(env.get("AUTH_DEV_MAILBOX_ENABLED") || "").trim().toLowerCase();
|
|
if (authDevMailboxEnabled === "true" || authDevMailboxEnabled === "1" || authDevMailboxEnabled === "yes") {
|
|
errors.push("AUTH_DEV_MAILBOX_ENABLED must be false in production");
|
|
}
|
|
|
|
const smtpPort = Number.parseInt(String(env.get("AUTH_SMTP_PORT") || ""), 10);
|
|
if (Number.isNaN(smtpPort) || smtpPort < 1 || smtpPort > 65535) {
|
|
errors.push("AUTH_SMTP_PORT must be a valid TCP port");
|
|
}
|
|
|
|
const smtpSecure = parseBoolean(env.get("AUTH_SMTP_SECURE"));
|
|
if (smtpSecure === null) {
|
|
errors.push("AUTH_SMTP_SECURE must be true/false");
|
|
} else if (smtpSecure === false && smtpPort === 465) {
|
|
errors.push("AUTH_SMTP_SECURE should be true when AUTH_SMTP_PORT=465");
|
|
}
|
|
|
|
const smtpSkipVerify = parseBoolean(env.get("AUTH_SMTP_SKIP_VERIFY"));
|
|
if (smtpSkipVerify === true) {
|
|
errors.push("AUTH_SMTP_SKIP_VERIFY must not be enabled in production");
|
|
}
|
|
|
|
const smtpRejectUnauthorized = parseBoolean(env.get("AUTH_SMTP_TLS_REJECT_UNAUTHORIZED"));
|
|
if (smtpRejectUnauthorized === false) {
|
|
errors.push("AUTH_SMTP_TLS_REJECT_UNAUTHORIZED must not be false in production");
|
|
}
|
|
|
|
const tlsEmail = String(env.get("TLS_EMAIL") || "").trim();
|
|
if (!isLikelyEmail(tlsEmail)) {
|
|
errors.push("TLS_EMAIL must be a valid email address for ACME certificate registration");
|
|
}
|
|
|
|
const authMailFrom = String(env.get("AUTH_MAIL_FROM") || "").trim();
|
|
if (!isLikelyEmail(authMailFrom)) {
|
|
errors.push("AUTH_MAIL_FROM must be a valid sender email address");
|
|
}
|
|
|
|
if (String(env.get("BETTER_AUTH_SECRET") || "") === String(env.get("MAIL_ENCRYPTION_KEY") || "")) {
|
|
errors.push("BETTER_AUTH_SECRET and MAIL_ENCRYPTION_KEY must be different secrets");
|
|
}
|
|
|
|
if (publicDomain === "localhost") {
|
|
warnings.push("PUBLIC_DOMAIN is localhost; use a real domain for external production traffic.");
|
|
}
|
|
|
|
if (warnings.length) {
|
|
for (const warning of warnings) {
|
|
console.warn(`[warn] ${warning}`);
|
|
}
|
|
}
|
|
|
|
if (errors.length) {
|
|
for (const error of errors) {
|
|
console.error(`[error] ${error}`);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(`[ok] ${targetPath} passed production environment validation.`);
|
|
}
|
|
|
|
void main();
|