chore(docker): .dockerignore angepasst; lokale Build-Schritte in Rebuild-Skripten; Doku/README zu production vs production-prebuilt aktualisiert

This commit is contained in:
2025-10-06 18:59:17 +02:00
parent 7a84130aec
commit 1124b1f40b
24 changed files with 1149 additions and 270 deletions

View File

@@ -4,6 +4,7 @@ import { createKV } from "../lib/create-kv.js";
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) {
@@ -13,6 +14,14 @@ function formatDateTime(dateStr, timeStr) {
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
@@ -31,7 +40,8 @@ X-WR-TIMEZONE:Europe/Berlin
const treatmentName = treatment?.name || 'Unbekannte Behandlung';
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
const endTime = formatDateTime(booking.appointmentDate, `${String(Math.floor((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) / 60)).padStart(2, '0')}:${String((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) % 60).padStart(2, '0')}`);
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
@@ -51,6 +61,56 @@ 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') {
@@ -133,36 +193,46 @@ caldavApp.all("/calendar/events.ics", async (c) => {
// GET Calendar Data (ICS-Datei)
caldavApp.get("/calendar/events.ics", async (c) => {
try {
// Authentifizierung über Token im Query-Parameter
const token = c.req.query('token');
if (!token) {
return c.text('Unauthorized - Token required', 401);
// 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"'
});
}
// Token validieren
const tokenData = await sessionsKV.getItem(token);
// Validate token against caldavTokens KV store
const tokenData = await caldavTokensKV.getItem(tokenResult.token);
if (!tokenData) {
return c.text('Unauthorized - Invalid token', 401);
return c.text('Unauthorized - Invalid or expired token', 401, {
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
});
}
// Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar)
// CalDAV-Tokens haben eine kürzere Ablaufzeit (24h) als normale Sessions
const tokenAge = Date.now() - new Date(tokenData.createdAt).getTime();
if (tokenAge > 24 * 60 * 60 * 1000) { // 24 Stunden
return c.text('Unauthorized - Token expired', 401);
}
// Token-Ablaufzeit prüfen
// Check token expiration
if (new Date(tokenData.expiresAt) < new Date()) {
return c.text('Unauthorized - Token expired', 401);
// 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);
return c.text(icsContent, 200, {
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);