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 = ` /caldav/ Stargirlnails Terminkalender Termine für Stargirlnails Europe/Berlin HTTP/1.1 200 OK `; 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 = ` /caldav/calendar/ Stargirlnails Termine Alle Termine von Stargirlnails Europe/Berlin ${Date.now()} ${Date.now()} HTTP/1.1 200 OK `; 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 = ` /caldav/calendar/events.ics text/calendar; charset=utf-8 "${Date.now()}" Stargirlnails Termine BEGIN:VCALENDAR\\nVERSION:2.0\\nEND:VCALENDAR HTTP/1.1 200 OK `; 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); });