feat: manage uploaded image retention

This commit is contained in:
Hide_D committed 2026-08-07 07:14:14 +00:00
1 parent dc0e1a6da9
commit a935b23571
13 files changed
+966 -23

No files matched your search

+234
View File
@@ -0,0 +1,234 @@
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.tLine truncated
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;
}
+355
View File
@@ -0,0 +1,355 @@
import { createHash } from 'node:crypto';
import { mkdir, readFile, readdir, rename, stat, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { DeploymentError } from './git-service.mjs';
const CLIENT_PATTERN = /^[a-z0-9][a-z0-9_-]{1,31}$/;
const FILE_PATTERN = /^[a-f0-9]{32}\.(?:avif|webp|jpe?g|png|gif)$/;
const CONTENT_PATH_PATTERN = /^uploads\/([a-z0-9][a-z0-9_-]{1,31})\/([a-f0-9]{32}\.(?:avif|webp|jpe?g|png|gif))$/;
function publicPath(category, client, filename) {
return category === 'user-icons'
? `icons/users/${client}/${filename}`
: `uploads/${client}/${filename}`;
}
function fileLocation(root, category, client, filename) {
return join(root, category, client, filename);
}
function rowToAsset(row, now, retentionMs, quarantineMs) {
if (!row) return null;
return {
path: row.path,
category: row.category,
client: row.client,
filename: row.filename,
sizeBytes: row.size_bytes,
digest: row.digest,
createdAt: new Date(row.created_at).toISOString(),
lastSeenAt: new Date(row.last_seen_at).toISOString(),
state: row.state,
candidateAt: row.candidate_at === null ? null : new Date(row.candidate_at).toISOString(),
quarantinedAt: row.quarantined_at === null ? null : new Date(row.quarantined_at).toISOString(),
deletedAt: row.deleted_at === null ? null : new Date(row.deleted_at).toISOString(),
eligibleAt: row.category === 'content'
? new Date(row.last_seen_at + retentionMs).toISOString()
: null,
deleteAvailableAt: row.quarantined_at === null
? null
: new Date(row.quarantined_at + quarantineMs).toISOString(),
deleteAvailable: row.state === 'quarantined' && row.quarantined_at + quarantineMs <= now,
};
}
export class AssetStore {
constructor(config, options = {}) {
this.root = config.uploadRoot;
this.dbPath = config.assetDbPath;
this.retentionMs = config.contentRetentionMs;
this.quarantineMs = config.contentQuarantineMs;
this.maintenanceIntervalMs = config.assetMaintenanceIntervalMs;
this.touchFlushIntervalMs = config.assetTouchFlushIntervalMs;
this.now = options.now ?? (() => Date.now());
this.pendingTouches = new Set();
this.touchTimer = null;
this.maintenanceTimer = null;
this.db = null;
}
async initialize() {
await mkdir(this.root, { recursive: true });
await mkdir(dirname(this.dbPath), { recursive: true });
this.db = new DatabaseSync(this.dbPath);
this.db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS asset (
path TEXT PRIMARY KEY,
category TEXT NOT NULL CHECK (category IN ('user-icons', 'content')),
client TEXT NOT NULL,
filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
digest TEXT,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'active'
CHECK (state IN ('active', 'candidate', 'quarantined', 'deleted')),
candidate_at INTEGER,
quarantined_at INTEGER,
deleted_at INTEGER
);
CREATE INDEX IF NOT EXISTS asset_state_seen_idx ON asset(category, state, last_seen_at);
CREATE INDEX IF NOT EXISTS asset_client_created_idx ON asset(client, created_at DESC);
`);
await this.#inventoryExisting();
this.markCandidates();
this.maintenanceTimer = setInterval(() => {
try {
this.flushTouches();
this.markCandidates();
} catch (error) {
console.error(JSON.stringify({ level: 'error', message: 'asset maintenance failed', reason: error.message }));
}
}, this.maintenanceIntervalMs);
this.maintenanceTimer.unref();
}
register({ category, client, filename, body, digest, createdAt = this.now() }) {
this.#assertOpen();
if (!['user-icons', 'content'].includes(category) || !CLIENT_PATTERN.test(client) || !FILE_PATTERN.test(filename)) {
throw new DeploymentError('Invalid asset path', 400);
}
const path = publicPath(category, client, filename);
const sizeBytes = body?.length ?? 0;
const sha256 = digest ?? (body ? createHash('sha256').update(body).digest('hex') : null);
this.db.prepare(`
INSERT INTO asset(path, category, client, filename, size_bytes, digest, created_at, last_seen_at, state)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active')
ON CONFLICT(path) DO UPDATE SET
size_bytes = excluded.size_bytes,
digest = COALESCE(asset.digest, excluded.digest)
WHERE asset.state != 'deleted'
`).run(path, category, client, filename, sizeBytes, sha256, createdAt, createdAt);
return path;
}
touch(path) {
if (!CONTENT_PATH_PATTERN.test(path)) return false;
this.pendingTouches.add(path);
if (!this.touchTimer) {
this.touchTimer = setTimeout(() => {
this.touchTimer = null;
try {
this.flushTouches();
} catch (error) {
console.error(JSON.stringify({ level: 'error', message: 'asset access flush failed', reason: error.message }));
}
}, this.touchFlushIntervalMs);
this.touchTimer.unref();
}
return true;
}
flushTouches() {
this.#assertOpen();
if (this.pendingTouches.size === 0) return 0;
const paths = [...this.pendingTouches];
this.pendingTouches.clear();
const touchedAt = this.now();
const update = this.db.prepare(`
UPDATE asset
SET last_seen_at = ?,
state = CASE WHEN state = 'candidate' THEN 'active' ELSE state END,
candidate_at = CASE WHEN state = 'candidate' THEN NULL ELSE candidate_at END
WHERE path = ? AND category = 'content' AND state IN ('active', 'candidate')
`);
this.db.exec('BEGIN IMMEDIATE');
try {
let changed = 0;
for (const path of paths) changed += Number(update.run(touchedAt, path).changes);
this.db.exec('COMMIT');
return changed;
} catch (error) {
this.db.exec('ROLLBACK');
for (const path of paths) this.pendingTouches.add(path);
throw error;
}
}
markCandidates() {
this.#assertOpen();
const now = this.now();
const cutoff = now - this.retentionMs;
return Number(this.db.prepare(`
UPDATE asset
SET state = 'candidate', candidate_at = ?
WHERE category = 'content' AND state = 'active' AND last_seen_at <= ?
`).run(now, cutoff).changes);
}
summary() {
this.#assertOpen();
const grouped = this.db.prepare(`
SELECT category, state, COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS bytes
FROM asset
GROUP BY category, state
`).all();
const result = { totalCount: 0, totalBytes: 0, groups: {} };
for (const row of grouped) {
const key = `${row.category}:${row.state}`;
result.groups[key] = { count: Number(row.count), bytes: Number(row.bytes) };
if (row.state !== 'deleted') {
result.totalCount += Number(row.count);
result.totalBytes += Number(row.bytes);
}
}
return result;
}
list({ category, state, client, search = '', limit = 100, offset = 0 } = {}) {
this.#assertOpen();
const where = [];
const values = [];
if (category) { where.push('category = ?'); values.push(category); }
if (state) { where.push('state = ?'); values.push(state); }
if (client) { where.push('client = ?'); values.push(client); }
if (search) { where.push('(path LIKE ? OR digest LIKE ?)'); values.push(`%${search}%`, `%${search}%`); }
const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
const boundedLimit = Math.max(1, Math.min(200, Number(limit) || 100));
const boundedOffset = Math.max(0, Number(offset) || 0);
const total = Number(this.db.prepare(`SELECT COUNT(*) AS count FROM asset ${clause}`).get(...values).count);
const rows = this.db.prepare(`
SELECT * FROM asset ${clause}
ORDER BY CASE state WHEN 'candidate' THEN 0 WHEN 'quarantined' THEN 1 ELSE 2 END,
last_seen_at ASC, path ASC
LIMIT ? OFFSET ?
`).all(...values, boundedLimit, boundedOffset);
const now = this.now();
return {
total,
limit: boundedLimit,
offset: boundedOffset,
assets: rows.map((row) => rowToAsset(row, now, this.retentionMs, this.quarantineMs)),
};
}
get(path) {
this.#assertOpen();
return rowToAsset(
this.db.prepare('SELECT * FROM asset WHERE path = ?').get(path),
this.now(),
this.retentionMs,
this.quarantineMs,
);
}
async preview(path) {
const asset = this.get(path);
if (!asset || !['active', 'candidate'].includes(asset.state)) {
throw new DeploymentError('Asset preview is not available', 404);
}
const contentTypeByExtension = {
avif: 'image/avif', webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
};
const extension = asset.filename.slice(asset.filename.lastIndexOf('.') + 1);
return {
body: await readFile(fileLocation(this.root, asset.category, asset.client, asset.filename)),
contentType: contentTypeByExtension[extension],
};
}
async quarantine(path) {
this.flushTouches();
const asset = this.get(path);
if (!asset || asset.category !== 'content' || asset.state !== 'candidate') {
throw new DeploymentError('Only content deletion candidates can be quarantined', 409);
}
const source = fileLocation(this.root, 'content', asset.client, asset.filename);
const destination = fileLocation(this.root, '.trash/content', asset.client, asset.filename);
await mkdir(dirname(destination), { recursive: true });
await rename(source, destination);
const now = this.now();
try {
this.db.prepare(`
UPDATE asset SET state = 'quarantined', quarantined_at = ? WHERE path = ? AND state = 'candidate'
`).run(now, path);
} catch (error) {
await rename(destination, source).catch(() => undefined);
throw error;
}
return this.get(path);
}
async restore(path) {
const asset = this.get(path);
if (!asset || asset.category !== 'content' || asset.state !== 'quarantined') {
throw new DeploymentError('Only quarantined content can be restored', 409);
}
const source = fileLocation(this.root, '.trash/content', asset.client, asset.filename);
const destination = fileLocation(this.root, 'content', asset.client, asset.filename);
await mkdir(dirname(destination), { recursive: true });
await rename(source, destination);
const now = this.now();
try {
this.db.prepare(`
UPDATE asset
SET state = 'active', last_seen_at = ?, candidate_at = NULL, quarantined_at = NULL
WHERE path = ? AND state = 'quarantined'
`).run(now, path);
} catch (error) {
await rename(destination, source).catch(() => undefined);
throw error;
}
return this.get(path);
}
async delete(path) {
const asset = this.get(path);
if (!asset || asset.category !== 'content' || asset.state !== 'quarantined') {
throw new DeploymentError('Only quarantined content can be deleted', 409);
}
if (!asset.deleteAvailable) {
throw new DeploymentError('Quarantine grace period has not elapsed', 409);
}
await unlink(fileLocation(this.root, '.trash/content', asset.client, asset.filename));
const now = this.now();
this.db.prepare(`
UPDATE asset SET state = 'deleted', deleted_at = ? WHERE path = ? AND state = 'quarantined'
`).run(now, path);
return this.get(path);
}
close() {
if (this.touchTimer) clearTimeout(this.touchTimer);
if (this.maintenanceTimer) clearInterval(this.maintenanceTimer);
this.touchTimer = null;
this.maintenanceTimer = null;
if (this.db) {
this.flushTouches();
this.db.close();
this.db = null;
}
}
async #inventoryExisting() {
for (const category of ['user-icons', 'content']) {
const categoryRoot = join(this.root, category);
let clients;
try {
clients = await readdir(categoryRoot, { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') continue;
throw error;
}
for (const clientEntry of clients) {
if (!clientEntry.isDirectory() || !CLIENT_PATTERN.test(clientEntry.name)) continue;
const files = await readdir(join(categoryRoot, clientEntry.name), { withFileTypes: true });
for (const fileEntry of files) {
if (!fileEntry.isFile() || !FILE_PATTERN.test(fileEntry.name)) continue;
const info = await stat(join(categoryRoot, clientEntry.name, fileEntry.name));
const createdAt = Math.trunc(info.birthtimeMs > 0 ? Math.min(info.birthtimeMs, info.mtimeMs) : info.mtimeMs);
this.db.prepare(`
INSERT INTO asset(path, category, client, filename, size_bytes, digest, created_at, last_seen_at, state)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, 'active')
ON CONFLICT(path) DO NOTHING
`).run(
publicPath(category, clientEntry.name, fileEntry.name),
category,
clientEntry.name,
fileEntry.name,
info.size,
createdAt,
createdAt,
);
}
}
}
}
#assertOpen() {
if (!this.db) throw new Error('AssetStore is not initialized');
}
}
+21 -2
View File
@@ -47,11 +47,22 @@ function clientSecrets(variableName) {
return result;
}
function positiveNumber(name, fallback) {
const value = Number(text(name, fallback));
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`${name} must be a positive number`);
}
return value;
}
export function loadConfig() {
const webhookSecret = secret('GITEA_WEBHOOK_SECRET', 'GITEA_WEBHOOK_SECRET_FILE');
const adminSecret = secret('IMAGE_ADMIN_SECRET', 'IMAGE_ADMIN_SECRET_FILE');
if (webhookSecret.length < 32 || adminSecret.length < 32) {
throw new Error('Webhook and admin secrets must be at least 32 characters');
const adminPanelPassword = secret('IMAGE_ADMIN_PANEL_PASSWORD', 'IMAGE_ADMIN_PANEL_PASSWORD_FILE');
const adminPanelSessionSecret = secret('IMAGE_ADMIN_PANEL_SESSION_SECRET', 'IMAGE_ADMIN_PANEL_SESSION_SECRET_FILE');
if (webhookSecret.length < 32 || adminSecret.length < 32
|| adminPanelPassword.length < 16 || adminPanelSessionSecret.length < 32) {
throw new Error('Webhook, deployment, and session secrets need 32 characters; panel password needs 16');
}
const allowedBranches = text('IMAGE_ALLOWED_BRANCHES', 'master')
@@ -80,5 +91,13 @@ export function loadConfig() {
maxContentUploadBytes: Number(text('MAX_CONTENT_UPLOAD_BYTES', '1048576')),
uploadRoot: text('IMAGE_UPLOAD_ROOT', '/var/lib/image-hook/uploads'),
uploadStatePath: text('IMAGE_UPLOAD_STATE_PATH', '/var/lib/image-hook/upload-state.json'),
assetDbPath: text('IMAGE_ASSET_DB_PATH', '/var/lib/image-hook/image-assets.sqlite3'),
contentRetentionMs: positiveNumber('IMAGE_CONTENT_RETENTION_DAYS', '730') * 86_400_000,
contentQuarantineMs: positiveNumber('IMAGE_CONTENT_QUARANTINE_DAYS', '30') * 86_400_000,
assetMaintenanceIntervalMs: positiveNumber('IMAGE_ASSET_MAINTENANCE_SECONDS', '21600') * 1000,
assetTouchFlushIntervalMs: positiveNumber('IMAGE_ASSET_TOUCH_FLUSH_SECONDS', '60') * 1000,
adminPanelPassword,
adminPanelSessionSecret,
adminPanelSessionTtlMs: positiveNumber('IMAGE_ADMIN_PANEL_SESSION_HOURS', '8') * 3_600_000,
};
}
+38 -2
View File
@@ -1,5 +1,7 @@
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { handleAdminPanel } from './admin-panel.mjs';
import { AssetStore } from './asset-store.mjs';
import { loadConfig } from './config.mjs';
import { verifyAdminSignature, verifyHexHmac, verifyUploadSignature } from './auth.mjs';
import { DeploymentError, GitService } from './git-service.mjs';
@@ -49,13 +51,18 @@ function hasImageSignature(body, extension) {
export async function createApp(config = loadConfig(), dependencies = {}) {
const service = dependencies.service ?? new GitService(config);
const uploadStore = dependencies.uploadStore ?? new UploadStore(config);
const assetStore = dependencies.assetStore ?? (dependencies.uploadStore ? {
async initialize() {}, touch() { return false; }, close() {},
} : new AssetStore(config));
const uploadStore = dependencies.uploadStore ?? new UploadStore(config, assetStore);
await service.initialize();
await assetStore.initialize();
await uploadStore.initialize();
const server = createServer(async (request, response) => {
const url = new URL(request.url, 'http://image-hook');
try {
if (await handleAdminPanel({ request, response, url, config, assetStore, readBody })) return;
if (request.method === 'GET' && url.pathname === '/healthz') {
return json(response, 200, { ok: true });
}
@@ -66,6 +73,23 @@ export async function createApp(config = loadConfig(), dependencies = {}) {
const inventory = JSON.parse(await readFile(`${config.repositoryPath}/hook/inventory.v2.json`, 'utf8'));
return json(response, 200, inventory);
}
if (['GET', 'HEAD'].includes(request.method) && url.pathname === '/v1/internal/content-access') {
const original = request.headers['x-image-path'];
let path;
try {
path = typeof original === 'string'
? new URL(original, 'http://image').pathname.replace(/^\/(?:image\/)?/, '')
: null;
} catch {
path = null;
}
if (!path || !assetStore.touch(path)) {
throw new DeploymentError('Invalid content image path', 400);
}
response.writeHead(204, { 'cache-control': 'no-store' });
response.end();
return;
}
if (request.method === 'POST' && url.pathname === '/v1/hooks/gitea') {
if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) {
throw new DeploymentError('Content-Type must be application/json', 415);
@@ -205,7 +229,9 @@ export async function createApp(config = loadConfig(), dependencies = {}) {
}
});
return { server, service, uploadStore };
server.once('close', () => assetStore.close?.());
return { server, service, uploadStore, assetStore };
}
if (process.argv[1] === new URL(import.meta.url).pathname) {
@@ -214,4 +240,14 @@ if (process.argv[1] === new URL(import.meta.url).pathname) {
server.listen(config.port, '0.0.0.0', () => {
console.log(JSON.stringify({ level: 'info', message: 'image hook listening', port: config.port }));
});
let stopping = false;
const shutdown = (signal) => {
if (stopping) return;
stopping = true;
console.log(JSON.stringify({ level: 'info', message: 'image hook stopping', signal }));
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
+4 -1
View File
@@ -4,9 +4,10 @@ import { dirname, join } from 'node:path';
import { DeploymentError } from './git-service.mjs';
export class UploadStore {
constructor(config) {
constructor(config, assetStore = null) {
this.root = config.uploadRoot;
this.statePath = config.uploadStatePath;
this.assetStore = assetStore;
this.queue = Promise.resolve();
this.uploads = [];
}
@@ -39,6 +40,7 @@ export class UploadStore {
if (previous.path !== path || previous.digest !== digest) {
throw new DeploymentError('Upload request ID was already used', 409);
}
this.assetStore?.register({ category, client, filename, body, digest });
return { duplicate: true, path: previous.path };
}
const destination = join(this.root, relativePath);
@@ -50,6 +52,7 @@ export class UploadStore {
throw new DeploymentError('Upload path already exists', 409);
}
}
this.assetStore?.register({ category, client, filename, body, digest });
this.uploads = [...this.uploads.slice(-999), { key: requestKey, path, digest }];
await this.#save();
return { duplicate: false, path };