Push-Zeitmail: Bestätigungsseite /zeit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1a1005d099
commit
a28ae21389
3 files changed
+381
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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/<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
|
||||
Reference in new issue
Block a user