368 lines
15 KiB
JavaScript
368 lines
15 KiB
JavaScript
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
|
|
}
|