Ein kopiertes Repo (git clone + push, Vorlage) bringt die .webklar-preview.json des Originals mit. Sein erster Push ueberschrieb die Preview des Originals, danach scheiterte der Webhook mit 500 (Unique-Index auf subdomain). Fast passiert bei Olzinger -> HSK-Vorlage, 04.10.2026. - foreignSubdomainOwner: Subdomain gehoert einem anderen Repo oder einem Projekt ohne Repo; ein in Gitea umgezogenes Repo (Weiterleitung) zaehlt nicht als fremd - Webhook: fremde Subdomain -> kein Deploy, Projekt ohne Preview (neu: synced, Repo-Name statt fremdem Anzeigenamen); eine schon vergebene eigene Preview bleibt, WOMS zeigt den Grund als Preview-Fehler - runDeploy prueft fuer alle anderen Wege (WOMS, Reaktivieren), vor dem Runner, damit ein gesperrter Aufruf keinen laufenden Bau verwaist; Appwrite-Fehler bei der Pruefung blockieren dort nicht (Webhook streng) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
953 lines
36 KiB
JavaScript
953 lines
36 KiB
JavaScript
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'
|
||
import { analysePrivacyData } from './analysePrivacy.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, '<')
|
||
.replace(/>/g, '>')
|
||
return `<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="robots" content="noindex">
|
||
<title>${name} – wird vorbereitet</title>
|
||
<style>
|
||
html,body{height:100%;margin:0}
|
||
body{display:flex;align-items:center;justify-content:center;
|
||
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
|
||
background:#0f172a;color:#e2e8f0}
|
||
.box{max-width:32rem;padding:2rem;text-align:center}
|
||
h1{font-size:1.4rem;margin:0 0 .75rem}
|
||
p{margin:0;color:#94a3b8;line-height:1.5}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="box">
|
||
<h1>${name}</h1>
|
||
<p>Diese Seite wird gerade vorbereitet oder aktualisiert.<br>Bitte in Kürze erneut laden.</p>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
`
|
||
}
|
||
|
||
/**
|
||
* 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:<pm> | dev:<pm> (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 privacy = analysePrivacyData(customer)
|
||
if (!privacy.ok) {
|
||
console.warn(`[previewDeploy] Analyse fuer Projekt ${project.$id} uebersprungen: Firma oder E-Mail fehlt/ungueltig`)
|
||
return null
|
||
}
|
||
const stored = String(feature.accentColor || '').toLowerCase()
|
||
const color = /^#[0-9a-f]{6}$/.test(stored) ? stored : '#111111'
|
||
return { siteKey: feature.siteKey, color, privacy: { company: privacy.company, address: privacy.address, email: privacy.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 }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gehoert `subdomain` schon einem ANDEREN Projekt? Ein kopiertes Repo (git clone + push, Vorlage)
|
||
* bringt die .webklar-preview.json des Originals mit - ohne diese Pruefung ueberschrieb sein erster
|
||
* Push die Preview des Originals (Olzinger -> HSK-Vorlage, 04.10.2026). Ein in Gitea umbenanntes
|
||
* oder umgezogenes Repo ist nicht fremd: Gitea leitet den alten Namen auf `repoFullName` weiter.
|
||
* Ein Projekt ohne Repo (z. B. die Relaunch-Vorschau) haelt seine Subdomain ebenfalls.
|
||
* @returns {Promise<string>} Repo-Name bzw. Projekt des fremden Besitzers, sonst ''
|
||
*/
|
||
export async function foreignSubdomainOwner(repoFullName, subdomain) {
|
||
const own = String(repoFullName || '').toLowerCase()
|
||
// ohne Repo gibt es nichts zu klonen - und ein Projekt ohne Repo sperrt sich nicht selbst
|
||
if (!subdomain || !own) return ''
|
||
const docs = await listDocuments(config.collections.websiteProjects, [
|
||
Query.equal('subdomain', subdomain),
|
||
Query.limit(5),
|
||
])
|
||
for (const doc of docs) {
|
||
const other = String(doc.repoFullName || '')
|
||
if (!other) return `Projekt „${doc.projectName || doc.$id}“ (ohne Repo)`
|
||
if (other.toLowerCase() === own) continue
|
||
let resolved = ''
|
||
try {
|
||
resolved = String((await giteaFetch(`/repos/${other}`))?.full_name || '')
|
||
} catch {
|
||
// geloescht oder Gitea nicht erreichbar: im Zweifel fremd
|
||
}
|
||
if (resolved.toLowerCase() !== own) return other
|
||
}
|
||
return ''
|
||
}
|
||
|
||
/**
|
||
* Ergebnis fuer einen gesperrten Deploy oder null. `failOpen`: ist die Pruefung selbst nicht
|
||
* moeglich (Appwrite weg), trotzdem deployen - so nutzt es runDeploy; der Webhook prueft vorher
|
||
* streng, und die WOMS-Wege lesen das Projekt ohnehin zuerst aus Appwrite.
|
||
*/
|
||
export async function foreignSubdomainBlock(repoFullName, subdomain, { failOpen = false } = {}) {
|
||
let owner
|
||
try {
|
||
owner = await foreignSubdomainOwner(repoFullName, subdomain)
|
||
} catch (err) {
|
||
if (!failOpen) throw err
|
||
console.error(`[previewDeploy] ${repoFullName}: Besitzer der Subdomain "${subdomain}" nicht pruefbar, deploye trotzdem:`, err.message)
|
||
return null
|
||
}
|
||
if (!owner) return null
|
||
console.warn(`[previewDeploy] ${repoFullName}: Subdomain "${subdomain}" gehoert ${owner} - kein Deploy`)
|
||
return { deployed: false, blocked: true, reason: 'subdomain_foreign', subdomain, owner }
|
||
}
|
||
|
||
/**
|
||
* Muss runDeploy den Besitzer der Subdomain pruefen? Nicht, wenn der Aufrufer es schon getan hat
|
||
* (`subdomainChecked`, der Webhook) oder wenn es die schon vergebene Subdomain des uebergebenen
|
||
* Projekts selbst ist.
|
||
*/
|
||
function needsOwnerCheck(repoFullName, subdomain, { project, subdomainChecked } = {}) {
|
||
if (subdomainChecked) return false
|
||
const same = (a, b) => String(a || '').toLowerCase() === String(b || '').toLowerCase()
|
||
return !(project?.subdomain && same(project.subdomain, subdomain) && same(project.repoFullName, repoFullName))
|
||
}
|
||
|
||
export async function runDeploy(repoFullName, branch, previewConfig, options = {}) {
|
||
// Vor dem Runner pruefen: ein gesperrter Aufruf belegt dort keinen Platz und laesst so den
|
||
// Verfolger eines laufenden Baus desselben Repos in Ruhe.
|
||
const target = previewConfig.subdomain || repoFullName.split('/').pop()
|
||
if (needsOwnerCheck(repoFullName, target, options)) {
|
||
const blocked = await foreignSubdomainBlock(repoFullName, target, { failOpen: true })
|
||
if (blocked) return blocked
|
||
}
|
||
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 }
|