/admin/pb-signin-check (behind the site sign-in) signs in to pb-dev through the team account, with the same realtime flow as the PocketBase SDK, and shows the email and groups it sees, or the exact refusal. The token is never stored.
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')
|