feat(05-03): calendar backend — model, crypto, source CRUD module

- Add CalendarSource Prisma model with encrypted credentials (AES-256-GCM)
- Create CalendarCryptoService with encrypt/decrypt using CALENDAR_ENCRYPTION_KEY
- Create CalendarController with source CRUD endpoints (GET/POST/PATCH/DELETE)
- Create CalendarService with ownership checks and SSRF URL validation
- Add DTOs with https-only URL validation and class-validator decorators
- Register CalendarModule in AppModule
- Install tsdav, node-ical, ews-javascript-api, @microsoft/microsoft-graph-client
- Stub provider files for Task 2 compilation
This commit is contained in:
2026-06-24 15:09:03 +02:00
parent 7616365cbe
commit 9ec6313f4d
14 changed files with 1114 additions and 6 deletions
@@ -0,0 +1,131 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Patch,
Post,
Query,
Req,
} from '@nestjs/common';
import { Request } from 'express';
import { CalendarService } from './calendar.service';
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
import { CalendarEventsQueryDto } from './dto/calendar-events-query.dto';
/**
* REST controller for calendar source management and event aggregation.
*
* All endpoints require JWT auth (global JwtAuthGuard).
* Every handler extracts userId and tenantId from the request
* and scopes all operations to the calling user (T-05-12).
*
* Routes:
* - GET /calendar/sources — list user's calendar sources (no passwords)
* - POST /calendar/sources — add a new calendar source
* - PATCH /calendar/sources/:id — update a calendar source
* - DELETE /calendar/sources/:id — remove a calendar source
* - POST /calendar/sources/:id/test — test connection to a source
* - GET /calendar/events — aggregated events from visible sources
*/
@Controller('calendar')
export class CalendarController {
constructor(private readonly calendarService: CalendarService) {}
private extractContext(req: Request) {
const userId = (req as any).user?.id;
const tenantId =
(req as any).tenantId ?? (req as any).user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('No tenant context');
}
if (!userId) {
throw new ForbiddenException('No user context');
}
return { userId, tenantId };
}
/**
* GET /calendar/sources
* Returns all calendar sources for the user WITHOUT encryptedPassword.
* T-05-09: Uses Prisma select to exclude credentials; returns hasCredentials boolean.
*/
@Get('sources')
async getSources(@Req() req: Request) {
const { userId } = this.extractContext(req);
return this.calendarService.getSources(userId);
}
/**
* POST /calendar/sources
* Creates a new calendar source. Password is encrypted before storage.
* T-05-11: URL validated as https-only + SSRF private IP check.
*/
@Post('sources')
async addSource(
@Req() req: Request,
@Body() dto: CreateCalendarSourceDto,
) {
const { userId, tenantId } = this.extractContext(req);
return this.calendarService.addSource(userId, tenantId, dto);
}
/**
* PATCH /calendar/sources/:id
* Updates an existing calendar source. Ownership verified.
* CAL-02: isVisible toggle is part of this DTO.
*/
@Patch('sources/:id')
async updateSource(
@Param('id') id: string,
@Req() req: Request,
@Body() dto: UpdateCalendarSourceDto,
) {
const { userId } = this.extractContext(req);
return this.calendarService.updateSource(id, userId, dto);
}
/**
* DELETE /calendar/sources/:id
* Removes a calendar source. Ownership verified (T-05-12).
*/
@Delete('sources/:id')
async deleteSource(
@Param('id') id: string,
@Req() req: Request,
) {
const { userId } = this.extractContext(req);
return this.calendarService.deleteSource(id, userId);
}
/**
* POST /calendar/sources/:id/test
* Tests connection to a calendar source. Implemented in Task 3.
*/
@Post('sources/:id/test')
async testSource(
@Param('id') id: string,
@Req() req: Request,
) {
const { userId } = this.extractContext(req);
return this.calendarService.testConnection(id, userId);
}
/**
* GET /calendar/events
* Returns aggregated events from all visible sources. Implemented in Task 3.
*/
@Get('events')
async getEvents(
@Req() req: Request,
@Query() query: CalendarEventsQueryDto,
) {
const { userId } = this.extractContext(req);
return this.calendarService.aggregateEvents(userId, query.from, query.to);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { CalendarController } from './calendar.controller';
import { CalendarService } from './calendar.service';
import { CalendarCryptoService } from './crypto.service';
import { CalDAVProvider } from './providers/caldav.provider';
import { ICSProvider } from './providers/ics.provider';
import { ExchangeProvider } from './providers/exchange.provider';
/**
* NestJS module for calendar source management and event aggregation.
*
* Provides:
* - CalendarCryptoService: AES-256-GCM encryption for calendar credentials
* - CalendarService: Source CRUD + event aggregation across providers
* - CalDAVProvider: CalDAV protocol integration via tsdav
* - ICSProvider: ICS file fetch + parse via node-ical
* - ExchangeProvider: Exchange Online (Graph) + Exchange Server (EWS) integration
* - CalendarController: REST API for sources and events
*
* Exports CalendarService for potential use by other modules.
*/
@Module({
controllers: [CalendarController],
providers: [
CalendarService,
CalendarCryptoService,
CalDAVProvider,
ICSProvider,
ExchangeProvider,
],
exports: [CalendarService],
})
export class CalendarModule {}
+281
View File
@@ -0,0 +1,281 @@
import {
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CalendarCryptoService } from './crypto.service';
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
/**
* 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; id: string; color?: string | null },
from: Date,
to: Date,
): Promise<CalendarEvent[]>;
testConnection(
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string },
): Promise<boolean>;
}
/**
* 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,
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:/,
];
/**
* Service for calendar source CRUD and event aggregation.
*
* Source config is per-user (D-09), not per-tenant.
* Credentials encrypted at rest via CalendarCryptoService (T-05-10).
*/
@Injectable()
export class CalendarService {
private readonly logger = new Logger(CalendarService.name);
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CalendarCryptoService,
) {}
/**
* 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) {
const sources = await this.prisma.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) {
await this.validateUrlNotPrivate(dto.url);
const data: Record<string, unknown> = {
userId,
tenantId,
name: dto.name,
type: dto.type,
url: dto.url,
username: dto.username ?? null,
exchangeMode: dto.exchangeMode ?? null,
color: dto.color ?? '#3B82F6',
};
if (dto.password) {
data.encryptedPassword = this.crypto.encrypt(dto.password);
}
const created = await this.prisma.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, dto: UpdateCalendarSourceDto) {
const existing = await this.prisma.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');
}
if (dto.url) {
await this.validateUrlNotPrivate(dto.url);
}
const data: Record<string, unknown> = {};
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.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 this.prisma.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) {
const existing = await this.prisma.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 this.prisma.calendarSource.delete({ where: { id } });
return { deleted: true };
}
/**
* Test connection to a calendar source via its provider.
* Stub — full implementation in Task 3.
*/
async testConnection(id: string, userId: string): Promise<{ success: boolean; error?: string }> {
const source = await this.prisma.calendarSource.findUnique({ where: { id } });
if (!source) throw new NotFoundException('Calendar source not found');
if (source.userId !== userId) throw new ForbiddenException('Not your calendar source');
return { success: false, error: 'Not yet implemented' };
}
/**
* Aggregate events from all visible sources for a user.
* Stub — full implementation in Task 3.
*/
async aggregateEvents(
userId: string,
from?: string,
to?: string,
): Promise<CalendarEvent[]> {
return [];
}
/**
* 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<void> {
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');
}
}
}
+75
View File
@@ -0,0 +1,75 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
/**
* Encryption service for calendar source credentials.
*
* Uses AES-256-GCM with a 32-byte hex key from CALENDAR_ENCRYPTION_KEY env var.
* Encrypted values are stored as `iv:authTag:ciphertext` (hex-joined).
*
* Security: T-05-10 — credentials encrypted at rest, never returned in GET responses.
*/
@Injectable()
export class CalendarCryptoService implements OnModuleInit {
private key!: Buffer;
constructor(private readonly configService: ConfigService) {}
onModuleInit() {
const hexKey = this.configService.get<string>('CALENDAR_ENCRYPTION_KEY');
if (!hexKey) {
throw new Error(
'CALENDAR_ENCRYPTION_KEY is not set. Generate one with: openssl rand -hex 32',
);
}
if (hexKey.length !== 64) {
throw new Error(
`CALENDAR_ENCRYPTION_KEY must be a 64-character hex string (32 bytes). Got ${hexKey.length} characters.`,
);
}
this.key = Buffer.from(hexKey, 'hex');
}
/**
* Encrypts plaintext using AES-256-GCM.
* @returns `iv:authTag:ciphertext` (all hex-encoded, colon-separated)
*/
encrypt(plaintext: string): string {
const iv = randomBytes(12); // 96-bit IV for GCM
const cipher = createCipheriv('aes-256-gcm', this.key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
}
/**
* Decrypts a stored `iv:authTag:ciphertext` value.
* @returns The original plaintext
*/
decrypt(stored: string): string {
const parts = stored.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted value format. Expected iv:authTag:ciphertext');
}
const [ivHex, authTagHex, ciphertext] = parts;
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = createDecipheriv('aes-256-gcm', this.key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}
@@ -0,0 +1,16 @@
import { IsDateString, IsOptional } from 'class-validator';
/**
* DTO for querying aggregated calendar events.
*
* Default window: now to now + 30 days (applied in CalendarService).
*/
export class CalendarEventsQueryDto {
@IsOptional()
@IsDateString()
from?: string;
@IsOptional()
@IsDateString()
to?: string;
}
@@ -0,0 +1,47 @@
import {
IsBoolean,
IsHexColor,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUrl,
} from 'class-validator';
/**
* DTO for creating a new calendar source.
*
* Security:
* - T-05-11: URL restricted to https only (SSRF mitigation)
* - T-05-10: password is plaintext in transit (over TLS), encrypted at rest by CryptoService
*/
export class CreateCalendarSourceDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsIn(['caldav', 'ics', 'exchange'])
type!: string;
@IsUrl(
{ protocols: ['https'], require_protocol: true },
{ message: 'URL must use HTTPS protocol' },
)
url!: string;
@IsOptional()
@IsString()
username?: string;
@IsOptional()
@IsString()
password?: string;
@IsOptional()
@IsIn(['ews', 'graph'])
exchangeMode?: string;
@IsOptional()
@IsHexColor()
color?: string;
}
@@ -0,0 +1,55 @@
import {
IsBoolean,
IsHexColor,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUrl,
} from 'class-validator';
/**
* DTO for updating an existing calendar source.
* All fields are optional — only provided fields are updated.
*
* Security:
* - T-05-11: URL restricted to https only if provided
* - CAL-02: isVisible toggle for widget source visibility
*/
export class UpdateCalendarSourceDto {
@IsOptional()
@IsString()
@IsNotEmpty()
name?: string;
@IsOptional()
@IsIn(['caldav', 'ics', 'exchange'])
type?: string;
@IsOptional()
@IsUrl(
{ protocols: ['https'], require_protocol: true },
{ message: 'URL must use HTTPS protocol' },
)
url?: string;
@IsOptional()
@IsString()
username?: string;
@IsOptional()
@IsString()
password?: string;
@IsOptional()
@IsIn(['ews', 'graph'])
exchangeMode?: string;
@IsOptional()
@IsHexColor()
color?: string;
@IsOptional()
@IsBoolean()
isVisible?: boolean;
}
@@ -0,0 +1,27 @@
import { Injectable, Logger } from '@nestjs/common';
import { CalendarEvent, CalendarProvider } from '../calendar.service';
/**
* CalDAV calendar provider — uses tsdav for protocol handling.
* Full implementation in Task 2.
*/
@Injectable()
export class CalDAVProvider implements CalendarProvider {
private readonly logger = new Logger(CalDAVProvider.name);
async fetchEvents(
source: { url: string; username?: string; password?: string; id: string; color?: string | null },
from: Date,
to: Date,
): Promise<CalendarEvent[]> {
// Stub — implemented in Task 2
return [];
}
async testConnection(
source: { url: string; username?: string; password?: string; id: string },
): Promise<boolean> {
// Stub — implemented in Task 2
return false;
}
}
@@ -0,0 +1,28 @@
import { Injectable, Logger } from '@nestjs/common';
import { CalendarEvent, CalendarProvider } from '../calendar.service';
/**
* Exchange calendar provider — dispatches on exchangeMode ('graph' vs 'ews').
* Uses @microsoft/microsoft-graph-client for Graph and ews-javascript-api for EWS.
* Full implementation in Task 2.
*/
@Injectable()
export class ExchangeProvider implements CalendarProvider {
private readonly logger = new Logger(ExchangeProvider.name);
async fetchEvents(
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string; color?: string | null },
from: Date,
to: Date,
): Promise<CalendarEvent[]> {
// Stub — implemented in Task 2
return [];
}
async testConnection(
source: { url: string; username?: string; password?: string; exchangeMode?: string | null; id: string },
): Promise<boolean> {
// Stub — implemented in Task 2
return false;
}
}
@@ -0,0 +1,27 @@
import { Injectable, Logger } from '@nestjs/common';
import { CalendarEvent, CalendarProvider } from '../calendar.service';
/**
* ICS calendar provider — fetches and parses .ics files via HTTPS.
* Uses node-ical for parsing. Full implementation in Task 2.
*/
@Injectable()
export class ICSProvider implements CalendarProvider {
private readonly logger = new Logger(ICSProvider.name);
async fetchEvents(
source: { url: string; id: string; color?: string | null },
from: Date,
to: Date,
): Promise<CalendarEvent[]> {
// Stub — implemented in Task 2
return [];
}
async testConnection(
source: { url: string; id: string },
): Promise<boolean> {
// Stub — implemented in Task 2
return false;
}
}