- 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
126 lines
4.3 KiB
JavaScript
126 lines
4.3 KiB
JavaScript
/**
|
||
* 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
|