121 lines
5.6 KiB
JavaScript
121 lines
5.6 KiB
JavaScript
import { readFileSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { Router } from 'express'
|
|
import { config } from '../config.js'
|
|
import { listDocuments, Query } from '../services/appwriteAdmin.js'
|
|
import { baueIcs } from '../services/unterkunft/ical.js'
|
|
import { plusTage } from '../services/unterkunft/datum.js'
|
|
import { heute as berlinHeute } from '../services/termine/zeit.js'
|
|
import { ipSchluessel, zaehle } from '../services/termine/limits.js'
|
|
import { ladeFuerProjekt, SITE_KEY_RE } from '../services/unterkunft/kontext.js'
|
|
import { unterkunftFuerKunde, unterkunftModus } from '../services/unterkunft/schalter.js'
|
|
import { listeAnfragenNachStatus, listeSperren } from '../services/unterkunft/speicher.js'
|
|
|
|
/**
|
|
* 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 EXPORT_JE_MINUTE = 60
|
|
const nicht = (res) => res.status(404).type('text/plain').set('Cache-Control', 'no-store').send('Not found')
|
|
|
|
async function exportEreignisse(unterkunft, jetzt) {
|
|
const gestern = plusTage(berlinHeute(new Date(jetzt)), -1)
|
|
const out = []
|
|
for (const s of await listeSperren(unterkunft.$id)) {
|
|
const quelle = String(s.quelle ?? 'manuell')
|
|
if (quelle.startsWith('ical') || !s.von || !s.bis) continue // nie zurueckspielen, was importiert wurde
|
|
if (s.bis <= gestern) continue // nur Ereignisse ab gestern
|
|
out.push({ uid: `${s.$id}@project.webklar.com`, von: s.von, bis: s.bis })
|
|
}
|
|
for (const a of await listeAnfragenNachStatus(unterkunft.$id, ['gebucht', 'bezahlt', 'freigegeben'])) {
|
|
if (a.status === 'freigegeben' && !(Date.parse(a.fristAt) > jetzt)) continue
|
|
if (!a.von || !a.bis || a.bis <= gestern) continue
|
|
out.push({ uid: `${a.$id}@project.webklar.com`, von: a.von, bis: a.bis })
|
|
}
|
|
return out.sort((x, y) => (x.von < y.von ? -1 : x.von > y.von ? 1 : x.uid < y.uid ? -1 : 1))
|
|
}
|
|
|
|
/**
|
|
* Oeffentliche Standalone-Seiten des Buchungskalenders (Vorbild: shopSeite.js):
|
|
* GET /unterkunft/:siteKey Widget public/embed/unterkunft.js auf neutraler Seite
|
|
* GET /unterkunft/:siteKey/buchung/:id Buchungsseite fuer Gaeste (public/unterkunft-buchung.js, Token ?t= liest nur das Skript)
|
|
* Die Seite verraet nicht, ob Schluessel oder Buchung existieren; das Skript fragt die oeffentliche Schnittstelle.
|
|
* Ungueltige Schluessel (z. B. "ical" der Export-Route /unterkunft/ical/:token.ics) und Ids: next(), am Ende 404.
|
|
*/
|
|
|
|
const ID_RE = /^[A-Za-z0-9_-]{1,36}$/
|
|
const DATEI = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'unterkunft-seite.html')
|
|
const CSP = [
|
|
"default-src 'self'",
|
|
"img-src 'self' data:",
|
|
"script-src 'self'",
|
|
"style-src 'self' 'unsafe-inline'", // das Widget legt ein <style>-Element an
|
|
"connect-src 'self'",
|
|
"frame-ancestors 'none'",
|
|
"base-uri 'none'",
|
|
"form-action 'self'",
|
|
].join('; ')
|
|
|
|
let vorlage = null
|
|
export function resetUnterkunftSeiteForTests() {
|
|
vorlage = null
|
|
}
|
|
|
|
function escapeAttr(s) {
|
|
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
|
}
|
|
|
|
function seite(res, next, siteKey, ansicht) {
|
|
try {
|
|
if (vorlage === null) vorlage = readFileSync(DATEI, 'utf8')
|
|
} catch (err) {
|
|
return next(err)
|
|
}
|
|
const html = vorlage.replaceAll('__SITEKEY__', escapeAttr(siteKey)).replaceAll('__ANSICHT__', escapeAttr(ansicht))
|
|
res.set({
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-store',
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'Referrer-Policy': 'no-referrer',
|
|
'Content-Security-Policy': CSP,
|
|
'X-Robots-Tag': 'noindex',
|
|
})
|
|
return res.status(200).send(html)
|
|
}
|
|
|
|
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)
|
|
if (!zaehle(`uk-ical:${ipSchluessel(req.ip)}`, EXPORT_JE_MINUTE, 60_000, new Date())) return res.status(429).type('text/plain').set({ 'Cache-Control': 'no-store', 'Retry-After': '60' }).send('Too many requests')
|
|
const token = req.params.datei.slice(0, -4)
|
|
const [u] = await listDocuments(config.collections.ukUnterkuenfte, [Query.equal('icalExportToken', token), Query.limit(1)])
|
|
if (!u || u.icalExportToken !== token || u.aktiv === false) return nicht(res)
|
|
const ctx = await ladeFuerProjekt(u.customerId, u.projectId)
|
|
if (!ctx || ctx.featureId !== u.featureId || ctx.state !== 'active' || !unterkunftFuerKunde(ctx.besitzerEmail)) return nicht(res)
|
|
const jetzt = new Date()
|
|
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)
|
|
}
|
|
})
|
|
router.get('/unterkunft/:siteKey', (req, res, next) => {
|
|
if (!SITE_KEY_RE.test(req.params.siteKey)) return next()
|
|
return seite(res, next, req.params.siteKey, 'widget')
|
|
})
|
|
router.get('/unterkunft/:siteKey/buchung/:id', (req, res, next) => {
|
|
if (!SITE_KEY_RE.test(req.params.siteKey) || !ID_RE.test(req.params.id)) return next()
|
|
return seite(res, next, req.params.siteKey, 'buchung')
|
|
})
|
|
return router
|
|
}
|
|
|
|
export default unterkunftSeiteRouter
|