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 }
}
/**
* 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 }
}