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, TIME_STEP } 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). Der Speicher bleibt begrenzt: abgelaufene // Eintraege fliegen hoechstens einmal pro Minute raus, und bei mehr als RATE_MAX_KEYS IPs wird der // aelteste Teil verworfen (Map behaelt die Einfuege-Reihenfolge). const RATE_LIMIT = 30 const RATE_WINDOW_MS = 15 * 60 * 1000 const RATE_MAX_KEYS = 10000 const hits = new Map() let lastPrune = 0 export function resetPushTimeRateLimitForTests() { hits.clear() lastPrune = 0 } export function pushTimeRateLimitSizeForTests() { return hits.size } export function isRateLimited(ip, now = Date.now()) { if (now - lastPrune >= 60 * 1000) { lastPrune = now for (const [key, entry] of hits) if (entry.resetAt <= now) hits.delete(key) } let entry = hits.get(ip) if (!entry || entry.resetAt <= now) { hits.delete(ip) if (hits.size >= RATE_MAX_KEYS) { let drop = hits.size - RATE_MAX_KEYS + 1 for (const key of hits.keys()) { if (drop-- <= 0) break hits.delete(key) } } entry = { count: 0, resetAt: now + RATE_WINDOW_MS } hits.set(ip, entry) } entry.count += 1 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 →`) } /** Stoerung (z. B. Appwrite nicht erreichbar): eigene HTML-Seite statt JSON des globalen Handlers */ function failurePage(res, err) { console.error('[pushTime]', err?.message || err) errorPage(res, 503, 'Gerade nicht erreichbar', 'Bitte versuch es gleich noch einmal. Die Zeit wurde nicht gespeichert.') } /** 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 } } // 15 Min bis 12 Std in 15er-Schritten; ohne gueltige Vorgabe ist 1 Std gewaehlt function minuteOptions(selected) { const chosen = parseMinutes(selected) ?? 60 const options = [] for (let m = TIME_STEP; m <= MAX_MINUTES; m += TIME_STEP) { options.push(``) } return options.join('') } 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 (isRateLimited(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 (err) { if (err?.status !== 404) throw err 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) => { 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) { failurePage(res, err) } }) router.post('/:token', async (req, res) => { 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 eine Zeit in 15-Minuten-Schritten wählen.', }) } const result = await applyPushTime(found.ws.$id, minutes) if (result.status === 'already') return alreadyPage(res, result.worksheet, found.ticket) if (result.status === 'invalid') { return errorPage(res, 422, 'Nicht möglich', 'Bei diesem Push fehlt die Uhrzeit. Trag die Zeit bitte direkt im Ticketsystem ein.') } 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) { failurePage(res, err) } }) export default router