Files
Announcements/server/src/config/index.js
T

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,
};