diff --git a/server/routes/unterkunftSeite.js b/server/routes/unterkunftSeite.js index 3d78e25..76c790c 100644 --- a/server/routes/unterkunftSeite.js +++ b/server/routes/unterkunftSeite.js @@ -1,11 +1,54 @@ import { Router } from 'express' +import { config } from '../config.js' +import { listDocuments, Query } from '../services/appwriteAdmin.js' +import { baueIcs } from '../services/unterkunft/ical.js' +import { ladeFuerProjekt } from '../services/unterkunft/kontext.js' +import { unterkunftFuerKunde, unterkunftModus } from '../services/unterkunft/schalter.js' +import { listeAnfragenNachStatus, listeSperren } from '../services/unterkunft/speicher.js' /** * Oeffentliche Standalone-Seiten des Buchungskalenders: /unterkunft/:siteKey und /unterkunft/:siteKey/buchung/:id * (Vorbild: shopSeite.js). Geruest (Task 5): Task 7 und Strang 3 fuellen die Seiten. + * Davor steht der iCal-Export /unterkunft/ical/.ics (Task 9): die Adresse ist das Geheimnis (Token, 43 Zeichen). */ + +const DATEI_RE = /^[A-Za-z0-9_-]{20,64}\.ics$/ +const nicht = (res) => res.status(404).type('text/plain').set('Cache-Control', 'no-store').send('Not found') + +async function exportEreignisse(unterkunft, jetzt) { + 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 + 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 + 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)) +} + export function unterkunftSeiteRouter() { const router = Router() + + router.get('/unterkunft/ical/:datei', async (req, res, next) => { + try { + if (unterkunftModus() === 'off' || !DATEI_RE.test(req.params.datei)) return nicht(res) + 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) + 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() + const text = baueIcs({ name: u.name, ereignisse: await exportEreignisse(u, jetzt.getTime()), jetzt }) + res.set({ 'Content-Type': 'text/calendar; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }).send(text) + } catch (err) { + next(err) + } + }) + return router } diff --git a/server/services/unterkunft/ical.js b/server/services/unterkunft/ical.js new file mode 100644 index 0000000..ed1dc05 --- /dev/null +++ b/server/services/unterkunft/ical.js @@ -0,0 +1,161 @@ +import { heute as berlinHeute } from '../termine/zeit.js' +import { istTag, naechte, plusTage } from './datum.js' +import { config } from '../../config.js' + +/** + * Buchungskalender: iCalendar lesen und schreiben (reine Funktionen, keine I/O). Gelesen werden nur VEVENT mit Anfangs- und + * Enddatum (DATE, lokale Zeit, TZID oder UTC); mehr braucht die Belegung nicht. Ergebnis sind Berliner Kalendertage [von, bis) + * wie bei allen Sperren. Wiederholungsregeln (RRULE) werden nicht aufgeloest: Booking.com und Airbnb liefern Einzeltermine. + */ + +const MAX_EREIGNISSE = 2000 +const MAX_NAECHTE = 731 + +/** Zeilenumbrueche entfalten: eine Zeile, die mit Leerzeichen oder Tab beginnt, setzt die vorige fort. */ +function entfalte(text) { + const out = [] + for (const z of text.split(/\r\n|\n|\r/)) { + if ((z.startsWith(' ') || z.startsWith('\t')) && out.length) out[out.length - 1] += z.slice(1) + else out.push(z) + } + return out +} + +/** 'NAME;PARAM=x:Wert' => { name (gross), params, wert }. Doppelpunkte in Anfuehrungszeichen der Parameter zaehlen nicht. */ +function zeile(z) { + let inAnf = false + let doppelpunkt = -1 + for (let i = 0; i < z.length; i++) { + const c = z[i] + if (c === '"') inAnf = !inAnf + else if (c === ':' && !inAnf) { + doppelpunkt = i + break + } + } + if (doppelpunkt < 1) return null + const kopf = z.slice(0, doppelpunkt).split(';') + const params = {} + for (const p of kopf.slice(1)) { + const i = p.indexOf('=') + if (i > 0) params[p.slice(0, i).toUpperCase()] = p.slice(i + 1).replace(/^"|"$/g, '') + } + return { name: kopf[0].trim().toUpperCase(), params, wert: z.slice(doppelpunkt + 1).trim() } +} + +const DATUM_RE = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})?(Z)?)?$/ + +/** iCal-Datum oder -Zeitpunkt => Berliner Kalendertag oder null. UTC (Z) wird nach Berlin umgerechnet, alles andere gilt als lokal. */ +function tagVon(wert) { + const m = DATUM_RE.exec(wert) + if (!m) return null + const [, j, mo, t, h, mi, s, z] = m + if (z && h !== undefined) { + const d = new Date(Date.UTC(+j, +mo - 1, +t, +h, +mi, +(s ?? 0))) + return Number.isNaN(d.getTime()) ? null : berlinHeute(d) + } + const tag = `${j}-${mo}-${t}` + return istTag(tag) ? tag : null +} + +function dauerTage(wert) { + const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T.*)?$/.exec(wert) + if (!m) return null + const tage = (m[1] ? +m[1] * 7 : 0) + (m[2] ? +m[2] : 0) + return tage > 0 ? tage : null +} + +/** + * ICS-Text lesen. Ereignisse mit STATUS:CANCELLED, ohne lesbaren Anfang, die vor gestern enden oder nach dem Horizont beginnen + * fallen weg. Fehlt das Ende, ist es eine Nacht. Kein VCALENDAR (z. B. HTML-Fehlerseite) => Error('kein_ics'). + * @param {string} text + * @param {{ heute?: string, horizontTage?: number }} [opt] heute: Berliner Tag (Vorgabe: jetzt); horizontTage: Vorgabe 31 x horizontMonate + */ +export function parseIcs(text, { heute = berlinHeute(), horizontTage = config.unterkunft.horizontMonate * 31 } = {}) { + if (typeof text !== 'string' || !/^\s*BEGIN:VCALENDAR/i.test(text.replace(/^\uFEFF/, ''))) throw new Error('kein_ics') + const gestern = plusTage(heute, -1) + const horizont = plusTage(heute, horizontTage) + const ereignisse = [] + const gesehen = new Set() + let ev = null + let tiefe = 0 + let nr = 0 + for (const roh of entfalte(text.replace(/^\uFEFF/, ''))) { + const p = zeile(roh) + if (!p) continue + if (p.name === 'BEGIN') { + if (p.wert.toUpperCase() === 'VEVENT') { + ev = { uid: '', start: null, ende: null, dauer: null, status: '' } + tiefe = 0 + } else if (ev) tiefe++ + continue + } + if (p.name === 'END') { + if (p.wert.toUpperCase() === 'VEVENT' && ev) { + const e = ev + ev = null + nr++ + if (e.status === 'CANCELLED' || !e.start) continue + let bis = e.ende + if (!bis && e.dauer) bis = plusTage(e.start, e.dauer) + if (!bis || bis <= e.start) bis = plusTage(e.start, 1) + if (naechte(e.start, bis) > MAX_NAECHTE) bis = plusTage(e.start, MAX_NAECHTE) + if (bis < gestern || e.start > horizont) continue + let uid = e.uid || `ohne-uid-${nr}` + if (gesehen.has(uid)) uid = `${uid}#${nr}` + gesehen.add(uid) + if (ereignisse.length < MAX_EREIGNISSE) ereignisse.push({ von: e.start, bis, uid }) + } else if (ev && tiefe > 0) tiefe-- + continue + } + if (!ev || tiefe > 0) continue + if (p.name === 'UID') ev.uid = p.wert.slice(0, 200) + else if (p.name === 'STATUS') ev.status = p.wert.toUpperCase() + else if (p.name === 'DTSTART') ev.start = tagVon(p.wert) + else if (p.name === 'DTEND') ev.ende = tagVon(p.wert) + else if (p.name === 'DURATION') ev.dauer = dauerTage(p.wert) + } + return { ereignisse } +} + +// ---- Schreiben + +/** Zeile auf hoechstens 75 Oktette falten (Fortsetzungszeilen beginnen mit einem Leerzeichen); schneidet nie mitten in ein Zeichen. */ +function falte(z) { + const teile = [] + let aktuell = '' + let bytes = 0 + let limit = 75 + for (const zeichen of z) { + const b = Buffer.byteLength(zeichen) + if (bytes + b > limit) { + teile.push(aktuell) + aktuell = ' ' + bytes = 1 + limit = 75 + } + aktuell += zeichen + bytes += b + } + teile.push(aktuell) + return teile +} + +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}/, '') + +/** + * Kalender mit ganztaegigen Ereignissen "Belegt" bauen (CRLF, gefaltet). Absichtlich keine Gastdaten. + * @param {{ name: string, ereignisse: {uid: string, von: string, bis: string}[], jetzt?: Date }} eingabe + */ +export function baueIcs({ name, ereignisse, jetzt = new Date() }) { + const stempel = zeitstempel(jetzt) + const z = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//WEBklar//Buchungskalender//DE', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH'] + if (name) z.push(`X-WR-CALNAME:${maskiere(name)}`) + for (const e of ereignisse) { + z.push('BEGIN:VEVENT', `UID:${e.uid}`, `DTSTAMP:${stempel}`, `DTSTART;VALUE=DATE:${ohneTrenner(e.von)}`, `DTEND;VALUE=DATE:${ohneTrenner(e.bis)}`, 'SUMMARY:Belegt', 'TRANSP:OPAQUE', 'END:VEVENT') + } + z.push('END:VCALENDAR') + return z.flatMap(falte).join('\r\n') + '\r\n' +} diff --git a/server/services/unterkunft/icalAbgleich.js b/server/services/unterkunft/icalAbgleich.js new file mode 100644 index 0000000..2b1e22e --- /dev/null +++ b/server/services/unterkunft/icalAbgleich.js @@ -0,0 +1,367 @@ +import dns from 'node:dns/promises' +import https from 'node:https' +import net from 'node:net' +import { Readable } from 'node:stream' +import { config } from '../../config.js' +import { listAllDocuments, Query } from '../appwriteAdmin.js' +import { withLocks } from '../keyedLock.js' +import { heute as berlinHeute } from '../termine/zeit.js' +import { parseIcs } from './ical.js' +import { ladeFuerProjekt } from './kontext.js' +import * as mails from './mails.js' +import { unterkunftFuerKunde, unterkunftModus } from './schalter.js' +import { ladeEinstellungen, ladeUnterkunft, legeSperreAn, listeSperren, listeUnterkuenfte, loescheSperre, speichereEinstellungen, speichereUnterkunft } from './speicher.js' + +/** + * Buchungskalender: iCal-Import. holeIcs ruft eine fremde Adresse ab (Booking.com, Airbnb, ...) und ist deshalb gegen SSRF + * gehaertet: nur https auf Port 443, keine Zugangsdaten, der Host wird selbst aufgeloest, jede Adresse muss oeffentlich sein, + * und der Abruf verbindet sich genau mit der geprueften Adresse (kein zweites DNS, also kein Rebinding). Weiterleitungen + * (hoechstens 3) werden jeweils neu geprueft. Fehler tragen einen Code (err.code) und nie die Adresse: Booking- und + * Airbnb-Adressen enthalten ein Geheimnis, deshalb wird nirgends eine volle Adresse protokolliert (nur der Host). + */ + +const MAX_WEITERLEITUNGEN = 3 +const TAG_MS = 24 * 3600 * 1000 +const MAX_IMPORTE_JE_LAUF = 200 + +class IcalFehler extends Error { + constructor(code) { + super(code) + this.code = code + } +} + +// ---- Adresspruefung + +function v4Privat(a) { + const [o1, o2, o3] = a.split('.').map(Number) + return ( + o1 === 0 || // "dieses Netz" inkl. 0.0.0.0 + o1 === 10 || + (o1 === 100 && o2 >= 64 && o2 <= 127) || // CGNAT + o1 === 127 || + (o1 === 169 && o2 === 254) || // Link-Local, Cloud-Metadaten + (o1 === 172 && o2 >= 16 && o2 <= 31) || + (o1 === 192 && o2 === 0 && o3 === 0) || + (o1 === 192 && o2 === 0 && o3 === 2) || + (o1 === 192 && o2 === 168) || + (o1 === 198 && (o2 === 18 || o2 === 19)) || + (o1 === 198 && o2 === 51 && o3 === 100) || + (o1 === 203 && o2 === 0 && o3 === 113) || + o1 >= 224 // Multicast, reserviert, Broadcast + ) +} + +function v6Gruppen(adresse) { + let a = adresse.toLowerCase().split('%')[0] + const m = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(a) + if (m) { + const b = m[2].split('.').map(Number) + a = `${m[1]}${((b[0] << 8) | b[1]).toString(16)}:${((b[2] << 8) | b[3]).toString(16)}` + } + const teile = a.split('::') + if (teile.length > 2) return null + const kopf = teile[0] ? teile[0].split(':') : [] + const rest = teile.length === 2 && teile[1] ? teile[1].split(':') : [] + let g + if (teile.length === 2) { + const luecke = 8 - kopf.length - rest.length + if (luecke < 1) return null + g = [...kopf, ...Array(luecke).fill('0'), ...rest] + } else g = kopf + if (g.length !== 8 || g.some((x) => !/^[0-9a-f]{1,4}$/.test(x))) return null + return g.map((x) => parseInt(x, 16)) +} + +const v4Aus = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}` + +function v6Privat(adresse) { + const g = v6Gruppen(adresse) + if (!g) return true + const nullBis = (n) => g.slice(0, n).every((x) => x === 0) + if (nullBis(5) && g[5] === 0xffff) return v4Privat(v4Aus(g[6], g[7])) // IPv4-mapped + if (nullBis(6)) return true // ::, ::1, IPv4-kompatibel + if (g[0] === 0x64 && g[1] === 0xff9b) return true // NAT64 + if (g[0] === 0x2002) return v4Privat(v4Aus(g[1], g[2])) // 6to4 + if (g[0] === 0x2001 && g[1] === 0) return true // Teredo + if (g[0] === 0x2001 && g[1] === 0xdb8) return true // Dokumentation + if (g[0] === 0x100 && g[1] === 0 && g[2] === 0 && g[3] === 0) return true // Discard + return (g[0] & 0xfe00) === 0xfc00 || (g[0] & 0xffc0) === 0xfe80 || (g[0] & 0xffc0) === 0xfec0 || (g[0] & 0xff00) === 0xff00 +} + +/** true fuer alles, was nicht eindeutig eine oeffentliche IPv4-/IPv6-Adresse ist. */ +export function istPrivateAdresse(adresse) { + if (net.isIPv4(adresse)) return v4Privat(adresse) + if (net.isIPv6(adresse)) return v6Privat(adresse) + return true +} + +// ---- Abruf + +/** Standard-Abruf: https.request mit fester Adresse (lookup-Override), SNI und Zertifikatspruefung gegen den Hostnamen. */ +function festerAbruf(url, { adresse, signal }) { + return new Promise((resolve, reject) => { + const u = new URL(url) + const host = u.hostname.replace(/^\[|\]$/g, '') + const req = https.request( + { + hostname: host, + port: 443, + path: `${u.pathname}${u.search}`, + method: 'GET', + headers: { 'user-agent': 'WEBklar-Buchungskalender/1.0', accept: 'text/calendar, text/plain;q=0.9, */*;q=0.1', 'accept-encoding': 'identity' }, + lookup: (_h, opts, cb) => (opts?.all ? cb(null, [{ address: adresse.address, family: adresse.family }]) : cb(null, adresse.address, adresse.family)), + signal, + }, + (res) => { + const headers = new Headers() + for (const [k, v] of Object.entries(res.headers)) if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(', ') : v) + const ohneKoerper = [204, 205, 304].includes(res.statusCode) + if (ohneKoerper) res.resume() + resolve(new Response(ohneKoerper ? null : Readable.toWeb(res), { status: res.statusCode, headers })) + }, + ) + req.on('error', reject) + req.end() + }) +} + +async function pruefeZiel(url, lookup) { + let u + try { + u = new URL(url) + } catch { + throw new IcalFehler('ungueltige_adresse') + } + if (u.protocol !== 'https:' || !u.hostname || u.username || u.password || (u.port && u.port !== '443')) throw new IcalFehler('ungueltige_adresse') + const host = u.hostname.replace(/^\[|\]$/g, '') + let adressen + if (net.isIP(host)) adressen = [{ address: host, family: net.isIPv6(host) ? 6 : 4 }] + else { + try { + adressen = await lookup(host, { all: true }) + } catch { + throw new IcalFehler('netzwerk') + } + } + if (!adressen?.length) throw new IcalFehler('netzwerk') + if (adressen.some((a) => istPrivateAdresse(a.address))) throw new IcalFehler('private_adresse') + return { adresse: adressen[0] } +} + +async function liesBegrenzt(res, max) { + const laenge = Number(res.headers.get('content-length')) + if (Number.isFinite(laenge) && laenge > max) { + await res.body?.cancel().catch(() => {}) + throw new IcalFehler('zu_gross') + } + if (!res.body) return '' + const reader = res.body.getReader() + const teile = [] + let summe = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + summe += value.byteLength + if (summe > max) { + await reader.cancel().catch(() => {}) + throw new IcalFehler('zu_gross') + } + teile.push(value) + } + return Buffer.concat(teile).toString('utf8') +} + +/** + * ICS-Text von einer Adresse holen. Fehler: Error mit code ungueltige_adresse | private_adresse | netzwerk | timeout | zu_gross | + * zu_viele_weiterleitungen | http_. + * @param {string} url + * @param {{ fetch?: Function, lookup?: Function }} [opt] fetch(url, { adresse, signal, redirect: 'manual' }) => Response; lookup(host, { all: true }) (Tests) + */ +export async function holeIcs(url, { fetch: abruf = festerAbruf, lookup = dns.lookup } = {}) { + const ac = new AbortController() + const timer = setTimeout(() => ac.abort(), config.unterkunft.icalTimeoutMs) + try { + let aktuell = url + for (let sprung = 0; ; sprung++) { + const { adresse } = await pruefeZiel(aktuell, lookup) + const res = await abruf(aktuell, { adresse, signal: ac.signal, redirect: 'manual' }) + if (res.status >= 300 && res.status < 400 && res.headers.get('location')) { + await res.body?.cancel().catch(() => {}) + if (sprung >= MAX_WEITERLEITUNGEN) throw new IcalFehler('zu_viele_weiterleitungen') + try { + aktuell = new URL(res.headers.get('location'), aktuell).href + } catch { + throw new IcalFehler('ungueltige_adresse') + } + continue + } + if (res.status !== 200) { + await res.body?.cancel().catch(() => {}) + throw new IcalFehler(`http_${res.status}`) + } + return await liesBegrenzt(res, config.unterkunft.icalMaxBytes) + } + } catch (err) { + if (err instanceof IcalFehler) throw err + throw new IcalFehler(ac.signal.aborted ? 'timeout' : 'netzwerk') + } finally { + clearTimeout(timer) + } +} + +// ---- Abgleich + +const ms = (now) => (now instanceof Date ? now.getTime() : Number(now)) +const iso = (t) => new Date(t).toISOString() +const lockKey = (id) => ['unterkunft:' + id] +const hostVon = (url) => { + try { + return new URL(url).hostname + } catch { + return '?' + } +} + +/** '07.11.2026, 06:00' in Berliner Zeit. */ +function berlinZeit(t) { + const p = Object.fromEntries( + new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) + .formatToParts(new Date(t)) + .map((x) => [x.type, x.value]), + ) + return `${p.day}.${p.month}.${p.year}, ${p.hour}:${p.minute}` +} + +async function ersetzeEintrag(unterkunft, importId, aenderung) { + const u = await ladeUnterkunft(unterkunft.$id) + const liste = Array.isArray(u?.icalImporte) ? u.icalImporte : [] + const i = liste.findIndex((x) => x.id === importId) + if (i < 0) return null + const neu = aenderung(liste[i]) + const kopie = [...liste] + kopie[i] = neu + await speichereUnterkunft(u.featureId, { icalImporte: kopie }, u.$id) + return neu +} + +/** Fehlermail K33, wenn der Import seit icalFehlerStunden nicht mehr gelang, hoechstens einmal je 24 h und Funktion. Wirft nie. */ +async function pruefeFehlerMail(unterkunft, eintrag, jetzt, ctxVorgabe) { + try { + const seit = Date.parse(eintrag.letzterErfolgAt) || Date.parse(eintrag.fehlerSeit) + if (!Number.isFinite(seit) || jetzt - seit < config.unterkunft.icalFehlerStunden * 3600e3) return + const einst = await ladeEinstellungen(unterkunft.featureId) + const letzte = Date.parse(einst?.icalFehlerMailAt) + if (Number.isFinite(letzte) && jetzt - letzte < TAG_MS) return + const ctx = ctxVorgabe ?? (await ladeFuerProjekt(unterkunft.customerId, unterkunft.projectId)) ?? { featureId: unterkunft.featureId, customerId: unterkunft.customerId, projectId: unterkunft.projectId, einstellungen: einst ?? {} } + await mails.anVermieter('K33', ctx, { unterkunft: unterkunft.name, quelle: eintrag.name, seit: berlinZeit(seit) }) + await speichereEinstellungen(unterkunft.featureId, { icalFehlerMailAt: iso(jetzt) }) + } catch (err) { + console.warn(`[unterkunft-ical] Fehlermail fehlgeschlagen: ${err?.name}: ${String(err?.message).slice(0, 120)}`) + } +} + +/** + * Einen Import abgleichen: Feed holen, lesen, die Sperren 'ical:' angleichen (fehlende zuerst anlegen, dann + * ueberzaehlige loeschen) und den Eintrag in icalImporte nachfuehren. Bei einem Fehler bleiben die alten Sperren stehen. + * @returns {Promise<{ok: true, anzahl: number} | {ok: false, fehler: string}>} + */ +export async function gleicheAb(unterkunft, importEintrag, { now = new Date(), fetch, lookup, ctx } = {}) { + const jetzt = ms(now) + const importId = importEintrag.id + const quelle = `ical:${importId}` + let ereignisse = null + let code = null + try { + const text = await holeIcs(importEintrag.url, { fetch, lookup }) + ereignisse = parseIcs(text, { heute: berlinHeute(new Date(jetzt)) }).ereignisse + } catch (err) { + code = err?.code || (err?.message === 'kein_ics' ? 'kein_ics' : 'netzwerk') + } + + const ergebnis = await withLocks(lockKey(unterkunft.$id), async () => { + const frisch = await ladeUnterkunft(unterkunft.$id) + const aktuellerEintrag = (Array.isArray(frisch?.icalImporte) ? frisch.icalImporte : []).find((x) => x.id === importId) + if (!aktuellerEintrag) return { ok: false, fehler: 'import_entfernt' } + if (code) { + const neu = await ersetzeEintrag(frisch, importId, (e) => ({ ...e, letzterAbrufAt: iso(jetzt), fehler: code, fehlerSeit: e.fehlerSeit || iso(jetzt) })) + return { ok: false, fehler: code, eintrag: neu } + } + const alt = (await listeSperren(frisch.$id)).filter((s) => s.quelle === quelle) + const behalten = new Set() + const vorhanden = new Map() + const ueberzaehlig = [] + for (const s of alt) { + const k = `${s.von}|${s.bis}` + if (vorhanden.has(k)) ueberzaehlig.push(s) + else vorhanden.set(k, s) + } + const wuensche = new Map() + for (const e of ereignisse) wuensche.set(`${e.von}|${e.bis}`, e) + for (const [k, e] of wuensche) { + if (vorhanden.has(k)) behalten.add(k) + else await legeSperreAn({ unterkunftId: frisch.$id, featureId: frisch.featureId, von: e.von, bis: e.bis, quelle, notiz: String(importEintrag.name ?? '').slice(0, 200) }) + } + for (const [k, s] of vorhanden) if (!behalten.has(k)) ueberzaehlig.push(s) + for (const s of ueberzaehlig) await loescheSperre(s.$id) + await ersetzeEintrag(frisch, importId, (e) => { + const { fehlerSeit, ...rest } = e + void fehlerSeit + return { ...rest, letzterAbrufAt: iso(jetzt), letzterErfolgAt: iso(jetzt), fehler: '' } + }) + return { ok: true, anzahl: wuensche.size } + }) + + if (!ergebnis.ok && ergebnis.eintrag) { + await pruefeFehlerMail(unterkunft, ergebnis.eintrag, jetzt, ctx) + return { ok: false, fehler: ergebnis.fehler } + } + return ergebnis.ok ? { ok: true, anzahl: ergebnis.anzahl } : { ok: false, fehler: ergebnis.fehler } +} + +/** + * Alle faelligen Importe aller aktiven Buchungskalender nacheinander abgleichen (Viertelstundenjob). Hoechstens maxImporte je Lauf; + * ein Fehler stoppt die anderen nicht. Protokoll nur mit Host, nie mit der vollen Adresse. + * @returns {Promise<{importe: number, erfolg: number, fehler: number}>} + */ +export async function gleicheAlleAb(now = new Date(), { fetch, lookup, maxImporte = MAX_IMPORTE_JE_LAUF } = {}) { + const jetzt = ms(now) + const zaehler = { importe: 0, erfolg: 0, fehler: 0 } + const faellig = (e) => { + const letzter = Date.parse(e.letzterAbrufAt) + return !Number.isFinite(letzter) || jetzt - letzter >= (config.unterkunft.icalIntervallMin - 1) * 60e3 + } + const features = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'unterkunft'), Query.equal('state', 'active')]) + for (const feature of features) { + if (zaehler.importe >= maxImporte) break + try { + if (unterkunftModus() === 'off') break + let ctx + if (unterkunftModus() === 'pilot') { + ctx = await ladeFuerProjekt(feature.customerId, feature.projectId) + if (!ctx || !unterkunftFuerKunde(ctx.besitzerEmail)) continue + } + for (const u of await listeUnterkuenfte(feature.$id, { nurAktive: true })) { + for (const eintrag of Array.isArray(u.icalImporte) ? u.icalImporte : []) { + if (zaehler.importe >= maxImporte) break + if (!eintrag?.url || !faellig(eintrag)) continue + zaehler.importe++ + try { + const r = await gleicheAb(u, eintrag, { now, fetch, lookup, ctx }) + if (r.ok) zaehler.erfolg++ + else { + zaehler.fehler++ + console.warn(`[unterkunft-ical] Import von ${hostVon(eintrag.url)} fehlgeschlagen: ${r.fehler}`) + } + } catch (err) { + zaehler.fehler++ + console.warn(`[unterkunft-ical] Import von ${hostVon(eintrag.url)} fehlgeschlagen: ${err?.name}`) + } + } + } + } catch (err) { + console.warn(`[unterkunft-ical] Funktion ${feature.$id} uebersprungen: ${err?.name}: ${String(err?.message).slice(0, 120)}`) + } + } + return zaehler +} diff --git a/server/services/unterkunft/jobs.js b/server/services/unterkunft/jobs.js index 71d1548..336a0ef 100644 --- a/server/services/unterkunft/jobs.js +++ b/server/services/unterkunft/jobs.js @@ -1,10 +1,11 @@ import { laufeFristenAb } from './ablauf.js' +import { gleicheAlleAb } from './icalAbgleich.js' import { unterkunftModus } from './schalter.js' /** * Jobs des Buchungskalenders (Registrierung in server/index.js: Minuten 80 s Start / 60 s, Viertelstunden 140 s / 15 min, * taeglich 210 s / 24 h). Jeder Job laeuft hoechstens einmal gleichzeitig (ein Lauf, der noch haengt, ueberspringt den naechsten - * Takt), wirft nie und kehrt bei UNTERKUNFT_ENABLED=off sofort zurueck. Geruest: Task 9/10 fuellen Viertelstunden- und Tagesjob. + * Takt), wirft nie und kehrt bei UNTERKUNFT_ENABLED=off sofort zurueck. Geruest: Task 10 fuellt den Tagesjob. */ const laufend = new Set() @@ -31,11 +32,10 @@ export async function minutenJob(now = new Date()) { return einzeln('minutenJob', () => laufeFristenAb(now)) } -/** Viertelstundenjob (Task 9: iCal-Import). Bei UNTERKUNFT_ENABLED=off aus. */ +/** Viertelstundenjob: iCal-Importe abgleichen. Bei UNTERKUNFT_ENABLED=off aus. */ export async function viertelstundenJob(now = new Date()) { - void now if (unterkunftModus() === 'off') return { aus: true } - return einzeln('viertelstundenJob', async () => ({})) + return einzeln('viertelstundenJob', () => gleicheAlleAb(now)) } /** Tagesjob (Task 10: Aufbewahrung, Loeschen). Bei UNTERKUNFT_ENABLED=off aus. */ diff --git a/server/services/unterkunft/schema.js b/server/services/unterkunft/schema.js index 7fabd85..efdd5de 100644 --- a/server/services/unterkunft/schema.js +++ b/server/services/unterkunft/schema.js @@ -32,7 +32,7 @@ export const UNTERKUNFT_COLLECTIONS = { str('icalImporte', 4000), str('icalExportToken', 64), ], - indexes: [index('idx_feature', 'featureId')], + indexes: [index('idx_feature', 'featureId'), index('idx_export_token', 'icalExportToken')], }, ukPreise: { attributes: [ diff --git a/test/fixtures/ical/airbnb.ics b/test/fixtures/ical/airbnb.ics new file mode 100644 index 0000000..ef58654 --- /dev/null +++ b/test/fixtures/ical/airbnb.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Airbnb Inc//Hosting Calendar 1.0//EN +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261120 +DTEND;VALUE=DATE:20261125 +DTSTAMP:20261001T120000Z +UID:a1-xyz@airbnb.com +SUMMARY:Reserved +DESCRIPTION:Reservation URL: https://www.airbnb.com/hosting/reservations/details/HM123\nPhone Number (Last 4 Digits): 1234 +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261201 +DTEND;VALUE=DATE:20261202 +UID:a2@airbnb.com +SUMMARY:Airbnb (Not available) +END:VEVENT +END:VCALENDAR diff --git a/test/fixtures/ical/booking.ics b/test/fixtures/ical/booking.ics new file mode 100644 index 0000000..8b95db4 --- /dev/null +++ b/test/fixtures/ical/booking.ics @@ -0,0 +1,17 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Booking.com//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261110 +DTEND;VALUE=DATE:20261113 +UID:b1@booking.com +SUMMARY:CLOSED - Not available +END:VEVENT +BEGIN:VEVENT +DTSTART;VALUE=DATE:20261201 +DTEND;VALUE=DATE:20261203 +UID:b2@booking.com +SUMMARY:CLOSED - Not available +END:VEVENT +END:VCALENDAR diff --git a/test/fixtures/ical/fehlerseite.html b/test/fixtures/ical/fehlerseite.html new file mode 100644 index 0000000..679fcec --- /dev/null +++ b/test/fixtures/ical/fehlerseite.html @@ -0,0 +1,2 @@ + +Anmelden

Bitte melden Sie sich an

diff --git a/test/fixtures/ical/gefaltet.ics b/test/fixtures/ical/gefaltet.ics new file mode 100644 index 0000000..17a4bf4 --- /dev/null +++ b/test/fixtures/ical/gefaltet.ics @@ -0,0 +1,13 @@ +BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:g1 +SUMMARY:Ein sehr langer Titel, der ueber mehrere Zeilen gefaltet + wurde und DTEND:20990101 enthaelt + + und weiter +DTSTART;VALUE=DATE:20261110 +DTEND;VALUE=DATE: + 20261112 +END:VEVENT +END:VCALENDAR diff --git a/test/fixtures/ical/tzid.ics b/test/fixtures/ical/tzid.ics new file mode 100644 index 0000000..590231f --- /dev/null +++ b/test/fixtures/ical/tzid.ics @@ -0,0 +1,39 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:z1 +DTSTART:20261110T140000Z +DTEND:20261112T100000Z +END:VEVENT +BEGIN:VEVENT +UID:z2 +DTSTART;TZID=Europe/Berlin:20261110T000000 +DTEND;TZID=Europe/Berlin:20261112T000000 +END:VEVENT +BEGIN:VEVENT +UID:z3 +DTSTART:20261130T233000Z +DTEND:20261202T100000Z +END:VEVENT +BEGIN:VEVENT +UID:z4 +DTSTART;VALUE=DATE:20261215 +END:VEVENT +BEGIN:VEVENT +UID:z5 +DTSTART;VALUE=DATE:20261220 +DTEND;VALUE=DATE:20261222 +STATUS:CANCELLED +END:VEVENT +BEGIN:VEVENT +UID:z6 +DTSTART;VALUE=DATE:20200101 +DTEND;VALUE=DATE:20200105 +END:VEVENT +BEGIN:VEVENT +UID:z7 +DTSTART;VALUE=DATE:20300101 +DTEND;VALUE=DATE:20300105 +END:VEVENT +END:VCALENDAR diff --git a/test/unterkunftIcal.test.js b/test/unterkunftIcal.test.js new file mode 100644 index 0000000..b1f0eef --- /dev/null +++ b/test/unterkunftIcal.test.js @@ -0,0 +1,326 @@ +import { test, after, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Buchungskalender Task 9: iCal-Parser/-Export (ical.js), Abruf + Abgleich (icalAbgleich.js), Export-Route (unterkunftSeite.js) +const NAMEN = ['portalFeatures', 'ukEinstellungen', 'ukUnterkuenfte', 'ukSperren', 'ukAnfragen', 'customers', 'websiteProjects'] +const aw = await startAppwriteMock({ + schema: Object.fromEntries(NAMEN.filter((n) => !['customers', 'websiteProjects'].includes(n)).map((n) => [n, schemaKeys(n)])), + datetimes: { ukAnfragen: ['fristAt'], ukEinstellungen: ['icalFehlerMailAt'] }, + defaultLimit: 25, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) + +const { config } = await import('../server/config.js') +const { startTestApp, rawGet } = await import('./support/testApp.js') +const { deleteDocument, updateDocument } = await import('../server/services/appwriteAdmin.js') +const mails = await import('../server/services/unterkunft/mails.js') +const { parseIcs, baueIcs } = await import('../server/services/unterkunft/ical.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') +const { ladeUnterkunft, listeSperren, ladeEinstellungen } = await import('../server/services/unterkunft/speicher.js') +const app = await startTestApp({ '/': unterkunftSeiteRouter() }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +const fx = (n) => readFileSync(new URL(`./fixtures/ical/${n}`, import.meta.url), 'utf8') +const FEATURE = 'f_p1_unterkunft' +const vorher = { ...config.unterkunft } +const OPT = { heute: '2026-11-07', horizontTage: 540 } +const antwort = (text, status = 200, headers = {}) => new Response(text, { status, headers }) +const oeffentlich = async () => [{ address: '93.184.216.34', family: 4 }] + +beforeEach(async () => { + for (const c of NAMEN) for (const id of [...aw.docs(c).keys()]) await deleteDocument(config.collections[c], id) + Object.assign(config.unterkunft, vorher, { mode: 'on' }) + mails._aufrufe.length = 0 + aw.seed('portalFeatures', { $id: FEATURE, customerId: 'c1', projectId: 'p1', featureKey: 'unterkunft', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true }) + aw.seed('customers', { $id: 'c1', email: 'mimi@example.com' }) + aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Donau' }) +}) +afterEach(() => Object.assign(config.unterkunft, vorher)) + +// ------------------------------------------------------------------------------------------------ Parser + +test('parseIcs: Booking-Fixture liefert DATE-Ereignisse, Airbnb ebenso', () => { + const b = parseIcs(fx('booking.ics'), OPT) + assert.deepEqual(b.ereignisse.map((e) => [e.von, e.bis, e.uid]), [['2026-11-10', '2026-11-13', 'b1@booking.com'], ['2026-12-01', '2026-12-03', 'b2@booking.com']]) + const a = parseIcs(fx('airbnb.ics'), OPT) + assert.deepEqual(a.ereignisse.map((e) => [e.von, e.bis]), [['2026-11-20', '2026-11-25'], ['2026-12-01', '2026-12-02']]) +}) + +test('parseIcs: UTC-, TZID- und Datumswerte, fehlendes DTEND = 1 Nacht, CANCELLED, Fenster', () => { + const t = parseIcs(fx('tzid.ics'), OPT).ereignisse + const nach = Object.fromEntries(t.map((e) => [e.uid, [e.von, e.bis]])) + assert.deepEqual(nach.z1, ['2026-11-10', '2026-11-12']) + assert.deepEqual(nach.z2, ['2026-11-10', '2026-11-12']) + assert.deepEqual(nach.z3, ['2026-12-01', '2026-12-02'], '23:30 UTC am 30.11. ist in Berlin schon der 1.12.') + assert.deepEqual(nach.z4, ['2026-12-15', '2026-12-16']) + assert.equal(nach.z5, undefined, 'CANCELLED ignoriert') + assert.equal(nach.z6, undefined, 'vergangen') + assert.equal(nach.z7, undefined, 'hinter dem Horizont') +}) + +test('parseIcs: gefaltete Zeilen stoeren nicht, DTEND-Text in SUMMARY wird nicht gelesen', () => { + const g = parseIcs(fx('gefaltet.ics'), OPT).ereignisse + assert.deepEqual(g.map((e) => [e.von, e.bis]), [['2026-11-10', '2026-11-12']]) +}) + +test('parseIcs: HTML oder Unsinn => kein_ics', () => { + assert.throws(() => parseIcs(fx('fehlerseite.html'), OPT), { message: 'kein_ics' }) + assert.throws(() => parseIcs('', OPT), { message: 'kein_ics' }) + assert.throws(() => parseIcs(null, OPT), { message: 'kein_ics' }) + assert.deepEqual(parseIcs('BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n', OPT).ereignisse, []) +}) + +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')) + assert.ok(!/(^|[^\r])\n/.test(s), 'nur CRLF') + for (const z of s.split('\r\n')) assert.ok(Buffer.byteLength(z) <= 75, z) + assert.match(s, /PRODID:-\/\/WEBklar\/\/Buchungskalender\/\/DE/) + assert.match(s, /SUMMARY:Belegt/) + assert.match(s, /DTSTART;VALUE=DATE:20261110/) + assert.match(s, /DTEND;VALUE=DATE:20261112/) + assert.match(s, /UID:a1@project\.webklar\.com/) + assert.match(s, /DTSTAMP:20261107T100000Z/) + assert.equal(parseIcs(s, OPT).ereignisse.length, 1, 'Rundreise') +}) + +// ------------------------------------------------------------------------------------------------ Abruf, SSRF + +test('istPrivateAdresse: private, Loopback, Link-Local, CGNAT, ULA, Multicast, unspecified, IPv4-mapped', () => { + for (const a of ['127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1', '169.254.169.254', '100.64.0.1', '0.0.0.0', '224.0.0.1', '255.255.255.255', '::1', '::', 'fe80::1', 'fc00::1', 'fd12::1', 'ff02::1', '::ffff:127.0.0.1', '::ffff:10.0.0.1', '::ffff:7f00:1', '64:ff9b::a00:1', '2002:7f00:1::1']) { + assert.equal(istPrivateAdresse(a), true, a) + } + for (const a of ['93.184.216.34', '8.8.8.8', '172.32.0.1', '2606:2800:220:1:248:1893:25c8:1946', '::ffff:8.8.8.8']) assert.equal(istPrivateAdresse(a), false, a) + assert.equal(istPrivateAdresse('kein-ip'), true, 'Unklares gilt als privat') +}) + +test('holeIcs: nur https, keine privaten Ziele, kein Abruf bei Ablehnung', async () => { + let aufrufe = 0 + const fetch = async () => (aufrufe++, antwort(fx('booking.ics'))) + await assert.rejects(holeIcs('http://example.com/a.ics', { fetch, lookup: oeffentlich }), { code: 'ungueltige_adresse' }) + for (const u of ['https://127.0.0.1/a.ics', 'https://10.0.0.5/', 'https://[::1]/', 'https://[::ffff:127.0.0.1]/', 'https://2130706433/', 'https://169.254.169.254/latest']) { + await assert.rejects(holeIcs(u, { fetch, lookup: oeffentlich }), { code: 'private_adresse' }, u) + } + await assert.rejects(holeIcs('https://intern.example/a.ics', { fetch, lookup: async () => [{ address: '93.184.216.34', family: 4 }, { address: '10.1.2.3', family: 4 }] }), { code: 'private_adresse' }, 'eine private Antwort genuegt') + await assert.rejects(holeIcs('https://u:p@example.com/a.ics', { fetch, lookup: oeffentlich }), { code: 'ungueltige_adresse' }) + assert.equal(aufrufe, 0) +}) + +test('holeIcs: Erfolg, Adresse wird dem Abruf mitgegeben (Pinning), Fehlerstatus, zu gross, Timeout', async () => { + let gesehen + const text = await holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: async (u, o) => ((gesehen = o), antwort(fx('booking.ics'))) }) + assert.match(text, /BEGIN:VCALENDAR/) + assert.deepEqual(gesehen.adresse, { address: '93.184.216.34', family: 4 }) + assert.equal(gesehen.redirect, 'manual') + await assert.rejects(holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: async () => antwort('x', 503) }), { code: 'http_503' }) + config.unterkunft.icalMaxBytes = 100 + await assert.rejects(holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: async () => antwort('x'.repeat(5000)) }), { code: 'zu_gross' }) + await assert.rejects(holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: async () => antwort('x'.repeat(50), 200, { 'content-length': '5000' }) }), { code: 'zu_gross' }) + config.unterkunft.icalMaxBytes = 1_000_000 + config.unterkunft.icalTimeoutMs = 50 + await assert.rejects(holeIcs('https://example.com/a.ics', { lookup: oeffentlich, fetch: (u, o) => new Promise((_, rej) => o.signal.addEventListener('abort', () => rej(new Error('abort')))) }), { code: 'timeout' }) +}) + +test('holeIcs: Weiterleitungen werden je erneut geprueft, hoechstens 3', async () => { + const ziele = [] + const weiter = (nach) => antwort('', 302, { location: nach }) + let fetch = async (u) => (ziele.push(u), u.endsWith('/ziel.ics') ? antwort(fx('booking.ics')) : weiter(u.replace(/\/\d$/, '') + '/ziel.ics')) + assert.match(await holeIcs('https://example.com/start/1', { fetch, lookup: oeffentlich }), /VCALENDAR/) + fetch = async () => weiter('http://example.com/x') + await assert.rejects(holeIcs('https://example.com/a', { fetch, lookup: oeffentlich }), { code: 'ungueltige_adresse' }) + fetch = async () => weiter('https://127.0.0.1/geheim') + await assert.rejects(holeIcs('https://example.com/a', { fetch, lookup: oeffentlich }), { code: 'private_adresse' }) + // relativer Pfad, Rebinding auf zweiten Host: der zweite Host loest privat auf + fetch = async () => weiter('https://zweiter.example/x') + await assert.rejects( + holeIcs('https://example.com/a', { fetch, lookup: async (h) => [{ address: h === 'example.com' ? '93.184.216.34' : '10.0.0.9', family: 4 }] }), + { code: 'private_adresse' }, + ) + let n = 0 + fetch = async () => (n++, weiter(`https://example.com/${n}`)) + await assert.rejects(holeIcs('https://example.com/a', { fetch, lookup: oeffentlich }), { code: 'zu_viele_weiterleitungen' }) + assert.equal(n, 4, 'erster Abruf + 3 Weiterleitungen') +}) + +// ------------------------------------------------------------------------------------------------ Abgleich + +const imp = (extra = {}) => ({ id: 'imp1', name: 'Booking.com', url: 'https://example.com/geheim-token/cal.ics', letzterAbrufAt: null, letzterErfolgAt: null, fehler: '', ...extra }) +function seedU(importe = [imp()], extra = {}) { + aw.seed('ukUnterkuenfte', { $id: 'u1', featureId: FEATURE, customerId: 'c1', projectId: 'p1', name: 'Erste', aktiv: true, sort: 1, icalExportToken: 'T'.repeat(43), icalImporte: JSON.stringify(importe), ...extra }) +} +const sperren = async () => (await listeSperren('u1')).map((s) => [s.von, s.bis, s.quelle]).sort() +const NOW = new Date('2026-11-07T10:00:00Z') + +test('gleicheAb: Erfolg legt Sperren an (quelle ical:), ersetzt alte, laesst manuelle stehen', async () => { + seedU() + aw.seed('ukSperren', { $id: 's_alt', unterkunftId: 'u1', featureId: FEATURE, von: '2026-10-01', bis: '2026-10-03', quelle: 'ical:imp1' }) + aw.seed('ukSperren', { $id: 's_fremd', unterkunftId: 'u1', featureId: FEATURE, von: '2026-10-01', bis: '2026-10-03', quelle: 'ical:imp2' }) + aw.seed('ukSperren', { $id: 's_man', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-01', bis: '2026-11-02', quelle: 'manuell' }) + const u = await ladeUnterkunft('u1') + const r = await gleicheAb(u, u.icalImporte[0], { now: NOW, lookup: oeffentlich, fetch: async () => antwort(fx('booking.ics')) }) + assert.deepEqual(r, { ok: true, anzahl: 2 }) + assert.deepEqual(await sperren(), [['2026-10-01', '2026-10-03', 'ical:imp2'], ['2026-11-01', '2026-11-02', 'manuell'], ['2026-11-10', '2026-11-13', 'ical:imp1'], ['2026-12-01', '2026-12-03', 'ical:imp1']]) + const i = (await ladeUnterkunft('u1')).icalImporte[0] + assert.equal(i.letzterErfolgAt, NOW.toISOString()) + assert.equal(i.letzterAbrufAt, NOW.toISOString()) + assert.equal(i.fehler, '') + // zweiter Lauf mit gleichem Feed: unveraendert + const vorIds = (await listeSperren('u1')).map((s) => s.$id).sort() + await gleicheAb(await ladeUnterkunft('u1'), i, { now: NOW, lookup: oeffentlich, fetch: async () => antwort(fx('booking.ics')) }) + assert.deepEqual((await listeSperren('u1')).map((s) => s.$id).sort(), vorIds) +}) + +test('gleicheAb: Fehler laesst alte Sperren stehen, setzt fehler, URL steht nie im Fehlertext', async () => { + seedU([imp({ letzterErfolgAt: '2026-11-07T08:00:00.000Z' })]) + aw.seed('ukSperren', { $id: 's_alt', unterkunftId: 'u1', featureId: FEATURE, von: '2026-11-10', bis: '2026-11-13', quelle: 'ical:imp1' }) + const u = await ladeUnterkunft('u1') + for (const [fetch, code] of [[async () => antwort(fx('fehlerseite.html')), 'kein_ics'], [async () => antwort('x', 500), 'http_500'], [async () => { throw new Error('ECONNRESET https://example.com/geheim-token/cal.ics') }, 'netzwerk']]) { + const r = await gleicheAb(u, u.icalImporte[0], { now: NOW, lookup: oeffentlich, fetch }) + assert.deepEqual(r, { ok: false, fehler: code }) + } + assert.deepEqual(await sperren(), [['2026-11-10', '2026-11-13', 'ical:imp1']]) + const i = (await ladeUnterkunft('u1')).icalImporte[0] + assert.equal(i.fehler, 'netzwerk') + assert.equal(i.letzterErfolgAt, '2026-11-07T08:00:00.000Z', 'letzter Erfolg bleibt') + assert.equal(i.letzterAbrufAt, NOW.toISOString()) + assert.ok(!JSON.stringify(i.fehler).includes('geheim')) +}) + +test('gleicheAb: wurde der Import waehrend des Abrufs entfernt, bleiben keine Sperren zurueck', async () => { + seedU() + const u = await ladeUnterkunft('u1') + const r = await gleicheAb(u, u.icalImporte[0], { + now: NOW, lookup: oeffentlich, + fetch: async () => { + await updateDocument(config.collections.ukUnterkuenfte, 'u1', { icalImporte: '[]' }) + return antwort(fx('booking.ics')) + }, + }) + assert.equal(r.ok, false) + assert.deepEqual(await sperren(), []) +}) + +test('K33: erst nach icalFehlerStunden ohne Erfolg, danach hoechstens 1x je 24 h', async () => { + const fetch = async () => antwort('x', 500) + const lauf = async (now) => { + const u = await ladeUnterkunft('u1') + return gleicheAb(u, u.icalImporte[0], { now, lookup: oeffentlich, fetch }) + } + const K33 = () => mails._aufrufe.filter((a) => a.template === 'K33') + seedU([imp({ letzterErfolgAt: new Date(NOW.getTime() - 5 * 3600e3).toISOString() })]) + await lauf(NOW) + assert.equal(K33().length, 0, 'nach 5 h noch keine Mail') + await lauf(new Date(NOW.getTime() + 2 * 3600e3)) + assert.equal(K33().length, 1, 'nach 7 h Mail') + assert.deepEqual(Object.keys(K33()[0].values).sort(), ['quelle', 'seit', 'unterkunft']) + assert.equal(K33()[0].values.quelle, 'Booking.com') + assert.equal(K33()[0].values.unterkunft, 'Erste') + assert.match(K33()[0].values.seit, /^07\.11\.2026, 06:00$/) + assert.ok(!JSON.stringify(K33()[0]).includes('geheim')) + assert.ok((await ladeEinstellungen(FEATURE)).icalFehlerMailAt) + await lauf(new Date(NOW.getTime() + 10 * 3600e3)) + assert.equal(K33().length, 1, 'binnen 24 h keine zweite') + await lauf(new Date(NOW.getTime() + 27 * 3600e3)) + assert.equal(K33().length, 2, 'nach 24 h wieder') +}) + +test('K33: Import ohne jeden Erfolg zaehlt ab dem ersten Fehler', async () => { + seedU([imp()]) + const fetch = async () => antwort('x', 404) + const lauf = async (now) => { const u = await ladeUnterkunft('u1'); return gleicheAb(u, u.icalImporte[0], { now, lookup: oeffentlich, fetch }) } + await lauf(NOW) + await lauf(new Date(NOW.getTime() + 3 * 3600e3)) + assert.equal(mails._aufrufe.length, 0) + await lauf(new Date(NOW.getTime() + 7 * 3600e3)) + assert.equal(mails._aufrufe.length, 1) +}) + +test('gleicheAlleAb: nur aktive Funktionen, nur faellige Importe, Deckel, Fehler eines Imports stoppt die anderen nicht', async () => { + seedU([imp({ id: 'a1', url: 'https://example.com/a' }), imp({ id: 'a2', url: 'https://example.com/b' }), imp({ id: 'a3', url: 'https://example.com/c', letzterAbrufAt: new Date(NOW.getTime() - 2 * 60e3).toISOString() })]) + aw.seed('portalFeatures', { $id: 'f_aus', customerId: 'c2', projectId: 'p2', featureKey: 'unterkunft', state: 'off' }) + aw.seed('ukUnterkuenfte', { $id: 'u_aus', featureId: 'f_aus', customerId: 'c2', projectId: 'p2', name: 'Aus', aktiv: true, icalImporte: JSON.stringify([imp({ id: 'x', url: 'https://example.com/x' })]) }) + const abgerufen = [] + const fetch = async (u) => { + abgerufen.push(u) + if (u.endsWith('/a')) throw new Error('boom') + return antwort(fx('booking.ics')) + } + const r = await gleicheAlleAb(NOW, { fetch, lookup: oeffentlich }) + assert.deepEqual(abgerufen.sort(), ['https://example.com/a', 'https://example.com/b']) + assert.equal(r.importe, 2) + assert.equal(r.fehler, 1) + const zweiter = await gleicheAlleAb(NOW, { fetch, lookup: oeffentlich, maxImporte: 0 }) + assert.equal(zweiter.importe, 0) +}) + +test('gleicheAlleAb: Log enthaelt nie die volle URL', async () => { + seedU([imp({ url: 'https://ical.example.com/SECRET123/cal.ics' })]) + const zeilen = [] + const orig = { log: console.log, warn: console.warn, error: console.error } + console.log = console.warn = console.error = (...a) => zeilen.push(a.join(' ')) + try { + await gleicheAlleAb(NOW, { lookup: oeffentlich, fetch: async () => { throw new Error('kaputt https://ical.example.com/SECRET123/cal.ics') } }) + } finally { + Object.assign(console, orig) + } + assert.ok(zeilen.length > 0) + assert.ok(!zeilen.join('\n').includes('SECRET123'), zeilen.join('\n')) + assert.ok(zeilen.join('\n').includes('ical.example.com')) +}) + +test('viertelstundenJob: aus bei off, laeuft bei on', async () => { + seedU([]) + config.unterkunft.mode = 'off' + assert.deepEqual(await viertelstundenJob(NOW), { aus: true }) + config.unterkunft.mode = 'on' + const r = await viertelstundenJob(NOW) + assert.equal(r.importe, 0) +}) + +// ------------------------------------------------------------------------------------------------ Export + +const TOKEN = 'ExportTokenAbcdefghijklmnopqrstuvwxyz0123456' +test('Export: Buchung + manuelle Sperre + Reservierung mit Frist, nie ical, nie Gastname; CRLF; Header', async () => { + seedU([imp()], { icalExportToken: TOKEN }) + const basis = { featureId: FEATURE, customerId: 'c1', projectId: 'p1', unterkunftId: 'u1', gast: JSON.stringify({ name: 'Eva Gast', email: 'eva@example.com' }), tokenHash: 'h', ipHash: 'i', personen: 2 } + aw.seed('ukAnfragen', { $id: 'a_geb', ...basis, status: 'gebucht', von: '2027-01-10', bis: '2027-01-12', nummer: 'A-1' }) + aw.seed('ukAnfragen', { $id: 'a_bez', ...basis, status: 'bezahlt', von: '2027-02-10', bis: '2027-02-12', nummer: 'A-2' }) + aw.seed('ukAnfragen', { $id: 'a_res', ...basis, status: 'freigegeben', fristAt: new Date(Date.now() + 86400e3).toISOString(), von: '2027-03-10', bis: '2027-03-12', nummer: 'A-3' }) + aw.seed('ukAnfragen', { $id: 'a_alt', ...basis, status: 'freigegeben', fristAt: new Date(Date.now() - 86400e3).toISOString(), von: '2027-04-10', bis: '2027-04-12', nummer: 'A-4' }) + aw.seed('ukAnfragen', { $id: 'a_neu', ...basis, status: 'neu', von: '2027-05-10', bis: '2027-05-12', nummer: 'A-5' }) + aw.seed('ukAnfragen', { $id: 'a_sto', ...basis, status: 'storniert', von: '2027-06-10', bis: '2027-06-12', nummer: 'A-6' }) + aw.seed('ukSperren', { $id: 's_man', unterkunftId: 'u1', featureId: FEATURE, von: '2027-07-01', bis: '2027-07-03', quelle: 'manuell', notiz: 'Eigenbedarf privat' }) + aw.seed('ukSperren', { $id: 's_ical', unterkunftId: 'u1', featureId: FEATURE, von: '2027-08-01', bis: '2027-08-03', quelle: 'ical:imp1', notiz: 'Booking' }) + const r = await rawGet(`/unterkunft/ical/${TOKEN}.ics`) + assert.equal(r.status, 200) + assert.match(r.headers.get('content-type'), /^text\/calendar; charset=utf-8/) + assert.equal(r.headers.get('cache-control'), 'no-store') + const body = r.body + assert.ok(!/(^|[^\r])\n/.test(body)) + const e = parseIcs(body, { heute: '2026-11-07', horizontTage: 900 }).ereignisse + assert.deepEqual(e.map((x) => x.von).sort(), ['2027-01-10', '2027-02-10', '2027-03-10', '2027-07-01']) + assert.ok(body.includes('UID:a_geb@project.webklar.com') && body.includes('UID:s_man@project.webklar.com')) + assert.ok(!body.includes('Eva') && !body.includes('eva@') && !body.includes('Eigenbedarf') && !body.includes('Booking')) +}) + +test('Export: unbekanntes Token, falsches Format, Funktion aus/inaktiv => 404', async () => { + seedU([], { icalExportToken: TOKEN }) + assert.equal((await rawGet(`/unterkunft/ical/${'Z'.repeat(43)}.ics`)).status, 404) + assert.equal((await rawGet('/unterkunft/ical/kurz.ics')).status, 404) + assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}`)).status, 404) + assert.equal((await rawGet(`/unterkunft/ical/..%2f${TOKEN}.ics`)).status, 404) + assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 200) + config.unterkunft.mode = 'off' + assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 404) + config.unterkunft.mode = 'on' + await updateDocument(config.collections.portalFeatures, FEATURE, { state: 'off' }) + assert.equal((await rawGet(`/unterkunft/ical/${TOKEN}.ics`)).status, 404) +}) diff --git a/test/unterkunftSetup.test.js b/test/unterkunftSetup.test.js index 5eed07b..5b082b4 100644 --- a/test/unterkunftSetup.test.js +++ b/test/unterkunftSetup.test.js @@ -143,7 +143,7 @@ test('Schema: jedes Attribut ist optional, hat gueltige Form und Schluessel (kei test('Schema: Indizes wie in der Spezifikation und nur auf vorhandenen Attributen', () => { const idx = (name) => UNTERKUNFT_COLLECTIONS[name].indexes.map((i) => i.attributes.join('+')) - assert.deepEqual(idx('ukUnterkuenfte'), ['featureId']) + assert.deepEqual(idx('ukUnterkuenfte'), ['featureId', 'icalExportToken']) assert.deepEqual(idx('ukPreise'), ['unterkunftId']) assert.deepEqual(idx('ukSperren'), ['unterkunftId', 'unterkunftId+quelle']) assert.deepEqual(idx('ukAnfragen'), ['featureId', 'unterkunftId', 'unterkunftId+status', 'status']) diff --git a/test/unterkunftWiring.test.js b/test/unterkunftWiring.test.js index 0b4b74a..f5c8d60 100644 --- a/test/unterkunftWiring.test.js +++ b/test/unterkunftWiring.test.js @@ -233,7 +233,7 @@ test('Jobs: im Modus off {aus:true}; Minutenjob ruft die Fristen ab, die anderen const r = await jobs.minutenJob(T0) assert.notEqual(r?.aus, true) assert.notEqual(r?.fehler, true, JSON.stringify(r)) - assert.deepEqual(await jobs.viertelstundenJob(T0), {}) + assert.deepEqual(await jobs.viertelstundenJob(T0), { importe: 0, erfolg: 0, fehler: 0 }) assert.deepEqual(await jobs.taeglicherJob(T0), {}) })