Buchungskalender: iCal-Import und -Export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3b399e003
commit
f57a3d477e
13 files changed
+993
-7
No files matched your search
@@ -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/<token>.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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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_<status>.
|
||||
* @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:<importId>' 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
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Vendored
+18
@@ -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
|
||||
Vendored
+17
@@ -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
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><title>Anmelden</title></head><body><h1>Bitte melden Sie sich an</h1></body></html>
|
||||
Vendored
+13
@@ -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
|
||||
Vendored
+39
@@ -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
|
||||
@@ -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:<id>), 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)
|
||||
})
|
||||
@@ -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'])
|
||||
|
||||
@@ -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), {})
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user