Files
Webklar-Kundenbereich/server/routes/webhook/gitea.js
T
ClaudeandClaude Opus 5.5 75794f14d5 Preview: Deploy auf fremde Subdomain sperren
Ein kopiertes Repo (git clone + push, Vorlage) bringt die
.webklar-preview.json des Originals mit. Sein erster Push ueberschrieb
die Preview des Originals, danach scheiterte der Webhook mit 500
(Unique-Index auf subdomain). Fast passiert bei Olzinger -> HSK-Vorlage,
04.10.2026.

- foreignSubdomainOwner: Subdomain gehoert einem anderen Repo oder einem
  Projekt ohne Repo; ein in Gitea umgezogenes Repo (Weiterleitung) zaehlt
  nicht als fremd
- Webhook: fremde Subdomain -> kein Deploy, Projekt ohne Preview (neu:
  synced, Repo-Name statt fremdem Anzeigenamen); eine schon vergebene
  eigene Preview bleibt, WOMS zeigt den Grund als Preview-Fehler
- runDeploy prueft fuer alle anderen Wege (WOMS, Reaktivieren), vor dem
  Runner, damit ein gesperrter Aufruf keinen laufenden Bau verwaist;
  Appwrite-Fehler bei der Pruefung blockieren dort nicht (Webhook streng)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:46:27 +00:00

272 lines
12 KiB
JavaScript

import crypto from 'node:crypto'
import { Router } from 'express'
import { config } from '../../config.js'
import { upsertWebsiteProjectByRepo, getDocument, listDocuments, Query } from '../../services/appwriteAdmin.js'
import {
fetchPreviewConfig,
defaultPreviewConfig,
runDeploy,
foreignSubdomainBlock,
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 wantsPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile)
// Kopiertes Repo mit der .webklar-preview.json eines anderen Projekts: dessen Subdomain weder
// beliefern noch uebernehmen - das Projekt bleibt ohne Preview, bis WOMS ihm eine eigene gibt.
const blocked = wantsPreview
? await foreignSubdomainBlock(name, previewConfig.subdomain || name.split('/').pop())
: null
const hasPreview = wantsPreview && !blocked
// 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 = blocked || { 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, subdomainChecked: true })
} 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 = {
// der Anzeigename einer fremden Config gehoert ebenfalls dem Original
projectName: (blocked ? '' : 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 if (blocked) {
// eine schon vergebene eigene Preview bleibt, wie sie ist - sonst Projekt ohne Preview;
// ein vorhandener Projektname auch (der Repo-Name waere nur der Ersatz fuer neue Projekte)
const existing = (await listDocuments(config.collections.websiteProjects, [
Query.equal('repoFullName', current),
Query.limit(1),
]))[0]
if (!existing?.subdomain) projectPayload.status = 'synced'
else {
// sonst stuende im WOMS weiter der alte Bau als aktuell da (Fehlertext nur bei 'failed')
projectPayload.previewState = 'failed'
projectPayload.previewError = `Neuer Stand nicht veröffentlicht: In .webklar-preview.json steht die Subdomain „${blocked.subdomain}“, die gehört ${blocked.owner}. Projekt im WOMS bearbeiten und speichern, dann stimmt die Datei wieder.`.slice(0, 500)
}
if (existing?.projectName) delete projectPayload.projectName
} 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 <name>" 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