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
+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');
}
}
}