Files
Webklar-Kundenbereich/server/routes/admin/preview.js
T
ClaudeandClaude Opus 5.5 3156bb790a Projekte: alle Mitarbeiter, private Repos, Archiv fuer Kunden ausgeblendet
Entscheidungen vom 04.10.2026:
- Projekt-Routen (website-projects, preview, gitea, services, analyse)
  fuer jeden Mitarbeiter (Label mitarbeiter oder admin); Kunden-,
  Mitarbeiter- und Finanz-Verwaltung bleiben nur fuer Admins
- Neue Repos aus der Vorlage werden privat angelegt
- Archivierte Projekte sieht der Kunde nicht mehr: Projektliste,
  Projektdetails, Preview-Zugang, Funktionen (Mitarbeiter-Ansicht schon)
- Projektliste des Kunden mit Limit (Appwrite liefert sonst nur 25)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:02:02 +00:00

184 lines
7.5 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 { 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