Files
image/node-hook/src/admin-panel.mjs
T

235 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 `<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:,">
<title>이미지 관리자 로그인</title><style nonce="${nonce}">
body{font-family:system-ui,sans-serif;background:#111827;color:#e5e7eb;display:grid;place-items:center;min-height:100vh;margin:0}
form{background:#1f2937;padding:2rem;border-radius:.75rem;width:min(22rem,calc(100vw - 3rem));box-shadow:0 1rem 3rem #0008}
label,input,button{display:block;width:100%;box-sizing:border-box}input{margin:.6rem 0 1rem;padding:.75rem;border-radius:.4rem;border:1px solid #4b5563;background:#111827;color:#fff}
button{padding:.75rem;border:0;border-radius:.4rem;background:#2563eb;color:#fff;font-weight:700;cursor:pointer}button:focus,input:focus{outline:2px solid #60a5fa;outline-offset:2px}.error{color:#fca5a5}
</style></head><body><form method="post" action="/admin/login"><h1>이미지 관리자</h1>
${failed ? '<p class="error">비밀번호가 올바르지 않거나 잠시 잠겼습니다.</p>' : ''}
<label>관리 비밀번호<input name="password" type="password" autocomplete="current-password" required autofocus></label>
<button type="submit">로그인</button></form></body></html>`;
}
function dashboardPage(nonce, csrf) {
return `<!doctype html>
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:,">
<meta name="csrf-token" content="${csrf}"><title>이미지 관리자</title><style nonce="${nonce}">
:root{color-scheme:dark}body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;margin:0}header,main{max-width:1200px;margin:auto;padding:1rem}
header{display:flex;align-items:center;justify-content:space-between}button,select,input{font:inherit;padding:.5rem;border:1px solid #475569;border-radius:.35rem;background:#1e293b;color:#e2e8f0}button{cursor:pointer}button:focus,select:focus,input:focus{outline:2px solid #60a5fa;outline-offset:2px}.danger{background:#991b1b}.safe{background:#166534}
.summary,.filters{display:flex;gap:.75rem;flex-wrap:wrap;margin-bottom:1rem}.card{background:#1e293b;padding:.8rem 1rem;border-radius:.5rem}.table-wrap{overflow:auto;background:#111827;border-radius:.5rem}
table{border-collapse:collapse;width:100%;min-width:900px}th,td{padding:.65rem;border-bottom:1px solid #334155;text-align:left}th{position:sticky;top:0;background:#1e293b}img{width:72px;height:72px;object-fit:contain;background:#fff1;border-radius:.25rem}
.path{max-width:28rem;word-break:break-all}.muted{color:#94a3b8}.state-candidate{color:#fbbf24}.state-quarantined{color:#fb7185}.state-deleted{color:#94a3b8}.actions{display:flex;gap:.4rem}.actions button{white-space:nowrap}.notice{min-height:1.5rem}
</style></head><body><header><h1>이미지 관리자</h1><form method="post" action="/admin/logout"><input type="hidden" name="csrf" value="${csrf}"><button>로그아웃</button></form></header>
<main><div id="summary" class="summary"></div><div class="filters">
<select id="category"><option value="">전체 종류</option><option value="user-icons">전용 아이콘</option><option value="content">Tiptap</option></select>
<select id="state"><option value="">전체 상태</option><option value="active">사용 중</option><option value="candidate">삭제 후보</option><option value="quarantined">격리</option><option value="deleted">삭제됨</option></select>
<select id="client"><option value="">전체 서비스</option><option value="core">core</option><option value="core2026">core2026</option></select>
<input id="search" placeholder="경로 또는 SHA-256 검색"><button id="reload">조회</button></div><p id="notice" class="notice"></p>
<div class="table-wrap"><table><thead><tr><th>미리보기</th><th>경로</th><th>종류</th><th>크기</th><th>업로드</th><th>마지막 요청</th><th>상태</th><th>관리</th></tr></thead><tbody id="assets"></tbody></table></div>
<div class="filters"><button id="prev">이전</button><span id="page" class="card"></span><button id="next">다음</button></div></main>
<script nonce="${nonce}">
const csrf=document.querySelector('meta[name=csrf-token]').content;const el=id=>document.getElementById(id);const stateNames={active:'사용 중',candidate:'삭제 후보',quarantined:'격리',deleted:'삭제됨'};const pageSize=100;let offset=0;const fmtBytes=n=>n<1024?n+' B':n<1048576?(n/1024).toFixed(1)+' KiB':(n/1048576).toFixed(1)+' MiB';const fmtDate=v=>v?new Date(v).toLocaleString('ko-KR'):'-';
function cell(text,cls=''){const td=document.createElement('td');td.textContent=text;if(cls)td.className=cls;return td}
async function action(path,action){if(action==='delete'&&!confirm('격리 유예기간이 지난 파일을 영구 삭제합니다. 계속할까요?'))return;if(action==='quarantine'&&!confirm('공개 경로에서 이미지를 내리고 격리합니다. 계속할까요?'))return;const response=await fetch('/admin/api/assets/action',{method:'POST',headers:{'content-type':'application/json','x-csrf-token':csrf},body:JSON.stringify({path,action})});const result=await response.json();el('notice').textContent=response.ok?'처리했습니다.':result.reason||'처리하지 못했습니다.';await load()}
function button(label,action,path,cls=''){const b=document.createElement('button');b.textContent=label;b.className=cls;b.onclick=()=>actionFn(path,action);return b}const actionFn=action;
async function load(){const params=new URLSearchParams({limit:String(pageSize),offset:String(offset)});for(const id of ['category','state','client','search'])if(el(id).value)params.set(id,el(id).value);const response=await fetch('/admin/api/assets?'+params);if(response.status===401){location.reload();return}const data=await response.json();el('summary').replaceChildren(...[['보관 파일',data.summary.totalCount],['사용량',fmtBytes(data.summary.totalBytes)],['검색 결과',data.total]].map(([k,v])=>{const d=document.createElement('div');d.className='card';d.textContent=k+': '+v;return d}));const rows=data.assets.map(a=>{const tr=document.createElement('tr');const preview=document.createElement('td');if(a.state!=='deleted'&&a.state!=='quarantined'){const img=document.createElement('img');img.src='/admin/api/assets/preview?path='+encodeURIComponent(a.path);img.loading='lazy';img.alt='';preview.append(img)}tr.append(preview,cell(a.path,'path'),cell(a.category==='user-icons'?'전용 아이콘':'Tiptap'),cell(fmtBytes(a.sizeBytes)),cell(fmtDate(a.createdAt)),cell(fmtDate(a.lastSeenAt)),cell(stateNames[a.state]||a.state,'state-'+a.state));const controls=document.createElement('td');controls.className='actions';if(a.state==='candidate')controls.append(button('격리','quarantine',a.path,'danger'));if(a.state==='quarantined'){controls.append(button('복구','restore',a.path,'safe'));const del=button('영구 삭제','delete',a.path,'danger');del.disabled=!a.deleteAvailable;del.title=a.deleteAvailable?'':('삭제 가능: '+fmtDate(a.deleteAvailableAt));controls.append(del)}tr.append(controls);return tr});el('assets').replaceChildren(...rows);el('prev').disabled=offset===0;el('next').disabled=offset+data.assets.length>=data.total;el('page').textContent=data.total===0?'0건':(offset+1)+''+(offset+data.assets.length)+' / '+data.total}
el('reload').onclick=()=>{offset=0;load()};el('prev').onclick=()=>{offset=Math.max(0,offset-pageSize);load()};el('next').onclick=()=>{offset+=pageSize;load()};load();
</script></body></html>`;
}
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;
}