247 lines
10 KiB
JavaScript
247 lines
10 KiB
JavaScript
import { Hono } from "hono";
|
|
import { createKV } from "../lib/create-kv.js";
|
|
// KV-Stores
|
|
const bookingsKV = createKV("bookings");
|
|
const treatmentsKV = createKV("treatments");
|
|
const sessionsKV = createKV("sessions");
|
|
const caldavTokensKV = createKV("caldavTokens");
|
|
export const caldavApp = new Hono();
|
|
// Helper-Funktionen für ICS-Format
|
|
function formatDateTime(dateStr, timeStr) {
|
|
// Konvertiere YYYY-MM-DD HH:MM zu UTC-Format für ICS
|
|
const [year, month, day] = dateStr.split('-').map(Number);
|
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
|
const date = new Date(year, month - 1, day, hours, minutes);
|
|
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
}
|
|
// Helper to add minutes to an HH:MM time string and return HH:MM
|
|
function addMinutesToTime(timeStr, minutesToAdd) {
|
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
|
const total = hours * 60 + minutes + minutesToAdd;
|
|
const endHours = Math.floor(total / 60) % 24;
|
|
const endMinutes = total % 60;
|
|
return `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`;
|
|
}
|
|
function generateICSContent(bookings, treatments) {
|
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
let ics = `BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
PRODID:-//Stargirlnails//Booking Calendar//DE
|
|
CALSCALE:GREGORIAN
|
|
METHOD:PUBLISH
|
|
X-WR-CALNAME:Stargirlnails Termine
|
|
X-WR-CALDESC:Terminkalender für Stargirlnails
|
|
X-WR-TIMEZONE:Europe/Berlin
|
|
`;
|
|
// Nur bestätigte und ausstehende Termine in den Kalender aufnehmen
|
|
const activeBookings = bookings.filter(b => b.status === 'confirmed' || b.status === 'pending');
|
|
for (const booking of activeBookings) {
|
|
const treatment = treatments.find(t => t.id === booking.treatmentId);
|
|
const treatmentName = treatment?.name || 'Unbekannte Behandlung';
|
|
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
|
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
|
|
const computedEnd = addMinutesToTime(booking.appointmentTime, duration);
|
|
const endTime = formatDateTime(booking.appointmentDate, computedEnd);
|
|
// UID für jeden Termin (eindeutig)
|
|
const uid = `booking-${booking.id}@stargirlnails.de`;
|
|
// Status für ICS
|
|
const status = booking.status === 'confirmed' ? 'CONFIRMED' : 'TENTATIVE';
|
|
ics += `BEGIN:VEVENT
|
|
UID:${uid}
|
|
DTSTAMP:${now}
|
|
DTSTART:${startTime}
|
|
DTEND:${endTime}
|
|
SUMMARY:${treatmentName} - ${booking.customerName}
|
|
DESCRIPTION:Behandlung: ${treatmentName}\\nKunde: ${booking.customerName}${booking.customerPhone ? `\\nTelefon: ${booking.customerPhone}` : ''}${booking.notes ? `\\nNotizen: ${booking.notes}` : ''}
|
|
STATUS:${status}
|
|
TRANSP:OPAQUE
|
|
END:VEVENT
|
|
`;
|
|
}
|
|
ics += `END:VCALENDAR`;
|
|
return ics;
|
|
}
|
|
/**
|
|
* Extract and validate CalDAV token from Authorization header or query parameter (legacy)
|
|
* @param c Hono context
|
|
* @returns { token: string; source: 'bearer'|'basic'|'query' } | null
|
|
*/
|
|
function extractCalDAVToken(c) {
|
|
// UUID v4 pattern for hardening (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)
|
|
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
// Prefer Authorization header (new secure methods: Bearer or Basic)
|
|
const authHeader = c.req.header('Authorization');
|
|
if (authHeader) {
|
|
// Bearer
|
|
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
if (bearerMatch) {
|
|
const token = bearerMatch[1].trim();
|
|
if (!uuidV4Regex.test(token)) {
|
|
console.warn('CalDAV: Bearer token does not match UUID v4 format.');
|
|
return null;
|
|
}
|
|
return { token, source: 'bearer' };
|
|
}
|
|
// Basic (use username or password as token)
|
|
const basicMatch = authHeader.match(/^Basic\s+(.+)$/i);
|
|
if (basicMatch) {
|
|
try {
|
|
const decoded = Buffer.from(basicMatch[1], 'base64').toString('utf8');
|
|
// Format: username:password (password optional)
|
|
const [username, password] = decoded.split(':');
|
|
const candidate = (username && username.trim().length > 0)
|
|
? username.trim()
|
|
: (password ? password.trim() : '');
|
|
if (candidate && uuidV4Regex.test(candidate)) {
|
|
return { token: candidate, source: 'basic' };
|
|
}
|
|
console.warn('CalDAV: Basic auth credential does not contain a valid UUID v4 token.');
|
|
}
|
|
catch (e) {
|
|
console.warn('CalDAV: Failed to decode Basic auth header');
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
// Fallback to query parameter (legacy, will be deprecated)
|
|
const queryToken = c.req.query('token');
|
|
if (queryToken) {
|
|
console.warn('CalDAV: Token passed via query parameter (deprecated). Please use Authorization header.');
|
|
return { token: queryToken, source: 'query' };
|
|
}
|
|
return null;
|
|
}
|
|
// CalDAV Discovery (PROPFIND auf Root)
|
|
caldavApp.all("/", async (c) => {
|
|
if (c.req.method !== 'PROPFIND') {
|
|
return c.text('Method Not Allowed', 405);
|
|
}
|
|
const response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
|
<D:response>
|
|
<D:href>/caldav/</D:href>
|
|
<D:propstat>
|
|
<D:prop>
|
|
<D:displayname>Stargirlnails Terminkalender</D:displayname>
|
|
<C:calendar-description>Termine für Stargirlnails</C:calendar-description>
|
|
<C:supported-calendar-component-set>
|
|
<C:comp name="VEVENT"/>
|
|
</C:supported-calendar-component-set>
|
|
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
|
|
</D:prop>
|
|
<D:status>HTTP/1.1 200 OK</D:status>
|
|
</D:propstat>
|
|
</D:response>
|
|
</D:multistatus>`;
|
|
return c.text(response, 207, {
|
|
"Content-Type": "application/xml; charset=utf-8",
|
|
"DAV": "1, 3, calendar-access, calendar-schedule",
|
|
});
|
|
});
|
|
// Calendar Collection PROPFIND
|
|
caldavApp.all("/calendar/", async (c) => {
|
|
if (c.req.method !== 'PROPFIND') {
|
|
return c.text('Method Not Allowed', 405);
|
|
}
|
|
const response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
|
|
<D:response>
|
|
<D:href>/caldav/calendar/</D:href>
|
|
<D:propstat>
|
|
<D:prop>
|
|
<D:displayname>Stargirlnails Termine</D:displayname>
|
|
<C:calendar-description>Alle Termine von Stargirlnails</C:calendar-description>
|
|
<C:supported-calendar-component-set>
|
|
<C:comp name="VEVENT"/>
|
|
</C:supported-calendar-component-set>
|
|
<C:calendar-timezone>Europe/Berlin</C:calendar-timezone>
|
|
<CS:getctag>${Date.now()}</CS:getctag>
|
|
<D:sync-token>${Date.now()}</D:sync-token>
|
|
</D:prop>
|
|
<D:status>HTTP/1.1 200 OK</D:status>
|
|
</D:propstat>
|
|
</D:response>
|
|
</D:multistatus>`;
|
|
return c.text(response, 207, {
|
|
"Content-Type": "application/xml; charset=utf-8",
|
|
});
|
|
});
|
|
// Calendar Events PROPFIND
|
|
caldavApp.all("/calendar/events.ics", async (c) => {
|
|
if (c.req.method !== 'PROPFIND') {
|
|
return c.text('Method Not Allowed', 405);
|
|
}
|
|
const response = `<?xml version="1.0" encoding="utf-8"?>
|
|
<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">
|
|
<D:response>
|
|
<D:href>/caldav/calendar/events.ics</D:href>
|
|
<D:propstat>
|
|
<D:prop>
|
|
<D:getcontenttype>text/calendar; charset=utf-8</D:getcontenttype>
|
|
<D:getetag>"${Date.now()}"</D:getetag>
|
|
<D:displayname>Stargirlnails Termine</D:displayname>
|
|
<C:calendar-data>BEGIN:VCALENDAR\\nVERSION:2.0\\nEND:VCALENDAR</C:calendar-data>
|
|
</D:prop>
|
|
<D:status>HTTP/1.1 200 OK</D:status>
|
|
</D:propstat>
|
|
</D:response>
|
|
</D:multistatus>`;
|
|
return c.text(response, 207, {
|
|
"Content-Type": "application/xml; charset=utf-8",
|
|
});
|
|
});
|
|
// GET Calendar Data (ICS-Datei)
|
|
caldavApp.get("/calendar/events.ics", async (c) => {
|
|
try {
|
|
// Extract token from Authorization header (Bearer/Basic) or query parameter (legacy)
|
|
const tokenResult = extractCalDAVToken(c);
|
|
if (!tokenResult) {
|
|
return c.text('Unauthorized - Token erforderlich via Authorization (Bearer oder Basic) oder (deprecated) ?token', 401, {
|
|
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
|
|
});
|
|
}
|
|
// Validate token against caldavTokens KV store
|
|
const tokenData = await caldavTokensKV.getItem(tokenResult.token);
|
|
if (!tokenData) {
|
|
return c.text('Unauthorized - Invalid or expired token', 401, {
|
|
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
|
|
});
|
|
}
|
|
// Check token expiration
|
|
if (new Date(tokenData.expiresAt) < new Date()) {
|
|
// Clean up expired token
|
|
await caldavTokensKV.removeItem(tokenResult.token);
|
|
return c.text('Unauthorized - Token expired', 401, {
|
|
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
|
|
});
|
|
}
|
|
// Note: Token is valid for 24 hours from creation.
|
|
// Expired tokens are cleaned up on access attempt.
|
|
const bookings = await bookingsKV.getAllItems();
|
|
const treatments = await treatmentsKV.getAllItems();
|
|
const icsContent = generateICSContent(bookings, treatments);
|
|
const headers = {
|
|
"Content-Type": "text/calendar; charset=utf-8",
|
|
"Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
|
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
"Pragma": "no-cache",
|
|
"Expires": "0",
|
|
};
|
|
// If legacy query token was used, inform clients about deprecation
|
|
if (tokenResult.source === 'query') {
|
|
headers["Deprecation"] = "true";
|
|
headers["Warning"] = "299 - \"Query parameter token authentication is deprecated. Use Authorization header (Bearer or Basic).\"";
|
|
}
|
|
return c.text(icsContent, 200, headers);
|
|
}
|
|
catch (error) {
|
|
console.error("CalDAV GET error:", error);
|
|
return c.text('Internal Server Error', 500);
|
|
}
|
|
});
|
|
// Fallback für andere CalDAV-Requests
|
|
caldavApp.all("*", async (c) => {
|
|
console.log(`CalDAV: Unhandled ${c.req.method} request to ${c.req.url}`);
|
|
return c.text('Not Found', 404);
|
|
});
|