import { config } from '../config.js' import { upsertWebsiteProjectByRepo, listDocuments, Query, getDocument, updateDocument, } from './appwriteAdmin.js' import { runDeploy, deployKeepsStatus, buildPreviewUrl, removeDeploy, syncPreviewTls, fetchPreviewConfig, } from './previewDeploy.js' import { giteaFetch } from './giteaClient.js' import { slugify } from './slug.js' import { withLocks } from './keyedLock.js' import { repoLockKey } from './repoMove.js' // Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte // Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht // endlos weiter (40 x 50 = 2000 Repos). const MAX_REPO_PAGES = 40 export async function listAllAccessibleRepos() { const repos = [] const limit = 50 let page = 1 while (true) { const batch = await giteaFetch(`/repos/search?limit=${limit}&page=${page}`) const data = Array.isArray(batch?.data) ? batch.data : Array.isArray(batch) ? batch : [] if (data.length === 0) break repos.push(...data) if (data.length < limit) break if (page >= MAX_REPO_PAGES) { console.warn( `[giteaAdmin] mehr als ${MAX_REPO_PAGES} Seiten Repos – weitere Repos werden nicht gelistet` ) break } page += 1 } return repos } /** * Findet Repos, die in Gitea liegen, aber noch kein Appwrite-Projektdokument haben, und legt fuer * jedes nicht archivierte ein minimales Dokument an (status "synced", ohne Hosting/Subdomain) - * ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Wird vom Projekt-Dialog bei jedem * Oeffnen aufgerufen, laeuft also oft: die aeussere `withLocks(['import'])`-Sperre serialisiert * parallele Laeufe komplett (ein zweiter Aufruf sieht danach garantiert den Stand des ersten), die * innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere * gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung). */ export async function importNewRepos() { return withLocks(['import'], async () => { const [repos, docs] = await Promise.all([ listAllAccessibleRepos(), listDocuments(config.collections.websiteProjects, [Query.limit(5000)]), ]) // Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling). const existingNames = new Set( docs.map((d) => String(d.repoFullName || '').toLowerCase()).filter(Boolean) ) let created = 0 const createdRepos = [] for (const repo of repos) { const fullName = repo.full_name if (!fullName || repo.archived) continue if (existingNames.has(fullName.toLowerCase())) continue await withLocks([repoLockKey(fullName)], async () => { const recheck = await listDocuments(config.collections.websiteProjects, [ Query.equal('repoFullName', fullName), Query.limit(1), ]) if (recheck[0]) return const doc = await upsertWebsiteProjectByRepo(fullName, { projectName: repo.description || repo.name || fullName.split('/').pop(), giteaRepoUrl: repo.html_url, giteaRepoName: repo.name, repoFullName: fullName, archived: false, status: 'synced', giteaSyncedAt: new Date().toISOString(), }) created += 1 createdRepos.push({ projectId: doc.$id, repo }) }) } return { created, createdRepos } }) } export async function syncReposToAppwrite() { const repos = await listAllAccessibleRepos() let created = 0 let updated = 0 for (const repo of repos) { const repoFullName = repo.full_name if (!repoFullName) continue const existing = await listDocuments(config.collections.websiteProjects, [ Query.equal('repoFullName', repoFullName), Query.limit(1), ]) const payload = { projectName: repo.description || repo.name || repoFullName.split('/').pop(), giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || repoFullName.split('/').pop(), repoFullName, // In Gitea archivierte Repos werden im Ticketsystem ausgeblendet. archived: Boolean(repo.archived), } if (!existing[0]) { payload.status = 'synced' created += 1 } else { updated += 1 } await upsertWebsiteProjectByRepo(repoFullName, payload) } return { synced: repos.length, created, updated } } export async function ensureSubdomainAvailable(subdomain) { const docs = await listDocuments(config.collections.websiteProjects, [ Query.equal('subdomain', subdomain), Query.limit(1), ]) if (docs[0]) { const error = new Error(`Subdomain "${subdomain}" ist bereits vergeben`) error.status = 409 throw error } } export async function generateRepoFromTemplate({ owner, repoName }) { const templateOwner = config.gitea.templateOwner const templateRepo = config.gitea.templateRepo const targetOwner = owner || config.gitea.defaultOwner if (!targetOwner) { throw new Error('GITEA_DEFAULT_OWNER fehlt in .env') } return giteaFetch(`/repos/${templateOwner}/${templateRepo}/generate`, { method: 'POST', body: JSON.stringify({ owner: targetOwner, name: repoName, private: false, description: `Website-Projekt ${repoName}`, git_content: true, }), }) } async function waitForRepoBranch(owner, repo, preferredBranch = 'main', maxAttempts = 15, delayMs = 400) { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { const repoInfo = await giteaFetch(`/repos/${owner}/${repo}`) const branches = await giteaFetch(`/repos/${owner}/${repo}/branches?limit=50`) const branchNames = Array.isArray(branches) ? branches.map((entry) => entry.name) : [] if (branchNames.includes(preferredBranch)) return preferredBranch if (repoInfo?.default_branch && branchNames.includes(repoInfo.default_branch)) { return repoInfo.default_branch } if (branchNames.length > 0) return branchNames[0] } catch { /* retry */ } await new Promise((resolve) => setTimeout(resolve, delayMs)) } throw new Error(`Branch "${preferredBranch}" existiert im Repository ${owner}/${repo} noch nicht`) } async function updatePreviewConfigFile(repoFullName, previewConfig, branch = 'main') { const [owner, repo] = repoFullName.split('/') const content = JSON.stringify(previewConfig, null, 2) + '\n' const encoded = Buffer.from(content).toString('base64') let sha = null try { const existing = await giteaFetch( `/repos/${owner}/${repo}/contents/.webklar-preview.json?ref=${encodeURIComponent(branch)}` ) sha = existing.sha } catch { /* file may not exist yet */ } await giteaFetch(`/repos/${owner}/${repo}/contents/.webklar-preview.json`, { method: sha ? 'PUT' : 'POST', body: JSON.stringify({ message: 'Update preview config from ticket system', content: encoded, branch, ...(sha ? { sha } : {}), }), }) } /** * true, wenn zu diesem Projekt wirklich ein Hosting gehoert (Preview/Website/Vorlage, oder ein * bereits deployter bzw. anstehender Stand) - im Unterschied zu einem nur importierten/ * abgeglichenen Repo ohne eigenes Hosting (z. B. status "synced" ohne Website). Bestimmt u. a., * ob die Selbstheilung (ensureRepoPreviewConfig) fuer dieses Projekt ueberhaupt aktiv werden darf - * sonst wuerde mit dem organisationsweiten Webhook jeder Push in ein bloss abgeglichenes Repo eine * Preview anlegen. */ export function isHostedProject(doc) { if (['preview', 'website', 'template'].includes(doc?.projectType)) return true if (['deployed', 'pending'].includes(doc?.status)) return true return false } /** * Selbstheilung fuer eine geloeschte/fehlende .webklar-preview.json: * Rekonstruiert die Hosting-Config aus dem in Appwrite gespeicherten * Projektstand (Subdomain, Typ, Name), schreibt sie ins Repo zurueck und liefert * sie fuer einen sofortigen Deploy zurueck. Gibt null zurueck, wenn zu dem Repo * kein gehostetes, aktives Projekt bekannt ist (dann bleibt es Repo-only). */ export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') { if (!repoFullName) return null const docs = await listDocuments(config.collections.websiteProjects, [ Query.equal('repoFullName', repoFullName), Query.limit(1), ]) const doc = docs[0] if (!doc || doc.archived) return null if (!isHostedProject(doc)) return null const type = deriveProjectType(doc) const subdomain = doc.subdomain || '' if (!subdomain) return null const previewConfig = { enabled: true, type: 'static', branch, displayName: doc.projectName || repoFullName.split('/').pop(), templateName: doc.templateName || config.gitea.templateRepo, projectType: type, public: type === 'website', template: type === 'template', subdomain, } try { await updatePreviewConfigFile(repoFullName, previewConfig, branch) } catch (err) { console.error('[ensureRepoPreviewConfig] .webklar-preview.json zurueckschreiben fehlgeschlagen:', err.message) } return previewConfig } export async function createProjectFromTemplate({ repoName, displayName, subdomain, customerId = '', ticketId = '', owner, }) { const targetOwner = owner || config.gitea.defaultOwner const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain) // Subdomain ist optional: ohne Subdomain wird nur das Repo angelegt (normales // Projekt ohne Website-Preview), mit Subdomain zusaetzlich deployed. const safeSubdomain = slugify(subdomain) const hasWebsite = Boolean(safeSubdomain) if (!safeRepoName) { throw new Error('repoName oder displayName ist erforderlich') } if (hasWebsite) { await ensureSubdomainAvailable(safeSubdomain) } const repo = await generateRepoFromTemplate({ owner: targetOwner, repoName: safeRepoName, }) const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}` const [repoOwner, repoSlug] = repoFullName.split('/') const branch = await waitForRepoBranch(repoOwner, repoSlug, 'main') const previewConfig = { enabled: hasWebsite, type: 'static', branch, displayName: displayName || safeRepoName, ...(hasWebsite ? { subdomain: safeSubdomain } : {}), templateName: config.gitea.templateRepo, } await updatePreviewConfigFile(repoFullName, previewConfig, branch) let deployResult = { deployed: false, skipped: !hasWebsite } if (hasWebsite) { try { deployResult = await runDeploy(repoFullName, branch, previewConfig) } catch (deployErr) { console.error('[giteaAdmin] deploy failed:', deployErr.message) deployResult = { deployed: false, error: deployErr.message } } } const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : '' const project = await upsertWebsiteProjectByRepo(repoFullName, { customerId, ticketId, projectName: previewConfig.displayName, templateName: config.gitea.templateRepo, giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || safeRepoName, repoFullName, subdomain: safeSubdomain, previewUrl, projectType: hasWebsite ? 'website' : 'repo', ...(hasWebsite && deployKeepsStatus(deployResult) ? {} // Preview-Runner (C1): Status setzt der Verfolger : { status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created', provisioningStatus: hasWebsite ? (deployResult.deployed ? 'ready' : 'deploy_failed') : 'ready', }), }) return { projectId: project.$id, repoFullName, previewUrl, subdomain: safeSubdomain, projectType: hasWebsite ? 'website' : 'repo', deploy: deployResult, } } /** * Leitet den Projekt-Typ aus einem bestehenden Dokument ab, wenn im Request * kein expliziter Typ mitgegeben wurde. * preview -> Website mit Login-geschuetzter Preview (Standard) * website -> oeffentliche Website ohne Login * template -> Website-Vorlage (login-geschuetzt, nur Admin, in Vorlagen-Ansicht) * project -> internes Projekt ohne Hosting/Subdomain */ function deriveProjectType(doc) { if (doc?.isTemplate) return 'template' if (doc?.isPublic) return 'website' if (doc?.subdomain) return 'preview' return 'project' } async function patchGiteaRepo(repoFullName, body) { const [owner, repo] = String(repoFullName || '').split('/') if (!owner || !repo) return null return giteaFetch(`/repos/${owner}/${repo}`, { method: 'PATCH', body: JSON.stringify(body), }) } /** * Bearbeitet ein bestehendes Website-Projekt (Name / Subdomain / Typ) und * synchronisiert die Aenderung ueber ALLE Systeme: * - Appwrite-Dokument (projectName, subdomain, previewUrl, projectType, isPublic, status) * - Gitea: .webklar-preview.json (enabled/subdomain/displayName/public) + Repo-Beschreibung * - Preview-Hosting: Redeploy in neues Verzeichnis, altes entfernen, Traefik-TLS neu * Der Gitea-Repo-Slug/die Klon-URL bleiben bewusst stabil (kein Repo-Rename). */ export async function updateWebsiteProject(projectId, { projectName, subdomain, projectType } = {}) { const doc = await getDocument(config.collections.websiteProjects, projectId) if (!doc) { const error = new Error('Projekt nicht gefunden') error.status = 404 throw error } const repoFullName = doc.repoFullName || '' const [owner, repoSlug] = repoFullName.split('/') const nextName = String(projectName ?? doc.projectName ?? '').trim() || doc.projectName || repoSlug || '' const nextType = projectType || deriveProjectType(doc) // template = login-geschuetzte Vorlage: braucht Preview (wie 'preview'), aber // nicht oeffentlich; wird als isTemplate markiert. const hasWebsite = nextType === 'preview' || nextType === 'website' || nextType === 'template' const isPublic = nextType === 'website' const isTemplate = nextType === 'template' const oldSubdomain = doc.subdomain || '' let nextSubdomain = '' if (hasWebsite) { const requested = slugify(subdomain !== undefined ? subdomain : oldSubdomain) nextSubdomain = requested || slugify(repoSlug) } const subdomainChanged = nextSubdomain !== oldSubdomain // Subdomain-Eindeutigkeit pruefen (eigenes Dokument ignorieren) if (hasWebsite && subdomainChanged && nextSubdomain) { const conflicts = await listDocuments(config.collections.websiteProjects, [ Query.equal('subdomain', nextSubdomain), Query.limit(5), ]) if (conflicts.some((c) => c.$id !== projectId)) { const error = new Error(`Subdomain "${nextSubdomain}" ist bereits vergeben`) error.status = 409 throw error } } let branch = 'main' try { const info = await getRepoInfo(repoFullName) branch = info?.defaultBranch || 'main' } catch { /* Default main */ } // Deploy-Typ (z.B. node_build) und index-Unterordner aus der vorhandenen // Repo-Config uebernehmen - sonst wuerde jedes Bearbeiten im Portal // App-Projekte (Expo/Vite/React) wieder auf "static" zuruecksetzen. let existingCfg = null try { existingCfg = await fetchPreviewConfig(repoFullName, branch) } catch { /* keine bestehende Config -> Defaults */ } const previewConfig = { enabled: hasWebsite, type: existingCfg?.type || 'static', ...(existingCfg?.index ? { index: existingCfg.index } : {}), branch, displayName: nextName, templateName: doc.templateName || config.gitea.templateRepo, projectType: nextType, public: isPublic, template: isTemplate, ...(hasWebsite && nextSubdomain ? { subdomain: nextSubdomain } : {}), } if (owner && repoSlug) { try { await updatePreviewConfigFile(repoFullName, previewConfig, branch) } catch (err) { console.error('[updateWebsiteProject] preview-config schreiben fehlgeschlagen:', err.message) } try { await patchGiteaRepo(repoFullName, { description: nextName }) } catch (err) { console.error('[updateWebsiteProject] Gitea-Beschreibung fehlgeschlagen:', err.message) } } // Deploy / Teardown let deployResult = { deployed: false, skipped: true } if (hasWebsite && nextSubdomain) { if (subdomainChanged && oldSubdomain) { try { await removeDeploy(oldSubdomain) } catch (err) { console.error('[updateWebsiteProject] alten Deploy entfernen fehlgeschlagen:', err.message) } } try { deployResult = await runDeploy(repoFullName, branch, previewConfig, { project: doc }) } catch (err) { console.error('[updateWebsiteProject] deploy fehlgeschlagen:', err.message) deployResult = { deployed: false, error: err.message } } } else if (oldSubdomain) { try { await removeDeploy(oldSubdomain) } catch (err) { console.error('[updateWebsiteProject] Deploy entfernen fehlgeschlagen:', err.message) } } const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : '' const updated = await updateDocument(config.collections.websiteProjects, projectId, { projectName: nextName, subdomain: nextSubdomain, previewUrl, projectType: nextType, isPublic, isTemplate, ...(hasWebsite && deployKeepsStatus(deployResult) ? {} // Preview-Runner (C1): Status setzt der Verfolger : { status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created', provisioningStatus: hasWebsite ? (deployResult.deployed ? 'ready' : (deployResult.error ? 'deploy_failed' : 'pending')) : 'ready', }), updatedAt: new Date().toISOString(), }) // TLS-Router NACH dem Appwrite-Update synchronisieren, damit der public/auth- // Zustand (isPublic) korrekt gelesen wird - auch wenn kein Redeploy stattfand // (z.B. nur Typ preview <-> website getauscht). try { await syncPreviewTls() } catch (err) { console.error('[updateWebsiteProject] TLS-Sync fehlgeschlagen:', err.message) } // Website-Analyse: Berechtigung/Domains neu pruefen (dynamischer Import wegen Zyklus); Fehler aendern das Ergebnis nicht try { const { analyseOnProjectChanged } = await import('./analyse.js') await analyseOnProjectChanged(projectId) } catch (err) { console.error('[updateWebsiteProject] Analyse-Abgleich fehlgeschlagen:', err.message) } return { project: updated, deploy: deployResult, previewUrl, subdomain: nextSubdomain, projectType: nextType, isPublic, } } /** * Archiviert (oder reaktiviert) ein Projekt: setzt das Gitea-Repo auf archived * und pflegt das Appwrite-Flag. Beim Archivieren wird zusaetzlich der Preview- * Deploy entfernt; beim Reaktivieren mit Subdomain neu deployt. */ export async function archiveRepo(projectId, archived = true) { const doc = await getDocument(config.collections.websiteProjects, projectId) if (!doc) { const error = new Error('Projekt nicht gefunden') error.status = 404 throw error } const repoFullName = doc.repoFullName || '' let repoMissing = false if (repoFullName.includes('/')) { try { await patchGiteaRepo(repoFullName, { archived: Boolean(archived) }) } catch (err) { // Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem // nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet. if (err.status === 404) { repoMissing = true console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`) } else { const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message) error.status = err.status || 500 throw error } } } if (archived && doc.subdomain) { try { await removeDeploy(doc.subdomain) } catch (err) { console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message) } } if (!archived && !repoMissing && doc.subdomain) { try { const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main' const cfg = (await fetchPreviewConfig(repoFullName, branch)) || { enabled: true, type: 'static', branch, subdomain: doc.subdomain, displayName: doc.projectName, } await runDeploy(repoFullName, branch, cfg, { project: doc }) } catch (err) { console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message) } } const updated = await updateDocument(config.collections.websiteProjects, projectId, { archived: Boolean(archived), ...(archived ? { status: 'archived' } : {}), updatedAt: new Date().toISOString(), }) return { project: updated, archived: Boolean(archived) } } /** Repo-Basisdaten (Groesse in KB, Default-Branch). */ export async function getRepoInfo(repoFullName) { const [owner, repo] = String(repoFullName || '').split('/') if (!owner || !repo) return null const data = await giteaFetch(`/repos/${owner}/${repo}`) return { sizeKb: Number(data.size || 0), defaultBranch: data.default_branch || 'main', updatedAt: data.updated_at || '', htmlUrl: data.html_url || `${config.gitea.baseUrl}/${repoFullName}`, } } /** Letzte Commits eines Repos (volle Message inkl. Body). */ export async function listRepoCommits(repoFullName, { branch = 'main', limit = 20 } = {}) { const [owner, repo] = String(repoFullName || '').split('/') if (!owner || !repo) return [] // stat/verification/files abschalten - sonst berechnet Gitea Diff-Stats pro Commit const data = await giteaFetch( `/repos/${owner}/${repo}/commits?sha=${encodeURIComponent(branch)}&limit=${limit}&stat=false&verification=false&files=false` ) return (Array.isArray(data) ? data : []).map((entry) => ({ sha: entry.sha || '', shortSha: (entry.sha || '').slice(0, 7), message: entry.commit?.message || '', authorName: entry.commit?.author?.name || entry.author?.login || entry.committer?.login || 'Unbekannt', date: entry.commit?.author?.date || entry.created || '', url: entry.html_url || `${config.gitea.baseUrl}/${repoFullName}/commit/${entry.sha}`, })) } /** * Datei-Uebersicht: aggregiert den Git-Baum serverseitig nach Top-Level-Eintraegen * (nie den rohen Baum ans Frontend geben - kann tausende Eintraege haben). */ export async function getRepoTreeSummary(repoFullName, ref = 'main') { const [owner, repo] = String(repoFullName || '').split('/') if (!owner || !repo) return null const data = await giteaFetch( `/repos/${owner}/${repo}/git/trees/${encodeURIComponent(ref)}?recursive=true&per_page=1000` ) const entries = Array.isArray(data?.tree) ? data.tree : [] const topLevelMap = new Map() let totalFiles = 0 let totalSizeBytes = 0 for (const entry of entries) { const isBlob = entry.type === 'blob' const size = isBlob ? Number(entry.size || 0) : 0 const path = String(entry.path || '') const slash = path.indexOf('/') const topSegment = slash === -1 ? path : path.slice(0, slash) const isDir = slash !== -1 || entry.type === 'tree' if (isBlob) { totalFiles += 1 totalSizeBytes += size } if (!topSegment) continue const existing = topLevelMap.get(topSegment) || { path: topSegment, type: isDir ? 'dir' : 'file', fileCount: 0, sizeBytes: 0, } if (isDir) existing.type = 'dir' if (isBlob) { existing.fileCount += 1 existing.sizeBytes += size } topLevelMap.set(topSegment, existing) } const topLevel = [...topLevelMap.values()] .sort((a, b) => b.sizeBytes - a.sizeBytes) .slice(0, 50) return { totalFiles, totalSizeBytes, truncated: Boolean(data?.truncated), topLevel, } } const README_CANDIDATES = ['README.md', 'readme.md', 'Readme.md', 'README.MD', 'README'] export async function fetchRepoReadme(repoFullName) { const [owner, repo] = repoFullName.split('/') if (!owner || !repo) { const error = new Error('repoFullName muss owner/repo sein') error.status = 400 throw error } for (const filename of README_CANDIDATES) { try { const data = await giteaFetch( `/repos/${owner}/${repo}/contents/${encodeURIComponent(filename)}` ) const encoding = data.encoding || 'base64' const raw = encoding === 'base64' ? Buffer.from(data.content || '', 'base64').toString('utf8') : String(data.content || '') return { filename: data.name || filename, content: raw, htmlUrl: data.html_url || `${config.gitea.baseUrl}/${repoFullName}/src/branch/main/${filename}`, } } catch (err) { if (err.status === 404) continue throw err } } return null }