This commit is contained in:
Marek Maslowski
2026-04-24 18:07:55 +02:00
parent 002ea91be8
commit 3a3267d998
10 changed files with 538 additions and 22 deletions
+16
View File
@@ -2130,6 +2130,22 @@ function runMigrations(db: Database.Database): void {
'ON journey_entries(journey_id, entry_date, sort_order)'
);
},
() => {
// Dedicated calendar subscription tokens for trips
db.exec(`
CREATE TABLE IF NOT EXISTS calendar_share_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
created_by INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id),
UNIQUE(trip_id)
)
`);
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_calendar_share_trip ON calendar_share_tokens(trip_id)');
},
];
if (currentVersion < migrations.length) {
+9
View File
@@ -202,6 +202,15 @@ function createTables(db: Database.Database): void {
UNIQUE(trip_id, user_id)
);
CREATE TABLE IF NOT EXISTS calendar_share_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_by INTEGER NOT NULL REFERENCES users(id),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id)
);
CREATE TABLE IF NOT EXISTS day_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
+12
View File
@@ -58,4 +58,16 @@ router.get('/shared/:token', (req: Request, res: Response) => {
res.json(data);
});
// Public calendar subscription payload (no auth required)
router.get('/shared/:token/calendar.ics', (req: Request, res: Response) => {
const { token } = req.params;
const exported = shareService.getSharedTripICS(token);
if (!exported) return res.status(404).json({ error: 'Invalid or expired link' });
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
// Inline lets calendar clients subscribe/fetch from URL instead of forced download.
res.setHeader('Content-Disposition', `inline; filename="${exported.filename}"`);
res.send(exported.ics);
});
export default router;
+54
View File
@@ -36,6 +36,7 @@ import { listItems as listTodoItems } from '../services/todoService';
import { listBudgetItems } from '../services/budgetService';
import { listReservations } from '../services/reservationService';
import { listFiles } from '../services/fileService';
import { createOrUpdateCalendarShareLink, getCalendarShareLink, deleteCalendarShareLink } from '../services/shareService';
const router = express.Router();
@@ -355,4 +356,57 @@ router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => {
}
});
// ── ICS calendar subscription link ───────────────────────────────────────
router.get('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.id, authReq.user.id)) {
return res.status(404).json({ error: 'Trip not found' });
}
const existing = getCalendarShareLink(req.params.id);
const token = existing?.token ?? null;
const host = req.get('host');
if (!host) return res.status(500).json({ error: 'Host header missing' });
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
? req.headers['x-forwarded-proto'].split(',')[0].trim()
: null;
const protocol = forwardedProto || req.protocol;
const url = token ? `${protocol}://${host}/api/shared/${encodeURIComponent(token)}/calendar.ics` : null;
const webcal_url = url ? url.replace(/^https?:\/\//, 'webcal://') : null;
res.json({ url, webcal_url, token, created: false });
});
router.post('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!canAccessTrip(req.params.id, authReq.user.id)) {
return res.status(404).json({ error: 'Trip not found' });
}
const result = createOrUpdateCalendarShareLink(req.params.id, authReq.user.id);
const host = req.get('host');
if (!host) return res.status(500).json({ error: 'Host header missing' });
const forwardedProto = typeof req.headers['x-forwarded-proto'] === 'string'
? req.headers['x-forwarded-proto'].split(',')[0].trim()
: null;
const protocol = forwardedProto || req.protocol;
const url = `${protocol}://${host}/api/shared/${encodeURIComponent(result.token)}/calendar.ics`;
const webcal_url = url.replace(/^https?:\/\//, 'webcal://');
res.status(result.created ? 201 : 200).json({ url, webcal_url, token: result.token, created: result.created });
});
router.delete('/:id/subscribe.ics', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const access = canAccessTrip(req.params.id, authReq.user.id);
if (!access) return res.status(404).json({ error: 'Trip not found' });
deleteCalendarShareLink(req.params.id);
res.json({ success: true });
});
export default router;
+57
View File
@@ -1,6 +1,7 @@
import { db, canAccessTrip } from '../db/database';
import crypto from 'crypto';
import { loadTagsByPlaceIds } from './queryHelpers';
import { exportICS } from './tripService';
interface SharePermissions {
share_map?: boolean;
@@ -20,6 +21,11 @@ interface ShareTokenInfo {
share_collab: boolean;
}
interface CalendarShareTokenInfo {
token: string;
created_at: string;
}
/**
* Creates a new share link or updates the permissions on an existing one.
* Returns an object with the token string and whether it was newly created.
@@ -79,6 +85,57 @@ export function deleteShareLink(tripId: string): void {
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
}
/**
* Creates or returns a dedicated calendar subscription link for a trip.
*/
export function createOrUpdateCalendarShareLink(
tripId: string,
createdBy: number,
): { token: string; created: boolean } {
const existing = db.prepare('SELECT token FROM calendar_share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
if (existing) {
return { token: existing.token, created: false };
}
const token = crypto.randomBytes(24).toString('base64url');
db.prepare('INSERT INTO calendar_share_tokens (trip_id, token, created_by) VALUES (?, ?, ?)')
.run(tripId, token, createdBy);
return { token, created: true };
}
/**
* Returns the calendar subscription link for a trip, or null if none exists.
*/
export function getCalendarShareLink(tripId: string): CalendarShareTokenInfo | null {
const row = db.prepare('SELECT * FROM calendar_share_tokens WHERE trip_id = ?').get(tripId) as any;
if (!row) return null;
return {
token: row.token,
created_at: row.created_at,
};
}
/**
* Deletes the calendar subscription link for a trip.
*/
export function deleteCalendarShareLink(tripId: string): void {
db.prepare('DELETE FROM calendar_share_tokens WHERE trip_id = ?').run(tripId);
}
/**
* Resolves a shared token to ICS calendar content.
* Returns null when token or trip is invalid.
*/
export function getSharedTripICS(token: string): { ics: string; filename: string } | null {
const shareRow = db.prepare('SELECT trip_id FROM calendar_share_tokens WHERE token = ?').get(token) as { trip_id: number } | undefined;
if (!shareRow) return null;
try {
return exportICS(shareRow.trip_id);
} catch {
return null;
}
}
/**
* Loads the full public trip data for a share token, filtered by the token's
* permission flags. Returns null if the token is invalid or the trip is gone.
+18
View File
@@ -204,6 +204,24 @@ describe('Shared trip access', () => {
.send({});
expect(res.status).toBe(404);
});
it('SHARE-009 — GET /shared/:token/calendar.ics returns public calendar payload', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Rome Calendar' });
const create = await request(app)
.post(`/api/trips/${trip.id}/subscribe.ics`)
.set('Host', 'trek.example.com')
.set('Cookie', authCookie(user.id))
.send({});
const token = create.body.token;
const res = await request(app).get(`/api/shared/${token}/calendar.ics`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/calendar/);
expect(res.text).toContain('BEGIN:VCALENDAR');
expect(res.text).toContain('END:VCALENDAR');
});
});
describe('Shared trip — day assignments and notes', () => {
+66
View File
@@ -855,6 +855,72 @@ describe('ICS export', () => {
const res = await request(app).get(`/api/trips/${trip.id}/export.ics`);
expect(res.status).toBe(401);
});
it('TRIP-025 — GET /api/trips/:id/subscribe.ics returns null before a calendar link exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
const res = await request(app)
.get(`/api/trips/${trip.id}/subscribe.ics`)
.set('Host', 'trek.example.com')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.url).toBeNull();
expect(res.body.webcal_url).toBeNull();
expect(res.body.token).toBeNull();
});
it('TRIP-025 — POST /api/trips/:id/subscribe.ics creates shareable http+webcal links', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Calendar Trip' });
const res = await request(app)
.post(`/api/trips/${trip.id}/subscribe.ics`)
.set('Host', 'trek.example.com')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(201);
expect(res.body.url).toMatch(/^http:\/\/trek\.example\.com\/api\/shared\/.+\/calendar\.ics$/);
expect(res.body.webcal_url).toMatch(/^webcal:\/\/trek\.example\.com\/api\/shared\/.+\/calendar\.ics$/);
expect(typeof res.body.token).toBe('string');
});
it('TRIP-025 — DELETE /api/trips/:id/subscribe.ics removes calendar token', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Delete Calendar Token' });
await request(app)
.post(`/api/trips/${trip.id}/subscribe.ics`)
.set('Host', 'trek.example.com')
.set('Cookie', authCookie(user.id));
const del = await request(app)
.delete(`/api/trips/${trip.id}/subscribe.ics`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
expect(del.body.success).toBe(true);
const status = await request(app)
.get(`/api/trips/${trip.id}/subscribe.ics`)
.set('Host', 'trek.example.com')
.set('Cookie', authCookie(user.id));
expect(status.body.token).toBeNull();
});
it('TRIP-025 — non-member cannot get subscribe link → 404', async () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Private Trip' });
const res = await request(app)
.get(`/api/trips/${trip.id}/subscribe.ics`)
.set('Cookie', authCookie(stranger.id));
expect(res.status).toBe(404);
});
});
// ─────────────────────────────────────────────────────────────────────────────