2026-03-08 18:11:26 -06:00

592 lines
19 KiB
TypeScript

import crypto from 'crypto';
import dns from 'dns/promises';
import { URL } from 'url';
import {
CalendarLayerType,
CalendarVisibility,
CalendarItemType,
CalendarItemSource,
CalendarFeedStatus,
CalendarFeedInterval,
Prisma,
} from '@prisma/client';
import ical, { ICalCalendarMethod } from 'ical-generator';
import nodeIcal from 'node-ical';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import type { CreateFeedInput, UpdateFeedInput, CreateExportTokenInput } from './feed.schemas';
const MAX_EVENTS_PER_FEED = 1000;
const FETCH_TIMEOUT_MS = 30_000;
const FETCH_MAX_BYTES = 5 * 1024 * 1024; // 5MB
const MATERIALIZE_MONTHS = 3;
// SSRF protection: block requests to private/reserved IP ranges and internal hosts
const BLOCKED_HOSTNAMES = new Set([
'localhost', '0.0.0.0', '[::]', '[::1]',
// Common Docker internal hostnames
'changemaker-v2-postgres', 'redis-changemaker', 'changemaker-v2-api',
'changemaker-v2-admin', 'changemaker-v2-nginx', 'changemaker-v2-nocodb',
'listmonk-app', 'listmonk-db', 'mailhog-changemaker',
]);
function isPrivateIP(ip: string): boolean {
// IPv4 private/reserved ranges
if (ip.startsWith('10.')) return true;
if (ip.startsWith('127.')) return true;
if (ip.startsWith('169.254.')) return true; // Link-local / cloud metadata
if (ip.startsWith('172.')) {
const second = parseInt(ip.split('.')[1], 10);
if (second >= 16 && second <= 31) return true;
}
if (ip.startsWith('192.168.')) return true;
if (ip === '0.0.0.0') return true;
// IPv6 private/reserved
if (ip === '::1' || ip === '::') return true;
if (ip.startsWith('fc') || ip.startsWith('fd')) return true; // ULA
if (ip.startsWith('fe80')) return true; // Link-local
return false;
}
async function validateFeedUrl(rawUrl: string): Promise<void> {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new AppError(400, 'Invalid URL format', 'INVALID_FEED_URL');
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new AppError(400, 'Only http and https URLs are allowed', 'INVALID_FEED_URL');
}
const hostname = parsed.hostname.toLowerCase();
if (BLOCKED_HOSTNAMES.has(hostname)) {
throw new AppError(400, 'This URL is not allowed', 'BLOCKED_FEED_URL');
}
// Resolve DNS and check all resolved IPs
try {
const addrs4 = await dns.resolve4(hostname).catch(() => [] as string[]);
const addrs6 = await dns.resolve6(hostname).catch(() => [] as string[]);
const allAddrs = [...addrs4, ...addrs6];
if (allAddrs.length === 0) {
throw new AppError(400, 'Could not resolve feed URL hostname', 'FEED_URL_UNREACHABLE');
}
for (const addr of allAddrs) {
if (isPrivateIP(addr)) {
throw new AppError(400, 'This URL is not allowed', 'BLOCKED_FEED_URL');
}
}
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError(400, 'Could not resolve feed URL hostname', 'FEED_URL_UNREACHABLE');
}
}
// Map CalendarFeedInterval to milliseconds
const INTERVAL_MS: Record<CalendarFeedInterval, number> = {
FIFTEEN_MIN: 15 * 60 * 1000,
HOURLY: 60 * 60 * 1000,
SIX_HOUR: 6 * 60 * 60 * 1000,
DAILY: 24 * 60 * 60 * 1000,
};
export const feedService = {
// =========================================================================
// Feed Management
// =========================================================================
async listFeeds(userId: string) {
return prisma.calendarFeed.findMany({
where: { userId },
include: { layer: { select: { id: true, name: true, color: true, isEnabled: true } } },
orderBy: { createdAt: 'asc' },
});
},
async createFeed(userId: string, data: CreateFeedInput) {
// SSRF protection: validate URL before making any request
await validateFeedUrl(data.url);
// Validate URL is reachable
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
const res = await fetch(data.url, {
method: 'HEAD',
signal: controller.signal,
redirect: 'manual', // Don't follow redirects (prevent SSRF via open redirects)
});
clearTimeout(timeout);
if (!res.ok && res.status !== 405 && !(res.status >= 300 && res.status < 400)) {
throw new AppError(400, `Feed URL returned status ${res.status}`, 'FEED_URL_UNREACHABLE');
}
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError(400, 'Feed URL is not reachable', 'FEED_URL_UNREACHABLE');
}
// Create EXTERNAL layer + feed in a transaction
const result = await prisma.$transaction(async (tx) => {
const layer = await tx.calendarLayer.create({
data: {
userId,
name: data.name,
color: '#1890ff',
layerType: CalendarLayerType.EXTERNAL,
visibility: CalendarVisibility.PRIVATE,
},
});
const feed = await tx.calendarFeed.create({
data: {
userId,
name: data.name,
url: data.url,
layerId: layer.id,
refreshInterval: data.refreshInterval as CalendarFeedInterval,
},
});
return feed;
});
// Trigger initial fetch (fire-and-forget)
this.refreshFeed(result.id).catch((err) => {
logger.warn(`Initial feed refresh failed for ${result.id}:`, err);
});
return prisma.calendarFeed.findUnique({
where: { id: result.id },
include: { layer: { select: { id: true, name: true, color: true, isEnabled: true } } },
});
},
async updateFeed(userId: string, feedId: string, data: UpdateFeedInput) {
const feed = await prisma.calendarFeed.findFirst({
where: { id: feedId, userId },
});
if (!feed) {
throw new AppError(404, 'Feed not found', 'NOT_FOUND');
}
const updateData: Prisma.CalendarFeedUncheckedUpdateInput = {};
if (data.name !== undefined) {
updateData.name = data.name;
// Also update layer name
await prisma.calendarLayer.update({
where: { id: feed.layerId },
data: { name: data.name },
});
}
if (data.url !== undefined) {
// SSRF protection: validate new URL before saving
await validateFeedUrl(data.url);
updateData.url = data.url;
}
if (data.refreshInterval !== undefined) {
updateData.refreshInterval = data.refreshInterval as CalendarFeedInterval;
}
return prisma.calendarFeed.update({
where: { id: feedId },
data: updateData,
include: { layer: { select: { id: true, name: true, color: true, isEnabled: true } } },
});
},
async deleteFeed(userId: string, feedId: string) {
const feed = await prisma.calendarFeed.findFirst({
where: { id: feedId, userId },
});
if (!feed) {
throw new AppError(404, 'Feed not found', 'NOT_FOUND');
}
// Deleting the layer cascades to items and the feed record
await prisma.calendarLayer.delete({ where: { id: feed.layerId } });
},
async refreshFeed(feedId: string) {
const feed = await prisma.calendarFeed.findUnique({ where: { id: feedId } });
if (!feed) return;
try {
// Fetch the ICS data
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
// Re-validate the stored URL in case it was changed outside the update flow
await validateFeedUrl(feed.url);
const response = await fetch(feed.url, {
signal: controller.signal,
redirect: 'manual', // Don't follow redirects (SSRF protection)
headers: { 'User-Agent': 'Changemaker-Calendar/1.0' },
});
clearTimeout(timeout);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// Read body with size limit
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const chunks: Uint8Array[] = [];
let totalSize = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.byteLength;
if (totalSize > FETCH_MAX_BYTES) {
reader.cancel();
throw new Error('Feed exceeds 5MB size limit');
}
chunks.push(value);
}
const icsText = Buffer.concat(chunks).toString('utf-8');
// Parse ICS
const parsed = nodeIcal.sync.parseICS(icsText);
// Extract VEVENT entries
const events: Array<{
uid: string;
title: string;
description: string | null;
date: Date;
startTime: string;
endTime: string;
isAllDay: boolean;
location: string | null;
}> = [];
const now = new Date();
const futureLimit = new Date(now);
futureLimit.setMonth(futureLimit.getMonth() + MATERIALIZE_MONTHS);
for (const key of Object.keys(parsed)) {
const component = parsed[key];
if (!component || component.type !== 'VEVENT') continue;
if (events.length >= MAX_EVENTS_PER_FEED) break;
const vevent = component as nodeIcal.VEvent;
if (!vevent.uid || !vevent.start) continue;
const start = vevent.start instanceof Date ? vevent.start : new Date(vevent.start as unknown as string);
if (isNaN(start.getTime())) continue;
// Check if this is an all-day event (dateOnly property from node-ical)
const isAllDay = !!(vevent.start as { dateOnly?: boolean }).dateOnly;
let startTime = '00:00';
let endTime = '23:59';
if (!isAllDay) {
startTime = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`;
if (vevent.end) {
const end = vevent.end instanceof Date ? vevent.end : new Date(vevent.end as unknown as string);
if (!isNaN(end.getTime())) {
endTime = `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`;
}
} else {
// Default 1 hour
const endDate = new Date(start);
endDate.setHours(endDate.getHours() + 1);
endTime = `${String(endDate.getHours()).padStart(2, '0')}:${String(endDate.getMinutes()).padStart(2, '0')}`;
}
}
// Handle RRULE: materialize recurring instances
if (vevent.rrule) {
try {
const occurrences = vevent.rrule.between(now, futureLimit, true);
for (const occ of occurrences) {
if (events.length >= MAX_EVENTS_PER_FEED) break;
const occDate = new Date(occ);
events.push({
uid: `${vevent.uid}_${occDate.toISOString().split('T')[0]}`,
title: String(vevent.summary || 'Untitled'),
description: typeof vevent.description === 'string' ? vevent.description.slice(0, 2000) : null,
date: occDate,
startTime,
endTime,
isAllDay,
location: typeof vevent.location === 'string' ? vevent.location.slice(0, 500) : null,
});
}
} catch {
// If rrule parsing fails, just add the single instance
events.push({
uid: vevent.uid,
title: String(vevent.summary || 'Untitled'),
description: typeof vevent.description === 'string' ? vevent.description.slice(0, 2000) : null,
date: start,
startTime,
endTime,
isAllDay,
location: typeof vevent.location === 'string' ? vevent.location.slice(0, 500) : null,
});
}
} else {
events.push({
uid: vevent.uid,
title: String(vevent.summary || 'Untitled'),
description: typeof vevent.description === 'string' ? vevent.description.slice(0, 2000) : null,
date: start,
startTime,
endTime,
isAllDay,
location: typeof vevent.location === 'string' ? vevent.location.slice(0, 500) : null,
});
}
}
// Upsert events and remove stale ones in a transaction
await prisma.$transaction(async (tx) => {
const sourceIds = events.map((e) => e.uid);
// Delete stale items (no longer in feed)
await tx.calendarItem.deleteMany({
where: {
layerId: feed.layerId,
userId: feed.userId,
sourceType: CalendarItemSource.ICS_FEED,
sourceId: { notIn: sourceIds.length > 0 ? sourceIds : ['__none__'] },
},
});
// Upsert each event (no unique constraint on sourceId, so find+update/create)
for (const event of events) {
const existing = await tx.calendarItem.findFirst({
where: {
userId: feed.userId,
layerId: feed.layerId,
sourceType: CalendarItemSource.ICS_FEED,
sourceId: event.uid,
},
});
if (existing) {
await tx.calendarItem.update({
where: { id: existing.id },
data: {
title: event.title,
description: event.description,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
isAllDay: event.isAllDay,
location: event.location,
},
});
} else {
await tx.calendarItem.create({
data: {
userId: feed.userId,
layerId: feed.layerId,
title: event.title,
description: event.description,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
isAllDay: event.isAllDay,
itemType: CalendarItemType.EVENT,
sourceType: CalendarItemSource.ICS_FEED,
sourceId: event.uid,
location: event.location,
},
});
}
}
});
// Update feed status
await prisma.calendarFeed.update({
where: { id: feedId },
data: {
lastFetchedAt: new Date(),
lastStatus: CalendarFeedStatus.OK,
lastError: null,
itemCount: events.length,
},
});
logger.debug(`Feed ${feed.name} refreshed: ${events.length} events`);
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
await prisma.calendarFeed.update({
where: { id: feedId },
data: {
lastFetchedAt: new Date(),
lastStatus: CalendarFeedStatus.ERROR,
lastError: message.slice(0, 500),
},
});
logger.warn(`Feed ${feed.name} refresh failed: ${message}`);
}
},
// =========================================================================
// Export
// =========================================================================
async createExportToken(userId: string, data: CreateExportTokenInput) {
const token = crypto.randomBytes(32).toString('hex');
return prisma.calendarExportToken.create({
data: {
userId,
token,
includePersonal: data.includePersonal,
includeLayers: data.includeLayers
? (data.includeLayers as unknown as Prisma.InputJsonValue)
: undefined,
},
});
},
async revokeExportToken(userId: string, tokenId: string) {
const exportToken = await prisma.calendarExportToken.findFirst({
where: { id: tokenId, userId },
});
if (!exportToken) {
throw new AppError(404, 'Export token not found', 'NOT_FOUND');
}
await prisma.calendarExportToken.delete({ where: { id: tokenId } });
},
async listExportTokens(userId: string) {
return prisma.calendarExportToken.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
},
async getExportFeed(token: string): Promise<string | null> {
const exportToken = await prisma.calendarExportToken.findUnique({
where: { token },
include: { user: { select: { id: true, name: true } } },
});
if (!exportToken) return null;
const now = new Date();
const pastLimit = new Date(now);
pastLimit.setMonth(pastLimit.getMonth() - 1);
const futureLimit = new Date(now);
futureLimit.setMonth(futureLimit.getMonth() + MATERIALIZE_MONTHS);
// Build layer filter
const layerWhere: Prisma.CalendarLayerWhereInput = {
userId: exportToken.userId,
isEnabled: true,
};
// If includeLayers is specified, filter to those layer IDs
const includeLayerIds = exportToken.includeLayers as string[] | null;
if (includeLayerIds && includeLayerIds.length > 0) {
layerWhere.id = { in: includeLayerIds };
}
// If not includePersonal, exclude USER layers
if (!exportToken.includePersonal) {
layerWhere.layerType = { not: CalendarLayerType.USER };
}
const layers = await prisma.calendarLayer.findMany({ where: layerWhere });
const layerIds = layers.map((l) => l.id);
if (layerIds.length === 0) {
// Return empty calendar
const cal = ical({ name: 'Changemaker Calendar' });
cal.method(ICalCalendarMethod.PUBLISH);
return cal.toString();
}
const items = await prisma.calendarItem.findMany({
where: {
userId: exportToken.userId,
layerId: { in: layerIds },
date: { gte: pastLimit, lte: futureLimit },
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
const cal = ical({
name: `${exportToken.user.name || 'Changemaker'} Calendar`,
prodId: { company: 'Changemaker', product: 'Calendar', language: 'EN' },
});
cal.method(ICalCalendarMethod.PUBLISH);
for (const item of items) {
const dateStr = item.date.toISOString().split('T')[0];
const [startH, startM] = item.startTime.split(':').map(Number);
const [endH, endM] = item.endTime.split(':').map(Number);
const startDt = new Date(item.date);
startDt.setHours(startH, startM, 0, 0);
const endDt = new Date(item.date);
endDt.setHours(endH, endM, 0, 0);
// If end is before start (shouldn't happen), add 1 hour
if (endDt <= startDt) {
endDt.setHours(startDt.getHours() + 1);
}
const event = cal.createEvent({
id: item.sourceId || item.id,
start: startDt,
end: endDt,
summary: item.title,
description: item.description || undefined,
location: item.location || undefined,
allDay: item.isAllDay,
});
if (item.isAllDay) {
event.allDay(true);
}
}
return cal.toString();
},
// =========================================================================
// Queue helpers
// =========================================================================
/**
* Get feeds that are due for refresh based on their interval and lastFetchedAt.
*/
async getFeedsDueForRefresh(limit: number = 10) {
const now = new Date();
const feeds = await prisma.calendarFeed.findMany({
orderBy: { lastFetchedAt: 'asc' },
take: limit * 2, // fetch extra to filter
});
return feeds.filter((feed) => {
if (!feed.lastFetchedAt) return true; // never fetched
const intervalMs = INTERVAL_MS[feed.refreshInterval];
const nextDue = new Date(feed.lastFetchedAt.getTime() + intervalMs);
return now >= nextDue;
}).slice(0, limit);
},
};