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:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user