Files
Hide_D 663e66b8f9 feat: Web Push 비활성 운영 설정을 준비한다
VAPID private key를 Compose secret으로 주입하고 Web Push를 기본 비활성으로 전달한다. 활성화 입력과 Compose 모델 검증, 운영 README를 함께 추가한다.
2026-08-23 13:16:21 +00:00

144 lines
5.4 KiB
JavaScript

import path from 'node:path';
import { fileURLToPath } from 'node:url';
const REQUIRED = [
'DOMAIN',
'ACME_EMAIL',
'CORE_REPOSITORY_URL',
'POSTGRES_PASSWORD',
'REDIS_PASSWORD',
'GAME_TOKEN_SECRET',
'GATEWAY_BOOTSTRAP_TOKEN',
'INITIAL_ADMIN_USERNAME',
'INITIAL_ADMIN_PASSWORD',
'KAKAO_REST_KEY',
];
const MIN_LENGTH = new Map([
['POSTGRES_PASSWORD', 24],
['REDIS_PASSWORD', 24],
['GAME_TOKEN_SECRET', 32],
['GATEWAY_BOOTSTRAP_TOKEN', 32],
['INITIAL_ADMIN_PASSWORD', 16],
]);
const isPlaceholder = (value) =>
value.startsWith('replace-with-') || value.includes('example.com') || value.includes('your-org');
const decodeBase64 = (value) => {
try {
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) return null;
return Buffer.from(value, 'base64').toString('utf8');
} catch {
return null;
}
};
export const validateEnvironment = (env) => {
const errors = [];
for (const key of REQUIRED) {
const value = env[key]?.trim() ?? '';
if (!value) errors.push(`${key} is required`);
else if (isPlaceholder(value)) errors.push(`${key} still contains an example placeholder`);
}
for (const [key, length] of MIN_LENGTH) {
const value = env[key] ?? '';
if (value && value.length < length) errors.push(`${key} must contain at least ${length} characters`);
}
const domain = env.DOMAIN?.trim() ?? '';
if (domain && !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(domain)) {
errors.push('DOMAIN must be a hostname without a scheme, path, or port');
}
const caddySiteAddress = env.CADDY_SITE_ADDRESS?.trim() ?? '';
if (caddySiteAddress) {
try {
const parsed = new URL(caddySiteAddress);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.hostname !== domain || parsed.pathname !== '/') {
errors.push('CADDY_SITE_ADDRESS must be http://DOMAIN or https://DOMAIN');
}
} catch {
errors.push('CADDY_SITE_ADDRESS must be http://DOMAIN or https://DOMAIN');
}
}
const email = env.ACME_EMAIL?.trim() ?? '';
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.push('ACME_EMAIL must be a valid email address');
const repositoryUrl = env.CORE_REPOSITORY_URL?.trim() ?? '';
if (repositoryUrl) {
if (/^https?:\/\//i.test(repositoryUrl)) {
try {
const parsed = new URL(repositoryUrl);
if (parsed.username || parsed.password) errors.push('CORE_REPOSITORY_URL must not embed credentials');
} catch {
errors.push('CORE_REPOSITORY_URL is not a valid HTTP(S) URL');
}
} else if (!/^(?:ssh:\/\/|git@)[^\s]+/i.test(repositoryUrl)) {
errors.push('CORE_REPOSITORY_URL must use HTTP(S) or SSH');
}
}
const httpsUser = env.CORE_REPOSITORY_USERNAME?.trim() ?? '';
const httpsToken = env.CORE_REPOSITORY_TOKEN?.trim() ?? '';
const sshKey = env.CORE_SSH_PRIVATE_KEY_BASE64?.trim() ?? '';
const sshHosts = env.CORE_SSH_KNOWN_HOSTS_BASE64?.trim() ?? '';
if (Boolean(httpsUser) !== Boolean(httpsToken)) {
errors.push('CORE_REPOSITORY_USERNAME and CORE_REPOSITORY_TOKEN must be set together');
}
if (Boolean(sshKey) !== Boolean(sshHosts)) {
errors.push('CORE_SSH_PRIVATE_KEY_BASE64 and CORE_SSH_KNOWN_HOSTS_BASE64 must be set together');
}
if ((httpsUser || httpsToken) && (sshKey || sshHosts)) errors.push('configure only one Core repository authentication mode');
if ((httpsUser || httpsToken) && !/^https?:\/\//i.test(repositoryUrl)) {
errors.push('HTTPS repository credentials require an HTTP(S) CORE_REPOSITORY_URL');
}
if ((sshKey || sshHosts) && !/^(?:ssh:\/\/|git@)/i.test(repositoryUrl)) {
errors.push('SSH repository credentials require an SSH CORE_REPOSITORY_URL');
}
if (sshKey) {
const decoded = decodeBase64(sshKey);
if (!decoded?.includes('BEGIN OPENSSH PRIVATE KEY')) {
errors.push('CORE_SSH_PRIVATE_KEY_BASE64 must encode an OpenSSH private key');
}
}
if (sshHosts && !decodeBase64(sshHosts)?.trim()) {
errors.push('CORE_SSH_KNOWN_HOSTS_BASE64 must contain valid base64-encoded known_hosts data');
}
const webPushEnabled = env.WEB_PUSH_ENABLED?.trim() === 'true';
if (env.WEB_PUSH_ENABLED && !['true', 'false'].includes(env.WEB_PUSH_ENABLED.trim())) {
errors.push('WEB_PUSH_ENABLED must be true or false');
}
if (webPushEnabled) {
const subject = env.WEB_PUSH_VAPID_SUBJECT?.trim() ?? '';
const publicKey = env.WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? '';
const privateKeyFile = env.WEB_PUSH_VAPID_PRIVATE_KEY_FILE?.trim() ?? '';
if (!/^(?:mailto:|https:\/\/)/i.test(subject) || isPlaceholder(subject)) {
errors.push('WEB_PUSH_VAPID_SUBJECT must be a non-placeholder mailto: or HTTPS contact');
}
if (!publicKey || isPlaceholder(publicKey)) {
errors.push('WEB_PUSH_VAPID_PUBLIC_KEY is required when Web Push is enabled');
}
if (!privateKeyFile || isPlaceholder(privateKeyFile) || privateKeyFile.endsWith('.example')) {
errors.push('WEB_PUSH_VAPID_PRIVATE_KEY_FILE is required when Web Push is enabled');
}
}
return errors;
};
const isMain = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isMain) {
const errors = validateEnvironment(process.env);
if (errors.length) {
console.error('Environment validation failed:');
for (const error of errors) console.error(`- ${error}`);
process.exitCode = 64;
} else {
console.log('Environment validation passed without printing secret values.');
}
}