// admin.mjs — the container's internal router (127.0.0.1:5175), behind oauth2-proxy. // /cdn/* the last good build (/data/cdn/current), PUBLIC (oauth2-proxy skips auth here) // /api-prod/* PocketBase PROD, PUBLIC; only /api/ passes, never the /_/ dashboard // /pb/* PocketBase DEV, signed-in; its /_/ dashboard only for OIDC_ADMIN_GROUPS // /admin/build POST: start a build (OIDC_ADMIN_GROUPS only); /admin/status GET // Groups come from oauth2-proxy's X-Forwarded-Groups, which it sets on signed-in routes only. import { spawn } from 'node:child_process' import { existsSync, readFileSync, statSync } from 'node:fs' import { join, normalize } from 'node:path' const env = process.env const ADMIN = (env.OIDC_ADMIN_GROUPS || '').split(',').map((g) => g.trim()).filter(Boolean) const CDN = '/data/cdn/current' let last = { state: 'idle' } const groupsOf = (req) => (req.headers.get('x-forwarded-groups') || '').split(',').map((g) => g.trim()) const isAdmin = (req) => ADMIN.length > 0 && groupsOf(req).some((g) => ADMIN.includes(g)) const text = (s, status = 200) => new Response(s + '\n', { status, headers: { 'content-type': 'text/plain; charset=utf-8' } }) async function proxy(req, url, prefix, port) { const target = new URL(url.pathname.slice(prefix.length - 1) + url.search, `http://127.0.0.1:${port}`) const r = await fetch(target, { method: req.method, headers: req.headers, body: ['GET', 'HEAD'].includes(req.method) ? undefined : req.body, redirect: 'manual' }) return new Response(r.body, { status: r.status, headers: r.headers }) } Bun.serve({ hostname: '127.0.0.1', port: 5175, async fetch(req) { const url = new URL(req.url) const p = url.pathname if (p.startsWith('/cdn/')) { const f = normalize(join(CDN, decodeURIComponent(p.slice(5)))) if (!f.startsWith(CDN)) return text('bad path', 400) const file = existsSync(f) && statSync(f).isDirectory() ? join(f, 'index.html') : f if (!existsSync(file)) return text('not found', 404) return new Response(Bun.file(file), { headers: { 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=86400, stale-while-revalidate=604800' } }) } if (p.startsWith('/api-prod/')) { if (!p.startsWith('/api-prod/api/')) return text('only /api/ is public', 403) return proxy(req, url, '/api-prod/', 8091) } if (p.startsWith('/pb/')) { if (p.startsWith('/pb/_/') && !isAdmin(req)) return text('the PocketBase dashboard is for admins', 403) return proxy(req, url, '/pb/', 8090) } if (p === '/admin/status') return Response.json(last) if (p === '/admin/build') { if (req.method !== 'POST') return text('POST only', 405) if (!isAdmin(req)) return text('builds are for team admins', 403) if (last.state === 'building') return text('a build is already running', 409) last = { state: 'building', started: new Date().toISOString() } const child = spawn('bash', ['/app/repo/deployment/cloud/build-cdn.sh'], { stdio: ['ignore', 'pipe', 'pipe'] }) let log = '' child.stdout.on('data', (d) => { log = (log + d).slice(-4000) }) child.stderr.on('data', (d) => { log = (log + d).slice(-4000) }) child.on('exit', (code) => { last = { state: code === 0 ? 'done' : code === 75 ? 'busy' : 'failed', code, finished: new Date().toISOString(), tail: log.slice(-1500), commit: existsSync('/data/cdn/COMMIT') ? readFileSync('/data/cdn/COMMIT', 'utf8').trim() : null } }) return text('build started', 202) } return text('not found', 404) }, }) console.log('[admin] listening on 127.0.0.1:5175')