{ 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.
- ) : (
- <>
-
-
-
-
- Regenerate 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);