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, deployKeepsStatus, buildPreviewUrl, } from '../../services/previewDeploy.js' import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js' import { createGitPushWorksheet, isSystemPusher } from '../../services/gitWorksheet.js' import { sendPushTimeMail } from '../../services/pushTimeMail.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' import { handleRepositoryCreated, handleRepositoryDeleted } from '../../services/repoAssignJob.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 || {} // Ereignis "repository": `created` (neues Repo -> Dokument + Zuordnungs-Mail) und `deleted` // (Platzhalter-Dokument entfernen), alles andere wird mit 200 ignoriert. Der Push-Pfad bleibt unberuehrt. if (req.get('X-Gitea-Event') === 'repository') { if (payload.action === 'deleted') { try { const r = await handleRepositoryDeleted(payload) return res.json({ ok: true, removed: r.removed }) } catch (err) { console.error('[webhook] repository/deleted:', err) return res.status(500).json({ error: err.message || 'Webhook-Verarbeitung fehlgeschlagen' }) } } if (payload.action !== 'created') return res.json({ ok: true, ignored: true }) try { const r = await handleRepositoryCreated(payload) return res.json({ ok: true, created: r.created }) } catch (err) { console.error('[webhook] repository/created:', err) return res.status(500).json({ error: err.message || 'Webhook-Verarbeitung fehlgeschlagen' }) } } 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, { sha: commitSha }) } 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 (deployKeepsStatus(deployResult) && hasPreview) { // Preview-Runner (C1): Status/Bereitschaft bleiben unveraendert, der Verfolger setzt sie } else 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 // System-Commits (z. B. .webklar-preview.json beim Anlegen aus dem Ticketsystem) bekommen weder // Arbeitsblatt noch Auto-Ticket - sonst entstand beim Anlegen mit Kunde ein zusaetzliches // Ticket "Website " neben dem, das im Zuordnen-Assistenten gewaehlt wird. if (commitSha && commitSha !== '0000000000000000000000000000000000000000' && !isSystemPusher(pusher)) { 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 } ) } // Phase 6: Push-Zeitmail an die pushende Person - nur fuer ein neu angelegtes Arbeitsblatt, // ohne auf den Versand zu warten; ein Fehler aendert die Webhook-Antwort nicht. if (gitWorksheet?.created) { sendPushTimeMail({ ticket: gitWorksheet.ticket, worksheet: gitWorksheet.worksheet, repoFullName: current, branch, commits, pusher, pushedAt: new Date(), }).catch((err) => console.error('[webhook] Zeitmail:', err.message)) const { created, wsid, worksheetId } = gitWorksheet gitWorksheet = { created, wsid, worksheetId } } 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