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 { 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 = { 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 { 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); }, };