218 lines
8.3 KiB
JavaScript
218 lines
8.3 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 } 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)
|
|
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(`<!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}
|
|
input[type=number]{width:100%;font-size:20px;padding:12px;border:1px solid #d9d6ce;border-radius:10px}
|
|
.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}input[type=number]{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>`)
|
|
}
|
|
|
|
/** 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',
|
|
`<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>
|
|
<input id="minutes" type="number" name="minutes" min="1" max="${MAX_MINUTES}" step="1" inputmode="numeric" value="${e(minutes ?? '')}" required autofocus>
|
|
<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 (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',
|
|
`<h1>Danke!</h1><p class="ok">Eingetragen: ${e(formatMinutes(minutes))} auf #${e(woid)}</p>${openTicket(woid)}`
|
|
)
|
|
} catch (err) {
|
|
next(err)
|
|
}
|
|
})
|
|
|
|
export default router
|