import { execFile } from 'node:child_process' import { promisify } from 'node:util' import fs from 'node:fs/promises' import path from 'node:path' import { config } from '../config.js' import { listDocuments, updateDocument, getDocument, Query } from './appwriteAdmin.js' import { getFeatureDoc } from './siteFeatures.js' import { withLocks } from './keyedLock.js' import { giteaFetch, splitRepo } from './giteaClient.js' import { envKey, decryptEnv, maskLog } from './previewEnv.js' import { saveRunLog } from './previewLogs.js' import * as runnerClient from './previewRunnerClient.js' const execFileAsync = promisify(execFile) /** * Subdomains, die als "oeffentliche Website" (ohne Login) laufen sollen. * Diese bekommen in syncPreviewTls einen Router OHNE preview-auth-ForwardAuth. * Liefert null, wenn Appwrite nicht antwortet (z.B. direkt nach einem * Server-Neustart, solange Appwrite noch hochfaehrt). */ async function getPublicSubdomains() { try { const docs = await listDocuments(config.collections.websiteProjects, [ Query.equal('isPublic', true), Query.limit(500), ]) return new Set(docs.map((d) => d.subdomain).filter(Boolean)) } catch (err) { console.error('[previewDeploy] getPublicSubdomains fehlgeschlagen:', err.message) return null } } /** * Oeffentliche Subdomains aus einer von syncPreviewTls geschriebenen * preview-hosts.yml: alle HTTPS-Router ohne preview-chain. */ export function publicSubdomainsFromHostsFile(content) { const result = new Set() let current = null let isProtected = false const finish = () => { if (current && !isProtected) result.add(current) current = null } for (const line of String(content || '').split('\n')) { if (/^ {4}\S/.test(line)) { finish() const match = line.match(/^ {4}preview-([a-z0-9-]+)-https:\s*$/) if (match) { current = match[1] isProtected = false } } else if (current && line.includes('preview-chain@docker')) { isProtected = true } } finish() return result } /** * Dev-Subdomains aus einer von syncPreviewTls geschriebenen preview-hosts.yml: * alle HTTPS-Router, deren Service preview-runner@docker ist. */ export function devSubdomainsFromHostsFile(content) { const result = new Set() let current = null for (const line of String(content || '').split('\n')) { if (/^ {4}\S/.test(line)) { const match = line.match(/^ {4}preview-([a-z0-9-]+)-https:\s*$/) current = match ? match[1] : null } else if (current && /^ {6}service:\s*preview-runner@docker\s*$/.test(line)) { result.add(current) } } return result } /** * Gehoert das Projekt zum Runner (Dev-Server)? Oeffentliche Seiten nur bei explizitem previewMode 'dev'. */ export function isRunnerDev(d) { return String(d?.previewDetected || '').startsWith('dev') && d.previewMode !== 'off' && (d.isPublic !== true || d.previewMode === 'dev') } /** * Projekte mit laufendem/schlafendem Dev-Server (nur mit Preview-Runner). * Liefert null, wenn Appwrite nicht antwortet. */ async function getDevProjects() { try { const docs = await listDocuments(config.collections.websiteProjects, [ Query.equal('previewState', ['sleeping', 'starting', 'online', 'building', 'failed']), Query.limit(500), ]) return docs.filter((d) => d.subdomain && /^[a-z0-9-]+$/.test(d.subdomain) && isRunnerDev(d)) } catch (err) { console.error('[previewDeploy] getDevProjects fehlgeschlagen:', err.message) return null } } export async function fetchPreviewConfig(repoFullName, branch) { const [owner, repo] = repoFullName.split('/') if (!owner || !repo || !config.gitea.apiToken) { return null } const url = `${config.gitea.baseUrl}/api/v1/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}` const response = await fetch(url, { headers: { Authorization: `token ${config.gitea.apiToken}` }, // Gleiches Zeitlimit wie giteaFetch (server/services/giteaClient.js) - sonst haengt ein // Preview-Deploy unbegrenzt, wenn Gitea nicht antwortet. signal: AbortSignal.timeout(config.gitea.timeoutMs), }) if (!response.ok) return null const data = await response.json() const content = Buffer.from(data.content || '', 'base64').toString('utf8') return JSON.parse(content) } export function defaultPreviewConfig(repoFullName) { const repoName = repoFullName.split('/').pop() || 'preview' return { enabled: false, type: 'static', branch: 'main', displayName: repoName, subdomain: repoName, } } /** * Baut aus einer Gitea-Clone-URL eine authentifizierte URL, damit auch PRIVATE * Repos geklont werden koennen. Ohne Token bleibt die URL unveraendert (anonymer * Clone fuer oeffentliche Repos). */ function authedCloneUrl(cloneUrl, token) { if (!token) return cloneUrl try { const u = new URL(cloneUrl) u.username = 'oauth2' u.password = token return u.toString() } catch { return cloneUrl } } async function cloneRepo(cloneUrl, targetDir, branch, token = '') { await fs.rm(targetDir, { recursive: true, force: true }) await fs.mkdir(targetDir, { recursive: true }) try { await execFileAsync('git', [ 'clone', '--depth', '1', '--branch', branch, authedCloneUrl(cloneUrl, token), targetDir, ]) } catch (err) { // Token niemals in Logs/Fehlermeldungen durchreichen. if (token && err && typeof err.message === 'string') { err.message = err.message.split(token).join('***') } throw err } } async function deployStatic(sourceDir, targetDir, indexSubdir = '') { let webroot = indexSubdir ? path.join(sourceDir, indexSubdir) : sourceDir // Falls der in .webklar-preview.json angegebene index-Unterordner nicht (mehr) // existiert, auf die Repo-Wurzel zurueckfallen statt den cp scheitern zu lassen. try { await fs.access(webroot) } catch { webroot = sourceDir } await fs.rm(targetDir, { recursive: true, force: true }) await fs.mkdir(targetDir, { recursive: true }) await execFileAsync('cp', ['-R', `${webroot}/.`, targetDir]) // Git-Metadaten nie mit ausliefern (verhindert Zugriff auf /.git/ im Preview). await fs.rm(path.join(targetDir, '.git'), { recursive: true, force: true }) } /** * Platzhalter-Seite, die statt eines nackten nginx-403 ausgeliefert wird, wenn * ein Deploy-Verzeichnis keine index.html enthaelt (z.B. leergeraeumtes Repo, * fehlerhafter Build oder falscher index-Unterordner). So bleibt die Subdomain * immer erreichbar und zeigt eine verstaendliche Meldung. */ function placeholderHtml(displayName) { const name = String(displayName || 'Diese Website') .replace(/&/g, '&') .replace(//g, '>') return ` ${name} – wird vorbereitet

${name}

Diese Seite wird gerade vorbereitet oder aktualisiert.
Bitte in Kürze erneut laden.

` } /** * Sucht im Deploy-Verzeichnis die beste vorhandene HTML-Datei als Startseiten- * Ersatz: bevorzugte Namen (index.htm, home, main, start, app), sonst die * einzige bzw. groesste HTML-Datei. Nur Dateien im Wurzelverzeichnis. */ async function findIndexCandidate(targetDir) { let entries = [] try { entries = await fs.readdir(targetDir, { withFileTypes: true }) } catch { return null } const htmlFiles = entries .filter((e) => e.isFile() && /\.html?$/i.test(e.name) && !e.name.startsWith('.')) .map((e) => e.name) .filter((name) => name.toLowerCase() !== 'index.html') if (!htmlFiles.length) return null const preferred = ['index.htm', 'home.html', 'homepage.html', 'main.html', 'start.html', 'app.html'] for (const want of preferred) { const hit = htmlFiles.find((name) => name.toLowerCase() === want) if (hit) return hit } if (htmlFiles.length === 1) return htmlFiles[0] // Mehrere Kandidaten ohne sprechenden Namen: die groesste Datei ist mit // Abstand am wahrscheinlichsten die eigentliche Projektseite. const sized = await Promise.all( htmlFiles.map(async (name) => { try { const stat = await fs.stat(path.join(targetDir, name)) return { name, size: stat.size } } catch { return { name, size: 0 } } }) ) sized.sort((a, b) => b.size - a.size) return sized[0].name } /** * Stellt sicher, dass ein Deploy-Verzeichnis eine index.html besitzt. Fehlt sie, * wird zuerst eine vorhandene HTML-Datei zur Startseite befoerdert (Kopie), * damit die Preview das echte Projekt zeigt statt "wird vorbereitet". Erst wenn * gar keine HTML-Datei existiert, kommt der Platzhalter (403-Schutz, nginx * liefert mit autoindex off sonst "403 Forbidden"). */ async function ensureIndexHtml(targetDir, displayName) { const indexPath = path.join(targetDir, 'index.html') try { await fs.access(indexPath) return { placeholder: false } } catch { /* keine index.html vorhanden -> Kandidat befoerdern oder Platzhalter */ } const candidate = await findIndexCandidate(targetDir) if (candidate) { await fs.copyFile(path.join(targetDir, candidate), indexPath) console.log(`[previewDeploy] keine index.html – "${candidate}" als Startseite uebernommen (${targetDir})`) return { placeholder: false, promotedFrom: candidate } } await fs.mkdir(targetDir, { recursive: true }) await fs.writeFile(indexPath, placeholderHtml(displayName), 'utf8') return { placeholder: true } } /** * Erkennt App-Repos, die einen Build-Schritt brauchen (Expo, Vite, React ...): * package.json mit "build"-Script vorhanden, aber keine index.html im Webroot. * Solche Repos wuerden als "static" nur den Platzhalter zeigen - deshalb wird * der Deploy-Typ automatisch auf node_build angehoben (Langzeit-Fix, damit * App-Projekte ohne manuelle Config-Pflege korrekt angezeigt werden). */ async function shouldAutoBuild(sourceDir, indexSubdir = '') { try { const pkg = JSON.parse(await fs.readFile(path.join(sourceDir, 'package.json'), 'utf8')) if (!pkg?.scripts?.build) return false } catch { return false } const webroot = indexSubdir ? path.join(sourceDir, indexSubdir) : sourceDir try { await fs.access(path.join(webroot, 'index.html')) return false // echte statische Startseite vorhanden -> kein Build noetig } catch { return true } } /** * npm ci + npm run build, dann das Build-Ausgabeverzeichnis deployen. * Ausgabeverzeichnis wird erkannt (dist > build > out > web-build), weil * Toolchains unterschiedliche Defaults haben (Expo/Vite: dist, CRA: build). */ async function deployNodeBuild(sourceDir, targetDir) { const execOpts = { cwd: sourceDir, maxBuffer: 64 * 1024 * 1024, timeout: 15 * 60 * 1000, env: { ...process.env, CI: '1' }, } // Der Server-Container laeuft mit NODE_ENV=production, dadurch setzt npm // automatisch omit=dev. Die Build-Toolchain (Next, Vite, Tailwind, TypeScript) // steht aber in den devDependencies -> ohne "--include=dev" bricht jeder // Build mit "Cannot find module ..." ab (Fall Anelia-Wolf/Next 16). try { await execFileAsync('npm', ['ci', '--include=dev'], execOpts) } catch { // Ohne (passendes) package-lock.json schlaegt npm ci fehl -> npm install. await execFileAsync('npm', ['install', '--include=dev', '--no-audit', '--no-fund'], execOpts) } await execFileAsync('npm', ['run', 'build'], execOpts) for (const dirName of ['dist', 'build', 'out', 'web-build']) { const candidate = path.join(sourceDir, dirName) try { await fs.access(path.join(candidate, 'index.html')) await deployStatic(candidate, targetDir) return } catch { /* naechsten Kandidaten pruefen */ } } throw new Error('Build erzeugte kein Ausgabeverzeichnis mit index.html (dist/build/out/web-build)') } export function buildPreviewUrl(subdomain) { return `https://${subdomain}.${config.preview.baseHost}` } /** Arbeitsverzeichnis fuer den flachen Klon eines Repos beim Preview-Deploy. */ export function previewWorkDir(repoFullName) { return path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_')) } /** * Schreibt pro deployter Preview-Subdomain einen Traefik-Router mit Host()-Regel * nach preview-hosts.yml, damit Let's Encrypt (HTTP-01) gueltige Zertifikate * ausstellt. HostRegexp allein liefert nur das Default-Zertifikat ("Nicht sicher"). * Ersetzt traefik/scripts/sync-preview-tls.sh (bash fehlt im Alpine-Container). */ export async function syncPreviewTls() { const previewRoot = config.preview.deployRoot const outDir = config.preview.traefikDynamicDir if (!previewRoot || !outDir) { return { synced: false, reason: 'PREVIEW_DEPLOY_ROOT oder TRAEFIK_DYNAMIC_DIR nicht gesetzt' } } const baseHost = config.preview.baseHost let entries = [] try { entries = await fs.readdir(previewRoot, { withFileTypes: true }) } catch { entries = [] } const folderSlugs = entries .filter((entry) => entry.isDirectory() && /^[a-z0-9-]+$/.test(entry.name)) .map((entry) => entry.name) const hostsFile = path.join(outDir, 'preview-hosts.yml') // Dev-Server (Preview-Runner): Hosts auch ohne Ordner, Service preview-runner let devSet = new Set() let devProjects = [] if (config.previewRunner.enabled) { const found = await getDevProjects() if (found) { devProjects = found devSet = new Set(found.map((d) => d.subdomain)) } else { // Appwrite antwortet nicht: Dev-Hosts aus der letzten Datei uebernehmen devSet = await fs.readFile(hostsFile, 'utf8').then(devSubdomainsFromHostsFile, () => new Set()) } } const slugs = [...new Set([...folderSlugs, ...devSet])].sort() let publicSet = await getPublicSubdomains() let publicSource = 'appwrite' if (!publicSet) { // Appwrite antwortet nicht: oeffentliche Seiten aus der letzten Datei // uebernehmen, statt sie alle hinter den Login zu setzen. Neue // Subdomains bleiben login-geschuetzt (sicherer Default). publicSet = await fs.readFile(hostsFile, 'utf8').then(publicSubdomainsFromHostsFile, () => new Set()) publicSource = 'previous-file' } const blocks = slugs.map((name) => { const host = `${name}.${baseHost}` const id = `preview-${name}` const isPublic = publicSet.has(name) const service = devSet.has(name) ? 'preview-runner@docker' : 'preview-nginx@docker' // Oeffentliche Website: HTTPS-Router OHNE preview-auth -> frei zugaenglich. // Preview (Standard): mit preview-chain (ForwardAuth) -> login-geschuetzt. const httpsMiddlewareLines = isPublic ? [] : [' middlewares:', ' - preview-chain@docker'] return [ ` ${id}-https:`, ` rule: "Host(\`${host}\`)"`, ' entryPoints:', ' - websecure', ` service: ${service}`, ...httpsMiddlewareLines, ' priority: 100', ' tls:', ' certResolver: letsencrypt', '', ` ${id}-http:`, ` rule: "Host(\`${host}\`)"`, ' entryPoints:', ' - web', ` service: ${service}`, ' middlewares:', ' - preview-sites-redirect@docker', ' priority: 100', ].join('\n') }) const content = [ '# Auto-generated by previewDeploy.syncPreviewTls - do not edit manually', 'http:', ' routers:', ...blocks, '', ].join('\n') await fs.mkdir(outDir, { recursive: true }) await fs.writeFile(hostsFile, content, 'utf8') if (devProjects.length) registerDevSpecs(devProjects) return { synced: true, hosts: slugs.length, publicSource } } // R12: Dev-Auftraege beim Runner hinterlegen (Hintergrund, nie blockierend/werfend) let registering = null /** Wartet auf eine laufende Registrierungsrunde (Tests). */ export async function waitForDevRegistration() { while (registering) await registering } let pendingProjects = null export async function registerDevRound(projects) { const key = envKey() await Promise.allSettled( projects.map(async (project) => { const sub = project.subdomain try { const repo = project.repoFullName const { previewBranch: branch, previewBuiltSha: sha } = project // Kein Raten (R17): ohne gespeicherten Branch/Stand wird uebersprungen if (!repo || !branch || !SHA_RE.test(String(sha || ''))) { console.error(`[previewDeploy] Dev-Registrierung ${sub} uebersprungen: Branch oder Stand unbekannt`) return } let env = {} if (project.previewEnv) { try { env = decryptEnv(project.previewEnv, key) } catch { console.error(`[previewDeploy] Dev-Registrierung ${sub} uebersprungen: Variablen nicht entschluesselbar`) return } } await runnerClient.startDev(sub, { sub, repo, sha, branch, mode: 'dev', env }) } catch (err) { console.error(`[previewDeploy] Dev-Registrierung ${sub} fehlgeschlagen:`, err.message) } }) ) } function registerDevSpecs(projects) { // Laeuft schon eine Runde: genau eine Wiederholung danach (mit dem neuesten Stand) if (registering) { pendingProjects = projects; return } registering = (async () => { let next = projects while (next) { pendingProjects = null await registerDevRound(next) next = pendingProjects } })().finally(() => { registering = null }) } /** * Nach einem Server-Neustart startet der Kundenbereich oft vor Appwrite. Dann * kennt syncPreviewTls die oeffentlichen Seiten nur aus der letzten Datei; der * Abgleich wird wiederholt, bis er mit Appwrite-Daten geklappt hat. */ export async function syncPreviewTlsOnStartup({ sync = syncPreviewTls, retryMs = 15_000, maxAttempts = 40, } = {}) { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const result = await sync() console.log('[startup] Preview-TLS-Sync:', JSON.stringify(result)) if (!result.synced || result.publicSource === 'appwrite') return result } catch (err) { console.error('[startup] Preview-TLS-Sync fehlgeschlagen:', err.message) } if (attempt < maxAttempts) await new Promise((resolve) => setTimeout(resolve, retryMs)) } console.error( `[startup] Preview-TLS-Sync: Appwrite nach ${maxAttempts} Versuchen nicht erreichbar, oeffentliche Seiten bleiben wie in der letzten Datei` ) return null } // ---- Preview-Runner (PREVIEW_RUNNER=on) ---- // Zeiten des Hintergrund-Verfolgers (fuer Tests ueberschreibbar) const NO_DEV_SCRIPT_ERROR = 'Kein dev-Skript in package.json' export const runnerTiming = { pollMs: 2000, maxMs: 60 * 60 * 1000, unreachableMs: 5 * 60 * 1000, docWaitMs: 15000, docPollMs: 50 } // Neuester Lauf je Repo: aeltere Verfolger schreiben nichts mehr (R14) const latestRun = new Map() const followers = new Set() const SHA_RE = /^[0-9a-f]{7,40}$/ const KEY_MISSING = 'Preview-Variablen nicht entschlüsselbar (PREVIEW_ENV_KEY fehlt)' const KEY_BROKEN = 'Preview-Variablen nicht entschlüsselbar' /** Gibt es fuer das Repo einen laufenden Verfolger in diesem Prozess? (Abgleich, R18) */ export const hasLiveFollower = (repoFullName) => latestRun.has(repoFullName) /** Wartet auf alle laufenden Hintergrund-Verfolger (Tests, geordnetes Beenden). */ export async function waitForRunFollowers() { while (followers.size) await Promise.allSettled([...followers]) } /** * C1: Mit aktivem Runner liefert runDeploy nur "queued"/"skipped"/Fehler des Auftrags - das ist * kein Deploy-Ergebnis. Aufrufer duerfen dann status/provisioningStatus NICHT anfassen * (sonst sperrt auth.js "not_ready" ein bereits bereites Projekt aus). Der Verfolger setzt * bei Erfolg 'deployed'/'ready'. */ export function deployKeepsStatus(deployResult) { return Boolean(config.previewRunner.enabled && deployResult && !deployResult.deployed) } // previewDetected: static | build: | dev: (der Runner liefert kein Framework) export function detectedLabel(detected, result) { const kind = result || detected?.kind if (kind === 'static') return 'static' const pm = detected?.pm || 'npm' return kind === 'dev' ? `dev:${pm}` : `build:${pm}` } async function findProject(repoFullName) { try { const docs = await listDocuments(config.collections.websiteProjects, [ Query.equal('repoFullName', repoFullName), Query.limit(1), ]) return docs[0] || null } catch (err) { console.error('[previewDeploy] Projekt-Lookup fehlgeschlagen:', err.message) return null } } // Attributgroessen (Appwrite lehnt laengere Werte ab und der Patch ginge verloren) const ATTR_MAX = { previewError: 500, previewDetected: 40, previewBranch: 100 } function clampAttrs(data) { const out = { ...data } for (const [key, max] of Object.entries(ATTR_MAX)) { if (typeof out[key] === 'string' && out[key].length > max) out[key] = out[key].slice(0, max) } return out } /** * Neues Projekt: der Webhook legt das Dokument erst NACH runDeploy an. Ein schneller Verfolger * findet es dann noch nicht - kurz auf die Anlage warten, sonst geht sein Ergebnis verloren. */ async function waitForProject(repoFullName, project) { if (project?.$id) return project const until = Date.now() + runnerTiming.docWaitMs for (;;) { const doc = await findProject(repoFullName) if (doc || Date.now() >= until) return doc || project await new Promise((r) => setTimeout(r, runnerTiming.docPollMs)) } } async function patchProject(repoFullName, project, data) { data = clampAttrs(data) const doc = project?.$id ? project : await findProject(repoFullName) if (!doc?.$id) return null try { return await updateDocument(config.collections.websiteProjects, doc.$id, data) } catch (err) { console.error('[previewDeploy] Projekt-Update fehlgeschlagen:', err.message) return null } } async function resolveSha(repoFullName, branch, sha) { if (SHA_RE.test(String(sha || ''))) return String(sha) const [owner, repo] = splitRepo(repoFullName) const data = await giteaFetch(`/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`) const head = data?.commit?.id || data?.commit?.sha if (!SHA_RE.test(String(head || ''))) throw new Error('Commit des Branches nicht ermittelbar') return head } async function followRun({ repoFullName, project, token, runId, subdomain, job, env, warning }) { const deadline = Date.now() + runnerTiming.maxMs const current = () => latestRun.get(repoFullName) === token let run = null let failure = null let unreachableSince = 0 try { while (current()) { await new Promise((r) => setTimeout(r, runnerTiming.pollMs)) if (!current()) return try { run = await runnerClient.getRun(runId) unreachableSince = 0 } catch (err) { run = null if (err.status === 404) { failure = 'Der Preview-Lauf ist beim Runner nicht mehr bekannt'; break } console.error('[previewDeploy] Runner-Abfrage fehlgeschlagen:', err.message) unreachableSince ||= Date.now() if (Date.now() - unreachableSince > runnerTiming.unreachableMs) { failure = 'Preview-Runner nicht erreichbar' break } } if (run?.state === 'done' || run?.state === 'failed') break if (Date.now() > deadline) { failure = 'Status unbekannt – bitte neu bauen'; break } } if (!current()) return project = await waitForProject(repoFullName, project) if (!current()) return if (run?.log?.length) { try { await saveRunLog(project?.$id, run.log, env) } catch (err) { console.error('[previewDeploy] Protokoll speichern fehlgeschlagen:', err.message) } } if (!current()) return const now = new Date().toISOString() if (!failure && run?.state === 'done') { const detected = detectedLabel(run.detected, run.result) const base = { previewDetected: detected, previewError: warning || '', previewBranch: job.branch, previewBuiltSha: run.sha || job.sha, // Wie der Legacy-Erfolg: Projekt ist bereit (C1). Ein Fehlschlag senkt nie herab. status: 'deployed', provisioningStatus: 'ready', } if (run.result === 'dev') { await patchProject(repoFullName, project, { ...base, previewState: 'sleeping' }) try { await runnerClient.startDev(subdomain, { ...job, mode: 'dev', displayName: undefined, analytics: undefined }) } catch (err) { console.error('[previewDeploy] Dev-Registrierung fehlgeschlagen:', err.message) } } else { await patchProject(repoFullName, project, { ...base, previewState: 'online', previewBuiltAt: now, }) } } else { await patchProject(repoFullName, project, { previewState: 'failed', previewError: run?.error === NO_DEV_SCRIPT_ERROR && !failure ? 'Dev-Modus hier nicht möglich – statische Version ist online' : maskLog(failure || run?.error || 'Preview-Build fehlgeschlagen', env), }) } if (!current()) return try { await syncPreviewTls() } catch (err) { console.error('[previewDeploy] TLS-Sync fehlgeschlagen:', err.message) } } catch (err) { console.error(`[previewDeploy] Verfolger ${runId} abgebrochen:`, err.message) } finally { if (latestRun.get(repoFullName) === token) latestRun.delete(repoFullName) } } /** * Analyse-Feld fuer den Build-Auftrag: nur bei aktiver Funktion 'analyse', oeffentlichem Projekt mit Subdomain * und vollstaendigen Datenschutz-Angaben (Firma + E-Mail Pflicht, Adresse optional). Sonst null. * Wirft nie: der Build darf an der Analyse nicht scheitern. */ async function buildAnalytics(project) { try { if (!project?.$id || project.isPublic !== true || !project.subdomain || !project.customerId) return null const feature = await getFeatureDoc(project.customerId, project.$id, 'analyse') if (!feature || feature.state !== 'active' || !feature.siteKey) return null let customer = null try { customer = await getDocument(config.collections.customers, project.customerId) } catch (err) { if (err.status !== 404) throw err } const clean = (v) => String(v ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim() const company = clean(customer?.companyName || customer?.name).slice(0, 200).trim() const email = clean(customer?.email) if (!company || !email || email.length > 200 || !email.includes('@')) { console.warn(`[previewDeploy] Analyse fuer Projekt ${project.$id} uebersprungen: Firma oder E-Mail fehlt/ungueltig`) return null } const address = [clean(customer.street), clean(`${customer.postalCode || ''} ${customer.city || ''}`)].filter(Boolean).join(', ').slice(0, 300).trim() const stored = String(feature.accentColor || '').toLowerCase() const color = /^#[0-9a-f]{6}$/.test(stored) ? stored : '#111111' return { siteKey: feature.siteKey, color, privacy: { company, address, email } } } catch (err) { console.warn(`[previewDeploy] Analyse-Abfrage fuer Projekt ${project?.$id} fehlgeschlagen: ${err.message}`) return null } } async function runDeployViaRunner(repoFullName, branch, previewConfig, { project, sha } = {}) { const subdomain = previewConfig.subdomain || repoFullName.split('/').pop() // Platz fuer diesen Aufruf sofort belegen (vor jedem await): aeltere Verfolger scheiden aus const token = Symbol('run') latestRun.set(repoFullName, token) const current = () => latestRun.get(repoFullName) === token const release = () => { if (current()) latestRun.delete(repoFullName) } const doc = project || (await findProject(repoFullName)) const mode = doc?.previewMode || 'auto' if (mode === 'off') { try { if (current()) await patchProject(repoFullName, doc, { previewState: 'off' }) } finally { release() } return { deployed: false, skipped: true, reason: 'Preview ist ausgeschaltet', subdomain } } let env = {} let warning = '' if (doc?.previewEnv) { const key = envKey() if (!key) warning = KEY_MISSING else { try { env = decryptEnv(doc.previewEnv, key) } catch { warning = KEY_BROKEN } } } const displayName = String(previewConfig.displayName || doc?.projectName || '').slice(0, 100).replace(/[\x00-\x1f\x7f]/g, ' ').trim() try { const job = { sub: subdomain, repo: repoFullName, sha: await resolveSha(repoFullName, branch, sha), branch, mode: ['build', 'dev'].includes(mode) ? mode : 'auto', ...(previewConfig.index ? { index: previewConfig.index } : {}), ...(displayName ? { displayName } : {}), env, analytics: await buildAnalytics(doc), } const { runId } = await runnerClient.submitBuild(job) if (current()) await patchProject(repoFullName, doc, { previewState: 'building', previewError: warning }) const follower = followRun({ repoFullName, project: doc, token, runId, subdomain, job, env, warning }) followers.add(follower) follower.finally(() => followers.delete(follower)) return { deployed: false, queued: true, runId, subdomain } } catch (err) { console.error(`[previewDeploy] Runner-Auftrag fuer ${repoFullName} fehlgeschlagen:`, err.message) try { if (current()) await patchProject(repoFullName, doc, { previewState: 'failed', previewError: maskLog(err.message, env) }) } finally { release() } return { deployed: false, error: err.message, subdomain } } } export async function runDeploy(repoFullName, branch, previewConfig, options = {}) { if (config.previewRunner.enabled) { return runDeployViaRunner(repoFullName, branch, previewConfig, options) } if (!config.preview.deployRoot) { return { deployed: false, reason: 'PREVIEW_DEPLOY_ROOT nicht gesetzt' } } const subdomain = previewConfig.subdomain || repoFullName.split('/').pop() const workDir = previewWorkDir(repoFullName) // Deploys desselben Repos oder derselben Subdomain nacheinander: Projektaenderung und // Gitea-Webhook kamen gleichzeitig, beide git clone liefen ins selbe workDir und // brachen ab (Kirschner Gebaeudeservice, 30.09.2026). return withLocks([`preview-work:${workDir}`, `preview-site:${subdomain}`], () => deployLocked(repoFullName, branch, previewConfig, subdomain, workDir) ) } async function deployLocked(repoFullName, branch, previewConfig, subdomain, workDir) { const cloneUrl = `${config.gitea.baseUrl}/${repoFullName}.git` const targetDir = path.join(config.preview.deployRoot, subdomain) await cloneRepo(cloneUrl, workDir, branch, config.gitea.apiToken) let deployType = previewConfig.type if (deployType !== 'node_build' && (await shouldAutoBuild(workDir, previewConfig.index || ''))) { console.log(`[previewDeploy] ${repoFullName}: package.json mit build-Script, keine index.html -> node_build (auto)`) deployType = 'node_build' } if (deployType === 'node_build') { try { await deployNodeBuild(workDir, targetDir) } catch (err) { // Build-Fehler duerfen die Subdomain nicht toeten: statisch deployen, // ensureIndexHtml haelt sie mit Kandidat/Platzhalter erreichbar. console.error(`[previewDeploy] node_build fuer ${repoFullName} fehlgeschlagen, Fallback auf static:`, err.message) await deployStatic(workDir, targetDir, previewConfig.index || '') } } else { await deployStatic(workDir, targetDir, previewConfig.index || '') } // Universeller 403-Schutz: ohne index.html liefert nginx (autoindex off) sonst // "403 Forbidden". Ein Platzhalter haelt die Subdomain immer erreichbar. const index = await ensureIndexHtml(targetDir, previewConfig.displayName || subdomain) let tls = { synced: false } try { tls = await syncPreviewTls() } catch (err) { console.error('[previewDeploy] TLS-Sync fehlgeschlagen:', err.message) tls = { synced: false, error: err.message } } return { deployed: true, subdomain, targetDir, tls, placeholder: index.placeholder } } /** * Entfernt das Deploy-Verzeichnis einer Subdomain und synchronisiert die * Traefik-TLS-Router neu (z.B. beim Aendern der Subdomain oder Archivieren). */ export async function removeDeploy(subdomain) { if (!subdomain || !config.preview.deployRoot) return { removed: false } if (!/^[a-z0-9-]+$/.test(subdomain)) return { removed: false, reason: 'ungueltige Subdomain' } const targetDir = path.join(config.preview.deployRoot, subdomain) await fs.rm(targetDir, { recursive: true, force: true }) let tls = { synced: false } try { tls = await syncPreviewTls() } catch (err) { console.error('[previewDeploy] TLS-Sync nach removeDeploy fehlgeschlagen:', err.message) tls = { synced: false, error: err.message } } return { removed: true, subdomain, tls } } // Nur fuer Tests (Vergleich Altpfad gegen Runner-Veroeffentlichung) export const previewDeployInternals = { deployStatic, ensureIndexHtml, placeholderHtml, findIndexCandidate }