Buchungskalender: Abgleich – iCal-Export (Escape, aktive Unterkünfte, ab gestern, Rate-Limit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:54:16 +00:00
1 parent 95cbd473d0
commit fbd07b3d8c
3 files changed
+40 -3

No files matched your search

+9 -2
View File
@@ -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()
+1 -1
View File
@@ -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}/, '')
+30
View File
@@ -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()
})