203 lines
8.0 KiB
JavaScript
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
|