Files
Webklar-Kundenbereich/server/services/previewDeploy.js
Claude 1b4262b09f fix: 403-Schutz + Selbstheilung fuer geloeschte Preview-Config
- runDeploy schreibt Platzhalter-index.html, wenn das Deploy-Verzeichnis
  keine index.html enthaelt (leergeraeumtes Repo/Fehlbuild) -> nie mehr
  nginx 403 Forbidden, Subdomain bleibt erreichbar.
- deployStatic: Fallback auf Repo-Wurzel bei fehlendem index-Unterordner,
  .git wird nicht mehr mit ausgeliefert.
- Webhook: fehlt .webklar-preview.json im Repo, wird die Hosting-Config aus
  dem Appwrite-Projektstand rekonstruiert, ins Repo zurueckgeschrieben und
  normal deployt (ensureRepoPreviewConfig).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 18:05:00 +00:00

278 lines
9.3 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.
* 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,
}
}
async function cloneRepo(cloneUrl, targetDir, branch) {
await fs.rm(targetDir, { recursive: true, force: true })
await fs.mkdir(targetDir, { recursive: true })
await execFileAsync('git', [
'clone',
'--depth',
'1',
'--branch',
branch,
cloneUrl,
targetDir,
])
}
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>
`
}
/**
* Stellt sicher, dass ein Deploy-Verzeichnis eine index.html besitzt. Fehlt sie,
* wird ein Platzhalter geschrieben, damit nginx (autoindex off) kein 403 liefert.
*/
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 -> Platzhalter schreiben */
}
await fs.mkdir(targetDir, { recursive: true })
await fs.writeFile(indexPath, placeholderHtml(displayName), 'utf8')
return { placeholder: true }
}
async function deployNodeBuild(sourceDir, targetDir) {
await execFileAsync('npm', ['ci'], { cwd: sourceDir })
await execFileAsync('npm', ['run', 'build'], { cwd: sourceDir })
const distDir = path.join(sourceDir, 'dist')
await deployStatic(distDir, targetDir)
}
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)
if (previewConfig.type === 'node_build') {
await deployNodeBuild(workDir, targetDir)
} 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 }
}