import { config } from '../config.js' import { upsertWebsiteProjectByRepo, listDocuments, Query, getDocument, updateDocument, } from './appwriteAdmin.js' import { runDeploy, buildPreviewUrl, removeDeploy, syncPreviewTls, fetchPreviewConfig, } from './previewDeploy.js' function giteaHeaders() { if (!config.gitea.apiToken) { throw new Error('GITEA_API_TOKEN fehlt in .env') } return { Authorization: `token ${config.gitea.apiToken}`, 'Content-Type': 'application/json', } } async function giteaFetch(path, options = {}) { const response = await fetch(`${config.gitea.baseUrl}/api/v1${path}`, { ...options, headers: { ...giteaHeaders(), ...options.headers }, }) const text = await response.text() let data = null if (text) { try { data = JSON.parse(text) } catch { data = { message: text } } } if (!response.ok) { let message = data?.message || `Gitea ${response.status}` if (response.status === 409 && /same name already exists/i.test(message)) { message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.' } const error = new Error(message) error.status = response.status throw error } return data } function slugify(value) { return String(value || '') .toLowerCase() .replace(/[^a-z0-9-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 100) } 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 page += 1 } return repos } async function resolveSubdomainForSync(repo, existingDoc) { if (existingDoc?.subdomain) return existingDoc.subdomain const candidate = slugify(repo.name) || slugify(repo.full_name?.split('/').pop()) if (!candidate) return '' const conflict = await listDocuments(config.collections.websiteProjects, [ Query.equal('subdomain', candidate), Query.limit(1), ]) if (!conflict[0] || conflict[0].repoFullName === repo.full_name) { return candidate } const ownerSlug = slugify(repo.full_name?.replace('/', '-')) if (!ownerSlug) return '' const ownerConflict = await listDocuments(config.collections.websiteProjects, [ Query.equal('subdomain', ownerSlug), Query.limit(1), ]) if (!ownerConflict[0] || ownerConflict[0].repoFullName === repo.full_name) { return ownerSlug } return '' } 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 branch = repo.default_branch || 'main' const subdomain = await resolveSubdomainForSync(repo, existing[0]) const previewUrl = subdomain ? buildPreviewUrl(subdomain) : '' 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 (subdomain) payload.subdomain = subdomain if (previewUrl) payload.previewUrl = previewUrl 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 } : {}), }), }) } 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', 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 * project -> internes Projekt ohne Hosting/Subdomain */ function deriveProjectType(doc) { 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) const hasWebsite = nextType === 'preview' || nextType === 'website' const isPublic = nextType === 'website' 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 */ } const previewConfig = { enabled: hasWebsite, type: 'static', branch, displayName: nextName, templateName: doc.templateName || config.gitea.templateRepo, projectType: nextType, public: isPublic, ...(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) } 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) } } // Sicherstellen, dass die Traefik-Router den public/auth-Zustand widerspiegeln, // 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) } const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : '' const updated = await updateDocument(config.collections.websiteProjects, projectId, { projectName: nextName, subdomain: nextSubdomain, previewUrl, projectType: nextType, isPublic, hasPreview: hasWebsite, status: hasWebsite ? (deployResult.deployed ? 'deployed' : 'pending') : 'created', provisioningStatus: hasWebsite ? (deployResult.deployed ? 'ready' : (deployResult.error ? 'deploy_failed' : 'pending')) : 'ready', updatedAt: new Date().toISOString(), }) 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 || '' if (repoFullName.includes('/')) { try { await patchGiteaRepo(repoFullName, { archived: Boolean(archived) }) } catch (err) { 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 && 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) } 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 }