- 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>
278 lines
9.3 KiB
JavaScript
278 lines
9.3 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, 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, '&')
|
||
.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>
|
||
`
|
||
}
|
||
|
||
/**
|
||
* 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 }
|
||
}
|