Preview: Admin-API und Modus-Vorgabe

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:54:01 +00:00
1 parent 3316b562af
commit 07bef6971c
8 files changed
+412 -1

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
// 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 { getRepoInfo } from '../../services/giteaAdmin.js'
const router = Router()
router.use(requireAppwriteStaff)
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
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,
}
return runDeploy(repo, branch, cfg, { project: doc })
}
const wrap = (fn) => async (req, res) => {
try {
await fn(req, res)
} catch (err) {
const status = err.status || 500
if (status >= 500 && status !== 503) console.error('[admin/preview]', err.message)
res.status(status).json({ error: status >= 500 ? err.message || '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') {
try { await runnerClient.stopDev(doc.subdomain) } catch (err) {
console.error('[admin/preview] Dev stoppen fehlgeschlagen:', err.message)
}
const updated = await updateDocument(col(), doc.$id, { previewMode: mode, previewState: 'off' })
return res.json(await view(updated))
}
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 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 { set, remove } = req.body || {}
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) : ''
const updated = await updateDocument(col(), doc.$id, { previewEnv: stored })
if ((updated.previewMode || 'auto') !== 'off') await triggerRebuild(updated)
res.json(await view(await getDocument(col(), doc.$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')
// 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 updated = await updateDocument(col(), doc.$id, { previewState: 'sleeping' })
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
+1
View File
@@ -51,6 +51,7 @@ router.get('/', async (_req, res) => {
ticketId: p.ticketId || '',
woid: tickets.get(p.ticketId)?.woid || '',
previewUrl: previewUrlFor(p),
previewState: p.previewState || '',
features: {},
})
}