harden calendar feeds: absolute URLs, real disable, folding, schema sync

- Resolve feed URLs against the request host when APP_URL is unset, so the
  webcal:// / Add-to-Google links work on a default install (not just behind a
  configured reverse proxy).
- Give the public link a real off switch: POST enables, PUT rotates, DELETE
  clears the token (feed_token = NULL). The subscribe dialog no longer mints a
  token just from being opened — the user opts in explicitly.
- Fold ICS content lines at 75 octets (UTF-8 safe) in exportICS, so download
  and feed both stay RFC 5545-compliant for long/non-ASCII summaries.
- Extract VEVENTs by structural line scan instead of a lazy END:VEVENT regex
  that user text could truncate.
- URL-encode the Google Calendar cid; mirror feed_token into schema.ts.
- Collapse the duplicated all-trips modal into the shared IcsSubscribeModal.
This commit is contained in:
Maurice
2026-06-29 21:42:24 +02:00
committed by Maurice
parent 7173e82fe8
commit 23987c76bb
10 changed files with 286 additions and 221 deletions
@@ -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(<DayPlanSidebar {...makeDefaultProps()} />)
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(<DayPlanSidebar {...makeDefaultProps()} />)
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()
})
})
@@ -178,7 +178,12 @@ export function DayPlanSidebarToolbar({
)}
</div>
{subscribeOpen && (
<IcsSubscribeModal tripId={tripId} onClose={() => setSubscribeOpen(false)} />
<IcsSubscribeModal
endpoint={`/api/trips/${tripId}/feed`}
title="Subscribe to calendar"
description="This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour."
onClose={() => setSubscribeOpen(false)}
/>
)}
{(() => {
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
@@ -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<string | null>(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) {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Calendar size={16} strokeWidth={2} style={{ color: 'var(--accent, #6366f1)' }} />
<span style={{ fontWeight: 600, fontSize: 14 }}>Subscribe to Calendar</span>
<span style={{ fontWeight: 600, fontSize: 14 }}>{title}</span>
</div>
<button
onClick={onClose}
@@ -97,41 +101,72 @@ export function IcsSubscribeModal({ tripId, onClose }: IcsSubscribeModalProps) {
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16, lineHeight: 1.5 }}>
This link stays in sync with your trip automatically. Calendar apps re-fetch it every hour.
{description}
</p>
{loading ? (
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>
Generating link
Loading
</div>
) : !feedUrl ? (
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>
Could not generate feed link.
</div>
<>
<button
onClick={() => mutate('POST')}
disabled={busy}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
width: '100%', padding: '9px 14px', borderRadius: 9, border: 'none',
background: 'var(--accent, #6366f1)', color: 'var(--accent-text, #fff)',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit',
cursor: busy ? 'default' : 'pointer', opacity: busy ? 0.6 : 1,
}}
>
<Calendar size={14} strokeWidth={2} />
Enable calendar subscription
</button>
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 8, lineHeight: 1.4 }}>
Creates a secret link anyone with it can read without logging in. You can turn it off anytime.
</p>
</>
) : (
<>
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)' }}>
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 8 }}>
<button
onClick={regenerate}
disabled={regenerating}
onClick={() => mutate('PUT')}
disabled={busy}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: '1px solid var(--border-primary)',
borderRadius: 7, padding: '5px 10px',
fontSize: 11, color: 'var(--text-muted)',
cursor: regenerating ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: regenerating ? 0.6 : 1,
cursor: busy ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: busy ? 0.6 : 1,
}}
>
<RefreshCw size={11} strokeWidth={2} style={{ animation: regenerating ? 'spin 0.8s linear infinite' : 'none' }} />
Regenerate link
<RefreshCw size={11} strokeWidth={2} style={{ animation: busy ? 'spin 0.8s linear infinite' : 'none' }} />
Regenerate
</button>
<button
onClick={() => mutate('DELETE')}
disabled={busy}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: '1px solid var(--border-primary)',
borderRadius: 7, padding: '5px 10px',
fontSize: 11, color: 'var(--danger, #dc2626)',
cursor: busy ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: busy ? 0.6 : 1,
}}
>
<Power size={11} strokeWidth={2} />
Turn off
</button>
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.4 }}>
Regenerating creates a new link and invalidates the old one.
</p>
</div>
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.4 }}>
Regenerating creates a new link and invalidates the old one. Turning off disables the link entirely.
</p>
</>
)}
</div>
@@ -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 {
+10 -102
View File
@@ -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 {
</button>
</div>
</div>
{allSubOpen && <AllTripsSubscribeModal onClose={() => setAllSubOpen(false)} />}
{allSubOpen && (
<IcsSubscribeModal
endpoint="/api/feed/user"
title="Subscribe to all trips"
description="One calendar feed for all your active trips, kept in sync automatically. Excludes archived trips and trips that ended more than 90 days ago."
onClose={() => setAllSubOpen(false)}
/>
)}
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
<div className="trips-empty">
@@ -755,101 +761,3 @@ function UpcomingTool({ items, locale, onOpen }: {
</div>
)
}
// ── All-trips subscribe modal ────────────────────────────────────────────────
function AllTripsSubscribeModal({ onClose }: { onClose: () => void }) {
const [feedUrl, setFeedUrl] = useState<string | null>(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(
<div
style={{
position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '16px',
}}
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div style={{
background: 'var(--bg-card, white)',
borderRadius: 14, padding: '22px 24px',
width: '100%', maxWidth: 420,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)',
border: '1px solid var(--border-faint)',
color: 'var(--text-primary)', fontFamily: 'inherit',
position: 'relative',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CalendarPlus size={16} strokeWidth={2} style={{ color: 'var(--accent, #6366f1)' }} />
<span style={{ fontWeight: 600, fontSize: 14 }}>Subscribe to All Trips</span>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-muted)', borderRadius: 6, display: 'flex' }}>
<X size={15} strokeWidth={2} />
</button>
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: 16, lineHeight: 1.5 }}>
Subscribe to all your active trips in one calendar feed. Updates automatically. Excludes archived trips and trips that ended more than 90 days ago.
</p>
{loading ? (
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>Generating link</div>
) : !feedUrl ? (
<div style={{ textAlign: 'center', padding: '16px 0', color: 'var(--text-muted)', fontSize: 12 }}>Could not generate feed link.</div>
) : (
<>
<SubscribeLinks httpsUrl={httpsUrl} webcalUrl={webcalUrl} />
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border-faint)' }}>
<button onClick={regenerate} disabled={regenerating} style={{ display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 7, padding: '5px 10px', fontSize: 11, color: 'var(--text-muted)', cursor: regenerating ? 'default' : 'pointer', fontFamily: 'inherit', opacity: regenerating ? 0.6 : 1 }}>
<RefreshCw size={11} strokeWidth={2} style={{ animation: regenerating ? 'spin 0.8s linear infinite' : 'none' }} />
Regenerate link
</button>
<p style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 6, lineHeight: 1.4 }}>Regenerating creates a new link and invalidates the old one.</p>
</div>
</>
)}
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>,
document.body
)
}
+2
View File
@@ -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
);
+59 -23
View File
@@ -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 };
}
}
+60 -34
View File
@@ -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;
}
+26 -1
View File
@@ -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 (0x800xBF = 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 ─────────────────────────────────────────────────────
+29 -4
View File
@@ -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);