mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
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:
@@ -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
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user