Gutschriften: signierte Links fuer Nachweise im Ticketsystem
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7d93ae0cd9
commit
0fdd13246c
3 files changed
+66
-2
No files matched your search
@@ -2,6 +2,7 @@ import { Router } from 'express'
|
|||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||||
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
|
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
|
||||||
|
import { signFileLink } from '../services/signedLinks.js'
|
||||||
import {
|
import {
|
||||||
answerQuestion,
|
answerQuestion,
|
||||||
applyTakeoverCredit,
|
applyTakeoverCredit,
|
||||||
@@ -176,9 +177,21 @@ takeoverAdminRouter.get('/open-count', async (_req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Nachweise bekommen einen signierten Link (15 min) auf den Team-Eingang: das Ticketsystem oeffnet sie ohne
|
||||||
|
// Authorization-Header (routes/team/files.js). Bei jedem Oeffnen des Antrags neu signiert.
|
||||||
|
function withSignedFileUrls(detail, now = Date.now()) {
|
||||||
|
const id = detail.request.id
|
||||||
|
const base = `${config.publicBaseUrl}/api/team/takeover-credits/${encodeURIComponent(id)}/files`
|
||||||
|
const files = detail.request.files.map((file) => {
|
||||||
|
const { exp, sig } = signFileLink({ kind: 'takeover', customerId: id, id: file.id, now })
|
||||||
|
return { ...file, url: `${base}/${encodeURIComponent(file.id)}?exp=${exp}&sig=${sig}` }
|
||||||
|
})
|
||||||
|
return { ...detail, request: { ...detail.request, files } }
|
||||||
|
}
|
||||||
|
|
||||||
takeoverAdminRouter.get('/:id', async (req, res) => {
|
takeoverAdminRouter.get('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
return res.json(await getAdminDetail(req.params.id))
|
return res.json(withSignedFileUrls(await getAdminDetail(req.params.id)))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendError(res, err, 'Antrag konnte nicht geladen werden')
|
return sendError(res, err, 'Antrag konnte nicht geladen werden')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { openAttachment, sendAttachment } from '../../services/chatAttachments.js'
|
import { openAttachment, sendAttachment } from '../../services/chatAttachments.js'
|
||||||
import { verifyFileLink } from '../../services/signedLinks.js'
|
import { verifyFileLink } from '../../services/signedLinks.js'
|
||||||
|
import { getTakeoverRequest, readUpload } from '../../services/takeoverCredits.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anhaenge mit signiertem Link (?exp=&sig=, 15 min) ohne JWT ausliefern: <img>, <audio> und Downloads im
|
* Chat-Anhaenge und Gutschrift-Nachweise mit signiertem Link (?exp=&sig=, 15 min) ohne JWT ausliefern: <img>, <audio> und Downloads im
|
||||||
* Ticketsystem koennen keinen Authorization-Header tragen. Ohne sig geht es weiter zur JWT-Pruefung.
|
* Ticketsystem koennen keinen Authorization-Header tragen. Ohne sig geht es weiter zur JWT-Pruefung.
|
||||||
* Eingehaengt in routes/team/index.js VOR requireStaffJwt.
|
* Eingehaengt in routes/team/index.js VOR requireStaffJwt.
|
||||||
*/
|
*/
|
||||||
@@ -23,4 +24,23 @@ router.get('/chats/:customerId/attachments/:id', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Nachweise einer Uebernahmegutschrift (wie takeoverAdminRouter GET /:id/files/:fileId), signiert ueber die Antrags-ID
|
||||||
|
router.get('/takeover-credits/:id/files/:fileId', async (req, res, next) => {
|
||||||
|
if (req.query.sig === undefined) return next()
|
||||||
|
const { id, fileId } = req.params
|
||||||
|
if (!verifyFileLink({ kind: 'takeover', customerId: id, id: fileId, exp: req.query.exp, sig: String(req.query.sig) })) {
|
||||||
|
return res.status(403).json({ error: 'Link abgelaufen. Bitte den Antrag neu öffnen.' })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { file, content } = await readUpload(await getTakeoverRequest(id), fileId)
|
||||||
|
res.setHeader('Content-Type', file.type)
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`)
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||||
|
res.setHeader('Cache-Control', 'private, no-store')
|
||||||
|
return res.send(content)
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(err.status || 500).send(err.message || 'Datei nicht verfügbar')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
@@ -100,3 +100,34 @@ test('ohne JWT 401 fuer alle neuen Pfade', async () => {
|
|||||||
assert.equal((await rawGet(p)).status, 401, p)
|
assert.equal((await rawGet(p)).status, 401, p)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Task 2: Nachweise per signiertem Link (15 min) – Links im Ticketsystem tragen keinen Authorization-Header
|
||||||
|
const FILE_ID = '0b9c2f0e-6d6e-4c1a-9a51-6a3a9d1b2c3d'
|
||||||
|
aw.seed('takeoverCredits', {
|
||||||
|
// eigener Kunde und entschieden, damit Filter und openCount der anderen Tests gleich bleiben
|
||||||
|
$id: 'tc9', customerId: 'c4', email: 'k@example.invalid', services: JSON.stringify(['hosting']), provider: 'IONOS', domain: 'kunde.de',
|
||||||
|
invoiceAmountCents: 12000, billingMonths: 12, paidUntil: '2026-12-31T00:00:00.000Z', message: 'x', status: 'abgelehnt', history: '[]',
|
||||||
|
takeoverFrom: '2026-10-01T00:00:00.000Z',
|
||||||
|
files: JSON.stringify([{ id: FILE_ID, name: 'rechnung.pdf', type: 'application/pdf', ext: 'pdf', size: 9, uploadedAt: '2026-10-01T08:00:00.000Z' }]),
|
||||||
|
})
|
||||||
|
mkdirSync(path.join(process.env.TAKEOVER_UPLOAD_DIR, 'tc9'), { recursive: true })
|
||||||
|
writeFileSync(path.join(process.env.TAKEOVER_UPLOAD_DIR, 'tc9', `${FILE_ID}.pdf`), '%PDF-1.4\n')
|
||||||
|
|
||||||
|
test('Nachweis: signierter Link ohne JWT 200 mit Inhalt, fremder Antrag/abgelaufen 403, ohne sig 401', async (t) => {
|
||||||
|
const detail = (await rawGet('/api/team/takeover-credits/tc9', staff)).body
|
||||||
|
const url = detail.request.files[0].url
|
||||||
|
assert.match(url, new RegExp(`^https://project\\.webklar\\.com/api/team/takeover-credits/tc9/files/${FILE_ID}\\?exp=\\d+&sig=[0-9a-f]{64}$`))
|
||||||
|
const u = new URL(url)
|
||||||
|
const rel = u.pathname + u.search
|
||||||
|
const ok = await fetch(app.url + rel)
|
||||||
|
assert.equal(ok.status, 200)
|
||||||
|
assert.equal(ok.headers.get('content-type'), 'application/pdf')
|
||||||
|
assert.equal(await ok.text(), '%PDF-1.4\n')
|
||||||
|
assert.equal((await rawGet(rel.replace('/tc9/', '/tc1/'))).status, 403)
|
||||||
|
assert.equal((await rawGet(u.pathname)).status, 401)
|
||||||
|
const now = Date.now()
|
||||||
|
t.mock.method(Date, 'now', () => now + 16 * 60 * 1000)
|
||||||
|
const expired = await rawGet(rel)
|
||||||
|
assert.equal(expired.status, 403)
|
||||||
|
assert.equal(expired.body.error, 'Link abgelaufen. Bitte den Antrag neu öffnen.')
|
||||||
|
})
|
||||||
Reference in new issue
Block a user