From a28ae21389b1993f2d337a9e43a6f5b7e84f8ed7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 13:41:57 +0000 Subject: [PATCH] =?UTF-8?q?Push-Zeitmail:=20Best=C3=A4tigungsseite=20/zeit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/index.js | 3 + server/routes/pushTime.js | 217 +++++++++++++++++++++++++++++++++++++ test/pushTimeRoute.test.js | 161 +++++++++++++++++++++++++++ 3 files changed, 381 insertions(+) create mode 100644 server/routes/pushTime.js create mode 100644 test/pushTimeRoute.test.js diff --git a/server/index.js b/server/index.js index 92943b8..107c3b5 100644 --- a/server/index.js +++ b/server/index.js @@ -16,6 +16,7 @@ import takeoverCreditRoutes from './routes/takeoverCredits.js' import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgePendingChatUploads } from './services/chatAttachments.js' import giteaWebhookRoutes from './routes/webhook/gitea.js' +import pushTimeRoutes from './routes/pushTime.js' import adminGiteaRoutes from './routes/admin/gitea.js' import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js' import adminCustomersRoutes from './routes/admin/customers.js' @@ -68,6 +69,8 @@ app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes) app.use('/api/admin/preview-open', adminPreviewOpenRoutes) app.use('/api/admin/switch-link', adminSwitchLinkRoutes) app.use('/webhook', giteaWebhookRoutes) +// Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token) +app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes) app.get('/api/health', async (_req, res) => { const dbAccess = await verifyDatabaseAccess() diff --git a/server/routes/pushTime.js b/server/routes/pushTime.js new file mode 100644 index 0000000..45f7d27 --- /dev/null +++ b/server/routes/pushTime.js @@ -0,0 +1,217 @@ +import { Router } from 'express' +import { config } from '../config.js' +import { getDocument } from '../services/appwriteAdmin.js' +import { escapeHtml } from '../services/mailTemplates.js' +import { verifyPushTimeToken } from '../services/pushTimeToken.js' +import { applyPushTime, formatMinutes, needsTimeEntry, parseMinutes, MAX_MINUTES } from '../services/pushTimeEntry.js' + +/** + * Bestaetigungsseite der Push-Zeitmail: /zeit/[?min=60]. + * GET zeigt nur an (Mail-Scanner und Link-Vorschauen oeffnen Links automatisch), erst POST speichert. + * Kein Login: der signierte Token erlaubt nur, die Zeit genau dieses Git-Arbeitsblatts einzutragen. + */ + +const router = Router() + +// Einfaches Rate-Limit je IP (30 Anfragen / 15 Minuten) +const RATE_LIMIT = 30 +const RATE_WINDOW_MS = 15 * 60 * 1000 +const hits = new Map() + +export function resetPushTimeRateLimitForTests() { + hits.clear() +} + +function rateLimited(ip) { + const now = Date.now() + for (const [key, entry] of hits) if (entry.resetAt <= now) hits.delete(key) + const entry = hits.get(ip) || { count: 0, resetAt: now + RATE_WINDOW_MS } + entry.count += 1 + hits.set(ip, entry) + return entry.count > RATE_LIMIT +} + +const e = (value) => escapeHtml(value ?? '') + +function page(res, status, title, inner) { + res.status(status) + res.set({ + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'X-Robots-Tag': 'noindex', + 'Referrer-Policy': 'no-referrer', + }) + res.send(` + + + + + +${e(title)} · WEBklar + + +
+
WEBklar
+
${inner}
+
+`) +} + +const womsHome = () => `${config.womsBaseUrl}/tickets` +const ticketLink = (woid) => `${config.womsBaseUrl}/tickets?woid=${encodeURIComponent(woid || '')}` +const openTicket = (woid) => `Ticket öffnen →` + +function errorPage(res, status, title, text) { + page(res, status, title, `

${e(title)}

${e(text)}

Zum Ticketsystem →`) +} + +/** Repo und Commit-Titel aus den Arbeitsblatt-Details (Format von gitWorksheet.js) */ +function pushInfo(details) { + const lines = String(details || '').split('\n') + const repo = lines[0]?.match(/`([^`]+)`/)?.[1] || '' + const commits = lines + .map((line) => line.match(/^- \[([0-9a-f]{7})\]\([^)]*\): (.*?)(?: \([^()]*\))?$/i)) + .filter(Boolean) + .map((m) => `${m[1]} ${m[2]}`) + return { repo, commits } +} + +async function loadTicket(ws) { + try { + return await getDocument(config.collections.workorders, ws.workorderId) + } catch { + return null + } +} + +function formPage(res, status, { ws, ticket, token, minutes, error }) { + const { repo, commits } = pushInfo(ws.details) + const woid = ticket?.woid || ws.woid + const endTime = String(ws.endTime || '') + const pushTime = `${ws.endDate || ''}${endTime ? ` ${endTime.slice(0, 2)}:${endTime.slice(2, 4)}` : ''}` + page( + res, + status, + 'Zeit eintragen', + `

Zeit eintragen

+

Für deinen Push auf Ticket #${e(woid)}${ticket?.customerName ? ` · ${e(ticket.customerName)}` : ''}

+
+${repo ? `
Repository
${e(repo)}
` : ''} +
Push
${e(pushTime)}
+${commits.length ? `
Commits
    ${commits.map((c) => `
  • ${e(c)}
  • `).join('')}
` : ''} +
+${error ? `
${e(error)}
` : ''} +
+ + + +
+${openTicket(woid)}` + ) +} + +function alreadyPage(res, ws, ticket) { + const woid = ticket?.woid || ws.woid + page( + res, + 200, + 'Schon eingetragen', + `

Schon eingetragen: ${e(formatMinutes(ws.totalTime))}

Die Zeit für diesen Push auf #${e(woid)} ist bereits erfasst. Ändern kannst du sie im Ticketsystem.

${openTicket(woid)}` + ) +} + +/** Token pruefen und Arbeitsblatt laden; schickt bei Fehlern selbst die Seite und liefert null. */ +async function resolve(req, res) { + if (rateLimited(req.ip || 'unbekannt')) { + errorPage(res, 429, 'Zu viele Anfragen', 'Bitte versuch es in ein paar Minuten noch einmal.') + return null + } + const check = verifyPushTimeToken(req.params.token) + if (check.error === 'expired') { + errorPage(res, 410, 'Link abgelaufen', 'Der Link aus der Mail gilt 14 Tage. Trag die Zeit bitte direkt im Ticketsystem ein.') + return null + } + if (check.error) { + errorPage(res, 400, 'Link ungültig', 'Dieser Link ist nicht gültig. Trag die Zeit bitte direkt im Ticketsystem ein.') + return null + } + let ws + try { + ws = await getDocument(config.collections.worksheets, check.worksheetId) + } catch { + errorPage(res, 404, 'Nicht mehr vorhanden', 'Dieses Arbeitsblatt gibt es nicht mehr.') + return null + } + if (ws.serviceType !== 'GIT') { + errorPage(res, 400, 'Link ungültig', 'Dieser Link ist nicht gültig. Trag die Zeit bitte direkt im Ticketsystem ein.') + return null + } + return { ws, ticket: await loadTicket(ws) } +} + +router.get('/:token', async (req, res, next) => { + try { + const found = await resolve(req, res) + if (!found) return + if (!needsTimeEntry(found.ws)) return alreadyPage(res, found.ws, found.ticket) + formPage(res, 200, { ...found, token: req.params.token, minutes: parseMinutes(req.query.min) ?? '' }) + } catch (err) { + next(err) + } +}) + +router.post('/:token', async (req, res, next) => { + try { + const found = await resolve(req, res) + if (!found) return + if (!needsTimeEntry(found.ws)) return alreadyPage(res, found.ws, found.ticket) + + const minutes = parseMinutes(req.body?.minutes) + if (minutes === null) { + return formPage(res, 400, { + ...found, + token: req.params.token, + minutes: '', + error: `Bitte ganze Minuten zwischen 1 und ${MAX_MINUTES} eingeben.`, + }) + } + + const result = await applyPushTime(found.ws.$id, minutes) + if (result.status === 'already') return alreadyPage(res, result.worksheet, found.ticket) + if (result.status !== 'saved') { + return errorPage(res, 404, 'Nicht mehr vorhanden', 'Dieses Arbeitsblatt gibt es nicht mehr.') + } + const woid = found.ticket?.woid || found.ws.woid + page( + res, + 200, + 'Eingetragen', + `

Danke!

Eingetragen: ${e(formatMinutes(minutes))} auf #${e(woid)}

${openTicket(woid)}` + ) + } catch (err) { + next(err) + } +}) + +export default router diff --git a/test/pushTimeRoute.test.js b/test/pushTimeRoute.test.js new file mode 100644 index 0000000..e1bd3d3 --- /dev/null +++ b/test/pushTimeRoute.test.js @@ -0,0 +1,161 @@ +import { test, before, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import express from 'express' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +let fake +let server +let baseUrl +let createPushTimeToken +let resetPushTimeRateLimitForTests + +const gitSheet = (id, extra = {}) => ({ + $id: id, wsid: '100300', woid: '100118', workorderId: 't1', serviceType: 'GIT', + employeeName: 'knso', endDate: '30.09.2026', endTime: '1646', startDate: '30.09.2026', startTime: '', + totalTime: 0, isComment: true, + details: 'Git Push auf `WEBklar/amin-friseur` (Branch: main)\nCommit: abc1234\n\n- [abc1234](https://git/x): Startseite neu (knso)', + ...extra, +}) + +before(async () => { + fake = await startFakeAppwrite({ + seed: { + workorders: [{ $id: 't1', woid: '100118', customerName: 'Amin Friseur' }], + worksheets: [ + gitSheet('open1'), + gitSheet('open2'), + gitSheet('open3'), + gitSheet('done1', { startTime: '1500', totalTime: 45, isComment: false }), + gitSheet('evil', { details: 'Git Push auf `X` (Branch: main)\n\n- [abc1234](u): fett' }), + { $id: 'remote1', serviceType: 'Remote', startTime: '', totalTime: 0 }, + ], + }, + }) + Object.assign(process.env, { + APPWRITE_ENDPOINT: fake.endpoint, + APPWRITE_API_KEY: 'test-key', + PUSH_TIME_SECRET: 'r'.repeat(40), + }) + const routes = await import('../server/routes/pushTime.js') + resetPushTimeRateLimitForTests = routes.resetPushTimeRateLimitForTests + ;({ createPushTimeToken } = await import('../server/services/pushTimeToken.js')) + + const app = express() + app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), routes.default) + server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)) + }) + baseUrl = `http://127.0.0.1:${server.address().port}` +}) + +after(async () => { + if (server) await new Promise((resolve) => server.close(resolve)) + await fake.close() +}) + +beforeEach(() => resetPushTimeRateLimitForTests?.()) + +const token = (worksheetId, now) => createPushTimeToken({ worksheetId, employeeId: 'k' }, now) + +async function get(path) { + const res = await fetch(`${baseUrl}${path}`) + return { status: res.status, html: await res.text(), headers: res.headers } +} + +async function post(path, form) { + const res = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(form).toString(), + }) + return { status: res.status, html: await res.text(), headers: res.headers } +} + +test('GET speichert nie', async () => { + const t = token('open1') + for (const m of ['15', '30', '45', '60', '90', '120', '180', '']) { + const r = await get(`/zeit/${t}${m ? `?min=${m}` : ''}`) + assert.equal(r.status, 200) + assert.match(r.html, /#100118/) + assert.match(r.html, /Amin Friseur/) + assert.match(r.html, /WEBklar\/amin-friseur/) + assert.match(r.html, /Startseite neu/) + } + const ws = fake.get('worksheets', 'open1') + assert.equal(ws.totalTime, 0) + assert.equal(ws.startTime, '') +}) + +test('GET mit min=60 füllt das Feld vor, Unsinn nicht', async () => { + const t = token('open1') + assert.match((await get(`/zeit/${t}?min=60`)).html, /name="minutes"[^>]*value="60"/) + assert.match((await get(`/zeit/${t}?min=abc`)).html, /name="minutes"[^>]*value=""/) +}) + +test('POST speichert', async () => { + const r = await post(`/zeit/${token('open2')}`, { minutes: '45' }) + assert.equal(r.status, 200) + assert.match(r.html, /Eingetragen: 45 Min auf #100118/) + const ws = fake.get('worksheets', 'open2') + assert.equal(ws.totalTime, 45) + assert.equal(ws.isComment, false) + assert.equal(ws.startTime, '1601') +}) + +test('POST mit ungültigen Minuten', async () => { + for (const minutes of ['0', '721', '1.5', '']) { + const r = await post(`/zeit/${token('open3')}`, { minutes }) + assert.equal(r.status, 400, minutes) + assert.match(r.html, /Bitte ganze Minuten zwischen 1 und 720 eingeben\./) + assert.match(r.html, /name="minutes"/) + } + assert.equal(fake.get('worksheets', 'open3').totalTime, 0) +}) + +test('schon eingetragen: nichts wird überschrieben', async () => { + const t = token('done1') + const g = await get(`/zeit/${t}?min=30`) + assert.equal(g.status, 200) + assert.match(g.html, /Schon eingetragen: 45 Min/) + assert.doesNotMatch(g.html, /name="minutes"/) + const p = await post(`/zeit/${t}`, { minutes: '30' }) + assert.match(p.html, /Schon eingetragen: 45 Min/) + assert.equal(fake.get('worksheets', 'done1').totalTime, 45) +}) + +test('Token-Fehler und fehlende Blätter', async () => { + assert.equal((await get('/zeit/muell')).status, 400) + const old = token('open1', Date.now() - 15 * 86400 * 1000) + const expired = await get(`/zeit/${old}`) + assert.equal(expired.status, 410) + assert.match(expired.html, /Link abgelaufen/) + assert.equal((await post(`/zeit/${old}`, { minutes: '30' })).status, 410) + const missing = await get(`/zeit/${token('gibtsnicht')}`) + assert.equal(missing.status, 404) + assert.match(missing.html, /Nicht mehr vorhanden/) + assert.equal((await get(`/zeit/${token('remote1')}`)).status, 400) + assert.equal((await post(`/zeit/${token('remote1')}`, { minutes: '30' })).status, 400) +}) + +test('Escaping', async () => { + const r = await get(`/zeit/${token('evil')}`) + assert.match(r.html, /<b>fett<\/b>/) + assert.doesNotMatch(r.html, /fett/) +}) + +test('Sicherheits-Header und Ticket-Link', async () => { + const r = await get(`/zeit/${token('open1')}`) + assert.equal(r.headers.get('cache-control'), 'no-store') + assert.equal(r.headers.get('x-robots-tag'), 'noindex') + assert.equal(r.headers.get('referrer-policy'), 'no-referrer') + assert.match(r.headers.get('content-type'), /text\/html; charset=utf-8/) + assert.match(r.html, /https:\/\/ticket\.webklar\.com\/tickets\?woid=100118/) +}) + +test('Rate-Limit: 31. Anfrage → 429', async () => { + const t = token('open1') + for (let i = 0; i < 30; i += 1) assert.equal((await get(`/zeit/${t}`)).status, 200) + const r = await get(`/zeit/${t}`) + assert.equal(r.status, 429) + assert.match(r.html, /Zu viele Anfragen/) +})