import { ForbiddenException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { CryptoService } from '../crypto/crypto.service'; import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto'; import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto'; import { TestCalendarSourceConfigDto } from './dto/test-calendar-source-config.dto'; import { ICSProvider } from './providers/ics.provider'; import { CalDAVProvider } from './providers/caldav.provider'; import { ExchangeProvider } from './providers/exchange.provider'; /** * Common interface for normalized calendar events across all provider types. */ export interface CalendarEvent { id: string; sourceId: string; title: string; start: Date; end: Date; allDay: boolean; location?: string; description?: string; color?: string; } /** * Provider interface for calendar source integrations. * Each provider (ICS, CalDAV, Exchange) implements this contract. */ export interface CalendarProvider { fetchEvents( source: { url: string; username?: string; password?: string; exchangeMode?: string | null; domain?: string; id: string; color?: string | null }, from: Date, to: Date, ): Promise; testConnection( source: { url: string; username?: string; password?: string; exchangeMode?: string | null; domain?: string; id: string }, ): Promise; } /** * Prisma `select` for safe source responses — NEVER includes encryptedPassword. * T-05-09: Return hasCredentials boolean instead. */ const SOURCE_SAFE_SELECT = { id: true, userId: true, tenantId: true, name: true, type: true, exchangeMode: true, domain: true, url: true, username: true, // encryptedPassword: NEVER included — T-05-09 color: true, isVisible: true, syncIntervalMin: true, lastSyncAt: true, lastSyncError: true, createdAt: true, updatedAt: true, } as const; /** * Private IP ranges for SSRF protection (T-05-11). */ const PRIVATE_IP_PATTERNS = [ /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, /^192\.168\.\d{1,3}\.\d{1,3}$/, /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, /^169\.254\.\d{1,3}\.\d{1,3}$/, /^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/, /^0\.0\.0\.0$/, /^\[::1\]$/, /^\[fc00:/, /^\[fd00:/, /^\[fe80:/, ]; /** * In-memory event cache entry with TTL (Pitfall 4). */ interface CacheEntry { events: CalendarEvent[]; expiresAt: number; } /** Cache TTL in milliseconds (5 minutes). */ const CACHE_TTL_MS = 5 * 60 * 1000; /** * Service for calendar source CRUD and event aggregation. * * Source config is per-user AND per-tenant bound (Mandantentrennung Etappe * 2, 260911-cwh) — `CalendarSource` carries a mandatory `tenantId` * (D-09/T-05-*: per-user ownership; row-level security: per-tenant * isolation). Every method with database access binds its own * `tenantPrisma` via `forTenant()`. * * The three ownership checks (`updateSource`/`deleteSource`/ * `testConnection`, comparing `existing.userId` against the calling user) * are kept UNCHANGED alongside the binding, not replaced by it: the RLS * policy on `CalendarSource` carried no user dimension when measured * 260911-cwh, Aufgabe 1 (`calendarsource-fremder-nutzer-desselben-mandanten-gebunden-sichtbar`), * so a colleague of the SAME tenant would otherwise see and modify a * fellow user's encrypted Exchange/CalDAV credentials. * * Nachtrag (260911-nke, Etappe 3b): seit Migration 20260911120000 traegt die * `tenant_isolation_policy` auf `CalendarSource` die Benutzerdimension * (`current_user_id() IS NULL OR "userId" = current_user_id()`) — jeder * `forTenant()`-Aufruf oben reicht `userId` als drittes Argument durch. Die * drei anwendungsseitigen Besitzpruefungen bleiben trotzdem UNVERAENDERT * bestehen: die Datenbankregel ist ein ZWEITES Netz, kein Ersatz dafuer, und * ein Aufrufer, der `userId` vergisst, saehe ohne sie den ganzen Mandanten * (siehe .planning/WINDOWS.md). * * Credentials encrypted at rest via CryptoService (T-05-10). */ @Injectable() export class CalendarService { private readonly logger = new Logger(CalendarService.name); /** * Per-user event cache with TTL (Pitfall 4). Key: `userId:from:to`. * * Cache-key judgment (260911-cwh, Aufgabe 1, Befund F — chain checked * link by link at execution time): `userId` here is `User.id` * (`apps/api/prisma/schema.prisma`, `model User`, `@id @default(uuid())`), * reached via `calendar.controller.ts` `extractContext()` * (`req.user?.id`), which is `JwtStrategy.validate()`'s `id: payload.sub` * (`apps/api/src/auth/strategies/jwt.strategy.ts`), which is * `sub: user.id` at token-issue time (`apps/api/src/auth/auth.service.ts`, * lines 143/332) — the database identity, not a login name. The * Etappe-3-Entscheidung (1) (tenant-scoped uniqueness for * `User.username`/`User.email`) does NOT touch `User.id`, which remains * a platform-wide UUID no tenant can share. The key therefore stays * without a tenant component. If any link of this chain changes, the key * needs a tenant component — the decision follows the measurement, not * this comment. */ private readonly eventCache = new Map(); constructor( private readonly prisma: PrismaService, private readonly crypto: CryptoService, private readonly icsProvider: ICSProvider, private readonly caldavProvider: CalDAVProvider, private readonly exchangeProvider: ExchangeProvider, ) {} /** * Returns all calendar sources for a user WITHOUT encryptedPassword. * Adds a `hasCredentials` boolean so the UI knows if credentials are set. */ async getSources(userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const sources = await tenantPrisma.calendarSource.findMany({ where: { userId }, select: { ...SOURCE_SAFE_SELECT, encryptedPassword: true, // Need it only to derive hasCredentials }, orderBy: { createdAt: 'asc' }, }); return sources.map(({ encryptedPassword, ...source }) => ({ ...source, hasCredentials: !!encryptedPassword, })); } /** * Creates a new calendar source. Encrypts password before storage. * T-05-11: Validates URL against private IP ranges (SSRF). */ async addSource(userId: string, tenantId: string, dto: CreateCalendarSourceDto) { // Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type if (dto.type !== 'exchange') await this.validateUrlNotPrivate(dto.url); const data: Record = { userId, tenantId, name: dto.name, type: dto.type, url: dto.url, username: dto.username ?? null, exchangeMode: dto.exchangeMode ?? null, domain: dto.domain ?? null, color: dto.color ?? '#3B82F6', }; if (dto.password) { data.encryptedPassword = this.crypto.encrypt(dto.password); } const tenantPrisma = forTenant(this.prisma, tenantId, userId); const created = await tenantPrisma.calendarSource.create({ data: data as any, select: SOURCE_SAFE_SELECT, }); return { ...created, hasCredentials: !!dto.password }; } /** * Updates a calendar source. Ownership check ensures user can only modify their own sources. * Re-encrypts password if provided; T-05-12 ownership enforcement. */ async updateSource(id: string, userId: string, tenantId: string, dto: UpdateCalendarSourceDto) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const existing = await tenantPrisma.calendarSource.findUnique({ where: { id }, select: { userId: true, type: true }, }); if (!existing) { throw new NotFoundException('Calendar source not found'); } if (existing.userId !== userId) { throw new ForbiddenException('Not your calendar source'); } const effectiveType = dto.type ?? existing.type; // Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type if (dto.url && effectiveType !== 'exchange') { await this.validateUrlNotPrivate(dto.url); } const data: Record = {}; if (dto.name !== undefined) data.name = dto.name; if (dto.type !== undefined) data.type = dto.type; if (dto.url !== undefined) data.url = dto.url; if (dto.username !== undefined) data.username = dto.username; if (dto.exchangeMode !== undefined) data.exchangeMode = dto.exchangeMode; if (dto.domain !== undefined) data.domain = dto.domain; if (dto.color !== undefined) data.color = dto.color; if (dto.isVisible !== undefined) data.isVisible = dto.isVisible; if (dto.password !== undefined) { data.encryptedPassword = dto.password ? this.crypto.encrypt(dto.password) : null; } const updated = await tenantPrisma.calendarSource.update({ where: { id }, data: data as any, select: { ...SOURCE_SAFE_SELECT, encryptedPassword: true, }, }); const { encryptedPassword, ...safe } = updated; return { ...safe, hasCredentials: !!encryptedPassword }; } /** * Deletes a calendar source. Ownership check enforced (T-05-12). */ async deleteSource(id: string, userId: string, tenantId: string) { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const existing = await tenantPrisma.calendarSource.findUnique({ where: { id }, select: { userId: true }, }); if (!existing) { throw new NotFoundException('Calendar source not found'); } if (existing.userId !== userId) { throw new ForbiddenException('Not your calendar source'); } await tenantPrisma.calendarSource.delete({ where: { id } }); return { deleted: true }; } /** * Test connection to a calendar source via its provider. * Updates lastSyncAt/lastSyncError on the source record. */ async testConnection(id: string, userId: string, tenantId: string): Promise<{ success: boolean; error?: string }> { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const source = await tenantPrisma.calendarSource.findUnique({ where: { id } }); if (!source) throw new NotFoundException('Calendar source not found'); if (source.userId !== userId) throw new ForbiddenException('Not your calendar source'); const provider = this.getProvider(source.type); const decryptedSource = { url: source.url, username: source.username ?? undefined, password: source.encryptedPassword ? this.crypto.decrypt(source.encryptedPassword) : undefined, exchangeMode: source.exchangeMode, domain: source.domain ?? undefined, id: source.id, }; try { const success = await provider.testConnection(decryptedSource); await tenantPrisma.calendarSource.update({ where: { id }, data: { lastSyncAt: success ? new Date() : undefined, lastSyncError: success ? null : 'Connection test failed', }, }); return { success }; } catch { const errorMsg = 'Connection failed'; // T-05-13: generic error, no credentials await tenantPrisma.calendarSource.update({ where: { id }, data: { lastSyncError: errorMsg }, }); return { success: false, error: errorMsg }; } } /** * Tests a calendar source configuration without saving it to the database. * Used by the "Test connection" button in the form before the source is created. */ async testConnectionFromConfig( dto: TestCalendarSourceConfigDto, ): Promise<{ success: boolean; error?: string }> { try { // Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type if (dto.type !== 'exchange') await this.validateUrlNotPrivate(dto.url); } catch (e: any) { return { success: false, error: e?.message ?? 'URL not allowed' }; } const provider = this.getProvider(dto.type); const tempSource = { url: dto.url, username: dto.username, password: dto.password, exchangeMode: dto.exchangeMode ?? null, domain: dto.domain, id: 'test-config', }; try { const success = await provider.testConnection(tempSource); return { success }; } catch { return { success: false, error: 'Connection failed' }; } } /** * Aggregate events from all visible sources for a user (CAL-02/CAL-03). * * - Loads only isVisible: true sources * - Decrypts credentials per source * - Fetches in parallel with Promise.allSettled (one failing source doesn't break others) * - Merges + sorts by start ascending * - Caches per user with 5-minute TTL (Pitfall 4) */ async aggregateEvents( userId: string, tenantId: string, from?: string, to?: string, ): Promise { const now = new Date(); const fromDate = from ? new Date(from) : now; const toDate = to ? new Date(to) : new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // Default: +30 days // Check cache first const cacheKey = `${userId}:${fromDate.toISOString()}:${toDate.toISOString()}`; const cached = this.eventCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { // Serve cached immediately, trigger background refresh if close to expiry if (cached.expiresAt - Date.now() < CACHE_TTL_MS / 2) { this.refreshCacheInBackground(userId, tenantId, fromDate, toDate, cacheKey); } return cached.events; } // Fetch fresh const events = await this.fetchAndCacheEvents(userId, tenantId, fromDate, toDate, cacheKey); return events; } /** * Fetches events from all visible sources, caches them, and returns. */ private async fetchAndCacheEvents( userId: string, tenantId: string, from: Date, to: Date, cacheKey: string, ): Promise { const tenantPrisma = forTenant(this.prisma, tenantId, userId); const sources = await tenantPrisma.calendarSource.findMany({ where: { userId, isVisible: true }, }); if (sources.length === 0) return []; // Fetch from all sources in parallel — Promise.allSettled so one failure doesn't break others const results = await Promise.allSettled( sources.map(async (source) => { const provider = this.getProvider(source.type); const decryptedSource = { url: source.url, username: source.username ?? undefined, password: source.encryptedPassword ? this.crypto.decrypt(source.encryptedPassword) : undefined, exchangeMode: source.exchangeMode, domain: source.domain ?? undefined, id: source.id, color: source.color, }; try { const events = await provider.fetchEvents(decryptedSource, from, to); // Update sync status on success await tenantPrisma.calendarSource.update({ where: { id: source.id }, data: { lastSyncAt: new Date(), lastSyncError: null }, }); return events; } catch (error) { // Update sync error — generic message (T-05-13) this.logger.warn( `Failed to fetch events from source ${source.id} (${source.type}): ${(error as Error).message}`, ); await tenantPrisma.calendarSource.update({ where: { id: source.id }, data: { lastSyncError: 'Event fetch failed' }, }); return [] as CalendarEvent[]; } }), ); // Merge all successful results const allEvents: CalendarEvent[] = []; for (const result of results) { if (result.status === 'fulfilled') { allEvents.push(...result.value); } } // Sort by start ascending allEvents.sort((a, b) => a.start.getTime() - b.start.getTime()); // Cache result this.eventCache.set(cacheKey, { events: allEvents, expiresAt: Date.now() + CACHE_TTL_MS, }); return allEvents; } /** * Refreshes cache in the background without blocking the response. * * This is a request context that outlives the request (Befund B, * 260911-cwh): it is NOT the "read across tenants, then bind per tenant" * shape of the background-service section in * docs/mandantentrennung-zugriffsklassifikation.md — it carries the * tenant of the ORIGINAL request that triggered it (`aggregateEvents`) * and cannot have any other tenant, because it never reads across * tenants in the first place. */ private refreshCacheInBackground( userId: string, tenantId: string, from: Date, to: Date, cacheKey: string, ): void { this.fetchAndCacheEvents(userId, tenantId, from, to, cacheKey).catch((error) => { this.logger.warn(`Background cache refresh failed: ${(error as Error).message}`); }); } /** * Returns the provider instance for a given source type. */ private getProvider(type: string): CalendarProvider { switch (type) { case 'ics': return this.icsProvider; case 'caldav': return this.caldavProvider; case 'exchange': return this.exchangeProvider; default: throw new Error(`Unknown calendar source type: ${type}`); } } /** * Decrypts stored credentials for a source (used internally by providers). * NEVER expose this in API responses. */ decryptSourcePassword(encryptedPassword: string): string { return this.crypto.decrypt(encryptedPassword); } /** * SSRF protection: reject URLs that resolve to private IP ranges. * T-05-11: Combined with https-only DTO validation. */ private async validateUrlNotPrivate(url: string): Promise { try { const parsed = new URL(url); const hostname = parsed.hostname; // Check against private IP patterns for (const pattern of PRIVATE_IP_PATTERNS) { if (pattern.test(hostname)) { throw new ForbiddenException( 'Calendar source URL must not point to private/internal networks', ); } } // Also block localhost variants if ( hostname === 'localhost' || hostname === 'ip6-localhost' || hostname.endsWith('.local') || hostname.endsWith('.internal') ) { throw new ForbiddenException( 'Calendar source URL must not point to localhost or local networks', ); } } catch (error) { if (error instanceof ForbiddenException) throw error; throw new ForbiddenException('Invalid calendar source URL'); } } }