Files
2026-09-30 15:09:12 +00:00

260 lines
9.8 KiB
JavaScript

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/<token>[?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(`<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>${e(title)} · WEBklar</title>
<style>
*{box-sizing:border-box}
body{margin:0;background:#f1f0ec;color:#111;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5}
main{max-width:520px;margin:0 auto;padding:32px 16px}
.brand{display:flex;align-items:center;gap:10px;font-size:22px;font-weight:bold;letter-spacing:-1px;margin-bottom:20px}
.brand img{width:36px;height:36px;border-radius:9px}
.card{background:#fff;border-radius:16px;padding:28px 24px}
h1{font-size:24px;line-height:1.25;margin:0 0 8px;letter-spacing:-.3px}
.mut{color:#6b6964}
dl{margin:18px 0 0;font-size:14px}
dt{color:#6b6964;margin-top:8px}
dd{margin:0}
ul{margin:4px 0 0;padding-left:18px;font-family:Menlo,Consolas,monospace;font-size:13px}
label{display:block;font-weight:bold;margin:22px 0 6px}
select{width:100%;font-size:20px;padding:12px;border:1px solid #d9d6ce;border-radius:10px;background:#fff;color:inherit}
.err{background:#fdecec;color:#8a1c1c;border-radius:10px;padding:10px 12px;margin-top:16px;font-size:14px}
.ok{background:#e6f4ea;color:#1d5b2c;border-radius:10px;padding:10px 12px;margin-top:4px}
button,.btn{display:inline-block;margin-top:16px;padding:13px 22px;border-radius:10px;font-size:16px;font-weight:bold;text-decoration:none;cursor:pointer}
button{background:#111;color:#fff;border:0;width:100%}
.btn{background:#fff;color:#111;border:1px solid #d9d6ce}
@media (prefers-color-scheme:dark){body{background:#161615;color:#f2f1ee}.card{background:#1f1f1e}select{background:#2a2a28;color:#f2f1ee;border-color:#3a3a37}button{background:#f2f1ee;color:#161615}.btn{background:#2a2a28;color:#f2f1ee;border-color:#3a3a37}.mut,dt{color:#a8a6a0}}
</style>
</head>
<body><main>
<div class="brand"><img src="/webklar-logo.png" alt="">WEBklar</div>
<div class="card">${inner}</div>
</main></body>
</html>`)
}
const womsHome = () => `${config.womsBaseUrl}/tickets`
const ticketLink = (woid) => `${config.womsBaseUrl}/tickets?woid=${encodeURIComponent(woid || '')}`
const openTicket = (woid) => `<a class="btn" href="${e(ticketLink(woid))}">Ticket öffnen →</a>`
function errorPage(res, status, title, text) {
page(res, status, title, `<h1>${e(title)}</h1><p class="mut">${e(text)}</p><a class="btn" href="${e(womsHome())}">Zum Ticketsystem →</a>`)
}
/** 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(`<option value="${m}"${m === chosen ? ' selected' : ''}>${e(formatMinutes(m))}</option>`)
}
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',
`<h1>Zeit eintragen</h1>
<p class="mut">Für deinen Push auf Ticket #${e(woid)}${ticket?.customerName ? ` · ${e(ticket.customerName)}` : ''}</p>
<dl>
${repo ? `<dt>Repository</dt><dd>${e(repo)}</dd>` : ''}
<dt>Push</dt><dd>${e(pushTime)}</dd>
${commits.length ? `<dt>Commits</dt><dd><ul>${commits.map((c) => `<li>${e(c)}</li>`).join('')}</ul></dd>` : ''}
</dl>
${error ? `<div class="err">${e(error)}</div>` : ''}
<form method="post" action="/zeit/${e(token)}">
<label for="minutes">Minuten</label>
<select id="minutes" name="minutes" required autofocus>${minuteOptions(minutes)}</select>
<button type="submit">Speichern</button>
</form>
${openTicket(woid)}`
)
}
function alreadyPage(res, ws, ticket) {
const woid = ticket?.woid || ws.woid
page(
res,
200,
'Schon eingetragen',
`<h1>Schon eingetragen: ${e(formatMinutes(ws.totalTime))}</h1><p class="mut">Die Zeit für diesen Push auf #${e(woid)} ist bereits erfasst. Ändern kannst du sie im Ticketsystem.</p>${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',
`<h1>Danke!</h1><p class="ok">Eingetragen: ${e(formatMinutes(minutes))} auf #${e(woid)}</p>${openTicket(woid)}`
)
} catch (err) {
failurePage(res, err)
}
})
export default router