Files
Webklar-Kundenbereich/server/services/previewDeploy.js
Claude 55991c9008 previewDeploy: node_build-Autodetect fuer App-Repos + Build-Output-Erkennung
- runDeploy: Repos mit package.json-build-Script ohne index.html werden
  automatisch als node_build gebaut (Expo/Vite/React zeigen sonst nur den
  Platzhalter); Build-Fehler fallen auf static+Platzhalter zurueck
- deployNodeBuild: npm-ci-Fallback auf npm install, Output-Erkennung
  dist/build/out/web-build, maxBuffer+Timeout
- updateWebsiteProject: bestehenden type/index aus .webklar-preview.json
  erhalten statt bei jedem Edit auf static zurueckzusetzen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:37:34 +00:00

415 lines
14 KiB
JavaScript
Raw Permalink 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.
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, Query } from './appwriteAdmin.js'
const execFileAsync = promisify(execFile)
/**
* Subdomains, die als "oeffentliche Website" (ohne Login) laufen sollen.
* Diese bekommen in syncPreviewTls einen Router OHNE preview-auth-ForwardAuth.
* Faellt bei Appwrite-Fehlern bewusst auf ein leeres Set zurueck -> alle Seiten
* bleiben dann login-geschuetzt (sicherer Default).
*/
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 new Set()
}
}
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}` },
})
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
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' },
}
try {
await execFileAsync('npm', ['ci'], execOpts)
} catch {
// Ohne (passendes) package-lock.json schlaegt npm ci fehl -> npm install.
await execFileAsync('npm', ['install', '--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}`
}
/**
* 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 slugs = entries
.filter((entry) => entry.isDirectory() && /^[a-z0-9-]+$/.test(entry.name))
.map((entry) => entry.name)
.sort()
const publicSet = await getPublicSubdomains()
const blocks = slugs.map((name) => {
const host = `${name}.${baseHost}`
const id = `preview-${name}`
const isPublic = publicSet.has(name)
// 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: preview-nginx@docker',
...httpsMiddlewareLines,
' priority: 100',
' tls:',
' certResolver: letsencrypt',
'',
` ${id}-http:`,
` rule: "Host(\`${host}\`)"`,
' entryPoints:',
' - web',
' service: preview-nginx@docker',
' 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(path.join(outDir, 'preview-hosts.yml'), content, 'utf8')
return { synced: true, hosts: slugs.length }
}
export async function runDeploy(repoFullName, branch, previewConfig) {
if (!config.preview.deployRoot) {
return { deployed: false, reason: 'PREVIEW_DEPLOY_ROOT nicht gesetzt' }
}
const subdomain = previewConfig.subdomain || repoFullName.split('/').pop()
const cloneUrl = `${config.gitea.baseUrl}/${repoFullName}.git`
const workDir = path.join(process.cwd(), 'preview-data', repoFullName.replace(/\//g, '_'))
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 }
}