VAPID private key를 Compose secret으로 주입하고 Web Push를 기본 비활성으로 전달한다. 활성화 입력과 Compose 모델 검증, 운영 README를 함께 추가한다.
144 lines
5.4 KiB
JavaScript
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.');
|
|
}
|
|
}
|