diff --git a/server/routes/unterkunftSeite.js b/server/routes/unterkunftSeite.js index 8fe74f9..776a47b 100644 --- a/server/routes/unterkunftSeite.js +++ b/server/routes/unterkunftSeite.js @@ -5,6 +5,9 @@ import { Router } from 'express' import { config } from '../config.js' import { listDocuments, Query } from '../services/appwriteAdmin.js' import { baueIcs } from '../services/unterkunft/ical.js' +import { plusTage } from '../services/unterkunft/datum.js' +import { heute as berlinHeute } from '../services/termine/zeit.js' +import { ipSchluessel, zaehle } from '../services/termine/limits.js' import { ladeFuerProjekt, SITE_KEY_RE } from '../services/unterkunft/kontext.js' import { unterkunftFuerKunde, unterkunftModus } from '../services/unterkunft/schalter.js' import { listeAnfragenNachStatus, listeSperren } from '../services/unterkunft/speicher.js' @@ -16,18 +19,21 @@ import { listeAnfragenNachStatus, listeSperren } from '../services/unterkunft/sp */ const DATEI_RE = /^[A-Za-z0-9_-]{20,64}\.ics$/ +const EXPORT_JE_MINUTE = 60 const nicht = (res) => res.status(404).type('text/plain').set('Cache-Control', 'no-store').send('Not found') async function exportEreignisse(unterkunft, jetzt) { + const gestern = plusTage(berlinHeute(new Date(jetzt)), -1) const out = [] for (const s of await listeSperren(unterkunft.$id)) { const quelle = String(s.quelle ?? 'manuell') if (quelle.startsWith('ical') || !s.von || !s.bis) continue // nie zurueckspielen, was importiert wurde + if (s.bis <= gestern) continue // nur Ereignisse ab gestern out.push({ uid: `${s.$id}@project.webklar.com`, von: s.von, bis: s.bis }) } for (const a of await listeAnfragenNachStatus(unterkunft.$id, ['gebucht', 'bezahlt', 'freigegeben'])) { if (a.status === 'freigegeben' && !(Date.parse(a.fristAt) > jetzt)) continue - if (!a.von || !a.bis) continue + if (!a.von || !a.bis || a.bis <= gestern) continue out.push({ uid: `${a.$id}@project.webklar.com`, von: a.von, bis: a.bis }) } return out.sort((x, y) => (x.von < y.von ? -1 : x.von > y.von ? 1 : x.uid < y.uid ? -1 : 1)) @@ -87,9 +93,10 @@ export function unterkunftSeiteRouter() { router.get('/unterkunft/ical/:datei', async (req, res, next) => { try { if (unterkunftModus() === 'off' || !DATEI_RE.test(req.params.datei)) return nicht(res) + if (!zaehle(`uk-ical:${ipSchluessel(req.ip)}`, EXPORT_JE_MINUTE, 60_000, new Date())) return res.status(429).type('text/plain').set({ 'Cache-Control': 'no-store', 'Retry-After': '60' }).send('Too many requests') const token = req.params.datei.slice(0, -4) const [u] = await listDocuments(config.collections.ukUnterkuenfte, [Query.equal('icalExportToken', token), Query.limit(1)]) - if (!u || u.icalExportToken !== token) return nicht(res) + if (!u || u.icalExportToken !== token || u.aktiv === false) return nicht(res) const ctx = await ladeFuerProjekt(u.customerId, u.projectId) if (!ctx || ctx.featureId !== u.featureId || ctx.state !== 'active' || !unterkunftFuerKunde(ctx.besitzerEmail)) return nicht(res) const jetzt = new Date() diff --git a/server/services/unterkunft/ical.js b/server/services/unterkunft/ical.js index ed1dc05..266b8e8 100644 --- a/server/services/unterkunft/ical.js +++ b/server/services/unterkunft/ical.js @@ -141,7 +141,7 @@ function falte(z) { return teile } -const maskiere = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n') +const maskiere = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n') const ohneTrenner = (tag) => tag.replace(/-/g, '') const zeitstempel = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') diff --git a/test/unterkunftIcal.test.js b/test/unterkunftIcal.test.js index 9cb9805..613f68b 100644 --- a/test/unterkunftIcal.test.js +++ b/test/unterkunftIcal.test.js @@ -24,6 +24,7 @@ const mails = await import('../server/services/unterkunft/mails.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { warteAufVersand } = await import('../server/services/termine/outbox.js') const { parseIcs, baueIcs } = await import('../server/services/unterkunft/ical.js') +const { resetTermineLimitsForTests } = await import('../server/services/termine/limits.js') const { holeIcs, gleicheAb, gleicheAlleAb, istPrivateAdresse } = await import('../server/services/unterkunft/icalAbgleich.js') const { viertelstundenJob } = await import('../server/services/unterkunft/jobs.js') const { unterkunftSeiteRouter } = await import('../server/routes/unterkunftSeite.js') @@ -85,6 +86,11 @@ test('parseIcs: HTML oder Unsinn => kein_ics', () => { assert.deepEqual(parseIcs('BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n', OPT).ereignisse, []) }) +test('baueIcs: Semikolon, Komma und Backslash im Namen werden maskiert', () => { + const s = baueIcs({ name: 'A;B,C\\D', ereignisse: [], jetzt: new Date('2026-11-07T10:00:00Z') }) + assert.match(s, /X-WR-CALNAME:A\\;B\\,C\\\\D\r\n/) +}) + test('baueIcs: CRLF, gefaltete Zeilen <= 75 Oktette, Pflichtfelder', () => { const s = baueIcs({ name: 'Wohnung mit sehr langem Namen äöü '.repeat(4), ereignisse: [{ uid: 'a1@project.webklar.com', von: '2026-11-10', bis: '2026-11-12' }], jetzt: new Date('2026-11-07T10:00:00Z') }) assert.ok(s.startsWith('BEGIN:VCALENDAR\r\n') && s.endsWith('END:VCALENDAR\r\n')) @@ -334,3 +340,27 @@ test('Export: unbekanntes Token, falsches Format, Funktion aus/inaktiv => 404', await updateDocument(config.collections.portalFeatures, FEATURE, { state: 'off' }) assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 404) }) + +test('Export: nur aktive Unterkuenfte; nur Ereignisse ab gestern', async () => { + const basis = { featureId: FEATURE, customerId: 'c1', projectId: 'p1', unterkunftId: 'u1', gast: JSON.stringify({ name: 'Eva' }), tokenHash: 'h', ipHash: 'i', personen: 2, status: 'gebucht' } + const tag = (n) => new Date(Date.now() + n * 86400e3).toISOString().slice(0, 10) + seedU([], { icalExportToken: TOKEN }) + aw.seed('ukAnfragen', { $id: 'a_lang_vorbei', ...basis, von: tag(-30), bis: tag(-10), nummer: 'A-1' }) + aw.seed('ukAnfragen', { $id: 'a_laeuft', ...basis, von: tag(-3), bis: tag(+3), nummer: 'A-2' }) + aw.seed('ukSperren', { $id: 's_vorbei', unterkunftId: 'u1', featureId: FEATURE, von: tag(-20), bis: tag(-15), quelle: 'manuell' }) + const r = await rawGet(`/unterkunft/ical/${TOKEN}.ics`) + assert.equal(r.status, 200) + assert.ok(r.body.includes('UID:a_laeuft@') && !r.body.includes('a_lang_vorbei') && !r.body.includes('s_vorbei')) + await updateDocument(config.collections.ukUnterkuenfte, 'u1', { aktiv: false }) + assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 404, 'inaktive Unterkunft') +}) + +test('Export: hoechstens 60 Abrufe je Minute und IP, danach 429', async () => { + resetTermineLimitsForTests() + seedU([], { icalExportToken: TOKEN }) + for (let i = 0; i < 60; i++) assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 200) + const r = await rawGet(`/unterkunft/ical/${TOKEN}.ics`) + assert.equal(r.status, 429) + assert.equal(r.headers.get('retry-after'), '60') + resetTermineLimitsForTests() +})