// 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 { requireAppwriteTeam } 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 { triggerRebuild, previewBranchOf } from '../../services/previewRebuild.js' import { withLocks } from '../../services/keyedLock.js' import { repoHasDevScript } from '../../services/previewDevScript.js' const router = Router() // Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) router.use(requireAppwriteTeam) const BUSY = 'Server ausgelastet, versuche es gleich noch mal' const MODES = ['auto', 'build', 'dev', 'off'] const NO_DEV = 'Dieses Projekt hat kein dev-Skript. Reines HTML braucht keinen Dev-Server – Änderungen erscheinen nach jedem Push automatisch.' 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), } } 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) => { const doc = await loadProject(req.params.id) const devAvailable = doc.repoFullName ? await repoHasDevScript(doc.repoFullName, await previewBranchOf(doc)).catch(() => null) : null res.json({ ...(await view(doc)), devAvailable }) })) 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))) } if (mode === 'dev' && doc.repoFullName) { const has = await repoHasDevScript(doc.repoFullName, await previewBranchOf(doc)).catch(() => null) if (has === false) throw fail(400, NO_DEV) } 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