101 lines
3.4 KiB
TypeScript
101 lines
3.4 KiB
TypeScript
import { Hono } from 'hono';
|
|
import { serve } from '@hono/node-server';
|
|
import { verifyPurgeToken } from './verify';
|
|
import { purgePath } from './purge';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Boot guard — fail loud if secret is absent or too short
|
|
// ---------------------------------------------------------------------------
|
|
const secret = process.env['EDGE_PURGE_TOKEN'] ?? '';
|
|
|
|
if (!secret) {
|
|
process.stderr.write('FATAL: EDGE_PURGE_TOKEN is not set\n');
|
|
process.exit(1);
|
|
}
|
|
|
|
// "32 bytes" = 32 raw bytes = 64 hex chars minimum
|
|
if (!/^[0-9a-fA-F]{64,}$/.test(secret)) {
|
|
process.stderr.write(
|
|
'FATAL: EDGE_PURGE_TOKEN must be at least 64 hex characters (32 bytes)\n'
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const port = Number(process.env['EDGE_PURGE_PORT'] ?? 9099);
|
|
const cacheDir = process.env['EDGE_PURGE_CACHE_DIR'] ?? '/var/cache/nginx/pseo';
|
|
|
|
function logLine(fields: Record<string, unknown>): void {
|
|
process.stderr.write(
|
|
JSON.stringify({ ts: Math.floor(Date.now() / 1000), ...fields }) + '\n'
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// App
|
|
// ---------------------------------------------------------------------------
|
|
const app = new Hono();
|
|
|
|
// auth_request-style verifier — side-effect free, kept for nginx subrequest use.
|
|
app.get('/verify', (c) => {
|
|
const uri = c.req.header('X-Original-URI');
|
|
const token = c.req.header('X-Purge-Token');
|
|
const tsRaw = c.req.header('X-Purge-Ts');
|
|
|
|
if (typeof uri !== 'string' || typeof token !== 'string' || typeof tsRaw !== 'string') {
|
|
logLine({ ok: false, reason: 'missing-headers' });
|
|
return c.body(null, 403);
|
|
}
|
|
|
|
const result = verifyPurgeToken({ uri, token, tsRaw, secret });
|
|
logLine({ ok: result.ok, ...(result.reason !== undefined ? { reason: result.reason } : {}) });
|
|
return result.ok ? c.body(null, 204) : c.body(null, 403);
|
|
});
|
|
|
|
// Purge endpoint — verifies the HMAC over the *target* path (the client signs
|
|
// path + "\n" + ts; see @features/api/src/lib/edge-purge.ts), then unlinks
|
|
// matching pseo_cache entries. Idempotent: purging an uncached path succeeds
|
|
// with purged=0.
|
|
app.post('/__purge', async (c) => {
|
|
const path = c.req.query('path');
|
|
const token = c.req.header('X-Purge-Token');
|
|
const tsRaw = c.req.header('X-Purge-Ts');
|
|
|
|
if (typeof path !== 'string' || typeof token !== 'string' || typeof tsRaw !== 'string') {
|
|
logLine({ ok: false, event: 'purge', reason: 'missing-params' });
|
|
return c.json({ ok: false }, 403);
|
|
}
|
|
|
|
if (!path.startsWith('/') || path.includes('\0') || path.length > 2048) {
|
|
logLine({ ok: false, event: 'purge', reason: 'bad-path' });
|
|
return c.json({ ok: false }, 400);
|
|
}
|
|
|
|
const result = verifyPurgeToken({ uri: path, token, tsRaw, secret });
|
|
if (!result.ok) {
|
|
logLine({
|
|
ok: false,
|
|
event: 'purge',
|
|
path,
|
|
...(result.reason !== undefined ? { reason: result.reason } : {}),
|
|
});
|
|
return c.json({ ok: false }, 403);
|
|
}
|
|
|
|
const { scanned, purged } = await purgePath(cacheDir, path);
|
|
logLine({ ok: true, event: 'purge', path, scanned, purged });
|
|
return c.json({ ok: true, purged });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Server — 127.0.0.1 only
|
|
// ---------------------------------------------------------------------------
|
|
serve(
|
|
{
|
|
fetch: app.fetch,
|
|
hostname: '127.0.0.1',
|
|
port,
|
|
},
|
|
(info) => {
|
|
logLine({ ok: true, reason: 'listening', port: info.port });
|
|
}
|
|
);
|