Files
Webklar-Kundenbereich/server/routes/admin/preview.js
T

193 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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