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 ` 이미지 관리자 로그인

이미지 관리자

${failed ? '

비밀번호가 올바르지 않거나 잠시 잠겼습니다.

' : ''}
`; } 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; }