/** * ICS and RSS feed routes for listing syndication (e.g. Mobilizon integration) * Gated behind the Site feature. */ import { decryptAttendees } from "#db/attendees/pii.ts"; import { getAttendeesByListingIds } from "#db/listings/attendees.ts"; import { getAllListings } from "#db/listings/records.ts"; import { bookingAssignmentKey, getLogisticsAssignmentsForAttendees, } from "#db/logistics.ts"; import { getNewsPostSummaries } from "#db/news-posts.ts"; import { settings } from "#db/settings.ts"; import { userAgents } from "#db/user-agents.ts"; import { identity, map, mapById, pipe } from "#fp"; import type { CoreListingFields } from "#routes/api/public-listing.ts"; import { withAuth } from "#routes/auth.ts"; import { isRegistrationClosed } from "#routes/format.ts"; import { classifyForDiscovery } from "#routes/public/discovery.ts"; import { loadBookablePackages, publicGroupSummary, } from "#routes/public/group-liveness.ts"; import { icsResponse, notFoundResponse, rssResponse, } from "#routes/response.ts"; import { createRouter, defineRoutes } from "#routes/router.ts"; import { getEffectiveDomain } from "#shared/config.ts"; import { isPublicListing } from "#shared/listing-visibility.ts"; import { nowIso } from "#shared/now.ts"; import { requirePublicSite } from "#shared/public-site.ts"; import { replacing } from "#shared/replacements.ts"; import { getRequestPrivateKey } from "#shared/session-private-key.ts"; import { type ListingWithCount, loadSortedListings, } from "#shared/sort-listings.ts"; import { escapeXml } from "#shared/xml.ts"; import type { Attendee, NewsPostSummary } from "#types"; /** Escape text for ICS (RFC 5545): backslash-escape special characters */ export const escapeIcs = replacing( [/\\/g, "\\\\"], [/;/g, "\\;"], [/,/g, "\\,"], [/\n/g, "\\n"], ); /** Format a date string as ICS UTC timestamp (YYYYMMDDTHHMMSSZ) */ const formatIcsDate = (dateStr: string): string => new Date(dateStr) .toISOString() .replace(/[-:]/g, "") .replace(/\.\d{3}/, ""); /** Format a date string as RFC 822 (for RSS pubDate) */ const formatRfc822 = (dateStr: string): string => new Date(dateStr).toUTCString(); /** One syndicated item — a standalone listing or a bookable package bundle, * both linking to their `/ticket/` page. `uid` keeps listing and package * ids from colliding in the ICS UID namespace. */ type FeedItem = CoreListingFields & { uid: string; location: string; /** RSS pubDate source; null for packages (groups carry no created stamp), * which simply omit the optional pubDate element. */ created: string | null; }; const listingFeedItem = (listing: ListingWithCount): FeedItem => ({ created: listing.created, date: listing.date || null, description: listing.description, location: listing.location, name: listing.name, slug: listing.slug, uid: `listing-${listing.id}`, }); /** Feed context: items, domain, and title */ type FeedData = { items: FeedItem[]; domain: string; title: string }; /** Load feed data: active open listings plus bookable packages. Children are * never syndicated (a feed item is a standalone `/ticket/` link, which a * booking can't start from), and a parent with no bookable child * is omitted (it would publish a link the gate rejects as sold out). * Packages are first-class products, so the bundle itself is syndicated at * `/ticket/`. */ const loadFeedData = async (): Promise => { const { listings } = await loadSortedListings( (e) => isPublicListing(e) && !e.purchase_only && !isRegistrationClosed(e), ); const { nonStandaloneChildIds, soldOutParentIds } = await classifyForDiscovery(listings); const packages = (await loadBookablePackages()).map( (loaded): FeedItem => ({ created: null, date: null, location: "", ...publicGroupSummary(loaded), uid: `package-${loaded.group.id}`, }), ); return { domain: getEffectiveDomain(), items: [ ...listings .filter( (e) => !nonStandaloneChildIds.has(e.id) && !soldOutParentIds.has(e.id), ) .map(listingFeedItem), ...packages, ], title: settings.websiteTitle || "Listings", }; }; /** Append the shared DTSTART/LOCATION lines for anything carrying a date and * location — feed items and (via the admin calendar's VEVENTs) listings. */ const appendItemSchedule = ( lines: string[], item: { date: string | null; location: string }, ): void => { if (item.date) lines.push(`DTSTART:${formatIcsDate(item.date)}`); if (item.location) lines.push(`LOCATION:${escapeIcs(item.location)}`); }; const buildVListing = ( item: FeedItem, domain: string, dtstamp: string, ): string => { const lines = [ "BEGIN:VLISTING", `UID:${item.uid}@${domain}`, `DTSTAMP:${dtstamp}`, `SUMMARY:${escapeIcs(item.name)}`, `URL:https://${domain}/ticket/${item.slug}`, ]; if (item.description) { lines.push(`DESCRIPTION:${escapeIcs(item.description)}`); } appendItemSchedule(lines, item); lines.push("END:VLISTING"); return lines.join("\r\n"); }; /** Build the full ICS calendar document */ const buildIcs = ({ items, domain, title }: FeedData): string => { const dtstamp = formatIcsDate(nowIso()); const vlistings = pipe( map((e: FeedItem) => buildVListing(e, domain, dtstamp)), )(items); return [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Chobble Tickets//EN", `X-WR-CALNAME:${escapeIcs(title)}`, ...vlistings, "END:VCALENDAR", ].join("\r\n"); }; /** Build a rich description for RSS items, including date and location */ const buildRssDescription = (item: FeedItem): string => { const parts: string[] = []; if (item.description) parts.push(item.description); if (item.date) parts.push(`Date: ${formatRfc822(item.date)}`); if (item.location) parts.push(`Location: ${item.location}`); return parts.join("\n"); }; /** One `` of any RSS channel — listings and news share this shape. */ type RssItem = { title: string; link: string; description: string; /** RSS pubDate source; null omits the optional element. */ pubDate: string | null; }; /** Build a single RSS item */ const buildRssItem = (item: RssItem): string => [ " ", ` ${escapeXml(item.title)}`, ` ${item.link}`, ` ${item.link}`, ` ${escapeXml(item.description)}`, ...(item.pubDate ? [` ${formatRfc822(item.pubDate)}`] : []), " ", ].join("\n"); /** Build a full RSS document from its channel header and items. */ const buildRssDocument = ( channel: { title: string; link: string; description: string }, items: RssItem[], ): string => [ '', '', " ", ` ${escapeXml(channel.title)}`, ` ${channel.link}`, ` ${escapeXml(channel.description)}`, ...pipe(map(buildRssItem))(items), " ", "", ].join("\n"); /** The listings RSS document: every syndicated listing/package as an item. */ const buildRss = ({ items: feedItems, domain, title }: FeedData): string => buildRssDocument( { description: `Listings from ${title}`, link: `https://${domain}/listings`, title, }, feedItems.map((item) => ({ description: buildRssDescription(item), link: `https://${domain}/ticket/${item.slug}`, pubDate: item.created, title: item.name, })), ); /** The news RSS document: every post, newest first, linking to `/news/:slug` * with the plain-text snippet as the description. */ const buildNewsRss = (posts: NewsPostSummary[], domain: string): string => { const title = settings.websiteTitle || "News"; return buildRssDocument( { description: `News from ${title}`, link: `https://${domain}/news`, title, }, posts.map((post) => ({ description: post.snippet, link: `https://${domain}/news/${post.slug}`, pubDate: post.created, title: post.name, })), ); }; const eventUrl = (domain: string, attendee: Attendee): string => `https://${domain}/admin/attendees/${attendee.id}`; const attendeeName = (attendee: Attendee): string => attendee.name || `Attendee ${attendee.id}`; const buildVEvent = (opts: { attendee: Attendee; domain: string; dtstamp: string; listing: ListingWithCount; }): string => { const { attendee, domain, dtstamp, listing } = opts; const summary = settings.calendarFeedsGroupBy === "listings" ? listing.name : attendeeName(attendee); const description = settings.calendarFeedsGroupBy === "listings" ? attendeeName(attendee) : listing.name; const lines = [ "BEGIN:VEVENT", `UID:attendee-${attendee.id}-listing-${listing.id}@${domain}`, `DTSTAMP:${dtstamp}`, `SUMMARY:${escapeIcs(summary)}`, `DESCRIPTION:${escapeIcs(`${description} — open the site for details`)}`, `URL:${eventUrl(domain, attendee)}`, ]; appendItemSchedule(lines, listing); lines.push("END:VEVENT"); return lines.join("\r\n"); }; const assignmentIncludesUser = ( assignment: { endAgentId: number | null; startAgentId: number | null }, userId: number, ): boolean => [assignment.startAgentId, assignment.endAgentId].includes(userId); const filterCalendarFeedAttendees = async ( attendees: Attendee[], session: { adminLevel: string; userId: number }, ): Promise => { if (session.adminLevel !== "agent") return attendees; const [assignments, agentIds] = await Promise.all([ getLogisticsAssignmentsForAttendees(attendees.map((a) => a.id)), userAgents.getIds(session.userId), ]); const visible = new Set(); for (const assignment of assignments) { if ( agentIds.some((agentId) => assignmentIncludesUser(assignment, agentId)) ) { visible.add( bookingAssignmentKey(assignment.attendeeId, assignment.listingId), ); } } return attendees.filter((a) => visible.has(bookingAssignmentKey(a.id, a.listing_id)), ); }; /* jscpd:ignore-start */ const buildCalendarFeed = async (request: Request): Promise => { if (!settings.calendarFeedsEnabled) { return new Response("Not found", { status: 404 }); } return withAuth( request, { allowApiKey: true, body: "json", roles: ["owner", "manager", "agent"] }, async (session) => { const privateKey = await getRequestPrivateKey(); const listings = await getAllListings(); const listingById = mapById(identity)(listings); const rawAttendees = await getAttendeesByListingIds( listings.map((l) => l.id), // Operational ICS feed: exclude no-quantity sentinel lines. true, ); const attendees = await filterCalendarFeedAttendees( await decryptAttendees(rawAttendees, privateKey!), session, ); const domain = getEffectiveDomain(); const dtstamp = formatIcsDate(nowIso()); const events = attendees.flatMap((attendee) => { const listing = listingById.get(attendee.listing_id); return listing?.date ? [buildVEvent({ attendee, domain, dtstamp, listing })] : []; }); return icsResponse( [ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Chobble Tickets//EN", `X-WR-CALNAME:${escapeIcs(settings.websiteTitle || "Tickets")}`, ...events, "END:VCALENDAR", ].join("\r\n"), ); }, ); }; /* jscpd:ignore-end */ /** A feed route: build the body, then send it, but only on a public site. A * builder with nothing to syndicate returns `null`, and the route sends the * site's not-found page instead. */ const feedRoute = (send: (body: string) => Response, build: () => Promise) => (): Promise => requirePublicSite(async () => { const body = await build(); return body === null ? notFoundResponse() : send(body); }) as Promise; /** Handle GET /feeds/listings.ics */ const handleIcs = feedRoute(icsResponse, async () => buildIcs(await loadFeedData()), ); /** Handle GET /feeds/listings.rss */ const handleRss = feedRoute(rssResponse, async () => buildRss(await loadFeedData()), ); /** Handle GET /feeds/news.rss — a site with no posts has no news feed, the * same answer `/news` gives. */ const handleNewsRss = feedRoute(rssResponse, async () => { const posts = await getNewsPostSummaries(); return posts.length === 0 ? null : buildNewsRss(posts, getEffectiveDomain()); }); /** Feed routes */ export const routeFeed = createRouter( defineRoutes({ "GET /caldav/events.ics": buildCalendarFeed, "GET /feeds/listings.ics": handleIcs, "GET /feeds/listings.rss": handleRss, "GET /feeds/news.rss": handleNewsRss, }), );