Files
hop.noname.company/apps/server/src/config.ts

107 lines
4.0 KiB
TypeScript

import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
export type Config = {
production: boolean;
appUrl: string;
databasePath: string;
sessionSecret: string;
discoverySecret: string;
discoveryEnabled: boolean;
trustedProxies: string[];
port: number;
host: string;
guestDays: number;
requestSeconds: number;
uploadStartHours: number;
spaceMinutes: number;
spaceMax: number;
offlineGraceMs: number;
maxUrlBytes: number;
maxItems: number;
urlRetentionDays: number;
sessionRate: number;
ipRate: number;
pairRate: number;
rejectCooldownMs: number;
uploadDir: string;
maxFileBytes: number;
guestQuota: number;
totalGuestQuota: number;
minFreeBytes: number;
fileRetentionDays: number;
orphanHours: number;
undoSeconds: number;
registrationMode: 'open' | 'closed';
setupToken?: string;
mailFrom: string;
resendApiKey?: string;
userQuota: number;
emailRequestDays: number;
webRoot?: string;
};
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const production = env.NODE_ENV === 'production';
const num = (key: string, fallback: number, min = 1) => {
const n = Number(env[key] ?? fallback);
if (!Number.isSafeInteger(n) || n < min) throw Error(`Invalid ${key}`);
return n;
};
const secret = (key: string) => {
const v = env[key];
if (production && (!v || v.length < 32 || v.startsWith('replace-')))
throw Error(`${key} must be a random secret (32+ characters)`);
return v && v.length >= 32 ? v : randomBytes(32).toString('base64url');
};
const port = num('PORT', 0, 0);
if (production && port === 0) throw Error('Set PORT explicitly before deployment');
const appUrl = env.APP_URL ?? 'http://127.0.0.1';
if (production && !appUrl.startsWith('https://'))
throw Error('Production APP_URL must use HTTPS');
return {
production,
appUrl,
databasePath:
env.DATABASE_PATH ?? fileURLToPath(new URL('../../../.data/hop.sqlite', import.meta.url)),
sessionSecret: secret('SESSION_SECRET'),
discoverySecret: secret('DISCOVERY_SECRET'),
discoveryEnabled:
env.SAME_NETWORK_DISCOVERY_ENABLED === undefined
? !production
: env.SAME_NETWORK_DISCOVERY_ENABLED === 'true',
trustedProxies: (env.TRUSTED_PROXY_CIDRS ?? '')
.split(',')
.map((v) => v.trim())
.filter(Boolean),
port,
host: env.HOST ?? '127.0.0.1',
guestDays: num('GUEST_SESSION_RETENTION_DAYS', 30),
requestSeconds: num('LOCAL_REQUEST_TIMEOUT_SECONDS', 60),
uploadStartHours: num('ACCEPTED_UPLOAD_START_HOURS', 24),
spaceMinutes: num('SPACE_RETENTION_MINUTES', 10),
spaceMax: num('SPACE_MAX_DEVICES', 10),
offlineGraceMs: num('OFFLINE_GRACE_MS', 3000, 0),
maxUrlBytes: num('MAX_URL_PAYLOAD_SIZE_KB', 1024) * 1024,
maxItems: num('MAX_ITEMS_PER_TRANSFER', 20),
urlRetentionDays: Math.min(7, num('DEFAULT_URL_RETENTION_DAYS', 7)),
sessionRate: num('GUEST_REQUESTS_PER_MINUTE', 5),
ipRate: num('IP_REQUESTS_PER_MINUTE', 20),
pairRate: num('PAIR_REQUESTS_PER_MINUTE', 2),
rejectCooldownMs: num('REJECT_COOLDOWN_SECONDS', 300) * 1000,
uploadDir: env.UPLOAD_DIR ?? fileURLToPath(new URL('../../../.data/uploads', import.meta.url)),
maxFileBytes: num('MAX_FILE_SIZE_MB', 2048) * 1048576,
guestQuota: num('GUEST_STORAGE_LIMIT_MB', 2048) * 1048576,
totalGuestQuota: num('TOTAL_GUEST_STORAGE_LIMIT_MB', 20480) * 1048576,
minFreeBytes: num('MIN_FREE_DISK_MB', 5120, 0) * 1048576,
fileRetentionDays: Math.min(7, num('DEFAULT_FILE_RETENTION_DAYS', 7)),
orphanHours: num('ORPHAN_UPLOAD_RETENTION_HOURS', 24),
undoSeconds: num('DELETE_UNDO_SECONDS', 5),
registrationMode: env.REGISTRATION_MODE === 'open' ? 'open' : 'closed',
setupToken: env.SETUP_TOKEN,
resendApiKey: env.RESEND_API_KEY?.trim() || undefined,
mailFrom: env.MAIL_FROM ?? 'hop. <hello@noname.company>',
userQuota: num('USER_STORAGE_LIMIT_MB', 10240) * 1048576,
emailRequestDays: num('EMAIL_REQUEST_RETENTION_DAYS', 7),
webRoot: env.WEB_ROOT,
};
}