/** * Webhook Routes (Gitea etc.) * Production: https://emailsorter.webklar.com/api/webhook/gitea * POST /api/webhook/gitea - Deployment on push (validates Bearer or X-Gitea-Signature) */ import express from 'express' import crypto from 'crypto' import { asyncHandler, AuthorizationError } from '../middleware/errorHandler.mjs' import { config } from '../config/index.mjs' import { log } from '../middleware/logger.mjs' const router = express.Router() const secret = config.gitea.webhookSecret const authToken = config.gitea.webhookAuthToken /** * Validate Gitea webhook request: * - Authorization: Bearer (Gitea 1.19+ or manual calls) * - X-Gitea-Signature: HMAC-SHA256 hex of raw body (Gitea default) */ function validateGiteaWebhook(req) { const rawBody = req.body if (!rawBody || !Buffer.isBuffer(rawBody)) { throw new AuthorizationError('Raw body fehlt (Webhook-Route muss vor JSON-Parser registriert sein)') } // 1) Bearer token (Header) const authHeader = req.get('Authorization') if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.slice(7).trim() const expected = authToken || secret if (expected && token === expected) { return true } } // 2) X-Gitea-Signature (HMAC-SHA256 hex) const signatureHeader = req.get('X-Gitea-Signature') if (signatureHeader && secret) { try { const expectedHex = crypto.createHmac('sha256', secret).update(rawBody).digest('hex') const received = signatureHeader.trim() const receivedHex = received.startsWith('sha256=') ? received.slice(7) : received if (expectedHex.length === receivedHex.length && expectedHex.length > 0) { const a = Buffer.from(expectedHex, 'hex') const b = Buffer.from(receivedHex, 'hex') if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true } } catch (_) { // invalid hex or comparison error – fall through to reject } } if (!secret && !authToken) { throw new AuthorizationError('GITEA_WEBHOOK_SECRET nicht konfiguriert') } throw new AuthorizationError('Ungültige Webhook-Signatur oder fehlender Authorization-Header') } /** * POST /api/webhook/gitea * Gitea push webhook – validates Bearer or X-Gitea-Signature, then accepts event */ router.post('/gitea', asyncHandler(async (req, res) => { try { validateGiteaWebhook(req) } catch (err) { if (err.name === 'AuthorizationError' || err.statusCode === 401) throw err log.error('Gitea Webhook: Validierung fehlgeschlagen', { error: err.message }) return res.status(401).json({ error: 'Webhook validation failed' }) } let payload try { const raw = req.body && typeof req.body.toString === 'function' ? req.body.toString('utf8') : '' payload = raw ? JSON.parse(raw) : {} } catch (e) { log.warn('Gitea Webhook: ungültiges JSON', { error: e.message }) return res.status(400).json({ error: 'Invalid JSON body' }) } const ref = payload.ref || '' const branch = ref.replace(/^refs\/heads\//, '') const event = req.get('X-Gitea-Event') || 'push' log.info('Gitea Webhook empfangen', { ref, branch, event }) // Optional: trigger deploy script in background (do not block response) setImmediate(async () => { try { const { spawn } = await import('child_process') const { fileURLToPath } = await import('url') const { dirname, join } = await import('path') const { existsSync } = await import('fs') const __dirname = dirname(fileURLToPath(import.meta.url)) const deployScript = join(__dirname, '..', '..', 'scripts', 'deploy-to-server.mjs') if (existsSync(deployScript)) { const child = spawn('node', [deployScript], { cwd: join(__dirname, '..', '..'), stdio: ['ignore', 'pipe', 'pipe'], detached: true, }) child.unref() child.stdout?.on('data', (d) => log.info('Deploy stdout:', d.toString().trim())) child.stderr?.on('data', (d) => log.warn('Deploy stderr:', d.toString().trim())) } } catch (_) {} }) res.status(202).json({ received: true, ref, branch }) })) /** * GET /api/webhook/status * Simple status for webhook endpoint (e.g. health check) */ router.get('/status', (req, res) => { res.json({ ok: true, webhook: 'gitea', configured: Boolean(secret || authToken), }) }) export default router