592 lines
19 KiB
TypeScript
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);
|
|
},
|
|
};
|