mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
v1
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user