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, '&') .replace(//g, '>') return ` ${name} – wird vorbereitet

${name}

Diese Seite wird gerade vorbereitet oder aktualisiert.
Bitte in Kürze erneut laden.

` } /** * 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 } } 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, config.gitea.apiToken) 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 } }