257 lines
7.3 KiB
JavaScript
257 lines
7.3 KiB
JavaScript
const path = require("node:path");
|
|
|
|
class ConfigError extends Error {
|
|
constructor(message, field) {
|
|
super(message);
|
|
this.name = "ConfigError";
|
|
this.field = field;
|
|
}
|
|
}
|
|
|
|
function requiredString(env, name) {
|
|
const value = env[name]?.trim();
|
|
if (!value) {
|
|
throw new ConfigError(`${name} is required`, name);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function optionalString(env, name) {
|
|
const value = env[name]?.trim();
|
|
return value || null;
|
|
}
|
|
|
|
function integer(env, name, defaultValue, { min, max } = {}) {
|
|
const raw = env[name];
|
|
if (raw === undefined || raw === "") {
|
|
return defaultValue;
|
|
}
|
|
|
|
if (!/^-?\d+$/.test(raw)) {
|
|
throw new ConfigError(`${name} must be an integer`, name);
|
|
}
|
|
|
|
const value = Number(raw);
|
|
if (!Number.isSafeInteger(value)) {
|
|
throw new ConfigError(`${name} must be a safe integer`, name);
|
|
}
|
|
if (min !== undefined && value < min) {
|
|
throw new ConfigError(`${name} must be at least ${min}`, name);
|
|
}
|
|
if (max !== undefined && value > max) {
|
|
throw new ConfigError(`${name} must be at most ${max}`, name);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function boolean(env, name, defaultValue) {
|
|
const raw = env[name];
|
|
if (raw === undefined || raw === "") {
|
|
return defaultValue;
|
|
}
|
|
|
|
const normalized = raw.trim().toLowerCase();
|
|
if (["true", "1", "yes", "on"].includes(normalized)) {
|
|
return true;
|
|
}
|
|
if (["false", "0", "no", "off"].includes(normalized)) {
|
|
return false;
|
|
}
|
|
throw new ConfigError(`${name} must be a boolean`, name);
|
|
}
|
|
|
|
function url(env, name, { required = false, protocols } = {}) {
|
|
const raw = required ? requiredString(env, name) : optionalString(env, name);
|
|
if (raw === null) {
|
|
return null;
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(raw);
|
|
} catch {
|
|
throw new ConfigError(`${name} must be a valid URL`, name);
|
|
}
|
|
|
|
if (protocols && !protocols.includes(parsed.protocol)) {
|
|
throw new ConfigError(
|
|
`${name} must use one of these protocols: ${protocols.join(", ")}`,
|
|
name,
|
|
);
|
|
}
|
|
return parsed.toString().replace(/\/$/, "");
|
|
}
|
|
|
|
function bucketName(env) {
|
|
const value = requiredString(env, "S3_BUCKET");
|
|
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)) {
|
|
throw new ConfigError(
|
|
"S3_BUCKET must be a valid lowercase S3 bucket name",
|
|
"S3_BUCKET",
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function nodeEnvironment(env) {
|
|
const value = env.NODE_ENV?.trim() || "development";
|
|
if (!["development", "test", "production"].includes(value)) {
|
|
throw new ConfigError(
|
|
"NODE_ENV must be development, test, or production",
|
|
"NODE_ENV",
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function timeZone(env) {
|
|
const value = requiredString(env, "APP_TIMEZONE");
|
|
try {
|
|
new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
|
|
} catch {
|
|
throw new ConfigError("APP_TIMEZONE must be a valid IANA time zone", "APP_TIMEZONE");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function storageCredential(env, name, environment) {
|
|
const value = optionalString(env, name);
|
|
if (environment === "production" && value === null) {
|
|
throw new ConfigError(`${name} is required in production`, name);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function loadConfig(env = process.env) {
|
|
const environment = nodeEnvironment(env);
|
|
const databasePath = requiredString(env, "DATABASE_PATH");
|
|
if (databasePath !== ":memory:" && !path.isAbsolute(databasePath)) {
|
|
throw new ConfigError("DATABASE_PATH must be an absolute path", "DATABASE_PATH");
|
|
}
|
|
|
|
return Object.freeze({
|
|
environment,
|
|
host: env.HOST?.trim() || "0.0.0.0",
|
|
port: integer(env, "PORT", 7532, { min: 0, max: 65535 }),
|
|
timeZone: timeZone(env),
|
|
shutdownTimeoutMs: integer(env, "SHUTDOWN_TIMEOUT_MS", 10_000, {
|
|
min: 1_000,
|
|
max: 60_000,
|
|
}),
|
|
trustProxy: boolean(env, "TRUST_PROXY", true),
|
|
database: Object.freeze({
|
|
path: databasePath,
|
|
busyTimeoutMs: integer(env, "DATABASE_BUSY_TIMEOUT_MS", 5_000, {
|
|
min: 0,
|
|
max: 60_000,
|
|
}),
|
|
}),
|
|
storage: Object.freeze({
|
|
endpoint: url(env, "S3_ENDPOINT", {
|
|
required: true,
|
|
protocols: ["http:", "https:"],
|
|
}),
|
|
region: env.S3_REGION?.trim() || "us-east-1",
|
|
bucket: bucketName(env),
|
|
accessKeyId: storageCredential(env, "S3_ACCESS_KEY_ID", environment),
|
|
secretAccessKey: storageCredential(env, "S3_SECRET_ACCESS_KEY", environment),
|
|
forcePathStyle: boolean(env, "S3_FORCE_PATH_STYLE", true),
|
|
healthProbeIntervalMs: integer(
|
|
env,
|
|
"S3_HEALTH_PROBE_INTERVAL_MS",
|
|
30_000,
|
|
{ min: 1_000, max: 300_000 },
|
|
),
|
|
connectionTimeoutMs: integer(env, "S3_CONNECT_TIMEOUT_MS", 2_000, {
|
|
min: 250,
|
|
max: 60_000,
|
|
}),
|
|
requestTimeoutMs: integer(env, "S3_REQUEST_TIMEOUT_MS", 5_000, {
|
|
min: 1_000,
|
|
max: 120_000,
|
|
}),
|
|
maxAttempts: integer(env, "S3_MAX_ATTEMPTS", 2, { min: 1, max: 5 }),
|
|
}),
|
|
processing: Object.freeze({
|
|
tempDirectory: (() => {
|
|
const value = env.UPLOAD_TEMP_DIRECTORY?.trim() || "/tmp/announcements-uploads";
|
|
if (!path.isAbsolute(value)) {
|
|
throw new ConfigError(
|
|
"UPLOAD_TEMP_DIRECTORY must be an absolute path",
|
|
"UPLOAD_TEMP_DIRECTORY",
|
|
);
|
|
}
|
|
return value;
|
|
})(),
|
|
maxFileBytes: integer(env, "UPLOAD_MAX_FILE_BYTES", 25 * 1024 * 1024, {
|
|
min: 1,
|
|
max: 1024 * 1024 * 1024,
|
|
}),
|
|
maxFiles: integer(env, "UPLOAD_MAX_FILES", 20, { min: 1, max: 100 }),
|
|
maxInputPixels: integer(env, "IMAGE_MAX_INPUT_PIXELS", 40_000_000, {
|
|
min: 1,
|
|
max: 500_000_000,
|
|
}),
|
|
}),
|
|
mqtt: Object.freeze({
|
|
url: url(env, "MQTT_URL", {
|
|
protocols: ["mqtt:", "mqtts:", "ws:", "wss:"],
|
|
}),
|
|
username: optionalString(env, "MQTT_USERNAME"),
|
|
password: optionalString(env, "MQTT_PASSWORD"),
|
|
}),
|
|
notifications: Object.freeze({
|
|
homeAssistantWebhookUrl: url(env, "HA_WEBHOOK_URL", {
|
|
protocols: ["http:", "https:"],
|
|
}),
|
|
homeAssistantWebhookSecret: optionalString(env, "HA_WEBHOOK_SECRET"),
|
|
}),
|
|
});
|
|
}
|
|
|
|
function redactedConfig(config) {
|
|
return {
|
|
environment: config.environment,
|
|
host: config.host,
|
|
port: config.port,
|
|
timeZone: config.timeZone,
|
|
trustProxy: config.trustProxy,
|
|
database: {
|
|
configured: Boolean(config.database.path),
|
|
},
|
|
storage: {
|
|
configured: Boolean(
|
|
config.storage.endpoint &&
|
|
config.storage.bucket &&
|
|
config.storage.accessKeyId &&
|
|
config.storage.secretAccessKey,
|
|
),
|
|
region: config.storage.region,
|
|
bucket: config.storage.bucket,
|
|
forcePathStyle: config.storage.forcePathStyle,
|
|
healthProbeIntervalMs: config.storage.healthProbeIntervalMs,
|
|
connectionTimeoutMs: config.storage.connectionTimeoutMs,
|
|
requestTimeoutMs: config.storage.requestTimeoutMs,
|
|
maxAttempts: config.storage.maxAttempts,
|
|
},
|
|
processing: {
|
|
tempDirectoryConfigured: Boolean(config.processing.tempDirectory),
|
|
maxFileBytes: config.processing.maxFileBytes,
|
|
maxFiles: config.processing.maxFiles,
|
|
maxInputPixels: config.processing.maxInputPixels,
|
|
},
|
|
mqtt: {
|
|
configured: Boolean(config.mqtt.url),
|
|
},
|
|
notifications: {
|
|
configured: Boolean(config.notifications.homeAssistantWebhookUrl),
|
|
},
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
ConfigError,
|
|
loadConfig,
|
|
redactedConfig,
|
|
};
|