193 lines
7.6 KiB
JavaScript
193 lines
7.6 KiB
JavaScript
// Admin-API fuer den Preview-Block je Projekt (WOMS). Nie Variablenwerte ausgeben.
|
||
import { Router } from 'express'
|
||
import { config } from '../../config.js'
|
||
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
|
||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||
import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js'
|
||
import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js'
|
||
import * as runnerClient from '../../services/previewRunnerClient.js'
|
||
import { runDeploy, fetchPreviewConfig } from '../../services/previewDeploy.js'
|
||
import { withLocks } from '../../services/keyedLock.js'
|
||
import { getRepoInfo } from '../../services/giteaAdmin.js'
|
||
|
||
const router = Router()
|
||
router.use(requireAppwriteStaff)
|
||
|
||
const BUSY = 'Server ausgelastet, versuche es gleich noch mal'
|
||
const MODES = ['auto', 'build', 'dev', 'off']
|
||
const SHA_RE = /^[0-9a-f]{7,40}$/
|
||
|
||
function fail(status, message) {
|
||
const err = new Error(message)
|
||
err.status = status
|
||
err.expected = true
|
||
return err
|
||
}
|
||
|
||
const col = () => config.collections.websiteProjects
|
||
|
||
async function loadProject(id) {
|
||
let doc
|
||
try {
|
||
doc = await getDocument(col(), id)
|
||
} catch (err) {
|
||
if (err.status === 404) throw fail(404, 'Projekt nicht gefunden')
|
||
throw err
|
||
}
|
||
if (!doc.subdomain) throw fail(400, 'Projekt hat keine Preview-Subdomain')
|
||
return doc
|
||
}
|
||
|
||
function requireRunner() {
|
||
if (!config.previewRunner.enabled) throw fail(409, 'Preview-Runner ist nicht aktiv')
|
||
}
|
||
|
||
async function view(doc) {
|
||
const key = envKey()
|
||
let names = []
|
||
if (doc.previewEnv && key) {
|
||
try { names = envNames(doc.previewEnv, key) } catch { names = [] }
|
||
}
|
||
const mode = doc.previewMode || 'auto'
|
||
return {
|
||
mode,
|
||
state: doc.previewState || (mode === 'off' ? 'off' : ''),
|
||
detected: doc.previewDetected || '',
|
||
builtSha: doc.previewBuiltSha || '',
|
||
builtAt: doc.previewBuiltAt || '',
|
||
error: doc.previewError || '',
|
||
branch: doc.previewBranch || '',
|
||
envNames: names,
|
||
logs: await listRunLogs(doc.$id),
|
||
}
|
||
}
|
||
|
||
// Neubau anstossen (wie beim Bearbeiten/Reaktivieren eines Projekts)
|
||
async function triggerRebuild(doc) {
|
||
const repo = doc.repoFullName
|
||
const branch = doc.previewBranch || (await getRepoInfo(repo).catch(() => null))?.defaultBranch || 'main'
|
||
const found = await fetchPreviewConfig(repo, branch).catch(() => null)
|
||
const cfg = {
|
||
enabled: true,
|
||
type: 'static',
|
||
displayName: doc.projectName,
|
||
...(found || {}),
|
||
branch,
|
||
subdomain: doc.subdomain,
|
||
}
|
||
const result = await runDeploy(repo, branch, cfg, { project: doc })
|
||
// runDeploy meldet Runner-Fehler im Ergebnis; Schutzschalter (503) an WOMS durchreichen
|
||
if (result?.error && /Server ausgelastet/.test(result.error)) throw fail(503, BUSY)
|
||
return result
|
||
}
|
||
|
||
const wrap = (fn) => async (req, res) => {
|
||
try {
|
||
await fn(req, res)
|
||
} catch (err) {
|
||
if (!err.expected && err.status === 503) return res.status(503).json({ error: BUSY })
|
||
const status = err.expected ? err.status : 500
|
||
if (!err.expected) console.error('[admin/preview]', err.message)
|
||
res.status(status).json({ error: !err.expected ? 'Preview-Aktion fehlgeschlagen' : err.message })
|
||
}
|
||
}
|
||
|
||
router.get('/:id/preview', wrap(async (req, res) => {
|
||
res.json(await view(await loadProject(req.params.id)))
|
||
}))
|
||
|
||
router.patch('/:id/preview', wrap(async (req, res) => {
|
||
requireRunner()
|
||
const mode = req.body?.mode
|
||
if (!MODES.includes(mode)) throw fail(400, 'Ungültiger Modus (auto, build, dev oder off)')
|
||
const doc = await loadProject(req.params.id)
|
||
if (mode === 'off') {
|
||
const saved = await updateDocument(col(), doc.$id, { previewMode: mode })
|
||
// runDeploy (Modus off) belegt den Platz des Repos: laufende Verfolger schreiben danach nichts mehr
|
||
await triggerRebuild(saved)
|
||
try { await runnerClient.stopDev(doc.subdomain) } catch (err) {
|
||
console.error('[admin/preview] Dev stoppen fehlgeschlagen:', err.message)
|
||
}
|
||
return res.json(await view(await getDocument(col(), doc.$id)))
|
||
}
|
||
const updated = await updateDocument(col(), doc.$id, { previewMode: mode })
|
||
await triggerRebuild(updated)
|
||
res.json(await view(await getDocument(col(), doc.$id)))
|
||
}))
|
||
|
||
router.put('/:id/preview/env', wrap(async (req, res) => {
|
||
requireRunner()
|
||
const key = envKey()
|
||
if (!key) throw fail(503, 'Preview-Variablen sind nicht eingerichtet')
|
||
const { set = {}, remove = [] } = req.body || {}
|
||
if (!set || typeof set !== 'object' || Array.isArray(set)) throw fail(400, '„set“ muss ein Objekt sein.')
|
||
if (!Array.isArray(remove)) throw fail(400, '„remove“ muss eine Liste sein.')
|
||
const updated = await withLocks(['preview-env:' + req.params.id], async () => {
|
||
const doc = await loadProject(req.params.id)
|
||
let current
|
||
try { current = decryptEnv(doc.previewEnv, key) } catch {
|
||
throw fail(409, 'Vorhandene Preview-Variablen sind nicht entschlüsselbar')
|
||
}
|
||
const result = validateEnvChange(current, { set, remove })
|
||
if (!result.ok) throw fail(400, result.error)
|
||
const stored = Object.keys(result.env).length ? encryptEnv(result.env, key) : ''
|
||
if (stored.length > 20000) throw fail(400, 'Zu viele oder zu lange Variablen')
|
||
return updateDocument(col(), doc.$id, { previewEnv: stored })
|
||
})
|
||
if ((updated.previewMode || 'auto') !== 'off') await triggerRebuild(updated)
|
||
res.json(await view(await getDocument(col(), updated.$id)))
|
||
}))
|
||
|
||
router.post('/:id/preview/rebuild', wrap(async (req, res) => {
|
||
requireRunner()
|
||
const doc = await loadProject(req.params.id)
|
||
if ((doc.previewMode || 'auto') === 'off') throw fail(409, 'Preview ist ausgeschaltet')
|
||
await triggerRebuild(doc)
|
||
res.json(await view(await getDocument(col(), doc.$id)))
|
||
}))
|
||
|
||
router.post('/:id/preview/dev/start', wrap(async (req, res) => {
|
||
requireRunner()
|
||
const doc = await loadProject(req.params.id)
|
||
if ((doc.previewMode || 'auto') === 'off') throw fail(409, 'Preview ist ausgeschaltet')
|
||
if (doc.isPublic === true && doc.previewMode !== 'dev') throw fail(409, 'Öffentliche Seite: Dev-Server nur im Modus Dev-Server')
|
||
// Gespeicherten Stand verwenden (R17), nie raten
|
||
if (!doc.repoFullName || !doc.previewBranch || !SHA_RE.test(String(doc.previewBuiltSha || ''))) {
|
||
throw fail(409, 'Noch kein Stand gespeichert – bitte zuerst neu bauen')
|
||
}
|
||
let env = {}
|
||
if (doc.previewEnv) {
|
||
const key = envKey()
|
||
try { env = decryptEnv(doc.previewEnv, key) } catch {
|
||
throw fail(409, 'Preview-Variablen nicht entschlüsselbar (PREVIEW_ENV_KEY fehlt)')
|
||
}
|
||
}
|
||
const out = await runnerClient.startDevNow(doc.subdomain, {
|
||
sub: doc.subdomain, repo: doc.repoFullName, sha: doc.previewBuiltSha, branch: doc.previewBranch, mode: 'dev', env,
|
||
})
|
||
const state = ['starting', 'online'].includes(out?.state) ? out.state : 'starting'
|
||
const updated = await updateDocument(col(), doc.$id, { previewState: state })
|
||
res.json(await view(updated))
|
||
}))
|
||
|
||
router.post('/:id/preview/dev/stop', wrap(async (req, res) => {
|
||
requireRunner()
|
||
const doc = await loadProject(req.params.id)
|
||
await runnerClient.stopDev(doc.subdomain)
|
||
const fresh = await getDocument(col(), doc.$id)
|
||
const updated = ['online', 'starting', 'sleeping'].includes(fresh.previewState)
|
||
? await updateDocument(col(), doc.$id, { previewState: 'sleeping' })
|
||
: fresh
|
||
res.json(await view(updated))
|
||
}))
|
||
|
||
router.get('/:id/preview/logs/:runFile', wrap(async (req, res) => {
|
||
const doc = await loadProject(req.params.id)
|
||
if (!isRunFile(req.params.runFile)) throw fail(400, 'Ungültiger Protokollname')
|
||
const text = await readRunLog(doc.$id, req.params.runFile)
|
||
if (text === null) throw fail(404, 'Protokoll nicht gefunden')
|
||
res.json({ lines: text.replace(/\n$/, '').split('\n') })
|
||
}))
|
||
|
||
export default router
|