diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index 28c37f1b..071f58a9 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -909,7 +909,7 @@ describe('DayPlanSidebar', () => { // ── ICS export click ───────────────────────────────────────────────── - it('FE-PLANNER-DAYPLAN-058: clicking ICS button calls fetch for .ics export', async () => { + it('FE-PLANNER-DAYPLAN-058: ICS menu "Download ICS" calls fetch for .ics export', async () => { const user = userEvent.setup() const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, @@ -919,7 +919,10 @@ describe('DayPlanSidebar', () => { const createObjURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock') const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) render() - await user.click(screen.getByText('ICS').closest('button')!) + // The ICS button now opens a hover menu (Download / Subscribe) instead of + // downloading on direct click. + await user.hover(screen.getByText('ICS').closest('button')!) + await user.click(await screen.findByText('Download ICS')) await waitFor(() => expect(fetchSpy).toHaveBeenCalledWith('/api/trips/1/export.ics', expect.any(Object))) fetchSpy.mockRestore() createObjURL.mockRestore() @@ -1550,14 +1553,14 @@ describe('DayPlanSidebar', () => { // ── ICS hover tooltip ───────────────────────────────────────────────────── - it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows tooltip', async () => { + it('FE-PLANNER-DAYPLAN-090: hovering ICS button shows the download/subscribe menu', async () => { const user = userEvent.setup() render() - const icsBtn = screen.getByRole('button', { name: /ICS/i }) + const icsBtn = screen.getByText('ICS').closest('button')! await user.hover(icsBtn) await waitFor(() => { - const tooltips = document.querySelectorAll('[style*="pointer-events: none"]') - expect(tooltips.length).toBeGreaterThan(0) + expect(screen.getByText('Download ICS')).toBeInTheDocument() + expect(screen.getByText('Subscribe to calendar')).toBeInTheDocument() }) }) diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.tsx index 3dedad11..ebe92dc2 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.tsx @@ -178,7 +178,12 @@ export function DayPlanSidebarToolbar({ )} {subscribeOpen && ( - setSubscribeOpen(false)} /> + setSubscribeOpen(false)} + /> )} {(() => { const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id)) diff --git a/client/src/components/Planner/IcsSubscribeModal.tsx b/client/src/components/Planner/IcsSubscribeModal.tsx index b80ad066..dc66002f 100644 --- a/client/src/components/Planner/IcsSubscribeModal.tsx +++ b/client/src/components/Planner/IcsSubscribeModal.tsx @@ -1,60 +1,64 @@ import { useState, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' -import { X, RefreshCw, Calendar } from 'lucide-react' +import { X, RefreshCw, Calendar, Power } from 'lucide-react' import { SubscribeLinks } from './SubscribeLinks' interface IcsSubscribeModalProps { - tripId: number + /** Token endpoint base, e.g. `/api/trips/123/feed` or `/api/feed/user`. */ + endpoint: string + title: string + description: string onClose: () => void } -export function IcsSubscribeModal({ tripId, onClose }: IcsSubscribeModalProps) { +// A server that has no APP_URL configured hands back a host-relative path; the +// webcal:// handoff and Google deep link need an absolute URL, so resolve it +// against the current origin as a fallback. +function absolutize(url: string): string { + if (!url) return '' + if (/^https?:\/\//i.test(url)) return url + if (url.startsWith('/')) return window.location.origin + url + return url +} + +/** + * Shared subscribe dialog for the per-trip and all-trips ICS feeds. Opening it + * only *reads* the current token — it never mints one silently. The user + * explicitly enables the public link, and can rotate or fully turn it off. + */ +export function IcsSubscribeModal({ endpoint, title, description, onClose }: IcsSubscribeModalProps) { + const tokenUrl = `${endpoint}/token` const [feedUrl, setFeedUrl] = useState(null) const [loading, setLoading] = useState(true) - const [regenerating, setRegenerating] = useState(false) + const [busy, setBusy] = useState(false) - const httpsUrl = feedUrl ?? '' - const webcalUrl = feedUrl ? feedUrl.replace(/^https?:\/\//, 'webcal://') : '' + const httpsUrl = feedUrl ? absolutize(feedUrl) : '' + const webcalUrl = httpsUrl ? httpsUrl.replace(/^https?:\/\//, 'webcal://') : '' - const loadToken = useCallback(async () => { + const load = useCallback(async () => { setLoading(true) try { - // Try to get existing token first - let res = await fetch(`/api/trips/${tripId}/feed/token`, { credentials: 'include' }) - if (!res.ok) { setLoading(false); return } - const data = await res.json() as { feed_url: string | null } - if (data.feed_url) { + const res = await fetch(tokenUrl, { credentials: 'include' }) + if (res.ok) { + const data = await res.json() as { feed_url: string | null } setFeedUrl(data.feed_url) - } else { - // Lazily generate on first open - res = await fetch(`/api/trips/${tripId}/feed/token`, { - method: 'POST', - credentials: 'include', - }) - if (res.ok) { - const gen = await res.json() as { feed_url: string } - setFeedUrl(gen.feed_url) - } } } catch { /* ignore */ } setLoading(false) - }, [tripId]) + }, [tokenUrl]) - useEffect(() => { loadToken() }, [loadToken]) + useEffect(() => { load() }, [load]) - const regenerate = async () => { - setRegenerating(true) + const mutate = async (method: 'POST' | 'PUT' | 'DELETE') => { + setBusy(true) try { - const res = await fetch(`/api/trips/${tripId}/feed/token`, { - method: 'DELETE', - credentials: 'include', - }) + const res = await fetch(tokenUrl, { method, credentials: 'include' }) if (res.ok) { - const data = await res.json() as { feed_url: string } + const data = await res.json() as { feed_url: string | null } setFeedUrl(data.feed_url) } } catch { /* ignore */ } - setRegenerating(false) + setBusy(false) } return createPortal( @@ -83,7 +87,7 @@ export function IcsSubscribeModal({ tripId, onClose }: IcsSubscribeModalProps) {
- Subscribe to Calendar + {title}
+

+ Creates a secret link anyone with it can read without logging in. You can turn it off anytime. +

+ ) : ( <> -
+
+ -

- Regenerating creates a new link and invalidates the old one. -

+

+ Regenerating creates a new link and invalidates the old one. Turning off disables the link entirely. +

)}
diff --git a/client/src/components/Planner/SubscribeLinks.tsx b/client/src/components/Planner/SubscribeLinks.tsx index bcfc109d..e5eed99a 100644 --- a/client/src/components/Planner/SubscribeLinks.tsx +++ b/client/src/components/Planner/SubscribeLinks.tsx @@ -15,8 +15,8 @@ export function SubscribeLinks({ httpsUrl, webcalUrl }: SubscribeLinksProps) { const [copied, setCopied] = useState<'https' | 'webcal' | null>(null) // Google Calendar's add-by-URL deep link. The cid must carry the webcal:// - // scheme (not https) and the feed must be served over HTTPS. - const googleUrl = `https://www.google.com/calendar/render?cid=${webcalUrl}` + // scheme (not https), URL-encoded, and the feed must be served over HTTPS. + const googleUrl = `https://www.google.com/calendar/render?cid=${encodeURIComponent(webcalUrl)}` const copy = async (url: string, which: 'https' | 'webcal') => { try { diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 2beabb45..a221cd60 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -1,5 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react' -import { createPortal } from 'react-dom' +import React, { useEffect, useState } from 'react' import { useTranslation } from '../i18n' import Navbar from '../components/Layout/Navbar' import DemoBanner from '../components/Layout/DemoBanner' @@ -19,7 +18,7 @@ import { Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar, LayoutGrid, List, Ticket, X, CalendarPlus, } from 'lucide-react' -import { SubscribeLinks } from '../components/Planner/SubscribeLinks' +import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal' import { formatTime, splitReservationDateTime } from '../utils/formatters' import { convertDistance, getDistanceUnitLabel } from '../utils/units' import { useSettingsStore } from '../store/settingsStore' @@ -166,7 +165,14 @@ export default function DashboardPage(): React.ReactElement {
- {allSubOpen && setAllSubOpen(false)} />} + {allSubOpen && ( + setAllSubOpen(false)} + /> + )} {gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
@@ -755,101 +761,3 @@ function UpcomingTool({ items, locale, onOpen }: {
) } - -// ── All-trips subscribe modal ──────────────────────────────────────────────── -function AllTripsSubscribeModal({ onClose }: { onClose: () => void }) { - const [feedUrl, setFeedUrl] = useState(null) - const [loading, setLoading] = useState(true) - const [regenerating, setRegenerating] = useState(false) - - const httpsUrl = feedUrl ?? '' - const webcalUrl = feedUrl ? feedUrl.replace(/^https?:\/\//, 'webcal://') : '' - - const loadToken = useCallback(async () => { - setLoading(true) - try { - let res = await fetch('/api/feed/user/token', { credentials: 'include' }) - if (!res.ok) { setLoading(false); return } - const data = await res.json() as { feed_url: string | null } - if (data.feed_url) { - setFeedUrl(data.feed_url) - } else { - res = await fetch('/api/feed/user/token', { method: 'POST', credentials: 'include' }) - if (res.ok) { - const gen = await res.json() as { feed_url: string } - setFeedUrl(gen.feed_url) - } - } - } catch { /* ignore */ } - setLoading(false) - }, []) - - useEffect(() => { loadToken() }, [loadToken]) - - const regenerate = async () => { - setRegenerating(true) - try { - const res = await fetch('/api/feed/user/token', { method: 'DELETE', credentials: 'include' }) - if (res.ok) { - const data = await res.json() as { feed_url: string } - setFeedUrl(data.feed_url) - } - } catch { /* ignore */ } - setRegenerating(false) - } - - return createPortal( -
{ if (e.target === e.currentTarget) onClose() }} - > -
-
-
- - Subscribe to All Trips -
- -
- -

- Subscribe to all your active trips in one calendar feed. Updates automatically. Excludes archived trips and trips that ended more than 90 days ago. -

- - {loading ? ( -
Generating link…
- ) : !feedUrl ? ( -
Could not generate feed link.
- ) : ( - <> - -
- -

Regenerating creates a new link and invalidates the old one.

-
- - )} -
- -
, - document.body - ) -} diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index d9930df7..d6a71b50 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -26,6 +26,7 @@ function createTables(db: Database.Database): void { synology_sid TEXT, must_change_password INTEGER DEFAULT 0, password_version INTEGER NOT NULL DEFAULT 0, + feed_token TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -87,6 +88,7 @@ function createTables(db: Database.Database): void { cover_image TEXT, is_archived INTEGER DEFAULT 0, reminder_days INTEGER DEFAULT 3, + feed_token TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); diff --git a/server/src/nest/feeds/feeds.controller.ts b/server/src/nest/feeds/feeds.controller.ts index 2708fae5..7e094bfb 100644 --- a/server/src/nest/feeds/feeds.controller.ts +++ b/server/src/nest/feeds/feeds.controller.ts @@ -5,16 +5,30 @@ import { HttpException, Param, Post, + Put, + Req, Res, UseGuards, } from '@nestjs/common'; -import type { Response } from 'express'; +import type { Request, Response } from 'express'; import { FeedsService } from './feeds.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { User } from '../../types'; import { db } from '../../db/database'; +// Resolve the public origin used to build feed URLs. APP_URL wins — it is the +// canonical externally-reachable URL behind a reverse proxy. When it is unset +// (the default on a plain `docker run`), fall back to the request's own host so +// the link is still absolute and copy-pasteable as webcal:// instead of a dead +// relative path. +function resolveFeedBase(req: Request): string { + const configured = (process.env.APP_URL || '').replace(/\/$/, ''); + if (configured) return configured; + const host = req.get('host'); + return host ? `${req.protocol}://${host}` : ''; +} + /** * Public subscribable ICS feed endpoints — no auth required. * The secret token in the URL acts as the access credential. @@ -53,55 +67,77 @@ export class FeedsPublicController { } /** - * Authenticated token management — generate / regenerate feed tokens. + * Authenticated token management for a single trip's feed. + * POST = enable (mint a token, idempotent) + * PUT = rotate (new token, invalidates the old URL) + * DELETE = disable (clear the token, public URL stops resolving) */ @Controller('api/trips/:tripId/feed') @UseGuards(JwtAuthGuard) export class TripFeedTokenController { constructor(private readonly feeds: FeedsService) {} + private assertAccess(tripId: string, userId: number): void { + const row = db + .prepare( + 'SELECT id FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))', + ) + .get(tripId, userId, userId); + if (!row) throw new HttpException({ error: 'Trip not found' }, 404); + } + @Get('token') - get(@CurrentUser() user: User, @Param('tripId') tripId: string) { - const result = this.feeds.getTripToken(tripId, user.id); - return result ?? { feed_url: null }; + get(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) { + return this.feeds.getTripToken(tripId, user.id, resolveFeedBase(req)); } @Post('token') - generate(@CurrentUser() user: User, @Param('tripId') tripId: string) { - const row = db - .prepare('SELECT id FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))') - .get(tripId, user.id, user.id); - if (!row) throw new HttpException({ error: 'Trip not found' }, 404); - return this.feeds.generateTripToken(tripId, user.id); + generate(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) { + this.assertAccess(tripId, user.id); + return this.feeds.generateTripToken(tripId, user.id, resolveFeedBase(req)); + } + + @Put('token') + rotate(@CurrentUser() user: User, @Param('tripId') tripId: string, @Req() req: Request) { + this.assertAccess(tripId, user.id); + return this.feeds.rotateTripToken(tripId, resolveFeedBase(req)); } @Delete('token') - regenerate(@CurrentUser() user: User, @Param('tripId') tripId: string) { - const row = db - .prepare('SELECT id FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))') - .get(tripId, user.id, user.id); - if (!row) throw new HttpException({ error: 'Trip not found' }, 404); - return this.feeds.regenerateTripToken(tripId, user.id); + disable(@CurrentUser() user: User, @Param('tripId') tripId: string) { + this.assertAccess(tripId, user.id); + this.feeds.disableTripToken(tripId); + return { feed_url: null }; } } +/** + * Authenticated token management for the all-trips (per-user) feed. + * POST = enable PUT = rotate DELETE = disable + */ @Controller('api/feed/user') @UseGuards(JwtAuthGuard) export class UserFeedTokenController { constructor(private readonly feeds: FeedsService) {} @Get('token') - get(@CurrentUser() user: User) { - return this.feeds.getUserToken(user.id) ?? { feed_url: null }; + get(@CurrentUser() user: User, @Req() req: Request) { + return this.feeds.getUserToken(user.id, resolveFeedBase(req)); } @Post('token') - generate(@CurrentUser() user: User) { - return this.feeds.generateUserToken(user.id); + generate(@CurrentUser() user: User, @Req() req: Request) { + return this.feeds.generateUserToken(user.id, resolveFeedBase(req)); + } + + @Put('token') + rotate(@CurrentUser() user: User, @Req() req: Request) { + return this.feeds.rotateUserToken(user.id, resolveFeedBase(req)); } @Delete('token') - regenerate(@CurrentUser() user: User) { - return this.feeds.regenerateUserToken(user.id); + disable(@CurrentUser() user: User) { + this.feeds.disableUserToken(user.id); + return { feed_url: null }; } } diff --git a/server/src/nest/feeds/feeds.service.ts b/server/src/nest/feeds/feeds.service.ts index 51f31d0b..e10b94be 100644 --- a/server/src/nest/feeds/feeds.service.ts +++ b/server/src/nest/feeds/feeds.service.ts @@ -9,63 +9,73 @@ const ninetyDaysAgo = () => { return d.toISOString().slice(0, 10); }; -function feedUrl(token: string, scope: 'trip' | 'user'): string { - const base = (process.env.APP_URL || '').replace(/\/$/, ''); - return `${base}/api/feed/${scope}/${token}.ics`; +function feedUrl(token: string, scope: 'trip' | 'user', base: string): string { + return `${base.replace(/\/$/, '')}/api/feed/${scope}/${token}.ics`; } @Injectable() export class FeedsService { // ── Trip feed token ───────────────────────────────────────────────────── - getTripToken(tripId: string, userId: number): { feed_url: string } | null { - const row = db - .prepare('SELECT feed_token FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))') + private tripTokenRow(tripId: string, userId: number) { + return db + .prepare( + 'SELECT feed_token FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))', + ) .get(tripId, userId, userId) as { feed_token: string | null } | undefined; - if (!row || !row.feed_token) return null; - return { feed_url: feedUrl(row.feed_token, 'trip') }; } - generateTripToken(tripId: string, userId: number): { feed_url: string } { - const existing = this.getTripToken(tripId, userId); - if (existing) return existing; - const token = randomUUID(); - db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId); - return { feed_url: feedUrl(token, 'trip') }; + getTripToken(tripId: string, userId: number, base: string): { feed_url: string | null } { + const row = this.tripTokenRow(tripId, userId); + return { feed_url: row?.feed_token ? feedUrl(row.feed_token, 'trip', base) : null }; } - regenerateTripToken(tripId: string, userId: number): { feed_url: string } { - const trip = db - .prepare('SELECT id FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))') - .get(tripId, userId, userId); - if (!trip) return { feed_url: '' }; + /** Enable (idempotent): mint a token only if the trip has none yet. */ + generateTripToken(tripId: string, userId: number, base: string): { feed_url: string } { + const row = this.tripTokenRow(tripId, userId); + if (row?.feed_token) return { feed_url: feedUrl(row.feed_token, 'trip', base) }; const token = randomUUID(); db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId); - return { feed_url: feedUrl(token, 'trip') }; + return { feed_url: feedUrl(token, 'trip', base) }; + } + + /** Rotate: always issue a fresh token, invalidating the previous URL. */ + rotateTripToken(tripId: string, base: string): { feed_url: string } { + const token = randomUUID(); + db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId); + return { feed_url: feedUrl(token, 'trip', base) }; + } + + /** Disable: clear the token so the public URL stops resolving. */ + disableTripToken(tripId: string): void { + db.prepare('UPDATE trips SET feed_token = NULL WHERE id = ?').run(tripId); } // ── User (all-trips) feed token ────────────────────────────────────────── - getUserToken(userId: number): { feed_url: string } | null { + getUserToken(userId: number, base: string): { feed_url: string | null } { const row = db.prepare('SELECT feed_token FROM users WHERE id = ?').get(userId) as | { feed_token: string | null } | undefined; - if (!row || !row.feed_token) return null; - return { feed_url: feedUrl(row.feed_token, 'user') }; + return { feed_url: row?.feed_token ? feedUrl(row.feed_token, 'user', base) : null }; } - generateUserToken(userId: number): { feed_url: string } { - const existing = this.getUserToken(userId); - if (existing) return existing; + generateUserToken(userId: number, base: string): { feed_url: string } { + const existing = this.getUserToken(userId, base); + if (existing.feed_url) return { feed_url: existing.feed_url }; const token = randomUUID(); db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId); - return { feed_url: feedUrl(token, 'user') }; + return { feed_url: feedUrl(token, 'user', base) }; } - regenerateUserToken(userId: number): { feed_url: string } { + rotateUserToken(userId: number, base: string): { feed_url: string } { const token = randomUUID(); db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId); - return { feed_url: feedUrl(token, 'user') }; + return { feed_url: feedUrl(token, 'user', base) }; + } + + disableUserToken(userId: number): void { + db.prepare('UPDATE users SET feed_token = NULL WHERE id = ?').run(userId); } // ── ICS generation ─────────────────────────────────────────────────────── @@ -110,23 +120,39 @@ export class FeedsService { const esc = (s: string) => s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n'); + const calName = `${user.username} – All Trips`; let combined = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n'; - combined += `X-WR-CALNAME:${esc(user.username + ' – All Trips')}\r\n`; + combined += `X-WR-CALNAME:${esc(calName)}\r\n`; combined += 'REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n'; for (const { id } of trips) { try { const { ics } = exportICS(id); - // Strip outer VCALENDAR wrapper and extract VEVENT blocks - const events = [...ics.matchAll(/BEGIN:VEVENT[\s\S]*?END:VEVENT/g)].map((m) => m[0]); - for (const ev of events) combined += ev + '\r\n'; + combined += extractVEvents(ics); } catch { // skip failed trips } } combined += 'END:VCALENDAR\r\n'; - return { ics: combined, calName: user.username + ' – All Trips' }; + return { ics: combined, calName }; } } + +// Pull the VEVENT blocks out of a single-trip calendar by structural line +// scanning rather than a lazy regex on "END:VEVENT". User-supplied text (escaped +// onto a SUMMARY/DESCRIPTION line) can legitimately contain the literal +// "END:VEVENT", which a non-greedy regex would mistake for a terminator and +// truncate the event. Folded continuation lines always begin with a space, so a +// bare "BEGIN:VEVENT"/"END:VEVENT" only ever appears as a real delimiter. +function extractVEvents(ics: string): string { + let out = ''; + let inside = false; + for (const line of ics.split('\r\n')) { + if (line === 'BEGIN:VEVENT') inside = true; + if (inside) out += line + '\r\n'; + if (line === 'END:VEVENT') inside = false; + } + return out; +} diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index 3267ff7c..994cd409 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -405,6 +405,31 @@ export function removeMember(tripId: string | number, targetUserId: number) { // ── ICS export ──────────────────────────────────────────────────────────── +// RFC 5545 §3.1: content lines longer than 75 octets must be folded with a CRLF +// followed by a single leading space. We fold on UTF-8 *octet* boundaries and +// never split a multi-byte codepoint, so non-ASCII titles/notes (accents, CJK, +// emoji) stay intact. Applied to the whole calendar, so both the one-time +// download and the subscribable feed emit spec-compliant output. +function foldICS(ics: string): string { + const foldLine = (line: string): string => { + const bytes = Buffer.from(line, 'utf8'); + if (bytes.length <= 75) return line; + const parts: Buffer[] = []; + let start = 0; + let limit = 75; // first physical line may use 75 octets + while (start < bytes.length) { + let end = Math.min(start + limit, bytes.length); + // Back off so we never cut a multi-byte UTF-8 sequence (0x80–0xBF = continuation byte). + while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--; + parts.push(bytes.subarray(start, end)); + start = end; + limit = 74; // continuation lines spend one octet on the leading space + } + return parts.map((b, i) => (i === 0 ? '' : ' ') + b.toString('utf8')).join('\r\n'); + }; + return ics.split('\r\n').map(foldLine).join('\r\n'); +} + export function exportICS(tripId: string | number): { ics: string; filename: string } { const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any; if (!trip) throw new NotFoundError('Trip not found'); @@ -599,7 +624,7 @@ export function exportICS(tripId: string | number): { ics: string; filename: str ics += 'END:VCALENDAR\r\n'; const safeFilename = (trip.title || 'trek-trip').replace(/["\r\n]/g, '').replace(/[^\w\s.-]/g, '_'); - return { ics, filename: `${safeFilename}.ics` }; + return { ics: foldICS(ics), filename: `${safeFilename}.ics` }; } // ── Copy / duplicate ───────────────────────────────────────────────────── diff --git a/server/tests/e2e/feeds.e2e.test.ts b/server/tests/e2e/feeds.e2e.test.ts index 008eb3c0..b28ba7bc 100644 --- a/server/tests/e2e/feeds.e2e.test.ts +++ b/server/tests/e2e/feeds.e2e.test.ts @@ -2,7 +2,8 @@ * Calendar-feed e2e — exercises the subscribable ICS feeds end-to-end against a * temp in-memory SQLite db through the REAL JwtAuthGuard: * - JWT-guarded token endpoints (/api/trips/:id/feed/token, /api/feed/user/token): - * lazy generate, idempotency, regenerate-invalidates-old, 404 on no access, 401 no cookie + * lazy generate, idempotency, rotate-invalidates-old, disable-clears-token, + * host fallback when APP_URL is unset, 404 on no access, 401 no cookie * - public unguarded feeds (/api/feed/trip/:token.ics, /api/feed/user/:token.ics): * valid token → 200 text/calendar with the injected REFRESH-INTERVAL / X-PUBLISHED-TTL * hints, unknown token → 404, all-trips feed excludes archived + >90-day-old trips @@ -106,18 +107,42 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { expect(second.body.feed_url).toBe(first.body.feed_url); // same token, not a new one }); - it('DELETE regenerates: a new token works and the old one 404s', async () => { + it('PUT rotates: a new token works and the old one 404s', async () => { const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1)); const oldToken = gen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1]; - const regen = await request(server).delete('/api/trips/5/feed/token').set('Cookie', sessionCookie(1)); - const newToken = regen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1]; + const rot = await request(server).put('/api/trips/5/feed/token').set('Cookie', sessionCookie(1)); + const newToken = rot.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1]; expect(newToken).not.toBe(oldToken); expect((await request(server).get(`/api/feed/trip/${oldToken}.ics`)).status).toBe(404); expect((await request(server).get(`/api/feed/trip/${newToken}.ics`)).status).toBe(200); }); + it('DELETE disables: the token is cleared, the URL 404s, and GET reports null', async () => { + const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1)); + const token = gen.body.feed_url.match(/trip\/([0-9a-f-]+)\.ics$/)![1]; + expect((await request(server).get(`/api/feed/trip/${token}.ics`)).status).toBe(200); + + const del = await request(server).delete('/api/trips/5/feed/token').set('Cookie', sessionCookie(1)); + expect(del.status).toBe(200); + expect(del.body).toEqual({ feed_url: null }); + + expect((await request(server).get(`/api/feed/trip/${token}.ics`)).status).toBe(404); + const after = await request(server).get('/api/trips/5/feed/token').set('Cookie', sessionCookie(1)); + expect(after.body).toEqual({ feed_url: null }); + }); + + it('feed URL falls back to the request host when APP_URL is unset', async () => { + delete process.env.APP_URL; + try { + const gen = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1)); + expect(gen.body.feed_url).toMatch(/^https?:\/\/[^/]+\/api\/feed\/trip\/[0-9a-f-]+\.ics$/); + } finally { + process.env.APP_URL = BASE; + } + }); + it('404 when generating for a trip the user cannot access', async () => { const res = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(2)); expect(res.status).toBe(404);