73 lines
4.4 KiB
JavaScript
73 lines
4.4 KiB
JavaScript
// 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. With PB_OIDC_CLIENT_SECRET,
|
|
// its /api/ is PUBLIC (PocketBase signs people in itself), except _superusers and _system collections
|
|
// /admin/build POST: start a build (OIDC_ADMIN_GROUPS only); /admin/status GET
|
|
// /admin/pb-signin-check a page that signs in to the team PocketBase and shows who it thinks you are
|
|
// 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)
|
|
// The superuser (and every _system collection) is for admins only. oauth2-proxy never opens these
|
|
// paths to the public (its skip routes exclude names starting with _), so the groups header is its own.
|
|
if (p.startsWith('/pb/api/collections/_') && !isAdmin(req)) return text('for admins only', 403)
|
|
return proxy(req, url, '/pb/', 8090)
|
|
}
|
|
// A one-click check of the team PocketBase sign-in (behind the site sign-in like every /admin/ path).
|
|
if (p === '/admin/pb-signin-check') return new Response(Bun.file('/app/repo/deployment/cloud/pb-signin-check.html'), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
|
|
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')
|