From b474c4aa488e7e3e958303ebe61fc4611d760364 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 00:21:50 +0000 Subject: [PATCH] feat: Website-Projekte bearbeiten (Name/Subdomain/Typ) + Archivierung - updateWebsiteProject: synct Name/Subdomain/Typ nach Gitea (.webklar-preview.json, Repo-Beschreibung), Preview-Deploy (Redeploy+Teardown+TLS) und Appwrite - Projekt-Typen: preview (Login), website (oeffentlich ohne Login), project (kein Hosting) - syncPreviewTls erzeugt fuer isPublic-Projekte Router ohne preview-auth - archiveRepo: Gitea archived + Appwrite-Flag + Preview-Teardown; Reaktivieren redeployt - syncReposToAppwrite uebernimmt repo.archived - Routen: PATCH /:id, POST /:id/archive, POST /:id/unarchive Co-Authored-By: Claude Opus 4.8 --- server/routes/admin/websiteProjects.js | 40 ++++- server/services/giteaAdmin.js | 218 ++++++++++++++++++++++++- server/services/previewDeploy.js | 51 +++++- 3 files changed, 305 insertions(+), 4 deletions(-) diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index 5d1738a..becf8af 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -1,5 +1,9 @@ import { Router } from 'express' -import { createProjectFromTemplate } from '../../services/giteaAdmin.js' +import { + createProjectFromTemplate, + updateWebsiteProject, + archiveRepo, +} from '../../services/giteaAdmin.js' import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' const router = Router() @@ -29,4 +33,38 @@ router.post('/create-from-template', async (req, res) => { } }) +// Projekt bearbeiten: Name / Subdomain / Typ -> synchronisiert Git + Preview + Appwrite +router.patch('/:id', async (req, res) => { + const { projectName, subdomain, projectType } = req.body || {} + try { + const result = await updateWebsiteProject(req.params.id, { projectName, subdomain, projectType }) + return res.json({ ok: true, ...result }) + } catch (err) { + const status = err.status || 500 + return res.status(status).json({ error: err.message || 'Projekt konnte nicht aktualisiert werden' }) + } +}) + +// Repository archivieren +router.post('/:id/archive', async (req, res) => { + try { + const result = await archiveRepo(req.params.id, true) + return res.json({ ok: true, ...result }) + } catch (err) { + const status = err.status || 500 + return res.status(status).json({ error: err.message || 'Projekt konnte nicht archiviert werden' }) + } +}) + +// Repository reaktivieren +router.post('/:id/unarchive', async (req, res) => { + try { + const result = await archiveRepo(req.params.id, false) + return res.json({ ok: true, ...result }) + } catch (err) { + const status = err.status || 500 + return res.status(status).json({ error: err.message || 'Projekt konnte nicht reaktiviert werden' }) + } +}) + export default router diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index b98c2b3..d134ec3 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -1,8 +1,17 @@ import { config } from '../config.js' -import { upsertWebsiteProjectByRepo, listDocuments, Query } from './appwriteAdmin.js' +import { + upsertWebsiteProjectByRepo, + listDocuments, + Query, + getDocument, + updateDocument, +} from './appwriteAdmin.js' import { runDeploy, buildPreviewUrl, + removeDeploy, + syncPreviewTls, + fetchPreviewConfig, } from './previewDeploy.js' function giteaHeaders() { @@ -122,6 +131,8 @@ export async function syncReposToAppwrite() { 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 @@ -300,6 +311,211 @@ export async function createProjectFromTemplate({ } } +/** + * 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('/') diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 6967d2b..7f9b273 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -3,9 +3,29 @@ 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) { @@ -93,17 +113,24 @@ export async function syncPreviewTls() { .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', - ' middlewares:', - ' - preview-chain@docker', + ...httpsMiddlewareLines, ' priority: 100', ' tls:', ' certResolver: letsencrypt', @@ -160,3 +187,23 @@ export async function runDeploy(repoFullName, branch, previewConfig) { return { deployed: true, subdomain, targetDir, tls } } + +/** + * 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 } +}