doxa-maps/deployment/cloud/admin.mjs
Doxa Maps d9548e5959
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 46b1e857b
2026-09-28 00:36:45 +00:00

76 lines
4.6 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,
// No idle cut-off: PocketBase's realtime stream (/pb/api/realtime) sits quiet while someone signs in in the
// popup, and Bun's default (10 s) closed it before the sign-in result could arrive.
idleTimeout: 0,
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')