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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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('/')
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user