Files
Webklar-Kundenbereich/server/routes/webhook/gitea.js
T
2026-09-30 10:21:34 +00:00

203 lines
8.0 KiB
JavaScript

import crypto from 'node:crypto'
import { Router } from 'express'
import { config } from '../../config.js'
import { upsertWebsiteProjectByRepo, getDocument } from '../../services/appwriteAdmin.js'
import {
fetchPreviewConfig,
defaultPreviewConfig,
runDeploy,
buildPreviewUrl,
} from '../../services/previewDeploy.js'
import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js'
import { createGitPushWorksheet } from '../../services/gitWorksheet.js'
import { ensureProjectTicket } from '../../services/ticketAdmin.js'
import { invalidateProjectCache } from '../../services/projectInsights.js'
import { withLocks } from '../../services/keyedLock.js'
import { repoLockKey, currentRepoFullName } from '../../services/repoMove.js'
const router = Router()
function verifyWebhookToken(req) {
const token = config.gitea.webhookToken
if (!token) return false
const queryToken = req.query.token
const headerToken = req.get('X-Gitea-Token')
if (queryToken === token || headerToken === token) return true
// Gitea sendet das Webhook-Secret nicht im Klartext, sondern als
// HMAC-SHA256-Signatur des Request-Bodys (X-Gitea-Signature).
const signature = req.get('X-Gitea-Signature') || req.get('X-Gogs-Signature')
if (signature && req.rawBody) {
const expected = crypto.createHmac('sha256', token).update(req.rawBody).digest('hex')
try {
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
} catch {
return false
}
}
return false
}
router.post('/gitea', async (req, res) => {
if (!verifyWebhookToken(req)) {
return res.status(401).json({ error: 'Unauthorized' })
}
const payload = req.body || {}
const ref = payload.ref || ''
const repo = payload.repository || {}
const payloadName = repo.full_name || ''
const commits = payload.commits || []
const pusher = payload.pusher || {}
const commitSha = payload.after || commits[commits.length - 1]?.id || ''
if (!payloadName) {
return res.status(400).json({ error: 'repository.full_name fehlt' })
}
const branch = ref.replace('refs/heads/', '') || 'main'
try {
// Phase 1: aktuellen Repo-Namen aufloesen - wartet auf einen laufenden Umzug DESSELBEN
// Repos (dieselbe Sperre wie moveRepoToOrg/importNewRepos, Controller-Ruling R17), damit
// der Webhook nie mit einem veralteten Namen weiterlaeuft. Keine Sperre bleibt darueber
// hinaus gehalten - ein Deploy kann lange dauern und darf sie nicht blockieren.
const name = await withLocks([repoLockKey(payloadName)], () => currentRepoFullName(payloadName), {
waitMs: 10000,
})
// Phase 2: Preview-Config + Deploy wie bisher, mit `name` - ohne gehaltene Sperre.
let previewConfigFile = await fetchPreviewConfig(name, branch)
// Selbstheilung: Wurde .webklar-preview.json im Repo geloescht (z.B. beim
// Leerraeumen/Ueberarbeiten der Seite), aber das Projekt ist in Appwrite als
// gehostet bekannt, dann Config aus dem gespeicherten Stand wiederherstellen
// (Datei ins Repo zurueckschreiben) und normal weiter-deployen.
if (!previewConfigFile) {
try {
const restored = await ensureRepoPreviewConfig(name, branch)
if (restored) previewConfigFile = restored
} catch (err) {
console.error('[webhook] preview-config-Wiederherstellung fehlgeschlagen:', err.message)
}
}
const previewConfig = previewConfigFile || defaultPreviewConfig(name)
const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile)
// Nur Projekte mit aktivierter Preview bekommen eine Subdomain zugewiesen -
// Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website.
const subdomain = hasPreview ? previewConfig.subdomain || name.split('/').pop() : ''
const previewUrl = subdomain ? buildPreviewUrl(subdomain) : ''
let deployResult = { deployed: false, skipped: true }
const configBranch = previewConfig.branch || 'main'
if (hasPreview && previewConfig.enabled !== false && branch === configBranch) {
try {
deployResult = await runDeploy(name, branch, previewConfig)
} catch (deployErr) {
console.error('[webhook] deploy failed:', deployErr.message)
deployResult = { deployed: false, error: deployErr.message }
}
}
// Phase 3: Dokument unter derselben Repo-Sperre wie Import/Umzug anlegen/aktualisieren.
// Namen dabei ERNEUT aufloesen - deckt einen Umzug ab, der waehrend des Deploys oben (ohne
// gehaltene Sperre) passiert ist. `current` ab hier fuer alle weiteren Schritte.
let current
const project = await withLocks(
[repoLockKey(name)],
async () => {
current = await currentRepoFullName(name)
// Ein spaet zugestellter Webhook (z. B. nach einem Umzug) traegt noch den ALTEN
// Repo-Namen in repo.html_url/repo.name - diese Payload-Felder duerfen das Dokument
// dann nicht ueberschreiben, sonst zeigt es mit repoFullName=current trotzdem auf die
// alte URL (Fix-Runde 1). Nur wenn `payloadName` noch zu `current` passt, gelten sie.
const payloadNameStillCurrent = payloadName.toLowerCase() === current.toLowerCase()
const projectPayload = {
projectName: previewConfig.displayName || (payloadNameStillCurrent ? repo.name : '') || subdomain,
templateName: previewConfig.templateName || config.gitea.templateRepo,
giteaRepoUrl: payloadNameStillCurrent
? repo.html_url || `${config.gitea.baseUrl}/${current}`
: `${config.gitea.baseUrl}/${current}`,
giteaRepoName: payloadNameStillCurrent ? repo.name || subdomain : current.split('/').pop(),
repoFullName: current,
}
if (subdomain) projectPayload.subdomain = subdomain
if (previewUrl) projectPayload.previewUrl = previewUrl
if (deployResult.deployed) {
projectPayload.status = 'deployed'
projectPayload.provisioningStatus = 'ready'
} else if (hasPreview) {
projectPayload.status = 'pending'
projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending'
} else {
projectPayload.status = 'synced'
}
return upsertWebsiteProjectByRepo(current, projectPayload)
},
{ waitMs: 10000 }
)
// Phase 4
invalidateProjectCache(current)
// Phase 5: nur bei einem echten Commit - Ticket + Arbeitsblatt unter der Projekt-Sperre
// (derselben wie assignProject/reconcileProject), damit ein gleichzeitiges Zuweisen nicht
// dieselbe ticketId-Aufloesung doppelt anstoesst.
let gitWorksheet = null
if (commitSha && commitSha !== '0000000000000000000000000000000000000000') {
gitWorksheet = await withLocks(
['project:' + project.$id],
async () => {
let fresh = project
try {
fresh = await getDocument(config.collections.websiteProjects, project.$id)
} catch {
// Nachlesen fehlgeschlagen: mit dem Upsert-Ergebnis von oben weitermachen (R17).
}
// Projekt ohne Ticket: automatisch ein Webpage-Ticket anlegen (nur mit Kunde)
let ticketId = fresh.ticketId
if (!ticketId) {
try {
ticketId = await ensureProjectTicket(fresh, { repoFullName: current })
} catch (err) {
console.error('[webhook] Auto-Ticket fehlgeschlagen:', err.message)
}
}
if (!ticketId) return null
return createGitPushWorksheet({
ticketId,
repoFullName: current,
branch,
commits,
pusher,
commitSha,
})
},
{ waitMs: 10000 }
)
}
return res.json({
ok: true,
repoFullName: current,
subdomain,
previewUrl,
deploy: deployResult,
projectId: project.$id,
gitWorksheet,
})
} catch (err) {
console.error('[webhook]', err)
return res.status(500).json({ error: err.message || 'Webhook-Verarbeitung fehlgeschlagen' })
}
})
export default router