Files
Emailsorter/server/routes/webhook.mjs
ANDJ cbb225c001 feat: Gitea Webhook, IMAP, Settings & Deployment docs
- Webhook route and Gitea integration
- IMAP service and Nextcloud/Porkbun setup docs
- Settings UI improvements and API updates
- SSH/Webhook fix prompt for emailsorter.webklar.com
- Bootstrap, config and AI sorter updates
2026-01-31 15:00:00 +01:00

126 lines
4.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <secret|authToken> (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