forked from devsam/image
235 lines
15 KiB
JavaScript
235 lines
15 KiB
JavaScript
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;
|
||
}
|