diff --git a/client/src/components/Planner/DayPlanSidebarToolbar.tsx b/client/src/components/Planner/DayPlanSidebarToolbar.tsx index 1412ec322..3dedad110 100644 --- a/client/src/components/Planner/DayPlanSidebarToolbar.tsx +++ b/client/src/components/Planner/DayPlanSidebarToolbar.tsx @@ -1,9 +1,10 @@ -import { useState } from 'react' -import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown } from 'lucide-react' +import React, { useState, useRef } from 'react' +import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown, CalendarPlus } from 'lucide-react' import { downloadTripPDF } from '../PDF/TripPDF' import { DayReorderPopup } from './DayReorderPopup' import Tooltip from '../shared/Tooltip' import { useToast } from '../shared/Toast' +import { IcsSubscribeModal } from './IcsSubscribeModal' import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types' interface DayPlanSidebarToolbarProps { @@ -36,11 +37,35 @@ interface DayPlanSidebarToolbarProps { export function DayPlanSidebarToolbar({ tripId, trip, days, places, categories, assignments, reservations, dayNotes, - t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover, + t, locale, toast, pdfHover, setPdfHover, setIcsHover, expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel, canEditDays, onReorderDays, onAddDay, }: DayPlanSidebarToolbarProps) { const [reorderOpen, setReorderOpen] = useState(false) + const [subscribeOpen, setSubscribeOpen] = useState(false) + const icsMenuTimeoutRef = useRef | null>(null) + const [icsMenuVisible, setIcsMenuVisible] = useState(false) + + const showIcsMenu = () => { + if (icsMenuTimeoutRef.current) clearTimeout(icsMenuTimeoutRef.current) + setIcsMenuVisible(true) + setIcsHover(true) + } + const hideIcsMenu = () => { + icsMenuTimeoutRef.current = setTimeout(() => { + setIcsMenuVisible(false) + setIcsHover(false) + }, 120) + } + + const menuItemStyle: React.CSSProperties = { + display: 'flex', alignItems: 'center', gap: 7, + width: '100%', padding: '7px 12px', border: 'none', + background: 'transparent', cursor: 'pointer', + fontSize: 11, fontWeight: 500, fontFamily: 'inherit', + color: 'var(--text-primary)', textAlign: 'left', + transition: 'background 0.1s', + } return (
@@ -83,25 +108,12 @@ export function DayPlanSidebarToolbar({
)}
-
+
- {icsHover && ( -
- {t('dayplan.icsTooltip')} + {icsMenuVisible && ( +
+ +
)}
+ {subscribeOpen && ( + setSubscribeOpen(false)} /> + )} {(() => { const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id)) const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll') diff --git a/client/src/components/Planner/IcsSubscribeModal.tsx b/client/src/components/Planner/IcsSubscribeModal.tsx new file mode 100644 index 000000000..b80ad066f --- /dev/null +++ b/client/src/components/Planner/IcsSubscribeModal.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { X, RefreshCw, Calendar } from 'lucide-react' +import { SubscribeLinks } from './SubscribeLinks' + +interface IcsSubscribeModalProps { + tripId: number + onClose: () => void +} + +export function IcsSubscribeModal({ tripId, onClose }: IcsSubscribeModalProps) { + 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 { + // 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) { + 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]) + + useEffect(() => { loadToken() }, [loadToken]) + + const regenerate = async () => { + setRegenerating(true) + try { + const res = await fetch(`/api/trips/${tripId}/feed/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() }} + > +
+ {/* Header */} +
+
+ + Subscribe to Calendar +
+ +
+ +

+ This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour. +

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

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

+
+ + )} +
+ + +
, + document.body + ) +} diff --git a/client/src/components/Planner/SubscribeLinks.tsx b/client/src/components/Planner/SubscribeLinks.tsx new file mode 100644 index 000000000..bcfc109d3 --- /dev/null +++ b/client/src/components/Planner/SubscribeLinks.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react' +import { Copy, Check, CalendarPlus, Calendar } from 'lucide-react' + +interface SubscribeLinksProps { + httpsUrl: string + webcalUrl: string +} + +/** + * Shared presentation for calendar subscription URLs. Renders one-click + * subscribe actions (Google deep link + webcal handoff) plus a copy fallback. + * Used by both the per-trip and all-trips subscribe modals. + */ +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}` + + const copy = async (url: string, which: 'https' | 'webcal') => { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(url) + } else { + // Fallback for non-secure contexts (plain HTTP) where navigator.clipboard is unavailable + const ta = document.createElement('textarea') + ta.value = url + ta.style.position = 'fixed' + ta.style.left = '-9999px' + document.body.appendChild(ta) + ta.focus() + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) + } + setCopied(which) + setTimeout(() => setCopied(null), 2000) + } catch { /* ignore */ } + } + + return ( +
+ {/* One-click subscribe actions */} + + + {/* Manual fallback — raw URLs for any other client / "From URL" boxes */} +
+ + Or copy a link manually + + copy(httpsUrl, 'https')} + /> + copy(webcalUrl, 'webcal')} + /> +
+
+ ) +} + +function UrlRow({ label, hint, url, copied, onCopy }: { + label: string; hint: string; url: string; copied: boolean; onCopy: () => void +}) { + return ( +
+
+ {label} — {hint} +
+
+
+ {url} +
+ +
+
+ ) +} diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index d93a4727d..2beabb456 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -1,4 +1,5 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useState, useCallback } from 'react' +import { createPortal } from 'react-dom' import { useTranslation } from '../i18n' import Navbar from '../components/Layout/Navbar' import DemoBanner from '../components/Layout/DemoBanner' @@ -16,8 +17,9 @@ import { import { Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin, Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar, - LayoutGrid, List, Ticket, X, + LayoutGrid, List, Ticket, X, CalendarPlus, } from 'lucide-react' +import { SubscribeLinks } from '../components/Planner/SubscribeLinks' import { formatTime, splitReservationDateTime } from '../utils/formatters' import { convertDistance, getDistanceUnitLabel } from '../utils/units' import { useSettingsStore } from '../store/settingsStore' @@ -91,6 +93,7 @@ export default function DashboardPage(): React.ReactElement { showForm, setShowForm, editingTrip, setEditingTrip, deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips, handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy, + allSubOpen, setAllSubOpen, } = useDashboard() // Per-device dashboard widget visibility (from the appearance config). @@ -149,11 +152,21 @@ export default function DashboardPage(): React.ReactElement {
+
+ {allSubOpen && setAllSubOpen(false)} />} {gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
@@ -742,3 +755,101 @@ 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/client/src/pages/dashboard/useDashboard.ts b/client/src/pages/dashboard/useDashboard.ts index 652bab1fc..c0223b59a 100644 --- a/client/src/pages/dashboard/useDashboard.ts +++ b/client/src/pages/dashboard/useDashboard.ts @@ -33,6 +33,7 @@ export function useDashboard() { const [deleteTrip, setDeleteTrip] = useState(null) const [copyTrip, setCopyTrip] = useState(null) const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned') + const [allSubOpen, setAllSubOpen] = useState(false) const [loadError, setLoadError] = useState(false) const [stats, setStats] = useState(null) @@ -192,6 +193,7 @@ export function useDashboard() { tripFilter, setTripFilter, viewMode, toggleViewMode, showForm, setShowForm, editingTrip, setEditingTrip, deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips, + allSubOpen, setAllSubOpen, // actions handleCreate, handleUpdate, confirmDelete, handleArchive, handleUnarchive, confirmCopy, } diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index a917a38ce..8cbfdd2c8 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3078,6 +3078,21 @@ function runMigrations(db: Database.Database): void { if (!err.message?.includes('duplicate column name')) throw err; } }, + // Calendar feed tokens — subscribable ICS links for per-trip and all-trips feeds + () => { + try { + db.exec('ALTER TABLE trips ADD COLUMN feed_token TEXT'); + } catch (err: any) { + if (!err.message?.includes('duplicate column name')) throw err; + } + try { + db.exec('ALTER TABLE users ADD COLUMN feed_token TEXT'); + } catch (err: any) { + if (!err.message?.includes('duplicate column name')) throw err; + } + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trips_feed_token ON trips(feed_token) WHERE feed_token IS NOT NULL'); + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_feed_token ON users(feed_token) WHERE feed_token IS NOT NULL'); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 284322ce5..be34ae24f 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -29,6 +29,7 @@ import { MemoriesModule } from './memories/memories.module'; import { AirtrailModule } from './integrations/airtrail.module'; import { JourneyModule } from './journey/journey.module'; import { ShareModule } from './share/share.module'; +import { FeedsModule } from './feeds/feeds.module'; import { SettingsModule } from './settings/settings.module'; import { BackupModule } from './backup/backup.module'; import { BookingImportModule } from './booking-import/booking-import.module'; @@ -47,7 +48,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * migrated. */ @Module({ - imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule], + imports: [DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule], controllers: [HealthController], providers: [ HealthService, diff --git a/server/src/nest/feeds/feeds.controller.ts b/server/src/nest/feeds/feeds.controller.ts new file mode 100644 index 000000000..2708fae51 --- /dev/null +++ b/server/src/nest/feeds/feeds.controller.ts @@ -0,0 +1,107 @@ +import { + Controller, + Delete, + Get, + HttpException, + Param, + Post, + Res, + UseGuards, +} from '@nestjs/common'; +import type { 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'; + +/** + * Public subscribable ICS feed endpoints — no auth required. + * The secret token in the URL acts as the access credential. + */ +@Controller('api/feed') +export class FeedsPublicController { + constructor(private readonly feeds: FeedsService) {} + + @Get('trip/:token.ics') + tripFeed(@Param('token') token: string, @Res() res: Response): void { + const result = this.feeds.buildTripIcs(token); + if (!result) { + res.status(404).json({ error: 'Feed not found' }); + return; + } + res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); + res.setHeader('Content-Disposition', `inline; filename="${result.filename}"`); + res.setHeader('Cache-Control', 'no-cache, no-store'); + res.setHeader('X-Published-TTL', 'PT1H'); + res.send(result.ics); + } + + @Get('user/:token.ics') + userFeed(@Param('token') token: string, @Res() res: Response): void { + const result = this.feeds.buildUserIcs(token); + if (!result) { + res.status(404).json({ error: 'Feed not found' }); + return; + } + res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); + res.setHeader('Content-Disposition', `inline; filename="all-trips.ics"`); + res.setHeader('Cache-Control', 'no-cache, no-store'); + res.setHeader('X-Published-TTL', 'PT1H'); + res.send(result.ics); + } +} + +/** + * Authenticated token management — generate / regenerate feed tokens. + */ +@Controller('api/trips/:tripId/feed') +@UseGuards(JwtAuthGuard) +export class TripFeedTokenController { + constructor(private readonly feeds: FeedsService) {} + + @Get('token') + get(@CurrentUser() user: User, @Param('tripId') tripId: string) { + const result = this.feeds.getTripToken(tripId, user.id); + return result ?? { feed_url: null }; + } + + @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); + } + + @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); + } +} + +@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 }; + } + + @Post('token') + generate(@CurrentUser() user: User) { + return this.feeds.generateUserToken(user.id); + } + + @Delete('token') + regenerate(@CurrentUser() user: User) { + return this.feeds.regenerateUserToken(user.id); + } +} diff --git a/server/src/nest/feeds/feeds.module.ts b/server/src/nest/feeds/feeds.module.ts new file mode 100644 index 000000000..3934f7fe8 --- /dev/null +++ b/server/src/nest/feeds/feeds.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { FeedsService } from './feeds.service'; +import { FeedsPublicController, TripFeedTokenController, UserFeedTokenController } from './feeds.controller'; + +@Module({ + controllers: [FeedsPublicController, TripFeedTokenController, UserFeedTokenController], + providers: [FeedsService], +}) +export class FeedsModule {} diff --git a/server/src/nest/feeds/feeds.service.ts b/server/src/nest/feeds/feeds.service.ts new file mode 100644 index 000000000..51f31d0b7 --- /dev/null +++ b/server/src/nest/feeds/feeds.service.ts @@ -0,0 +1,132 @@ +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { db } from '../../db/database'; +import { exportICS } from '../../services/tripService'; + +const ninetyDaysAgo = () => { + const d = new Date(); + d.setDate(d.getDate() - 90); + 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`; +} + +@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 = ?))') + .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') }; + } + + 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: '' }; + const token = randomUUID(); + db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId); + return { feed_url: feedUrl(token, 'trip') }; + } + + // ── User (all-trips) feed token ────────────────────────────────────────── + + getUserToken(userId: number): { 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') }; + } + + generateUserToken(userId: number): { feed_url: string } { + const existing = this.getUserToken(userId); + if (existing) return existing; + const token = randomUUID(); + db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId); + return { feed_url: feedUrl(token, 'user') }; + } + + regenerateUserToken(userId: number): { feed_url: string } { + const token = randomUUID(); + db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId); + return { feed_url: feedUrl(token, 'user') }; + } + + // ── ICS generation ─────────────────────────────────────────────────────── + + buildTripIcs(token: string): { ics: string; filename: string } | null { + const row = db.prepare('SELECT id FROM trips WHERE feed_token = ?').get(token) as + | { id: number } + | undefined; + if (!row) return null; + try { + const { ics, filename } = exportICS(row.id); + // Inject calendar-subscription refresh hints into the VCALENDAR header so + // clients re-fetch hourly. The one-time download path (exportICS) is left + // untouched; this is feed-only. + const withHints = ics.replace( + 'METHOD:PUBLISH\r\n', + 'METHOD:PUBLISH\r\nREFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n', + ); + return { ics: withHints, filename }; + } catch { + return null; + } + } + + buildUserIcs(token: string): { ics: string; calName: string } | null { + const user = db.prepare('SELECT id, username FROM users WHERE feed_token = ?').get(token) as + | { id: number; username: string } + | undefined; + if (!user) return null; + + const cutoff = ninetyDaysAgo(); + const trips = db + .prepare( + `SELECT id FROM trips + WHERE user_id = ? + AND is_archived = 0 + AND (end_date IS NULL OR end_date >= ?) + ORDER BY start_date ASC`, + ) + .all(user.id, cutoff) as { id: number }[]; + + const esc = (s: string) => + s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n'); + + 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 += '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'; + } catch { + // skip failed trips + } + } + + combined += 'END:VCALENDAR\r\n'; + return { ics: combined, calName: user.username + ' – All Trips' }; + } +} diff --git a/server/tests/e2e/feeds.e2e.test.ts b/server/tests/e2e/feeds.e2e.test.ts new file mode 100644 index 000000000..008eb3c04 --- /dev/null +++ b/server/tests/e2e/feeds.e2e.test.ts @@ -0,0 +1,178 @@ +/** + * 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 + * - 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 + * + * exportICS is mocked so the test owns the ICS payload and can assert which trips + * the all-trips feed pulled in without seeding the full trip/day/reservation schema. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; +import { seedUser, sessionCookie } from './harness'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', + password_version INTEGER NOT NULL DEFAULT 0, feed_token TEXT);`); + tmp.exec(`CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, + title TEXT, is_archived INTEGER NOT NULL DEFAULT 0, start_date TEXT, end_date TEXT, feed_token TEXT);`); + tmp.exec(`CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);`); + return { db: tmp }; +}); + +vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); + +// Own the ICS payload so we control the events and can assert which trips were pulled. +const SAMPLE_ICS = + 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\n' + + 'METHOD:PUBLISH\r\nX-WR-CALNAME:Sample\r\n' + + 'BEGIN:VEVENT\r\nUID:trek-trip-x@trek\r\nDTSTAMP:20260101T000000Z\r\n' + + 'DTSTART;VALUE=DATE:20260101\r\nDTEND;VALUE=DATE:20260102\r\nSUMMARY:Sample\r\nEND:VEVENT\r\n' + + 'END:VCALENDAR\r\n'; +const { exportICS } = vi.hoisted(() => ({ exportICS: vi.fn() })); +vi.mock('../../src/services/tripService', () => ({ exportICS })); + +import { FeedsModule } from '../../src/nest/feeds/feeds.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; + +const BASE = 'https://trek.example.test'; + +describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + let prevAppUrl: string | undefined; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [FeedsModule] }).compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + prevAppUrl = process.env.APP_URL; + process.env.APP_URL = BASE; + seedUser(db as never, { id: 1, username: 'e2e-user' }); + seedUser(db as never, { id: 2, username: 'other-user', email: 'other@example.test' }); + app = await build(); + server = app.getHttpServer(); + }); + + beforeEach(() => { + exportICS.mockReset(); + exportICS.mockReturnValue({ ics: SAMPLE_ICS, filename: 'sample.ics' }); + // Reset feed tokens + trips between tests for isolation. + db.exec('DELETE FROM trips; DELETE FROM trip_members; UPDATE users SET feed_token = NULL;'); + db.prepare("INSERT INTO trips (id, user_id, title, is_archived, start_date, end_date) VALUES (5, 1, 'Owned', 0, '2026-01-01', '2099-01-01')").run(); + }); + + afterAll(async () => { + if (prevAppUrl === undefined) delete process.env.APP_URL; + else process.env.APP_URL = prevAppUrl; + await app.close(); + }); + + // ── Trip token endpoints ─────────────────────────────────────────────────── + + it('401 without a session cookie', async () => { + expect((await request(server).get('/api/trips/5/feed/token')).status).toBe(401); + }); + + it('GET token returns {feed_url:null} before one is generated', async () => { + const res = await request(server).get('/api/trips/5/feed/token').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ feed_url: null }); + }); + + it('POST generates a token lazily and is idempotent', async () => { + const first = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1)); + expect(first.status).toBe(201); + expect(first.body.feed_url).toMatch(new RegExp(`^${BASE}/api/feed/trip/[0-9a-f-]+\\.ics$`)); + + const second = await request(server).post('/api/trips/5/feed/token').set('Cookie', sessionCookie(1)); + 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 () => { + 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]; + 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('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); + expect(res.body).toEqual({ error: 'Trip not found' }); + }); + + // ── Public trip feed ─────────────────────────────────────────────────────── + + it('public trip feed: 200 text/calendar with injected refresh hints', 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]; + + const res = await request(server).get(`/api/feed/trip/${token}.ics`); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/calendar'); + expect(res.text).toContain('REFRESH-INTERVAL;VALUE=DURATION:PT1H'); + expect(res.text).toContain('X-PUBLISHED-TTL:PT1H'); + expect(res.text).toContain('BEGIN:VEVENT'); + expect(exportICS).toHaveBeenCalledWith(5); + }); + + it('public trip feed: 404 for an unknown token', async () => { + const res = await request(server).get('/api/feed/trip/00000000-0000-0000-0000-000000000000.ics'); + expect(res.status).toBe(404); + }); + + // ── User (all-trips) feed ────────────────────────────────────────────────── + + it('user feed token: 401 without a cookie, generates with one', async () => { + expect((await request(server).get('/api/feed/user/token')).status).toBe(401); + const gen = await request(server).post('/api/feed/user/token').set('Cookie', sessionCookie(1)); + expect(gen.status).toBe(201); + expect(gen.body.feed_url).toMatch(new RegExp(`^${BASE}/api/feed/user/[0-9a-f-]+\\.ics$`)); + }); + + it('all-trips feed excludes archived and >90-day-old trips', async () => { + // user 1 already owns active trip 5 (end 2099). Add an archived + a long-finished one. + db.prepare("INSERT INTO trips (id, user_id, title, is_archived, start_date, end_date) VALUES (6, 1, 'Archived', 1, '2026-01-01', '2099-01-01')").run(); + db.prepare("INSERT INTO trips (id, user_id, title, is_archived, start_date, end_date) VALUES (7, 1, 'Old', 0, '2000-01-01', '2000-01-10')").run(); + + const gen = await request(server).post('/api/feed/user/token').set('Cookie', sessionCookie(1)); + const token = gen.body.feed_url.match(/user\/([0-9a-f-]+)\.ics$/)![1]; + + const res = await request(server).get(`/api/feed/user/${token}.ics`); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('text/calendar'); + expect(res.text).toContain('REFRESH-INTERVAL;VALUE=DURATION:PT1H'); + expect(res.text).toContain('X-WR-CALNAME:e2e-user'); + + const calledIds = exportICS.mock.calls.map((c) => c[0]).sort(); + expect(calledIds).toEqual([5]); // only the active, recent trip — not 6 (archived) or 7 (old) + }); + + it('public user feed: 404 for an unknown token', async () => { + const res = await request(server).get('/api/feed/user/00000000-0000-0000-0000-000000000000.ics'); + expect(res.status).toBe(404); + }); +}); diff --git a/wiki/Environment-Variables.md b/wiki/Environment-Variables.md index 5c933fc09..2f04d1c2e 100644 --- a/wiki/Environment-Variables.md +++ b/wiki/Environment-Variables.md @@ -27,7 +27,7 @@ Complete reference for all environment variables TREK reads. | `SESSION_DURATION_REMEMBER` | Session length used when the user **ticks "Remember me"** on login: a longer-lived JWT `exp` claim plus a **persistent** `trek_session` cookie whose `maxAge` matches, so the session survives browser restarts. Same `ms`-style format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` | | `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email notification links | same-origin | | `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs. Set `true` if Immich or other integrated services are on your local network. Loopback (`127.x`) and link-local (`169.254.x`) addresses remain blocked regardless. | `false` | -| `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links. | — | +| `APP_URL` | Public base URL (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for email notification links and subscribable calendar feed URLs (the `webcal://`/`https://` links the Subscribe dialog hands to Google/Apple/Outlook). | — | ### `HOST` — Source and Proxmox installs only