Files
Webklar-Kundenbereich/server/services/previewDeploy.js
T
2026-09-30 10:21:34 +00:00

487 lines
17 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.
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.
* 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
}
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, '&')
.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' },
}
// 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 slugs = entries
.filter((entry) => entry.isDirectory() && /^[a-z0-9-]+$/.test(entry.name))
.map((entry) => entry.name)
.sort()
const hostsFile = path.join(outDir, 'preview-hosts.yml')
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)
// 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(hostsFile, content, 'utf8')
return { synced: true, hosts: slugs.length, publicSource }
}
/**
* 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
}
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 = previewWorkDir(repoFullName)
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 }
}