import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { DeploymentError } from './git-service.mjs';
const failedLogins = [];
function baseHeaders(extra = {}) {
return {
'cache-control': 'no-store',
'content-security-policy': "default-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY',
...extra,
};
}
function sendHtml(response, status, body, nonce) {
response.writeHead(status, baseHeaders({
'content-type': 'text/html; charset=utf-8',
'content-length': Buffer.byteLength(body),
'content-security-policy': `default-src 'self'; img-src 'self' data:; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'`,
}));
response.end(body);
}
function sendJson(response, status, value) {
const body = JSON.stringify(value);
response.writeHead(status, baseHeaders({
'content-type': 'application/json; charset=utf-8',
'content-length': Buffer.byteLength(body),
}));
response.end(body);
}
function sendBinary(response, status, body, contentType) {
response.writeHead(status, baseHeaders({
'content-type': contentType,
'content-length': body.length,
'content-disposition': 'inline',
}));
response.end(body);
}
function safeTextEqual(expected, supplied) {
const left = createHash('sha256').update(expected).digest();
const right = createHash('sha256').update(String(supplied ?? '')).digest();
return timingSafeEqual(left, right);
}
function sessionSignature(secret, payload) {
return createHmac('sha256', secret).update(payload).digest('base64url');
}
function createSession(secret, ttlMs, now) {
const session = {
expiresAt: now + ttlMs,
csrf: randomBytes(24).toString('base64url'),
};
const payload = Buffer.from(JSON.stringify(session)).toString('base64url');
return { token: `${payload}.${sessionSignature(secret, payload)}`, session };
}
function parseCookies(header) {
const result = Object.create(null);
for (const part of String(header ?? '').split(';')) {
const separator = part.indexOf('=');
if (separator < 1) continue;
result[part.slice(0, separator).trim()] = part.slice(separator + 1).trim();
}
return result;
}
function verifySession(secret, token, now) {
if (typeof token !== 'string') return null;
const separator = token.lastIndexOf('.');
if (separator < 1) return null;
const payload = token.slice(0, separator);
const supplied = token.slice(separator + 1);
const expected = sessionSignature(secret, payload);
if (!safeTextEqual(expected, supplied)) return null;
try {
const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
if (!Number.isFinite(session.expiresAt) || session.expiresAt <= now
|| typeof session.csrf !== 'string' || session.csrf.length < 24) return null;
return session;
} catch {
return null;
}
}
function loginAllowed(now) {
while (failedLogins.length > 0 && failedLogins[0] < now - 5 * 60_000) failedLogins.shift();
return failedLogins.length < 10;
}
function loginPage(nonce, failed = false) {
return `
이미지 관리자 로그인`;
}
function dashboardPage(nonce, csrf) {
return `
이미지 관리자
`;
}
export async function handleAdminPanel({ request, response, url, config, assetStore, readBody, now = Date.now() }) {
if (url.pathname !== '/admin' && !url.pathname.startsWith('/admin/')) return false;
const nonce = randomBytes(18).toString('base64url');
if (url.pathname === '/admin') {
response.writeHead(308, baseHeaders({ location: '/admin/' }));
response.end();
return true;
}
if (request.method === 'POST' && url.pathname === '/admin/login') {
if (!request.headers['content-type']?.toLowerCase().startsWith('application/x-www-form-urlencoded')) {
throw new DeploymentError('Login form must be URL encoded', 415);
}
const body = await readBody(request, 4096);
const password = new URLSearchParams(body.toString('utf8')).get('password');
if (!loginAllowed(now) || !safeTextEqual(config.adminPanelPassword, password)) {
failedLogins.push(now);
sendHtml(response, 401, loginPage(nonce, true), nonce);
return true;
}
failedLogins.length = 0;
const { token } = createSession(config.adminPanelSessionSecret, config.adminPanelSessionTtlMs, now);
response.writeHead(303, baseHeaders({
location: '/admin/',
'set-cookie': `sam_image_admin=${token}; Path=/admin; Max-Age=${Math.floor(config.adminPanelSessionTtlMs / 1000)}; HttpOnly; Secure; SameSite=Strict`,
}));
response.end();
return true;
}
const cookies = parseCookies(request.headers.cookie);
const session = verifySession(config.adminPanelSessionSecret, cookies.sam_image_admin, now);
if (!session) {
if (url.pathname.startsWith('/admin/api/')) sendJson(response, 401, { ok: false, reason: 'authentication required' });
else sendHtml(response, 200, loginPage(nonce), nonce);
return true;
}
if (request.method === 'POST' && url.pathname === '/admin/logout') {
const body = await readBody(request, 4096);
const csrf = new URLSearchParams(body.toString('utf8')).get('csrf');
if (!safeTextEqual(session.csrf, csrf)) throw new DeploymentError('Invalid CSRF token', 403);
response.writeHead(303, baseHeaders({
location: '/admin/',
'set-cookie': 'sam_image_admin=; Path=/admin; Max-Age=0; HttpOnly; Secure; SameSite=Strict',
}));
response.end();
return true;
}
if (request.method === 'GET' && url.pathname === '/admin/') {
sendHtml(response, 200, dashboardPage(nonce, session.csrf), nonce);
return true;
}
if (request.method === 'GET' && url.pathname === '/admin/api/assets') {
const listed = assetStore.list({
category: url.searchParams.get('category') || undefined,
state: url.searchParams.get('state') || undefined,
client: url.searchParams.get('client') || undefined,
search: url.searchParams.get('search') ?? '',
limit: url.searchParams.get('limit') ?? 100,
offset: url.searchParams.get('offset') ?? 0,
});
sendJson(response, 200, { ok: true, summary: assetStore.summary(), ...listed });
return true;
}
if (request.method === 'GET' && url.pathname === '/admin/api/assets/preview') {
const preview = await assetStore.preview(url.searchParams.get('path') ?? '');
sendBinary(response, 200, preview.body, preview.contentType);
return true;
}
if (request.method === 'POST' && url.pathname === '/admin/api/assets/action') {
if (!safeTextEqual(session.csrf, request.headers['x-csrf-token'])) {
throw new DeploymentError('Invalid CSRF token', 403);
}
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
throw new DeploymentError('Content-Type must be application/json', 415);
}
let payload;
try {
payload = JSON.parse((await readBody(request, 4096)).toString('utf8'));
} catch {
throw new DeploymentError('Invalid JSON body', 400);
}
if (typeof payload?.path !== 'string' || !['quarantine', 'restore', 'delete'].includes(payload?.action)) {
throw new DeploymentError('Invalid asset action', 400);
}
const result = await assetStore[payload.action](payload.path);
sendJson(response, 200, { ok: true, asset: result });
return true;
}
sendJson(response, 404, { ok: false, reason: 'not found' });
return true;
}