Merge remote-tracking branch 'origin/dev' into asteriskyg/main

# Conflicts:
#	server/src/routes/files.ts
This commit is contained in:
Maurice
2026-03-31 20:08:42 +02:00
91 changed files with 13537 additions and 429 deletions

View File

@@ -321,6 +321,112 @@ function runMigrations(db: Database.Database): void {
UNIQUE(file_id, place_id)
)`);
},
() => {
// Add day_plan_position to reservations for persistent transport ordering in day timeline
try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch {}
},
() => {
// Add paid_by_user_id to budget_items for expense tracking / settlement
try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch {}
},
() => {
// Add target_date to bucket_list for optional visit planning
try { db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); } catch {}
},
() => {
// Notification preferences per user
db.exec(`CREATE TABLE IF NOT EXISTS notification_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notify_trip_invite INTEGER DEFAULT 1,
notify_booking_change INTEGER DEFAULT 1,
notify_trip_reminder INTEGER DEFAULT 1,
notify_vacay_invite INTEGER DEFAULT 1,
notify_photos_shared INTEGER DEFAULT 1,
notify_collab_message INTEGER DEFAULT 1,
notify_packing_tagged INTEGER DEFAULT 1,
notify_webhook INTEGER DEFAULT 0,
UNIQUE(user_id)
)`);
},
() => {
// Add missing notification preference columns for existing tables
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); } catch {}
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); } catch {}
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); } catch {}
try { db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); } catch {}
},
() => {
// Public share links for read-only trip access
db.exec(`CREATE TABLE IF NOT EXISTS 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),
share_map INTEGER DEFAULT 1,
share_bookings INTEGER DEFAULT 1,
share_packing INTEGER DEFAULT 0,
share_budget INTEGER DEFAULT 0,
share_collab INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
},
() => {
// Add permission columns to share_tokens
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); } catch {}
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); } catch {}
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); } catch {}
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); } catch {}
try { db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); } catch {}
},
() => {
// Audit log
db.exec(`
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource TEXT,
details TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
`);
},
() => {
// MFA backup/recovery codes
try { db.exec('ALTER TABLE users ADD COLUMN mfa_backup_codes TEXT'); } catch {}
},
// MCP long-lived API tokens
() => db.exec(`
CREATE TABLE IF NOT EXISTS mcp_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME
)
`),
// MCP addon entry
() => {
try {
db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)")
.run('mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'integration', 'Terminal', 0, 12);
} catch {}
},
// Index on mcp_tokens.token_hash
() => db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_tokens_hash ON mcp_tokens(token_hash)
`),
// Ensure MCP addon type is 'integration'
() => {
try {
db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'mcp'").run();
} catch {}
},
];
if (currentVersion < migrations.length) {

View File

@@ -17,6 +17,7 @@ function createTables(db: Database.Database): void {
last_login DATETIME,
mfa_enabled INTEGER DEFAULT 0,
mfa_secret TEXT,
mfa_backup_codes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -380,6 +381,17 @@ function createTables(db: Database.Database): void {
UNIQUE(assignment_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource TEXT,
details TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
`);
}

View File

@@ -33,6 +33,7 @@ function seedAddons(db: Database.Database): void {
{ id: 'documents', name: 'Documents', description: 'Store and manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
{ id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
];
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');

View File

@@ -1,11 +1,14 @@
import 'dotenv/config';
import './config';
import express, { Request, Response, NextFunction } from 'express';
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
import cors from 'cors';
import helmet from 'helmet';
import path from 'path';
import fs from 'fs';
const app = express();
const DEBUG = String(process.env.DEBUG || 'false').toLowerCase() === 'true';
// Trust first proxy (nginx/Docker) for correct req.ip
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
@@ -79,10 +82,42 @@ if (shouldForceHttps) {
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: true }));
// Avatars are public (shown on login, sharing screens)
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
app.use(enforceGlobalMfaPolicy);
// All other uploads require authentication
if (DEBUG) {
app.use((req: Request, res: Response, next: NextFunction) => {
const startedAt = Date.now();
const requestId = Math.random().toString(36).slice(2, 10);
const redact = (value: unknown): unknown => {
if (!value || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map(redact);
const hidden = new Set(['password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code']);
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = hidden.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
}
return out;
};
const safeQuery = redact(req.query);
const safeBody = redact(req.body);
console.log(`[DEBUG][REQ ${requestId}] ${req.method} ${req.originalUrl} ip=${req.ip} query=${JSON.stringify(safeQuery)} body=${JSON.stringify(safeBody)}`);
res.on('finish', () => {
const elapsedMs = Date.now() - startedAt;
console.log(`[DEBUG][RES ${requestId}] ${req.method} ${req.originalUrl} status=${res.statusCode} elapsed_ms=${elapsedMs}`);
});
next();
});
}
// Avatars are public (shown on login, sharing screens)
import { authenticate } from './middleware/auth';
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
// Serve uploaded files (UUIDs are unguessable, path traversal protected)
app.get('/uploads/:type/:filename', (req: Request, res: Response) => {
const { type, filename } = req.params;
const allowedTypes = ['covers', 'files', 'photos'];
@@ -160,11 +195,31 @@ app.use('/api/weather', weatherRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/backup', backupRoutes);
import notificationRoutes from './routes/notifications';
app.use('/api/notifications', notificationRoutes);
import shareRoutes from './routes/share';
app.use('/api', shareRoutes);
// MCP endpoint (Streamable HTTP transport, per-user auth)
import { mcpHandler, closeMcpSessions } from './mcp';
app.post('/mcp', mcpHandler);
app.get('/mcp', mcpHandler);
app.delete('/mcp', mcpHandler);
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
app.use(express.static(publicPath));
app.use(express.static(publicPath, {
setHeaders: (res, filePath) => {
// Never cache index.html so version updates are picked up immediately
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
},
}));
app.get('*', (req: Request, res: Response) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(publicPath, 'index.html'));
});
}
@@ -181,6 +236,7 @@ const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => {
console.log(`TREK API running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`Debug logs: ${DEBUG ? 'ENABLED' : 'disabled'}`);
if (process.env.DEMO_MODE === 'true') console.log('Demo mode: ENABLED');
if (process.env.DEMO_MODE === 'true' && process.env.NODE_ENV === 'production') {
console.warn('[SECURITY WARNING] DEMO_MODE is enabled in production! Demo credentials are publicly exposed.');
@@ -196,6 +252,7 @@ const server = app.listen(PORT, () => {
function shutdown(signal: string): void {
console.log(`\n${signal} received — shutting down gracefully...`);
scheduler.stop();
closeMcpSessions();
server.close(() => {
console.log('HTTP server closed');
const { closeDb } = require('./db/database');

189
server/src/mcp/index.ts Normal file
View File

@@ -0,0 +1,189 @@
import { Request, Response } from 'express';
import { randomUUID, createHash } from 'crypto';
import jwt from 'jsonwebtoken';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp';
import { JWT_SECRET } from '../config';
import { db } from '../db/database';
import { User } from '../types';
import { registerResources } from './resources';
import { registerTools } from './tools';
interface McpSession {
server: McpServer;
transport: StreamableHTTPServerTransport;
userId: number;
lastActivity: number;
}
const sessions = new Map<string, McpSession>();
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
const MAX_SESSIONS_PER_USER = 5;
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute
const RATE_LIMIT_MAX = 60; // requests per minute per user
interface RateLimitEntry {
count: number;
windowStart: number;
}
const rateLimitMap = new Map<number, RateLimitEntry>();
function isRateLimited(userId: number): boolean {
const now = Date.now();
const entry = rateLimitMap.get(userId);
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
rateLimitMap.set(userId, { count: 1, windowStart: now });
return false;
}
entry.count += 1;
return entry.count > RATE_LIMIT_MAX;
}
function countSessionsForUser(userId: number): number {
const cutoff = Date.now() - SESSION_TTL_MS;
let count = 0;
for (const session of sessions.values()) {
if (session.userId === userId && session.lastActivity >= cutoff) count++;
}
return count;
}
const sessionSweepInterval = setInterval(() => {
const cutoff = Date.now() - SESSION_TTL_MS;
for (const [sid, session] of sessions) {
if (session.lastActivity < cutoff) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
}
const rateCutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
for (const [uid, entry] of rateLimitMap) {
if (entry.windowStart < rateCutoff) rateLimitMap.delete(uid);
}
}, 10 * 60 * 1000); // sweep every 10 minutes
// Prevent the interval from keeping the process alive if nothing else is running
sessionSweepInterval.unref();
function verifyToken(authHeader: string | undefined): User | null {
const token = authHeader && authHeader.split(' ')[1];
if (!token) return null;
// Long-lived MCP API token (trek_...)
if (token.startsWith('trek_')) {
const hash = createHash('sha256').update(token).digest('hex');
const row = db.prepare(`
SELECT u.id, u.username, u.email, u.role
FROM mcp_tokens mt
JOIN users u ON mt.user_id = u.id
WHERE mt.token_hash = ?
`).get(hash) as User | undefined;
if (row) {
// Update last_used_at (fire-and-forget, non-blocking)
db.prepare('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash);
return row;
}
return null;
}
// Short-lived JWT
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
const user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
return user || null;
} catch {
return null;
}
}
export async function mcpHandler(req: Request, res: Response): Promise<void> {
const mcpAddon = db.prepare("SELECT enabled FROM addons WHERE id = 'mcp'").get() as { enabled: number } | undefined;
if (!mcpAddon || !mcpAddon.enabled) {
res.status(403).json({ error: 'MCP is not enabled' });
return;
}
const user = verifyToken(req.headers['authorization']);
if (!user) {
res.status(401).json({ error: 'Access token required' });
return;
}
if (isRateLimited(user.id)) {
res.status(429).json({ error: 'Too many requests. Please slow down.' });
return;
}
const sessionId = req.headers['mcp-session-id'] as string | undefined;
// Resume an existing session
if (sessionId) {
const session = sessions.get(sessionId);
if (!session) {
res.status(404).json({ error: 'Session not found' });
return;
}
if (session.userId !== user.id) {
res.status(403).json({ error: 'Session belongs to a different user' });
return;
}
session.lastActivity = Date.now();
await session.transport.handleRequest(req, res, req.body);
return;
}
// Only POST can initialize a new session
if (req.method !== 'POST') {
res.status(400).json({ error: 'Missing mcp-session-id header' });
return;
}
if (countSessionsForUser(user.id) >= MAX_SESSIONS_PER_USER) {
res.status(429).json({ error: 'Session limit reached. Close an existing session before opening a new one.' });
return;
}
// Create a new per-user MCP server and session
const server = new McpServer({ name: 'trek', version: '1.0.0' });
registerResources(server, user.id);
registerTools(server, user.id);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
sessions.set(sid, { server, transport, userId: user.id, lastActivity: Date.now() });
},
onsessionclosed: (sid) => {
sessions.delete(sid);
},
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
}
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
export function revokeUserSessions(userId: number): void {
for (const [sid, session] of sessions) {
if (session.userId === userId) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
}
}
}
/** Close all active MCP sessions (call during graceful shutdown). */
export function closeMcpSessions(): void {
clearInterval(sessionSweepInterval);
for (const [, session] of sessions) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
}
sessions.clear();
rateLimitMap.clear();
}

304
server/src/mcp/resources.ts Normal file
View File

@@ -0,0 +1,304 @@
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
import { db, canAccessTrip } from '../db/database';
const TRIP_SELECT = `
SELECT t.*,
(SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count,
(SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count,
CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner,
u.username as owner_username,
(SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count
FROM trips t
JOIN users u ON u.id = t.user_id
`;
function parseId(value: string | string[]): number | null {
const n = Number(Array.isArray(value) ? value[0] : value);
return Number.isInteger(n) && n > 0 ? n : null;
}
function accessDenied(uri: string) {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify({ error: 'Trip not found or access denied' }),
}],
};
}
function jsonContent(uri: string, data: unknown) {
return {
contents: [{
uri,
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
}],
};
}
export function registerResources(server: McpServer, userId: number): void {
// List all accessible trips
server.registerResource(
'trips',
'trek://trips',
{ description: 'All trips the user owns or is a member of' },
async (uri) => {
const trips = db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = 0
ORDER BY t.created_at DESC
`).all({ userId });
return jsonContent(uri.href, trips);
}
);
// Single trip detail
server.registerResource(
'trip',
new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
{ description: 'A single trip with metadata and member count' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const trip = db.prepare(`
${TRIP_SELECT}
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId
WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL)
`).get({ userId, tripId: id });
return jsonContent(uri.href, trip);
}
);
// Days with assigned places
server.registerResource(
'trip-days',
new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }),
{ description: 'Days of a trip with their assigned places' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const days = db.prepare(
'SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC'
).all(id) as { id: number; day_number: number; date: string | null; title: string | null; notes: string | null }[];
const dayIds = days.map(d => d.id);
const assignmentsByDay: Record<number, unknown[]> = {};
if (dayIds.length > 0) {
const placeholders = dayIds.map(() => '?').join(',');
const assignments = db.prepare(`
SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes,
p.id as place_id, p.name, p.address, p.lat, p.lng, p.category_id,
COALESCE(da.assignment_time, p.place_time) as place_time,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id IN (${placeholders})
ORDER BY da.order_index ASC, da.created_at ASC
`).all(...dayIds) as (Record<string, unknown> & { day_id: number })[];
for (const a of assignments) {
if (!assignmentsByDay[a.day_id]) assignmentsByDay[a.day_id] = [];
assignmentsByDay[a.day_id].push(a);
}
}
const result = days.map(d => ({ ...d, assignments: assignmentsByDay[d.id] || [] }));
return jsonContent(uri.href, result);
}
);
// Places in a trip
server.registerResource(
'trip-places',
new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }),
{ description: 'All places/POIs saved in a trip' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const places = db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p
LEFT JOIN categories c ON p.category_id = c.id
WHERE p.trip_id = ?
ORDER BY p.created_at DESC
`).all(id);
return jsonContent(uri.href, places);
}
);
// Budget items
server.registerResource(
'trip-budget',
new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
{ description: 'Budget and expense items for a trip' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const items = db.prepare(
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
).all(id);
return jsonContent(uri.href, items);
}
);
// Packing checklist
server.registerResource(
'trip-packing',
new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }),
{ description: 'Packing checklist for a trip' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const items = db.prepare(
'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(id);
return jsonContent(uri.href, items);
}
);
// Reservations (flights, hotels, restaurants)
server.registerResource(
'trip-reservations',
new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }),
{ description: 'Reservations (flights, hotels, restaurants) for a trip' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name
FROM reservations r
LEFT JOIN days d ON r.day_id = d.id
LEFT JOIN places p ON r.place_id = p.id
WHERE r.trip_id = ?
ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(id);
return jsonContent(uri.href, reservations);
}
);
// Day notes
server.registerResource(
'day-notes',
new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }),
{ description: 'Notes for a specific day in a trip' },
async (uri, { tripId, dayId }) => {
const tId = parseId(tripId);
const dId = parseId(dayId);
if (tId === null || dId === null || !canAccessTrip(tId, userId)) return accessDenied(uri.href);
const notes = db.prepare(
'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC'
).all(dId, tId);
return jsonContent(uri.href, notes);
}
);
// Accommodations (hotels, rentals) per trip
server.registerResource(
'trip-accommodations',
new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }),
{ description: 'Accommodations (hotels, rentals) for a trip with check-in/out details' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const accommodations = db.prepare(`
SELECT da.*, p.name as place_name, p.address as place_address, p.lat, p.lng,
ds.day_number as start_day_number, de.day_number as end_day_number
FROM day_accommodations da
JOIN places p ON da.place_id = p.id
LEFT JOIN days ds ON da.start_day_id = ds.id
LEFT JOIN days de ON da.end_day_id = de.id
WHERE da.trip_id = ?
ORDER BY ds.day_number ASC
`).all(id);
return jsonContent(uri.href, accommodations);
}
);
// Trip members (owner + collaborators)
server.registerResource(
'trip-members',
new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
{ description: 'Owner and collaborators of a trip' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(id) as { user_id: number } | undefined;
if (!trip) return accessDenied(uri.href);
const owner = db.prepare('SELECT id, username, avatar FROM users WHERE id = ?').get(trip.user_id) as Record<string, unknown> | undefined;
const members = db.prepare(`
SELECT u.id, u.username, u.avatar, tm.added_at
FROM trip_members tm
JOIN users u ON tm.user_id = u.id
WHERE tm.trip_id = ?
ORDER BY tm.added_at ASC
`).all(id);
return jsonContent(uri.href, {
owner: owner ? { ...owner, role: 'owner' } : null,
members,
});
}
);
// Collab notes for a trip
server.registerResource(
'trip-collab-notes',
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
{ description: 'Shared collaborative notes for a trip' },
async (uri, { tripId }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const notes = db.prepare(`
SELECT cn.*, u.username
FROM collab_notes cn
JOIN users u ON cn.user_id = u.id
WHERE cn.trip_id = ?
ORDER BY cn.pinned DESC, cn.updated_at DESC
`).all(id);
return jsonContent(uri.href, notes);
}
);
// All place categories (global, no trip filter)
server.registerResource(
'categories',
'trek://categories',
{ description: 'All available place categories (id, name, color, icon) for use when creating places' },
async (uri) => {
const categories = db.prepare(
'SELECT id, name, color, icon FROM categories ORDER BY name ASC'
).all();
return jsonContent(uri.href, categories);
}
);
// User's bucket list
server.registerResource(
'bucket-list',
'trek://bucket-list',
{ description: 'Your personal travel bucket list' },
async (uri) => {
const items = db.prepare(
'SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC'
).all(userId);
return jsonContent(uri.href, items);
}
);
// User's visited countries
server.registerResource(
'visited-countries',
'trek://visited-countries',
{ description: 'Countries you have marked as visited in Atlas' },
async (uri) => {
const countries = db.prepare(
'SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC'
).all(userId);
return jsonContent(uri.href, countries);
}
);
}

1175
server/src/mcp/tools.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
/** Paths that never require MFA (public or pre-auth). */
function isPublicApiPath(method: string, pathNoQuery: string): boolean {
if (method === 'GET' && pathNoQuery === '/api/health') return true;
if (method === 'GET' && pathNoQuery === '/api/auth/app-config') return true;
if (method === 'POST' && pathNoQuery === '/api/auth/login') return true;
if (method === 'POST' && pathNoQuery === '/api/auth/register') return true;
if (method === 'POST' && pathNoQuery === '/api/auth/demo-login') return true;
if (method === 'GET' && pathNoQuery.startsWith('/api/auth/invite/')) return true;
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/verify-login') return true;
if (pathNoQuery.startsWith('/api/auth/oidc/')) return true;
return false;
}
/** Authenticated paths allowed while MFA is not yet enabled (setup + lockout recovery). */
function isMfaSetupExemptPath(method: string, pathNoQuery: string): boolean {
if (method === 'GET' && pathNoQuery === '/api/auth/me') return true;
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true;
if ((method === 'GET' || method === 'PUT') && pathNoQuery === '/api/auth/app-settings') return true;
return false;
}
/**
* When app_settings.require_mfa is true, block API access for users without MFA enabled,
* except for public routes and MFA setup endpoints.
*/
export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFunction): void {
const pathNoQuery = (req.originalUrl || req.url || '').split('?')[0];
if (!pathNoQuery.startsWith('/api')) {
next();
return;
}
if (isPublicApiPath(req.method, pathNoQuery)) {
next();
return;
}
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
next();
return;
}
let userId: number;
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
userId = decoded.id;
} catch {
next();
return;
}
const requireRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
if (requireRow?.value !== 'true') {
next();
return;
}
if (process.env.DEMO_MODE === 'true') {
const demo = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
if (demo?.email === 'demo@trek.app' || demo?.email === 'demo@nomad.app') {
next();
return;
}
}
const row = db.prepare('SELECT mfa_enabled, role FROM users WHERE id = ?').get(userId) as
| { mfa_enabled: number | boolean; role: string }
| undefined;
if (!row) {
next();
return;
}
const mfaOk = row.mfa_enabled === 1 || row.mfa_enabled === true;
if (mfaOk) {
next();
return;
}
if (isMfaSetupExemptPath(req.method, pathNoQuery)) {
next();
return;
}
res.status(403).json({
error: 'Two-factor authentication is required. Complete setup in Settings.',
code: 'MFA_REQUIRED',
});
}

View File

@@ -7,11 +7,18 @@ import fs from 'fs';
import { db } from '../db/database';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest, User, Addon } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { revokeUserSessions } from '../mcp';
const router = express.Router();
router.use(authenticate, adminOnly);
function utcSuffix(ts: string | null | undefined): string | null {
if (!ts) return null;
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
}
router.get('/users', (req: Request, res: Response) => {
const users = db.prepare(
'SELECT id, username, email, role, created_at, updated_at, last_login FROM users ORDER BY created_at DESC'
@@ -21,7 +28,13 @@ router.get('/users', (req: Request, res: Response) => {
const { getOnlineUserIds } = require('../websocket');
onlineUserIds = getOnlineUserIds();
} catch { /* */ }
const usersWithStatus = users.map(u => ({ ...u, online: onlineUserIds.has(u.id) }));
const usersWithStatus = users.map(u => ({
...u,
created_at: utcSuffix(u.created_at),
updated_at: utcSuffix(u.updated_at as string),
last_login: utcSuffix(u.last_login),
online: onlineUserIds.has(u.id),
}));
res.json({ users: usersWithStatus });
});
@@ -52,6 +65,14 @@ router.post('/users', (req: Request, res: Response) => {
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(result.lastInsertRowid);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.user_create',
resource: String(result.lastInsertRowid),
ip: getClientIp(req),
details: { username: username.trim(), email: email.trim(), role: role || 'user' },
});
res.status(201).json({ user });
});
@@ -90,6 +111,19 @@ router.put('/users/:id', (req: Request, res: Response) => {
'SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?'
).get(req.params.id);
const authReq = req as AuthRequest;
const changed: string[] = [];
if (username) changed.push('username');
if (email) changed.push('email');
if (role) changed.push('role');
if (password) changed.push('password');
writeAudit({
userId: authReq.user.id,
action: 'admin.user_update',
resource: String(req.params.id),
ip: getClientIp(req),
details: { fields: changed },
});
res.json({ user: updated });
});
@@ -103,6 +137,12 @@ router.delete('/users/:id', (req: Request, res: Response) => {
if (!user) return res.status(404).json({ error: 'User not found' });
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
writeAudit({
userId: authReq.user.id,
action: 'admin.user_delete',
resource: String(req.params.id),
ip: getClientIp(req),
});
res.json({ success: true });
});
@@ -115,6 +155,48 @@ router.get('/stats', (_req: Request, res: Response) => {
res.json({ totalUsers, totalTrips, totalPlaces, totalFiles });
});
router.get('/audit-log', (req: Request, res: Response) => {
const limitRaw = parseInt(String(req.query.limit || '100'), 10);
const offsetRaw = parseInt(String(req.query.offset || '0'), 10);
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0);
type Row = {
id: number;
created_at: string;
user_id: number | null;
username: string | null;
user_email: string | null;
action: string;
resource: string | null;
details: string | null;
ip: string | null;
};
const rows = db.prepare(`
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
FROM audit_log a
LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.id DESC
LIMIT ? OFFSET ?
`).all(limit, offset) as Row[];
const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
res.json({
entries: rows.map((r) => {
let details: Record<string, unknown> | null = null;
if (r.details) {
try {
details = JSON.parse(r.details) as Record<string, unknown>;
} catch {
details = { _parse_error: true };
}
}
return { ...r, details };
}),
total,
limit,
offset,
});
});
router.get('/oidc', (_req: Request, res: Response) => {
const get = (key: string) => (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || '';
const secret = get('oidc_client_secret');
@@ -135,16 +217,25 @@ router.put('/oidc', (req: Request, res: Response) => {
if (client_secret !== undefined) set('oidc_client_secret', client_secret);
set('oidc_display_name', display_name);
set('oidc_only', oidc_only ? 'true' : 'false');
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.oidc_update',
ip: getClientIp(req),
details: { oidc_only: !!oidc_only, issuer_set: !!issuer },
});
res.json({ success: true });
});
router.post('/save-demo-baseline', (_req: Request, res: Response) => {
router.post('/save-demo-baseline', (req: Request, res: Response) => {
if (process.env.DEMO_MODE !== 'true') {
return res.status(404).json({ error: 'Not found' });
}
try {
const { saveBaseline } = require('../demo/demo-reset');
saveBaseline();
const authReq = req as AuthRequest;
writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) });
res.json({ success: true, message: 'Demo baseline saved. Hourly resets will restore to this state.' });
} catch (err: unknown) {
console.error(err);
@@ -201,7 +292,7 @@ router.get('/version-check', async (_req: Request, res: Response) => {
}
});
router.post('/update', async (_req: Request, res: Response) => {
router.post('/update', async (req: Request, res: Response) => {
const rootDir = path.resolve(__dirname, '../../..');
const serverDir = path.resolve(__dirname, '../..');
const clientDir = path.join(rootDir, 'client');
@@ -224,6 +315,13 @@ router.post('/update', async (_req: Request, res: Response) => {
const { version: newVersion } = require('../../package.json');
steps.push({ step: 'version', version: newVersion });
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.system_update',
resource: newVersion,
ip: getClientIp(req),
});
res.json({ success: true, steps, restarting: true });
setTimeout(() => {
@@ -260,24 +358,39 @@ router.post('/invites', (req: Request, res: Response) => {
? new Date(Date.now() + parseInt(expires_in_days) * 86400000).toISOString()
: null;
db.prepare(
const ins = db.prepare(
'INSERT INTO invite_tokens (token, max_uses, expires_at, created_by) VALUES (?, ?, ?, ?)'
).run(token, uses, expiresAt, authReq.user.id);
const inviteId = Number(ins.lastInsertRowid);
const invite = db.prepare(`
SELECT i.*, u.username as created_by_name
FROM invite_tokens i
JOIN users u ON i.created_by = u.id
WHERE i.id = last_insert_rowid()
`).get();
WHERE i.id = ?
`).get(inviteId);
writeAudit({
userId: authReq.user.id,
action: 'admin.invite_create',
resource: String(inviteId),
ip: getClientIp(req),
details: { max_uses: uses, expires_in_days: expires_in_days ?? null },
});
res.status(201).json({ invite });
});
router.delete('/invites/:id', (_req: Request, res: Response) => {
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(_req.params.id);
router.delete('/invites/:id', (req: Request, res: Response) => {
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(req.params.id);
if (!invite) return res.status(404).json({ error: 'Invite not found' });
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(_req.params.id);
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(req.params.id);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.invite_delete',
resource: String(req.params.id),
ip: getClientIp(req),
});
res.json({ success: true });
});
@@ -291,6 +404,13 @@ router.get('/bag-tracking', (_req: Request, res: Response) => {
router.put('/bag-tracking', (req: Request, res: Response) => {
const { enabled } = req.body;
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.bag_tracking',
ip: getClientIp(req),
details: { enabled: !!enabled },
});
res.json({ enabled: !!enabled });
});
@@ -337,10 +457,19 @@ router.put('/packing-templates/:id', (req: Request, res: Response) => {
res.json({ template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id) });
});
router.delete('/packing-templates/:id', (_req: Request, res: Response) => {
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(_req.params.id);
router.delete('/packing-templates/:id', (req: Request, res: Response) => {
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(req.params.id);
if (!template) return res.status(404).json({ error: 'Template not found' });
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(_req.params.id);
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(req.params.id);
const authReq = req as AuthRequest;
const t = template as { name?: string };
writeAudit({
userId: authReq.user.id,
action: 'admin.packing_template_delete',
resource: String(req.params.id),
ip: getClientIp(req),
details: { name: t.name },
});
res.json({ success: true });
});
@@ -408,7 +537,33 @@ router.put('/addons/:id', (req: Request, res: Response) => {
if (enabled !== undefined) db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, req.params.id);
if (config !== undefined) db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(config), req.params.id);
const updated = db.prepare('SELECT * FROM addons WHERE id = ?').get(req.params.id) as Addon;
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.addon_update',
resource: String(req.params.id),
ip: getClientIp(req),
details: { enabled: enabled !== undefined ? !!enabled : undefined, config_changed: config !== undefined },
});
res.json({ addon: { ...updated, enabled: !!updated.enabled, config: JSON.parse(updated.config || '{}') } });
});
router.get('/mcp-tokens', (req: Request, res: Response) => {
const tokens = db.prepare(`
SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username
FROM mcp_tokens t
JOIN users u ON u.id = t.user_id
ORDER BY t.created_at DESC
`).all();
res.json({ tokens });
});
router.delete('/mcp-tokens/:id', (req: Request, res: Response) => {
const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(req.params.id) as { id: number; user_id: number } | undefined;
if (!token) return res.status(404).json({ error: 'Token not found' });
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(req.params.id);
revokeUserSessions(token.user_id);
res.json({ success: true });
});
export default router;

View File

@@ -277,10 +277,10 @@ router.get('/bucket-list', (req: Request, res: Response) => {
router.post('/bucket-list', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { name, lat, lng, country_code, notes } = req.body;
const { name, lat, lng, country_code, notes, target_date } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)').run(
authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null
const result = db.prepare('INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
authReq.user.id, name.trim(), lat ?? null, lng ?? null, country_code ?? null, notes ?? null, target_date ?? null
);
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json({ item });
@@ -288,10 +288,25 @@ router.post('/bucket-list', (req: Request, res: Response) => {
router.put('/bucket-list/:id', (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { name, notes } = req.body;
const { name, notes, lat, lng, country_code, target_date } = req.body;
const item = db.prepare('SELECT * FROM bucket_list WHERE id = ? AND user_id = ?').get(req.params.id, authReq.user.id);
if (!item) return res.status(404).json({ error: 'Item not found' });
db.prepare('UPDATE bucket_list SET name = COALESCE(?, name), notes = COALESCE(?, notes) WHERE id = ?').run(name?.trim() || null, notes ?? null, req.params.id);
db.prepare(`UPDATE bucket_list SET
name = COALESCE(?, name),
notes = CASE WHEN ? THEN ? ELSE notes END,
lat = CASE WHEN ? THEN ? ELSE lat END,
lng = CASE WHEN ? THEN ? ELSE lng END,
country_code = CASE WHEN ? THEN ? ELSE country_code END,
target_date = CASE WHEN ? THEN ? ELSE target_date END
WHERE id = ?`).run(
name?.trim() || null,
notes !== undefined ? 1 : 0, notes !== undefined ? (notes || null) : null,
lat !== undefined ? 1 : 0, lat !== undefined ? (lat || null) : null,
lng !== undefined ? 1 : 0, lng !== undefined ? (lng || null) : null,
country_code !== undefined ? 1 : 0, country_code !== undefined ? (country_code || null) : null,
target_date !== undefined ? 1 : 0, target_date !== undefined ? (target_date || null) : null,
req.params.id
);
res.json({ item: db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(req.params.id) });
});

View File

@@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { v4 as uuid } from 'uuid';
import fetch from 'node-fetch';
import { authenticator } from 'otplib';
@@ -12,12 +13,45 @@ import { db } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth';
import { JWT_SECRET } from '../config';
import { encryptMfaSecret, decryptMfaSecret } from '../services/mfaCrypto';
import { randomBytes, createHash } from 'crypto';
import { revokeUserSessions } from '../mcp';
import { AuthRequest, User } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { decrypt_api_key, maybe_encrypt_api_key } from '../services/apiKeyCrypto';
authenticator.options = { window: 1 };
const MFA_SETUP_TTL_MS = 15 * 60 * 1000;
const mfaSetupPending = new Map<number, { secret: string; exp: number }>();
const MFA_BACKUP_CODE_COUNT = 10;
function normalizeBackupCode(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
}
function hashBackupCode(input: string): string {
return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex');
}
function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] {
const codes: string[] = [];
while (codes.length < count) {
const raw = crypto.randomBytes(4).toString('hex').toUpperCase();
const code = `${raw.slice(0, 4)}-${raw.slice(4)}`;
if (!codes.includes(code)) codes.push(code);
}
return codes;
}
function parseBackupCodeHashes(raw: string | null | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter(v => typeof v === 'string') : [];
} catch {
return [];
}
}
function getPendingMfaSecret(userId: number): string | null {
const row = mfaSetupPending.get(userId);
@@ -28,6 +62,11 @@ function getPendingMfaSecret(userId: number): string | null {
return row.secret;
}
function utcSuffix(ts: string | null | undefined): string | null {
if (!ts) return null;
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
}
function stripUserForClient(user: User): Record<string, unknown> {
const {
password_hash: _p,
@@ -35,10 +74,14 @@ function stripUserForClient(user: User): Record<string, unknown> {
openweather_api_key: _o,
unsplash_api_key: _u,
mfa_secret: _mf,
mfa_backup_codes: _mbc,
...rest
} = user;
return {
...rest,
created_at: utcSuffix(rest.created_at),
updated_at: utcSuffix(rest.updated_at),
last_login: utcSuffix(rest.last_login),
mfa_enabled: !!(user.mfa_enabled === 1 || user.mfa_enabled === true),
};
}
@@ -108,6 +151,11 @@ function maskKey(key: string | null | undefined): string | null {
return '----' + key.slice(-4);
}
function mask_stored_api_key(key: string | null | undefined): string | null {
const plain = decrypt_api_key(key);
return maskKey(plain);
}
function avatarUrl(user: { avatar?: string | null }): string | null {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
@@ -134,6 +182,7 @@ router.get('/app-config', (_req: Request, res: Response) => {
);
const oidcOnlySetting = process.env.OIDC_ONLY || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
res.json({
allow_registration: isDemo ? false : allowRegistration,
has_users: userCount > 0,
@@ -142,10 +191,12 @@ router.get('/app-config', (_req: Request, res: Response) => {
oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
oidc_only_mode: oidcOnlyMode,
require_mfa: requireMfaRow?.value === 'true',
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
demo_mode: isDemo,
demo_email: isDemo ? 'demo@trek.app' : undefined,
demo_password: isDemo ? 'demo12345' : undefined,
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
});
});
@@ -344,9 +395,9 @@ router.put('/me/maps-key', authenticate, (req: Request, res: Response) => {
db.prepare(
'UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(maps_api_key || null, authReq.user.id);
).run(maybe_encrypt_api_key(maps_api_key), authReq.user.id);
res.json({ success: true, maps_api_key: maps_api_key || null });
res.json({ success: true, maps_api_key: mask_stored_api_key(maps_api_key) });
});
router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
@@ -357,8 +408,8 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
db.prepare(
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(
maps_api_key !== undefined ? (maps_api_key || null) : current.maps_api_key,
openweather_api_key !== undefined ? (openweather_api_key || null) : current.openweather_api_key,
maps_api_key !== undefined ? maybe_encrypt_api_key(maps_api_key) : current.maps_api_key,
openweather_api_key !== undefined ? maybe_encrypt_api_key(openweather_api_key) : current.openweather_api_key,
authReq.user.id
);
@@ -367,7 +418,7 @@ router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
res.json({ success: true, user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
});
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
@@ -399,8 +450,8 @@ router.put('/me/settings', authenticate, (req: Request, res: Response) => {
const updates: string[] = [];
const params: (string | number | null)[] = [];
if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maps_api_key || null); }
if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(openweather_api_key || null); }
if (maps_api_key !== undefined) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); }
if (openweather_api_key !== undefined) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); }
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
@@ -415,7 +466,7 @@ router.put('/me/settings', authenticate, (req: Request, res: Response) => {
).get(authReq.user.id) as Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'avatar' | 'mfa_enabled'> | undefined;
const u = updated ? { ...updated, mfa_enabled: !!(updated.mfa_enabled === 1 || updated.mfa_enabled === true) } : undefined;
res.json({ success: true, user: { ...u, maps_api_key: maskKey(u?.maps_api_key), openweather_api_key: maskKey(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
res.json({ success: true, user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), avatar_url: avatarUrl(updated || {}) } });
});
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
@@ -425,7 +476,12 @@ router.get('/me/settings', authenticate, (req: Request, res: Response) => {
).get(authReq.user.id) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | undefined;
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
res.json({ settings: { maps_api_key: user.maps_api_key, openweather_api_key: user.openweather_api_key } });
res.json({
settings: {
maps_api_key: decrypt_api_key(user.maps_api_key),
openweather_api_key: decrypt_api_key(user.openweather_api_key),
}
});
});
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), (req: Request, res: Response) => {
@@ -470,9 +526,21 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
const user = db.prepare('SELECT role, maps_api_key, openweather_api_key FROM users WHERE id = ?').get(authReq.user.id) as Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key'> | undefined;
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
const result = { maps: false, weather: false };
const result: {
maps: boolean;
weather: boolean;
maps_details: null | {
ok: boolean;
status: number | null;
status_text: string | null;
error_message: string | null;
error_status: string | null;
error_raw: string | null;
};
} = { maps: false, weather: false, maps_details: null };
if (user.maps_api_key) {
const maps_api_key = decrypt_api_key(user.maps_api_key);
if (maps_api_key) {
try {
const mapsRes = await fetch(
`https://places.googleapis.com/v1/places:searchText`,
@@ -480,22 +548,54 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Goog-Api-Key': user.maps_api_key,
'X-Goog-Api-Key': maps_api_key,
'X-Goog-FieldMask': 'places.displayName',
},
body: JSON.stringify({ textQuery: 'test' }),
}
);
result.maps = mapsRes.status === 200;
let error_text: string | null = null;
let error_json: any = null;
if (!result.maps) {
try {
error_text = await mapsRes.text();
try {
error_json = JSON.parse(error_text);
} catch {
error_json = null;
}
} catch {
error_text = null;
error_json = null;
}
}
result.maps_details = {
ok: result.maps,
status: mapsRes.status,
status_text: mapsRes.statusText || null,
error_message: error_json?.error?.message || null,
error_status: error_json?.error?.status || null,
error_raw: error_text,
};
} catch (err: unknown) {
result.maps = false;
result.maps_details = {
ok: false,
status: null,
status_text: null,
error_message: err instanceof Error ? err.message : 'Request failed',
error_status: 'FETCH_ERROR',
error_raw: null,
};
}
}
if (user.openweather_api_key) {
const openweather_api_key = decrypt_api_key(user.openweather_api_key);
if (openweather_api_key) {
try {
const weatherRes = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${user.openweather_api_key}`
`https://api.openweathermap.org/data/2.5/weather?q=London&appid=${openweather_api_key}`
);
result.weather = weatherRes.status === 200;
} catch (err: unknown) {
@@ -506,18 +606,58 @@ router.get('/validate-keys', authenticate, async (req: Request, res: Response) =
res.json(result);
});
const ADMIN_SETTINGS_KEYS = ['allow_registration', 'allowed_file_types', 'require_mfa', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify', 'notification_webhook_url', 'app_url'];
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
const result: Record<string, string> = {};
for (const key of ADMIN_SETTINGS_KEYS) {
const row = db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined;
if (row) result[key] = key === 'smtp_pass' ? '••••••••' : row.value;
}
res.json(result);
});
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = db.prepare('SELECT role FROM users WHERE id = ?').get(authReq.user.id) as { role: string } | undefined;
if (user?.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
const { allow_registration, allowed_file_types } = req.body;
if (allow_registration !== undefined) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', ?)").run(String(allow_registration));
const { allow_registration, allowed_file_types, require_mfa } = req.body as Record<string, unknown>;
if (require_mfa === true || require_mfa === 'true') {
const adminMfa = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(authReq.user.id) as { mfa_enabled: number } | undefined;
if (!(adminMfa?.mfa_enabled === 1)) {
return res.status(400).json({
error: 'Enable two-factor authentication on your own account before requiring it for all users.',
});
}
}
if (allowed_file_types !== undefined) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', ?)").run(String(allowed_file_types));
for (const key of ADMIN_SETTINGS_KEYS) {
if (req.body[key] !== undefined) {
let val = String(req.body[key]);
if (key === 'require_mfa') {
val = req.body[key] === true || val === 'true' ? 'true' : 'false';
}
// Don't save masked password
if (key === 'smtp_pass' && val === '••••••••') continue;
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val);
}
}
writeAudit({
userId: authReq.user.id,
action: 'settings.app_update',
ip: getClientIp(req),
details: {
allow_registration: allow_registration !== undefined ? Boolean(allow_registration) : undefined,
allowed_file_types_changed: allowed_file_types !== undefined,
require_mfa: require_mfa !== undefined ? (require_mfa === true || require_mfa === 'true') : undefined,
},
});
res.json({ success: true });
});
@@ -610,10 +750,20 @@ router.post('/mfa/verify-login', authLimiter, (req: Request, res: Response) => {
return res.status(401).json({ error: 'Invalid session' });
}
const secret = decryptMfaSecret(user.mfa_secret);
const tokenStr = String(code).replace(/\s/g, '');
const ok = authenticator.verify({ token: tokenStr, secret });
if (!ok) {
return res.status(401).json({ error: 'Invalid verification code' });
const tokenStr = String(code).trim();
const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
if (!okTotp) {
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
const candidateHash = hashBackupCode(tokenStr);
const idx = hashes.findIndex(h => h === candidateHash);
if (idx === -1) {
return res.status(401).json({ error: 'Invalid verification code' });
}
hashes.splice(idx, 1);
db.prepare('UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
JSON.stringify(hashes),
user.id
);
}
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(user.id);
const sessionToken = generateToken(user);
@@ -667,13 +817,17 @@ router.post('/mfa/enable', authenticate, (req: Request, res: Response) => {
if (!ok) {
return res.status(401).json({ error: 'Invalid verification code' });
}
const backupCodes = generateBackupCodes();
const backupHashes = backupCodes.map(hashBackupCode);
const enc = encryptMfaSecret(pending);
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
enc,
JSON.stringify(backupHashes),
authReq.user.id
);
mfaSetupPending.delete(authReq.user.id);
res.json({ success: true, mfa_enabled: true });
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: true, backup_codes: backupCodes });
});
router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
@@ -681,6 +835,10 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re
if (process.env.DEMO_MODE === 'true' && authReq.user.email === 'demo@nomad.app') {
return res.status(403).json({ error: 'MFA cannot be changed in demo mode.' });
}
const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
if (policy?.value === 'true') {
return res.status(403).json({ error: 'Two-factor authentication cannot be disabled while it is required for all users.' });
}
const { password, code } = req.body as { password?: string; code?: string };
if (!password || !code) {
return res.status(400).json({ error: 'Password and authenticator code are required' });
@@ -698,11 +856,56 @@ router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (re
if (!ok) {
return res.status(401).json({ error: 'Invalid verification code' });
}
db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
db.prepare('UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
authReq.user.id
);
mfaSetupPending.delete(authReq.user.id);
writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: false });
});
// --- MCP Token Management ---
router.get('/mcp-tokens', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const tokens = db.prepare(
'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? ORDER BY created_at DESC'
).all(authReq.user.id);
res.json({ tokens });
});
router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Token name is required' });
if (name.trim().length > 100) return res.status(400).json({ error: 'Token name must be 100 characters or less' });
const tokenCount = (db.prepare('SELECT COUNT(*) as count FROM mcp_tokens WHERE user_id = ?').get(authReq.user.id) as { count: number }).count;
if (tokenCount >= 10) return res.status(400).json({ error: 'Maximum of 10 tokens per user reached' });
const rawToken = 'trek_' + randomBytes(24).toString('hex');
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const tokenPrefix = rawToken.slice(0, 13); // "trek_" + 8 hex chars
const result = db.prepare(
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)'
).run(authReq.user.id, name.trim(), tokenHash, tokenPrefix);
const token = db.prepare(
'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE id = ?'
).get(result.lastInsertRowid);
res.status(201).json({ token: { ...(token as object), raw_token: rawToken } });
});
router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { id } = req.params;
const token = db.prepare('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ?').get(id, authReq.user.id);
if (!token) return res.status(404).json({ error: 'Token not found' });
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(id);
revokeUserSessions(authReq.user.id);
res.json({ success: true });
});
export default router;

View File

@@ -7,6 +7,10 @@ import fs from 'fs';
import { authenticate, adminOnly } from '../middleware/auth';
import * as scheduler from '../scheduler';
import { db, closeDb, reinitialize } from '../db/database';
import { AuthRequest } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
type RestoreAuditInfo = { userId: number; ip: string | null; source: 'backup.restore' | 'backup.upload_restore'; label: string };
const router = express.Router();
@@ -103,6 +107,14 @@ router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (_req: Re
});
const stat = fs.statSync(outputPath);
const authReq = _req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.create',
resource: filename,
ip: getClientIp(_req),
details: { size: stat.size },
});
res.json({
success: true,
backup: {
@@ -134,7 +146,7 @@ router.get('/download/:filename', (req: Request, res: Response) => {
res.download(filePath, filename);
});
async function restoreFromZip(zipPath: string, res: Response) {
async function restoreFromZip(zipPath: string, res: Response, audit?: RestoreAuditInfo) {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
try {
await fs.createReadStream(zipPath)
@@ -174,6 +186,14 @@ async function restoreFromZip(zipPath: string, res: Response) {
fs.rmSync(extractDir, { recursive: true, force: true });
if (audit) {
writeAudit({
userId: audit.userId,
action: audit.source,
resource: audit.label,
ip: audit.ip,
});
}
res.json({ success: true });
} catch (err: unknown) {
console.error('Restore error:', err);
@@ -191,7 +211,13 @@ router.post('/restore/:filename', async (req: Request, res: Response) => {
if (!fs.existsSync(zipPath)) {
return res.status(404).json({ error: 'Backup not found' });
}
await restoreFromZip(zipPath, res);
const authReq = req as AuthRequest;
await restoreFromZip(zipPath, res, {
userId: authReq.user.id,
ip: getClientIp(req),
source: 'backup.restore',
label: filename,
});
});
const uploadTmp = multer({
@@ -206,23 +232,43 @@ const uploadTmp = multer({
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const zipPath = req.file.path;
await restoreFromZip(zipPath, res);
const authReq = req as AuthRequest;
const origName = req.file.originalname || 'upload.zip';
await restoreFromZip(zipPath, res, {
userId: authReq.user.id,
ip: getClientIp(req),
source: 'backup.upload_restore',
label: origName,
});
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
});
router.get('/auto-settings', (_req: Request, res: Response) => {
try {
res.json({ settings: scheduler.loadSettings() });
const tz = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
res.json({ settings: scheduler.loadSettings(), timezone: tz });
} catch (err: unknown) {
console.error('[backup] GET auto-settings:', err);
res.status(500).json({ error: 'Could not load backup settings' });
}
});
function parseIntField(raw: unknown, fallback: number): number {
if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
if (typeof raw === 'string' && raw.trim() !== '') {
const n = parseInt(raw, 10);
if (Number.isFinite(n)) return n;
}
return fallback;
}
function parseAutoBackupBody(body: Record<string, unknown>): {
enabled: boolean;
interval: string;
keep_days: number;
hour: number;
day_of_week: number;
day_of_month: number;
} {
const enabled = body.enabled === true || body.enabled === 'true' || body.enabled === 1;
const rawInterval = body.interval;
@@ -230,17 +276,11 @@ function parseAutoBackupBody(body: Record<string, unknown>): {
typeof rawInterval === 'string' && scheduler.VALID_INTERVALS.includes(rawInterval)
? rawInterval
: 'daily';
const rawKeep = body.keep_days;
let keepNum: number;
if (typeof rawKeep === 'number' && Number.isFinite(rawKeep)) {
keepNum = Math.floor(rawKeep);
} else if (typeof rawKeep === 'string' && rawKeep.trim() !== '') {
keepNum = parseInt(rawKeep, 10);
} else {
keepNum = NaN;
}
const keep_days = Number.isFinite(keepNum) && keepNum >= 0 ? keepNum : 7;
return { enabled, interval, keep_days };
const keep_days = Math.max(0, parseIntField(body.keep_days, 7));
const hour = Math.min(23, Math.max(0, parseIntField(body.hour, 2)));
const day_of_week = Math.min(6, Math.max(0, parseIntField(body.day_of_week, 0)));
const day_of_month = Math.min(28, Math.max(1, parseIntField(body.day_of_month, 1)));
return { enabled, interval, keep_days, hour, day_of_week, day_of_month };
}
router.put('/auto-settings', (req: Request, res: Response) => {
@@ -248,6 +288,13 @@ router.put('/auto-settings', (req: Request, res: Response) => {
const settings = parseAutoBackupBody((req.body || {}) as Record<string, unknown>);
scheduler.saveSettings(settings);
scheduler.start();
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.auto_settings',
ip: getClientIp(req),
details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days },
});
res.json({ settings });
} catch (err: unknown) {
console.error('[backup] PUT auto-settings:', err);
@@ -272,6 +319,13 @@ router.delete('/:filename', (req: Request, res: Response) => {
}
fs.unlinkSync(filePath);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.delete',
resource: filename,
ip: getClientIp(req),
});
res.json({ success: true });
});

View File

@@ -195,6 +195,77 @@ router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Respon
broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string);
});
// Settlement calculation: who owes whom
router.get('/settlement', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[];
const allMembers = db.prepare(`
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
FROM budget_item_members bm
JOIN users u ON bm.user_id = u.id
WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)
`).all(tripId) as (BudgetItemMember & { budget_item_id: number })[];
// Calculate net balance per user: positive = is owed money, negative = owes money
const balances: Record<number, { user_id: number; username: string; avatar_url: string | null; balance: number }> = {};
for (const item of items) {
const members = allMembers.filter(m => m.budget_item_id === item.id);
if (members.length === 0) continue;
const payers = members.filter(m => m.paid);
if (payers.length === 0) continue; // no one marked as paid
const sharePerMember = item.total_price / members.length;
const paidPerPayer = item.total_price / payers.length;
for (const m of members) {
if (!balances[m.user_id]) {
balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 };
}
// Everyone owes their share
balances[m.user_id].balance -= sharePerMember;
// Payers get credited what they paid
if (m.paid) balances[m.user_id].balance += paidPerPayer;
}
}
// Calculate optimized payment flows (greedy algorithm)
const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01);
const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance }));
const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance }));
// Sort by amount descending for efficient matching
debtors.sort((a, b) => b.amount - a.amount);
creditors.sort((a, b) => b.amount - a.amount);
const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = [];
let di = 0, ci = 0;
while (di < debtors.length && ci < creditors.length) {
const transfer = Math.min(debtors[di].amount, creditors[ci].amount);
if (transfer > 0.01) {
flows.push({
from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url },
to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url },
amount: Math.round(transfer * 100) / 100,
});
}
debtors[di].amount -= transfer;
creditors[ci].amount -= transfer;
if (debtors[di].amount < 0.01) di++;
if (creditors[ci].amount < 0.01) ci++;
}
res.json({
balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })),
flows,
});
});
router.delete('/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId, id } = req.params;

View File

@@ -420,6 +420,13 @@ router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (r
const formatted = formatMessage(message);
res.status(201).json({ message: formatted });
broadcast(tripId, 'collab:message:created', { message: formatted }, req.headers['x-socket-id'] as string);
// Notify trip members about new chat message
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim();
notifyTripMembers(Number(tripId), authReq.user.id, 'collab_message', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, preview }).catch(() => {});
});
});
router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => {

View File

@@ -77,20 +77,32 @@ router.post('/search', authenticate, async (req: Request, res: Response) => {
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
try {
const resp = await fetch(`${user.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
type: 'IMAGE',
size: 200,
}),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' });
const data = await resp.json() as { assets?: { items?: any[] } };
const assets = (data.assets?.items || []).map((a: any) => ({
// Paginate through all results (Immich limits per-page to 1000)
const allAssets: any[] = [];
let page = 1;
const pageSize = 1000;
while (true) {
const resp = await fetch(`${user.immich_url}/api/search/metadata`, {
method: 'POST',
headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' },
body: JSON.stringify({
takenAfter: from ? `${from}T00:00:00.000Z` : undefined,
takenBefore: to ? `${to}T23:59:59.999Z` : undefined,
type: 'IMAGE',
size: pageSize,
page,
}),
signal: AbortSignal.timeout(15000),
});
if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' });
const data = await resp.json() as { assets?: { items?: any[] } };
const items = data.assets?.items || [];
allAssets.push(...items);
if (items.length < pageSize) break; // Last page
page++;
if (page > 20) break; // Safety limit (20k photos max)
}
const assets = allAssets.map((a: any) => ({
id: a.id,
takenAt: a.fileCreatedAt || a.createdAt,
city: a.exifInfo?.city || null,
@@ -143,6 +155,14 @@ router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response)
res.json({ success: true, added });
broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string);
// Notify trip members about shared photos
if (shared && added > 0) {
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
notifyTripMembers(Number(tripId), authReq.user.id, 'photos_shared', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, count: String(added) }).catch(() => {});
});
}
});
// Remove a photo from a trip (own photos only)

View File

@@ -3,6 +3,7 @@ import fetch from 'node-fetch';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { decrypt_api_key } from '../services/apiKeyCrypto';
interface NominatimResult {
osm_type: string;
@@ -197,9 +198,10 @@ const router = express.Router();
function getMapsKey(userId: number): string | null {
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as { maps_api_key: string | null } | undefined;
if (user?.maps_api_key) return user.maps_api_key;
const user_key = decrypt_api_key(user?.maps_api_key);
if (user_key) return user_key;
const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get() as { maps_api_key: string } | undefined;
return admin?.maps_api_key || null;
return decrypt_api_key(admin?.maps_api_key) || null;
}
const photoCache = new Map<string, { photoUrl: string; attribution: string | null; fetchedAt: number }>();
@@ -474,4 +476,68 @@ router.get('/reverse', authenticate, async (req: Request, res: Response) => {
}
});
// Resolve a Google Maps URL to place data (coordinates, name, address)
router.post('/resolve-url', authenticate, async (req: Request, res: Response) => {
const { url } = req.body;
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
try {
let resolvedUrl = url;
// Follow redirects for short URLs (goo.gl, maps.app.goo.gl)
if (url.includes('goo.gl') || url.includes('maps.app')) {
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
resolvedUrl = redirectRes.url;
}
// Extract coordinates from Google Maps URL patterns:
// /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522
// ?q=48.8566,2.3522 or ?ll=48.8566,2.3522
let lat: number | null = null;
let lng: number | null = null;
let placeName: string | null = null;
// Pattern: /@lat,lng
const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); }
// Pattern: !3dlat!4dlng (Google Maps data params)
if (!lat) {
const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/);
if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); }
}
// Pattern: ?q=lat,lng or &q=lat,lng
if (!lat) {
const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/);
if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); }
}
// Extract place name from URL path: /place/Place+Name/@...
const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/);
if (placeMatch) {
placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' '));
}
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
return res.status(400).json({ error: 'Could not extract coordinates from URL' });
}
// Reverse geocode to get address
const nominatimRes = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
{ headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }
);
const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record<string, string> };
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
const address = nominatim.display_name || null;
res.json({ lat, lng, name, address });
} catch (err: unknown) {
console.error('[Maps] URL resolve error:', err instanceof Error ? err.message : err);
res.status(400).json({ error: 'Failed to resolve URL' });
}
});
export default router;

View File

@@ -0,0 +1,58 @@
import express, { Request, Response } from 'express';
import { db } from '../db/database';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { testSmtp } from '../services/notifications';
const router = express.Router();
// Get user's notification preferences
router.get('/preferences', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
let prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
if (!prefs) {
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id);
prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
}
res.json({ preferences: prefs });
});
// Update user's notification preferences
router.put('/preferences', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { notify_trip_invite, notify_booking_change, notify_trip_reminder, notify_webhook } = req.body;
// Ensure row exists
const existing = db.prepare('SELECT id FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
if (!existing) {
db.prepare('INSERT INTO notification_preferences (user_id) VALUES (?)').run(authReq.user.id);
}
db.prepare(`UPDATE notification_preferences SET
notify_trip_invite = COALESCE(?, notify_trip_invite),
notify_booking_change = COALESCE(?, notify_booking_change),
notify_trip_reminder = COALESCE(?, notify_trip_reminder),
notify_webhook = COALESCE(?, notify_webhook)
WHERE user_id = ?`).run(
notify_trip_invite !== undefined ? (notify_trip_invite ? 1 : 0) : null,
notify_booking_change !== undefined ? (notify_booking_change ? 1 : 0) : null,
notify_trip_reminder !== undefined ? (notify_trip_reminder ? 1 : 0) : null,
notify_webhook !== undefined ? (notify_webhook ? 1 : 0) : null,
authReq.user.id
);
const prefs = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(authReq.user.id);
res.json({ preferences: prefs });
});
// Admin: test SMTP configuration
router.post('/test-smtp', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
const { email } = req.body;
const result = await testSmtp(email || authReq.user.email);
res.json(result);
});
export default router;

View File

@@ -24,6 +24,9 @@ interface OidcUserInfo {
email?: string;
name?: string;
preferred_username?: string;
groups?: string[];
roles?: string[];
[key: string]: unknown;
}
const router = express.Router();
@@ -41,7 +44,7 @@ setInterval(() => {
}
}, AUTH_CODE_CLEANUP);
const pendingStates = new Map<string, { createdAt: number; redirectUri: string }>();
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string }>();
setInterval(() => {
const now = Date.now();
@@ -85,6 +88,23 @@ function generateToken(user: { id: number; username: string; email: string; role
);
}
// Check if user should be admin based on OIDC claims
// Env: OIDC_ADMIN_CLAIM (default: "groups"), OIDC_ADMIN_VALUE (required, e.g. "app-trek-admins")
function resolveOidcRole(userInfo: OidcUserInfo, isFirstUser: boolean): 'admin' | 'user' {
if (isFirstUser) return 'admin';
const adminValue = process.env.OIDC_ADMIN_VALUE;
if (!adminValue) return 'user'; // No claim mapping configured
const claimKey = process.env.OIDC_ADMIN_CLAIM || 'groups';
const claimData = userInfo[claimKey];
if (Array.isArray(claimData)) {
return claimData.some(v => String(v) === adminValue) ? 'admin' : 'user';
}
if (typeof claimData === 'string') {
return claimData === adminValue ? 'admin' : 'user';
}
return 'user';
}
function frontendUrl(path: string): string {
const base = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:5173';
return base + path;
@@ -104,8 +124,9 @@ router.get('/login', async (req: Request, res: Response) => {
const proto = (req.headers['x-forwarded-proto'] as string) || req.protocol;
const host = (req.headers['x-forwarded-host'] as string) || req.headers.host;
const redirectUri = `${proto}://${host}/api/auth/oidc/callback`;
const inviteToken = req.query.invite as string | undefined;
pendingStates.set(state, { createdAt: Date.now(), redirectUri });
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken });
const params = new URLSearchParams({
response_type: 'code',
@@ -190,18 +211,35 @@ router.get('/callback', async (req: Request, res: Response) => {
if (!user.oidc_sub) {
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
}
// Update role based on OIDC claims on every login (if claim mapping is configured)
if (process.env.OIDC_ADMIN_VALUE) {
const newRole = resolveOidcRole(userInfo, false);
if (user.role !== newRole) {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
user = { ...user, role: newRole } as User;
}
}
} else {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const isFirstUser = userCount === 0;
if (!isFirstUser) {
let validInvite: any = null;
if (pending.inviteToken) {
validInvite = db.prepare('SELECT * FROM invite_tokens WHERE token = ?').get(pending.inviteToken);
if (validInvite) {
if (validInvite.max_uses > 0 && validInvite.used_count >= validInvite.max_uses) validInvite = null;
if (validInvite?.expires_at && new Date(validInvite.expires_at) < new Date()) validInvite = null;
}
}
if (!isFirstUser && !validInvite) {
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
if (setting?.value === 'false') {
return res.redirect(frontendUrl('/login?oidc_error=registration_disabled'));
}
}
const role = isFirstUser ? 'admin' : 'user';
const role = resolveOidcRole(userInfo, isFirstUser);
const randomPass = crypto.randomBytes(32).toString('hex');
const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync(randomPass, 10);
@@ -214,6 +252,15 @@ router.get('/callback', async (req: Request, res: Response) => {
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer) VALUES (?, ?, ?, ?, ?, ?)'
).run(username, email, hash, role, sub, config.issuer);
if (validInvite) {
const updated = db.prepare(
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)'
).run(validInvite.id);
if (updated.changes === 0) {
console.warn(`[OIDC] Invite token ${pending.inviteToken?.slice(0, 8)}... exceeded max_uses (race condition)`);
}
}
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
}

View File

@@ -24,6 +24,53 @@ router.get('/', authenticate, (req: Request, res: Response) => {
res.json({ items });
});
// Bulk import packing items (must be before /:id)
router.post('/import', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { items } = req.body; // [{ name, category?, quantity? }]
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items must be a non-empty array' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
const created: any[] = [];
const insertAll = db.transaction(() => {
for (const item of items) {
if (!item.name?.trim()) continue;
const checked = item.checked ? 1 : 0;
const weight = item.weight_grams ? parseInt(item.weight_grams) || null : null;
// Resolve bag by name if provided
let bagId = null;
if (item.bag?.trim()) {
const bagName = item.bag.trim();
const existing = db.prepare('SELECT id FROM packing_bags WHERE trip_id = ? AND name = ?').get(tripId, bagName) as { id: number } | undefined;
if (existing) {
bagId = existing.id;
} else {
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
const bagCount = (db.prepare('SELECT COUNT(*) as c FROM packing_bags WHERE trip_id = ?').get(tripId) as { c: number }).c;
const newBag = db.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(tripId, bagName, BAG_COLORS[bagCount % BAG_COLORS.length]);
bagId = newBag.lastInsertRowid;
}
}
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++);
created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid));
}
});
insertAll();
res.status(201).json({ items: created, count: created.length });
for (const item of created) {
broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string);
}
});
router.post('/', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
@@ -231,6 +278,18 @@ router.put('/category-assignees/:categoryName', authenticate, (req: Request, res
res.json({ assignees: rows });
broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string);
// Notify newly assigned users
if (Array.isArray(user_ids) && user_ids.length > 0) {
import('../services/notifications').then(({ notify }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
for (const uid of user_ids) {
if (uid !== authReq.user.id) {
notify({ userId: uid, event: 'packing_tagged', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, category: cat } }).catch(() => {});
}
}
});
}
});
router.put('/reorder', authenticate, (req: Request, res: Response) => {

View File

@@ -1,5 +1,6 @@
import express, { Request, Response } from 'express';
import fetch from 'node-fetch';
import multer from 'multer';
import { db, getPlaceWithTags } from '../db/database';
import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess';
@@ -8,6 +9,8 @@ import { loadTagsByPlaceIds } from '../services/queryHelpers';
import { validateStringLengths } from '../middleware/validate';
import { AuthRequest, Place } from '../types';
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
interface PlaceWithCategory extends Place {
category_name: string | null;
category_color: string | null;
@@ -112,6 +115,94 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
});
// Import places from GPX file (must be before /:id)
router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => {
const { tripId } = req.params;
const file = (req as any).file;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
const xml = file.buffer.toString('utf-8');
const parseCoords = (attrs: string): { lat: number; lng: number } | null => {
const latMatch = attrs.match(/lat=["']([^"']+)["']/i);
const lonMatch = attrs.match(/lon=["']([^"']+)["']/i);
if (!latMatch || !lonMatch) return null;
const lat = parseFloat(latMatch[1]);
const lng = parseFloat(lonMatch[1]);
return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : null;
};
const stripCdata = (s: string) => s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim();
const extractName = (body: string) => { const m = body.match(/<name[^>]*>([\s\S]*?)<\/name>/i); return m ? stripCdata(m[1]) : null };
const extractDesc = (body: string) => { const m = body.match(/<desc[^>]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null };
const waypoints: { name: string; lat: number; lng: number; description: string | null }[] = [];
// 1) Parse <wpt> elements (named waypoints / POIs)
const wptRegex = /<wpt\s([^>]+)>([\s\S]*?)<\/wpt>/gi;
let match;
while ((match = wptRegex.exec(xml)) !== null) {
const coords = parseCoords(match[1]);
if (!coords) continue;
const name = extractName(match[2]) || `Waypoint ${waypoints.length + 1}`;
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
}
// 2) If no <wpt>, try <rtept> (route points)
if (waypoints.length === 0) {
const rteptRegex = /<rtept\s([^>]+)>([\s\S]*?)<\/rtept>/gi;
while ((match = rteptRegex.exec(xml)) !== null) {
const coords = parseCoords(match[1]);
if (!coords) continue;
const name = extractName(match[2]) || `Route Point ${waypoints.length + 1}`;
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
}
}
// 3) If still nothing, extract track name + start/end points from <trkpt>
if (waypoints.length === 0) {
const trackNameMatch = xml.match(/<trk[^>]*>[\s\S]*?<name[^>]*>([\s\S]*?)<\/name>/i);
const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track';
const trkptRegex = /<trkpt\s([^>]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi;
const trackPoints: { lat: number; lng: number }[] = [];
while ((match = trkptRegex.exec(xml)) !== null) {
const coords = parseCoords(match[1]);
if (coords) trackPoints.push(coords);
}
if (trackPoints.length > 0) {
const start = trackPoints[0];
waypoints.push({ ...start, name: `${trackName} — Start`, description: null });
if (trackPoints.length > 1) {
const end = trackPoints[trackPoints.length - 1];
waypoints.push({ ...end, name: `${trackName} — End`, description: null });
}
}
}
if (waypoints.length === 0) {
return res.status(400).json({ error: 'No waypoints found in GPX file' });
}
const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode)
VALUES (?, ?, ?, ?, ?, 'walking')
`);
const created: any[] = [];
const insertAll = db.transaction(() => {
for (const wp of waypoints) {
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng);
const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place);
}
});
insertAll();
res.status(201).json({ places: created, count: created.length });
for (const place of created) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
});
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params

View File

@@ -101,6 +101,35 @@ router.post('/', authenticate, (req: Request, res: Response) => {
res.status(201).json({ reservation });
broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string);
// Notify trip members about new booking
import('../services/notifications').then(({ notifyTripMembers }) => {
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
notifyTripMembers(Number(tripId), authReq.user.id, 'booking_change', { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username, booking: title, type: type || 'booking' }).catch(() => {});
});
});
// Batch update day_plan_position for multiple reservations (must be before /:id)
router.put('/positions', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
const { positions } = req.body;
const trip = verifyTripOwnership(tripId, authReq.user.id);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
for (const item of items) {
stmt.run(item.day_plan_position, item.id, tripId);
}
});
updateMany(positions);
res.json({ success: true });
broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string);
});
router.put('/:id', authenticate, (req: Request, res: Response) => {

165
server/src/routes/share.ts Normal file
View File

@@ -0,0 +1,165 @@
import express, { Request, Response } from 'express';
import crypto from 'crypto';
import { db, canAccessTrip } from '../db/database';
import { authenticate } from '../middleware/auth';
import { AuthRequest } from '../types';
import { loadTagsByPlaceIds } from '../services/queryHelpers';
const router = express.Router();
// Create a share link for a trip (owner/member only)
router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const { share_map = true, share_bookings = true, share_packing = false, share_budget = false, share_collab = false } = req.body || {};
// Check if token already exists
const existing = db.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(tripId) as { token: string } | undefined;
if (existing) {
// Update permissions
db.prepare('UPDATE share_tokens SET share_map = ?, share_bookings = ?, share_packing = ?, share_budget = ?, share_collab = ? WHERE trip_id = ?')
.run(share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, tripId);
return res.json({ token: existing.token });
}
const token = crypto.randomBytes(24).toString('base64url');
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
.run(tripId, token, authReq.user.id, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0);
res.status(201).json({ token });
});
// Get share link status
router.get('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
const row = db.prepare('SELECT * FROM share_tokens WHERE trip_id = ?').get(tripId) as any;
res.json(row ? { token: row.token, created_at: row.created_at, share_map: !!row.share_map, share_bookings: !!row.share_bookings, share_packing: !!row.share_packing, share_budget: !!row.share_budget, share_collab: !!row.share_collab } : { token: null });
});
// Delete share link
router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const { tripId } = req.params;
if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' });
db.prepare('DELETE FROM share_tokens WHERE trip_id = ?').run(tripId);
res.json({ success: true });
});
// Public read-only trip data (no auth required)
router.get('/shared/:token', (req: Request, res: Response) => {
const { token } = req.params;
const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any;
if (!shareRow) return res.status(404).json({ error: 'Invalid or expired link' });
const tripId = shareRow.trip_id;
// Trip
const trip = db.prepare('SELECT id, title, description, start_date, end_date, cover_image, currency FROM trips WHERE id = ?').get(tripId);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
// Days with assignments
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as any[];
const dayIds = days.map(d => d.id);
let assignments = {};
let dayNotes = {};
if (dayIds.length > 0) {
const ph = dayIds.map(() => '?').join(',');
const allAssignments = db.prepare(`
SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description,
p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency,
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes, p.image_url, p.transport_mode,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.day_id IN (${ph})
ORDER BY da.order_index ASC
`).all(...dayIds);
const placeIds = [...new Set(allAssignments.map((a: any) => a.place_id))];
const tagsByPlace = loadTagsByPlaceIds(placeIds, { compact: true });
const byDay: Record<number, any[]> = {};
for (const a of allAssignments as any[]) {
if (!byDay[a.day_id]) byDay[a.day_id] = [];
byDay[a.day_id].push({
id: a.id, day_id: a.day_id, order_index: a.order_index, notes: a.notes,
place: {
id: a.place_id, name: a.place_name, description: a.place_description,
lat: a.lat, lng: a.lng, address: a.address, category_id: a.category_id,
price: a.price, place_time: a.place_time, end_time: a.end_time,
image_url: a.image_url, transport_mode: a.transport_mode,
category: a.category_id ? { id: a.category_id, name: a.category_name, color: a.category_color, icon: a.category_icon } : null,
tags: tagsByPlace[a.place_id] || [],
}
});
}
assignments = byDay;
const allNotes = db.prepare(`SELECT * FROM day_notes WHERE day_id IN (${ph}) ORDER BY sort_order ASC`).all(...dayIds);
const notesByDay: Record<number, any[]> = {};
for (const n of allNotes as any[]) {
if (!notesByDay[n.day_id]) notesByDay[n.day_id] = [];
notesByDay[n.day_id].push(n);
}
dayNotes = notesByDay;
}
// Places
const places = db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p LEFT JOIN categories c ON p.category_id = c.id
WHERE p.trip_id = ? ORDER BY p.created_at DESC
`).all(tripId);
// Reservations
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time ASC').all(tripId);
// Accommodations
const accommodations = db.prepare(`
SELECT a.*, p.name as place_name, p.address as place_address, p.lat as place_lat, p.lng as place_lng
FROM day_accommodations a JOIN places p ON a.place_id = p.id
WHERE a.trip_id = ?
`).all(tripId);
// Packing
const packing = db.prepare('SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC').all(tripId);
// Budget
const budget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC').all(tripId);
// Categories
const categories = db.prepare('SELECT * FROM categories').all();
const permissions = {
share_map: !!shareRow.share_map,
share_bookings: !!shareRow.share_bookings,
share_packing: !!shareRow.share_packing,
share_budget: !!shareRow.share_budget,
share_collab: !!shareRow.share_collab,
};
// Only include data the owner chose to share
const collabMessages = permissions.share_collab
? db.prepare('SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u ON m.user_id = u.id WHERE m.trip_id = ? ORDER BY m.created_at ASC').all(tripId)
: [];
res.json({
trip, days, assignments, dayNotes, places, categories, permissions,
reservations: permissions.share_bookings ? reservations : [],
accommodations: permissions.share_bookings ? accommodations : [],
packing: permissions.share_packing ? packing : [],
budget: permissions.share_budget ? budget : [],
collab: collabMessages,
});
});
export default router;

View File

@@ -284,6 +284,12 @@ router.post('/:id/members', authenticate, (req: Request, res: Response) => {
db.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(req.params.id, target.id, authReq.user.id);
// Notify invited user
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(req.params.id) as { title: string } | undefined;
import('../services/notifications').then(({ notify }) => {
notify({ userId: target.id, event: 'trip_invite', params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.username } }).catch(() => {});
});
res.status(201).json({ member: { ...target, role: 'member', avatar_url: target.avatar ? `/uploads/avatars/${target.avatar}` : null } });
});
@@ -301,4 +307,83 @@ router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response
res.json({ success: true });
});
// ICS calendar export
router.get('/:id/export.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 trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as any;
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(req.params.id) as any[];
const reservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[];
const esc = (s: string) => s.replace(/[\\;,\n]/g, m => m === '\n' ? '\\n' : '\\' + m);
const fmtDate = (d: string) => d.replace(/-/g, '');
const now = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
const uid = (id: number, type: string) => `trek-${type}-${id}@trek`;
// Format datetime: handles full ISO "2026-03-30T09:00" and time-only "10:00"
const fmtDateTime = (d: string, refDate?: string) => {
if (d.includes('T')) return d.replace(/[-:]/g, '').split('.')[0];
// Time-only: combine with reference date
if (refDate && d.match(/^\d{2}:\d{2}/)) {
const datePart = refDate.split('T')[0];
return `${datePart}T${d.replace(/:/g, '')}00`.replace(/-/g, '');
}
return d.replace(/[-:]/g, '');
};
let ics = 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//TREK//Travel Planner//EN\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\n';
ics += `X-WR-CALNAME:${esc(trip.title || 'TREK Trip')}\r\n`;
// Trip as all-day event
if (trip.start_date && trip.end_date) {
const endNext = new Date(trip.end_date + 'T00:00:00');
endNext.setDate(endNext.getDate() + 1);
const endStr = endNext.toISOString().split('T')[0].replace(/-/g, '');
ics += `BEGIN:VEVENT\r\nUID:${uid(trip.id, 'trip')}\r\nDTSTAMP:${now}\r\nDTSTART;VALUE=DATE:${fmtDate(trip.start_date)}\r\nDTEND;VALUE=DATE:${endStr}\r\nSUMMARY:${esc(trip.title || 'Trip')}\r\n`;
if (trip.description) ics += `DESCRIPTION:${esc(trip.description)}\r\n`;
ics += `END:VEVENT\r\n`;
}
// Reservations as events
for (const r of reservations) {
if (!r.reservation_time) continue;
const hasTime = r.reservation_time.includes('T');
const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};
ics += `BEGIN:VEVENT\r\nUID:${uid(r.id, 'res')}\r\nDTSTAMP:${now}\r\n`;
if (hasTime) {
ics += `DTSTART:${fmtDateTime(r.reservation_time)}\r\n`;
if (r.reservation_end_time) {
const endDt = fmtDateTime(r.reservation_end_time, r.reservation_time);
if (endDt.length >= 15) ics += `DTEND:${endDt}\r\n`;
}
} else {
ics += `DTSTART;VALUE=DATE:${fmtDate(r.reservation_time)}\r\n`;
}
ics += `SUMMARY:${esc(r.title)}\r\n`;
let desc = r.type ? `Type: ${r.type}` : '';
if (r.confirmation_number) desc += `\\nConfirmation: ${r.confirmation_number}`;
if (meta.airline) desc += `\\nAirline: ${meta.airline}`;
if (meta.flight_number) desc += `\\nFlight: ${meta.flight_number}`;
if (meta.departure_airport) desc += `\\nFrom: ${meta.departure_airport}`;
if (meta.arrival_airport) desc += `\\nTo: ${meta.arrival_airport}`;
if (meta.train_number) desc += `\\nTrain: ${meta.train_number}`;
if (r.notes) desc += `\\n${r.notes}`;
if (desc) ics += `DESCRIPTION:${desc}\r\n`;
if (r.location) ics += `LOCATION:${esc(r.location)}\r\n`;
ics += `END:VEVENT\r\n`;
}
ics += 'END:VCALENDAR\r\n';
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${esc(trip.title || 'trek-trip')}.ics"`);
res.send(ics);
});
export default router;

View File

@@ -349,6 +349,11 @@ router.post('/invite', (req: Request, res: Response) => {
});
} catch { /* websocket not available */ }
// Notify invited user
import('../services/notifications').then(({ notify }) => {
notify({ userId: user_id, event: 'vacay_invite', params: { actor: authReq.user.username } }).catch(() => {});
});
res.json({ success: true });
});

View File

@@ -8,30 +8,48 @@ const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../uploads');
const settingsFile = path.join(dataDir, 'backup-settings.json');
const CRON_EXPRESSIONS: Record<string, string> = {
hourly: '0 * * * *',
daily: '0 2 * * *',
weekly: '0 2 * * 0',
monthly: '0 2 1 * *',
};
const VALID_INTERVALS = Object.keys(CRON_EXPRESSIONS);
const VALID_INTERVALS = ['hourly', 'daily', 'weekly', 'monthly'];
const VALID_DAYS_OF_WEEK = [0, 1, 2, 3, 4, 5, 6]; // 0=Sunday
const VALID_HOURS = Array.from({ length: 24 }, (_, i) => i);
interface BackupSettings {
enabled: boolean;
interval: string;
keep_days: number;
hour: number;
day_of_week: number;
day_of_month: number;
}
function buildCronExpression(settings: BackupSettings): string {
const hour = VALID_HOURS.includes(settings.hour) ? settings.hour : 2;
const dow = VALID_DAYS_OF_WEEK.includes(settings.day_of_week) ? settings.day_of_week : 0;
const dom = settings.day_of_month >= 1 && settings.day_of_month <= 28 ? settings.day_of_month : 1;
switch (settings.interval) {
case 'hourly': return '0 * * * *';
case 'daily': return `0 ${hour} * * *`;
case 'weekly': return `0 ${hour} * * ${dow}`;
case 'monthly': return `0 ${hour} ${dom} * *`;
default: return `0 ${hour} * * *`;
}
}
let currentTask: ScheduledTask | null = null;
function getDefaults(): BackupSettings {
return { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 };
}
function loadSettings(): BackupSettings {
let settings = getDefaults();
try {
if (fs.existsSync(settingsFile)) {
return JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
const saved = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
settings = { ...settings, ...saved };
}
} catch (e) {}
return { enabled: false, interval: 'daily', keep_days: 7 };
return settings;
}
function saveSettings(settings: BackupSettings): void {
@@ -104,9 +122,10 @@ function start(): void {
return;
}
const expression = CRON_EXPRESSIONS[settings.interval] || CRON_EXPRESSIONS.daily;
currentTask = cron.schedule(expression, runBackup);
console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
const expression = buildCronExpression(settings);
const tz = process.env.TZ || 'UTC';
currentTask = cron.schedule(expression, runBackup, { timezone: tz });
console.log(`[Auto-Backup] Scheduled: ${settings.interval} (${expression}), tz: ${tz}, retention: ${settings.keep_days === 0 ? 'forever' : settings.keep_days + ' days'}`);
}
// Demo mode: hourly reset of demo user data

View File

@@ -0,0 +1,43 @@
import * as crypto from 'crypto';
import { JWT_SECRET } from '../config';
const ENCRYPTED_PREFIX = 'enc:v1:';
function get_key() {
return crypto.createHash('sha256').update(`${JWT_SECRET}:api_keys:v1`).digest();
}
export function encrypt_api_key(plain: unknown) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', get_key(), iv);
const enc = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
const blob = Buffer.concat([iv, tag, enc]).toString('base64');
return `${ENCRYPTED_PREFIX}${blob}`;
}
export function decrypt_api_key(value: unknown) {
if (!value) return null;
if (typeof value !== 'string') return null;
if (!value.startsWith(ENCRYPTED_PREFIX)) return value; // legacy plaintext
const blob = value.slice(ENCRYPTED_PREFIX.length);
try {
const buf = Buffer.from(blob, 'base64');
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const enc = buf.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', get_key(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
} catch {
return null;
}
}
export function maybe_encrypt_api_key(value: unknown) {
const trimmed = String(value || '').trim();
if (!trimmed) return null;
if (trimmed.startsWith(ENCRYPTED_PREFIX)) return trimmed;
return encrypt_api_key(trimmed);
}

View File

@@ -0,0 +1,30 @@
import { Request } from 'express';
import { db } from '../db/database';
export function getClientIp(req: Request): string | null {
const xff = req.headers['x-forwarded-for'];
if (typeof xff === 'string') {
const first = xff.split(',')[0]?.trim();
return first || null;
}
if (Array.isArray(xff) && xff[0]) return String(xff[0]).trim() || null;
return req.socket?.remoteAddress || null;
}
/** Best-effort; never throws — failures are logged only. */
export function writeAudit(entry: {
userId: number | null;
action: string;
resource?: string | null;
details?: Record<string, unknown>;
ip?: string | null;
}): void {
try {
const detailsJson = entry.details && Object.keys(entry.details).length > 0 ? JSON.stringify(entry.details) : null;
db.prepare(
`INSERT INTO audit_log (user_id, action, resource, details, ip) VALUES (?, ?, ?, ?, ?)`
).run(entry.userId, entry.action, entry.resource ?? null, detailsJson, entry.ip ?? null);
} catch (e) {
console.error('[audit] write failed:', e instanceof Error ? e.message : e);
}
}

View File

@@ -0,0 +1,299 @@
import nodemailer from 'nodemailer';
import fetch from 'node-fetch';
import { db } from '../db/database';
// ── Types ──────────────────────────────────────────────────────────────────
type EventType = 'trip_invite' | 'booking_change' | 'trip_reminder' | 'vacay_invite' | 'photos_shared' | 'collab_message' | 'packing_tagged';
interface NotificationPayload {
userId: number;
event: EventType;
params: Record<string, string>;
}
interface SmtpConfig {
host: string;
port: number;
user: string;
pass: string;
from: string;
secure: boolean;
}
// ── Settings helpers ───────────────────────────────────────────────────────
function getAppSetting(key: string): string | null {
return (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
}
function getSmtpConfig(): SmtpConfig | null {
const host = process.env.SMTP_HOST || getAppSetting('smtp_host');
const port = process.env.SMTP_PORT || getAppSetting('smtp_port');
const user = process.env.SMTP_USER || getAppSetting('smtp_user');
const pass = process.env.SMTP_PASS || getAppSetting('smtp_pass');
const from = process.env.SMTP_FROM || getAppSetting('smtp_from');
if (!host || !port || !from) return null;
return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 };
}
function getWebhookUrl(): string | null {
return process.env.NOTIFICATION_WEBHOOK_URL || getAppSetting('notification_webhook_url');
}
function getAppUrl(): string {
return process.env.APP_URL || getAppSetting('app_url') || '';
}
function getUserEmail(userId: number): string | null {
return (db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined)?.email || null;
}
function getUserLanguage(userId: number): string {
return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en';
}
function getUserPrefs(userId: number): Record<string, number> {
const row = db.prepare('SELECT * FROM notification_preferences WHERE user_id = ?').get(userId) as any;
return row || { notify_trip_invite: 1, notify_booking_change: 1, notify_trip_reminder: 1, notify_vacay_invite: 1, notify_photos_shared: 1, notify_collab_message: 1, notify_packing_tagged: 1, notify_webhook: 0 };
}
// Event → preference column mapping
const EVENT_PREF_MAP: Record<EventType, string> = {
trip_invite: 'notify_trip_invite',
booking_change: 'notify_booking_change',
trip_reminder: 'notify_trip_reminder',
vacay_invite: 'notify_vacay_invite',
photos_shared: 'notify_photos_shared',
collab_message: 'notify_collab_message',
packing_tagged: 'notify_packing_tagged',
};
// ── Email i18n strings ─────────────────────────────────────────────────────
interface EmailStrings { footer: string; manage: string; madeWith: string; openTrek: string }
const I18N: Record<string, EmailStrings> = {
en: { footer: 'You received this because you have notifications enabled in TREK.', manage: 'Manage preferences in Settings', madeWith: 'Made with', openTrek: 'Open TREK' },
de: { footer: 'Du erhältst diese E-Mail, weil du Benachrichtigungen in TREK aktiviert hast.', manage: 'Einstellungen verwalten', madeWith: 'Made with', openTrek: 'TREK öffnen' },
fr: { footer: 'Vous recevez cet e-mail car les notifications sont activées dans TREK.', manage: 'Gérer les préférences', madeWith: 'Made with', openTrek: 'Ouvrir TREK' },
es: { footer: 'Recibiste esto porque tienes las notificaciones activadas en TREK.', manage: 'Gestionar preferencias', madeWith: 'Made with', openTrek: 'Abrir TREK' },
nl: { footer: 'Je ontvangt dit omdat je meldingen hebt ingeschakeld in TREK.', manage: 'Voorkeuren beheren', madeWith: 'Made with', openTrek: 'TREK openen' },
ru: { footer: 'Вы получили это, потому что у вас включены уведомления в TREK.', manage: 'Управление настройками', madeWith: 'Made with', openTrek: 'Открыть TREK' },
zh: { footer: '您收到此邮件是因为您在 TREK 中启用了通知。', manage: '管理偏好设置', madeWith: 'Made with', openTrek: '打开 TREK' },
ar: { footer: 'تلقيت هذا لأنك قمت بتفعيل الإشعارات في TREK.', manage: 'إدارة التفضيلات', madeWith: 'Made with', openTrek: 'فتح TREK' },
};
// Translated notification texts per event type
interface EventText { title: string; body: string }
type EventTextFn = (params: Record<string, string>) => EventText
const EVENT_TEXTS: Record<string, Record<EventType, EventTextFn>> = {
en: {
trip_invite: p => ({ title: `You've been invited to "${p.trip}"`, body: `${p.actor} invited you to the trip "${p.trip}". Open TREK to view and start planning!` }),
booking_change: p => ({ title: `New booking: ${p.booking}`, body: `${p.actor} added a new ${p.type} "${p.booking}" to "${p.trip}".` }),
trip_reminder: p => ({ title: `Trip reminder: ${p.trip}`, body: `Your trip "${p.trip}" is coming up soon!` }),
vacay_invite: p => ({ title: 'Vacay Fusion Invite', body: `${p.actor} invited you to fuse vacation plans. Open TREK to accept or decline.` }),
photos_shared: p => ({ title: `${p.count} photos shared`, body: `${p.actor} shared ${p.count} photo(s) in "${p.trip}".` }),
collab_message: p => ({ title: `New message in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Packing: ${p.category}`, body: `${p.actor} assigned you to the "${p.category}" packing category in "${p.trip}".` }),
},
de: {
trip_invite: p => ({ title: `Einladung zu "${p.trip}"`, body: `${p.actor} hat dich zur Reise "${p.trip}" eingeladen. Öffne TREK um die Planung zu starten!` }),
booking_change: p => ({ title: `Neue Buchung: ${p.booking}`, body: `${p.actor} hat eine neue Buchung "${p.booking}" (${p.type}) zu "${p.trip}" hinzugefügt.` }),
trip_reminder: p => ({ title: `Reiseerinnerung: ${p.trip}`, body: `Deine Reise "${p.trip}" steht bald an!` }),
vacay_invite: p => ({ title: 'Vacay Fusion-Einladung', body: `${p.actor} hat dich eingeladen, Urlaubspläne zu fusionieren. Öffne TREK um anzunehmen oder abzulehnen.` }),
photos_shared: p => ({ title: `${p.count} Fotos geteilt`, body: `${p.actor} hat ${p.count} Foto(s) in "${p.trip}" geteilt.` }),
collab_message: p => ({ title: `Neue Nachricht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Packliste: ${p.category}`, body: `${p.actor} hat dich der Kategorie "${p.category}" in der Packliste von "${p.trip}" zugewiesen.` }),
},
fr: {
trip_invite: p => ({ title: `Invitation à "${p.trip}"`, body: `${p.actor} vous a invité au voyage "${p.trip}". Ouvrez TREK pour commencer la planification !` }),
booking_change: p => ({ title: `Nouvelle réservation : ${p.booking}`, body: `${p.actor} a ajouté une réservation "${p.booking}" (${p.type}) à "${p.trip}".` }),
trip_reminder: p => ({ title: `Rappel de voyage : ${p.trip}`, body: `Votre voyage "${p.trip}" approche !` }),
vacay_invite: p => ({ title: 'Invitation Vacay Fusion', body: `${p.actor} vous invite à fusionner les plans de vacances. Ouvrez TREK pour accepter ou refuser.` }),
photos_shared: p => ({ title: `${p.count} photos partagées`, body: `${p.actor} a partagé ${p.count} photo(s) dans "${p.trip}".` }),
collab_message: p => ({ title: `Nouveau message dans "${p.trip}"`, body: `${p.actor} : ${p.preview}` }),
packing_tagged: p => ({ title: `Bagages : ${p.category}`, body: `${p.actor} vous a assigné à la catégorie "${p.category}" dans "${p.trip}".` }),
},
es: {
trip_invite: p => ({ title: `Invitación a "${p.trip}"`, body: `${p.actor} te invitó al viaje "${p.trip}". ¡Abre TREK para comenzar a planificar!` }),
booking_change: p => ({ title: `Nueva reserva: ${p.booking}`, body: `${p.actor} añadió una reserva "${p.booking}" (${p.type}) a "${p.trip}".` }),
trip_reminder: p => ({ title: `Recordatorio: ${p.trip}`, body: `¡Tu viaje "${p.trip}" se acerca!` }),
vacay_invite: p => ({ title: 'Invitación Vacay Fusion', body: `${p.actor} te invitó a fusionar planes de vacaciones. Abre TREK para aceptar o rechazar.` }),
photos_shared: p => ({ title: `${p.count} fotos compartidas`, body: `${p.actor} compartió ${p.count} foto(s) en "${p.trip}".` }),
collab_message: p => ({ title: `Nuevo mensaje en "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Equipaje: ${p.category}`, body: `${p.actor} te asignó a la categoría "${p.category}" en "${p.trip}".` }),
},
nl: {
trip_invite: p => ({ title: `Uitgenodigd voor "${p.trip}"`, body: `${p.actor} heeft je uitgenodigd voor de reis "${p.trip}". Open TREK om te beginnen met plannen!` }),
booking_change: p => ({ title: `Nieuwe boeking: ${p.booking}`, body: `${p.actor} heeft een boeking "${p.booking}" (${p.type}) toegevoegd aan "${p.trip}".` }),
trip_reminder: p => ({ title: `Reisherinnering: ${p.trip}`, body: `Je reis "${p.trip}" komt eraan!` }),
vacay_invite: p => ({ title: 'Vacay Fusion uitnodiging', body: `${p.actor} nodigt je uit om vakantieplannen te fuseren. Open TREK om te accepteren of af te wijzen.` }),
photos_shared: p => ({ title: `${p.count} foto's gedeeld`, body: `${p.actor} heeft ${p.count} foto('s) gedeeld in "${p.trip}".` }),
collab_message: p => ({ title: `Nieuw bericht in "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Paklijst: ${p.category}`, body: `${p.actor} heeft je toegewezen aan de categorie "${p.category}" in "${p.trip}".` }),
},
ru: {
trip_invite: p => ({ title: `Приглашение в "${p.trip}"`, body: `${p.actor} пригласил вас в поездку "${p.trip}". Откройте TREK чтобы начать планирование!` }),
booking_change: p => ({ title: `Новое бронирование: ${p.booking}`, body: `${p.actor} добавил бронирование "${p.booking}" (${p.type}) в "${p.trip}".` }),
trip_reminder: p => ({ title: `Напоминание: ${p.trip}`, body: `Ваша поездка "${p.trip}" скоро начнётся!` }),
vacay_invite: p => ({ title: 'Приглашение Vacay Fusion', body: `${p.actor} приглашает вас объединить планы отпуска. Откройте TREK для подтверждения.` }),
photos_shared: p => ({ title: `${p.count} фото`, body: `${p.actor} поделился ${p.count} фото в "${p.trip}".` }),
collab_message: p => ({ title: `Новое сообщение в "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `Список вещей: ${p.category}`, body: `${p.actor} назначил вас в категорию "${p.category}" в "${p.trip}".` }),
},
zh: {
trip_invite: p => ({ title: `邀请加入"${p.trip}"`, body: `${p.actor} 邀请你加入旅行"${p.trip}"。打开 TREK 开始规划!` }),
booking_change: p => ({ title: `新预订:${p.booking}`, body: `${p.actor} 在"${p.trip}"中添加了预订"${p.booking}"${p.type})。` }),
trip_reminder: p => ({ title: `旅行提醒:${p.trip}`, body: `你的旅行"${p.trip}"即将开始!` }),
vacay_invite: p => ({ title: 'Vacay 融合邀请', body: `${p.actor} 邀请你合并假期计划。打开 TREK 接受或拒绝。` }),
photos_shared: p => ({ title: `${p.count} 张照片已分享`, body: `${p.actor} 在"${p.trip}"中分享了 ${p.count} 张照片。` }),
collab_message: p => ({ title: `"${p.trip}"中的新消息`, body: `${p.actor}${p.preview}` }),
packing_tagged: p => ({ title: `行李清单:${p.category}`, body: `${p.actor} 将你分配到"${p.trip}"中的"${p.category}"类别。` }),
},
ar: {
trip_invite: p => ({ title: `دعوة إلى "${p.trip}"`, body: `${p.actor} دعاك إلى الرحلة "${p.trip}". افتح TREK لبدء التخطيط!` }),
booking_change: p => ({ title: `حجز جديد: ${p.booking}`, body: `${p.actor} أضاف حجز "${p.booking}" (${p.type}) إلى "${p.trip}".` }),
trip_reminder: p => ({ title: `تذكير: ${p.trip}`, body: `رحلتك "${p.trip}" تقترب!` }),
vacay_invite: p => ({ title: 'دعوة دمج الإجازة', body: `${p.actor} يدعوك لدمج خطط الإجازة. افتح TREK للقبول أو الرفض.` }),
photos_shared: p => ({ title: `${p.count} صور مشتركة`, body: `${p.actor} شارك ${p.count} صورة في "${p.trip}".` }),
collab_message: p => ({ title: `رسالة جديدة في "${p.trip}"`, body: `${p.actor}: ${p.preview}` }),
packing_tagged: p => ({ title: `قائمة التعبئة: ${p.category}`, body: `${p.actor} عيّنك في فئة "${p.category}" في "${p.trip}".` }),
},
};
// Get localized event text
function getEventText(lang: string, event: EventType, params: Record<string, string>): EventText {
const texts = EVENT_TEXTS[lang] || EVENT_TEXTS.en;
return texts[event](params);
}
// ── Email HTML builder ─────────────────────────────────────────────────────
function buildEmailHtml(subject: string, body: string, lang: string): string {
const s = I18N[lang] || I18N.en;
const appUrl = getAppUrl();
const ctaHref = appUrl || '#';
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin: 0; padding: 0; background-color: #f3f4f6; font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f3f4f6; padding: 40px 20px;">
<tr><td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 24px rgba(0,0,0,0.06);">
<!-- Header -->
<tr><td style="background: linear-gradient(135deg, #000000 0%, #1a1a2e 100%); padding: 32px 32px 28px; text-align: center;">
<img src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MTIgNTEyIj4NCiAgPGRlZnM+DQogICAgPGxpbmVhckdyYWRpZW50IGlkPSJiZyIgeDE9IjAiIHkxPSIwIiB4Mj0iMSIgeTI9IjEiPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iIzFlMjkzYiIvPg0KICAgICAgPHN0b3Agb2Zmc2V0PSIxMDAlIiBzdG9wLWNvbG9yPSIjMGYxNzJhIi8+DQogICAgPC9saW5lYXJHcmFkaWVudD4NCiAgICA8Y2xpcFBhdGggaWQ9Imljb24iPg0KICAgICAgPHBhdGggZD0iTSA4NTUuNjM2NzE5IDY5OS4yMDMxMjUgTCAyMjIuMjQ2MDk0IDY5OS4yMDMxMjUgQyAxOTcuNjc5Njg4IDY5OS4yMDMxMjUgMTc5LjkwNjI1IDY3NS43NSAxODYuNTM5MDYyIDY1Mi4xMDE1NjIgTCAzNjAuNDI5Njg4IDMyLjM5MDYyNSBDIDM2NC45MjE4NzUgMTYuMzg2NzE5IDM3OS41MTE3MTkgNS4zMjgxMjUgMzk2LjEzMjgxMiA1LjMyODEyNSBMIDEwMjkuNTI3MzQ0IDUuMzI4MTI1IEMgMTA1NC4wODk4NDQgNS4zMjgxMjUgMTA3MS44NjcxODggMjguNzc3MzQ0IDEwNjUuMjMwNDY5IDUyLjQyOTY4OCBMIDg5MS4zMzk4NDQgNjcyLjEzNjcxOSBDIDg4Ni44NTE1NjIgNjg4LjE0MDYyNSA4NzIuMjU3ODEyIDY5OS4yMDMxMjUgODU1LjYzNjcxOSA2OTkuMjAzMTI1IFogTSA0NDQuMjM4MjgxIDExNjYuOTgwNDY5IEwgNTMzLjc3MzQzOCA4NDcuODk4NDM4IEMgNTQwLjQxMDE1NiA4MjQuMjQ2MDk0IDUyMi42MzI4MTIgODAwLjc5Njg3NSA0OTguMDcwMzEyIDgwMC43OTY4NzUgTCAxNzIuNDcyNjU2IDgwMC43OTY4NzUgQyAxNTUuODUxNTYyIDgwMC43OTY4NzUgMTQxLjI2MTcxOSA4MTEuODU1NDY5IDEzNi43Njk1MzEgODI3Ljg1OTM3NSBMIDQ3LjIzNDM3NSAxMTQ2Ljk0MTQwNiBDIDQwLjU5NzY1NiAxMTcwLjU5Mzc1IDU4LjM3NSAxMTk0LjA0Mjk2OSA4Mi45Mzc1IDExOTQuMDQyOTY5IEwgNDA4LjUzNTE1NiAxMTk0LjA0Mjk2OSBDIDQyNS4xNTYyNSAxMTk0LjA0Mjk2OSA0MzkuNzUgMTE4Mi45ODQzNzUgNDQ0LjIzODI4MSAxMTY2Ljk4MDQ2OSBaIE0gNjA5LjAwMzkwNiA4MjcuODU5Mzc1IEwgNDM1LjExMzI4MSAxNDQ3LjU3MDMxMiBDIDQyOC40NzY1NjIgMTQ3MS4yMTg3NSA0NDYuMjUzOTA2IDE0OTQuNjcxODc1IDQ3MC44MTY0MDYgMTQ5NC42NzE4NzUgTCAxMTA0LjIxMDkzOCAxNDk0LjY3MTg3NSBDIDExMjAuODMyMDMxIDE0OTQuNjcxODc1IDExMzUuNDIxODc1IDE0ODMuNjA5Mzc1IDExMzkuOTE0MDYyIDE0NjcuNjA1NDY5IEwgMTMxMy44MDQ2ODggODQ3Ljg5ODQzOCBDIDEzMjAuNDQxNDA2IDgyNC4yNDYwOTQgMTMwMi42NjQwNjIgODAwLjc5Njg3NSAxMjc4LjEwMTU2MiA4MDAuNzk2ODc1IEwgNjQ0LjcwNzAzMSA4MDAuNzk2ODc1IEMgNjI4LjA4NTkzOCA4MDAuNzk2ODc1IDYxMy40OTIxODggODExLjg1NTQ2OSA2MDkuMDAzOTA2IDgyNy44NTkzNzUgWiBNIDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgTCA5NjYuNTcwMzEyIDY1Mi4xMDE1NjIgQyA5NTkuOTMzNTk0IDY3NS43NSA5NzcuNzEwOTM4IDY5OS4yMDMxMjUgMTAwMi4yNzM0MzggNjk5LjIwMzEyNSBMIDEzMjcuODcxMDk0IDY5OS4yMDMxMjUgQyAxMzQ0LjQ5MjE4OCA2OTkuMjAzMTI1IDEzNTkuMDg1OTM4IDY4OC4xNDA2MjUgMTM2My41NzQyMTkgNjcyLjEzNjcxOSBMIDE0NTMuMTA5Mzc1IDM1My4wNTQ2ODggQyAxNDU5Ljc0NjA5NCAzMjkuNDA2MjUgMTQ0MS45Njg3NSAzMDUuOTUzMTI1IDE0MTcuNDA2MjUgMzA1Ljk1MzEyNSBMIDEwOTEuODA4NTk0IDMwNS45NTMxMjUgQyAxMDc1LjE4NzUgMzA1Ljk1MzEyNSAxMDYwLjU5NzY1NiAzMTcuMDE1NjI1IDEwNTYuMTA1NDY5IDMzMy4wMTk1MzEgWiIvPg0KICAgIDwvY2xpcFBhdGg+DQogIDwvZGVmcz4NCiAgPHJlY3Qgd2lkdGg9IjUxMiIgaGVpZ2h0PSI1MTIiIGZpbGw9InVybCgjYmcpIi8+DQogIDxnIHRyYW5zZm9ybT0idHJhbnNsYXRlKDU2LDUxKSBzY2FsZSgwLjI2NykiPg0KICAgIDxyZWN0IHdpZHRoPSIxNTAwIiBoZWlnaHQ9IjE1MDAiIGZpbGw9IiNmZmZmZmYiIGNsaXAtcGF0aD0idXJsKCNpY29uKSIvPg0KICA8L2c+DQo8L3N2Zz4NCg==" alt="TREK" width="48" height="48" style="border-radius: 14px; margin-bottom: 14px; display: block; margin-left: auto; margin-right: auto;" />
<div style="color: #ffffff; font-size: 24px; font-weight: 700; letter-spacing: -0.5px;">TREK</div>
<div style="color: rgba(255,255,255,0.4); font-size: 10px; font-weight: 500; letter-spacing: 2px; text-transform: uppercase; margin-top: 4px;">Travel Resource &amp; Exploration Kit</div>
</td></tr>
<!-- Content -->
<tr><td style="padding: 32px 32px 16px;">
<h1 style="margin: 0 0 8px; font-size: 18px; font-weight: 700; color: #111827; line-height: 1.3;">${subject}</h1>
<div style="width: 32px; height: 3px; background: #111827; border-radius: 2px; margin-bottom: 20px;"></div>
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${body}</p>
</td></tr>
<!-- CTA -->
${appUrl ? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
<a href="${ctaHref}" style="display: inline-block; padding: 12px 28px; background: #111827; color: #ffffff; font-size: 13px; font-weight: 600; text-decoration: none; border-radius: 10px; letter-spacing: 0.2px;">${s.openTrek}</a>
</td></tr>` : ''}
<!-- Footer -->
<tr><td style="padding: 20px 32px; background: #f9fafb; border-top: 1px solid #f3f4f6; text-align: center;">
<p style="margin: 0 0 8px; font-size: 11px; color: #9ca3af; line-height: 1.5;">${s.footer}<br>${s.manage}</p>
<p style="margin: 0; font-size: 10px; color: #d1d5db;">${s.madeWith} <span style="color: #ef4444;">&hearts;</span> by Maurice &middot; <a href="https://github.com/mauriceboe/TREK" style="color: #9ca3af; text-decoration: none;">GitHub</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
// ── Send functions ─────────────────────────────────────────────────────────
async function sendEmail(to: string, subject: string, body: string, userId?: number): Promise<boolean> {
const config = getSmtpConfig();
if (!config) return false;
const lang = userId ? getUserLanguage(userId) : 'en';
try {
const skipTls = process.env.SMTP_SKIP_TLS_VERIFY === 'true' || getAppSetting('smtp_skip_tls_verify') === 'true';
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.user ? { user: config.user, pass: config.pass } : undefined,
...(skipTls ? { tls: { rejectUnauthorized: false } } : {}),
});
await transporter.sendMail({
from: config.from,
to,
subject: `TREK — ${subject}`,
text: body,
html: buildEmailHtml(subject, body, lang),
});
return true;
} catch (err) {
console.error('[Notifications] Email send failed:', err instanceof Error ? err.message : err);
return false;
}
}
async function sendWebhook(payload: { event: string; title: string; body: string; tripName?: string }): Promise<boolean> {
const url = getWebhookUrl();
if (!url) return false;
try {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' }),
signal: AbortSignal.timeout(10000),
});
return true;
} catch (err) {
console.error('[Notifications] Webhook failed:', err instanceof Error ? err.message : err);
return false;
}
}
// ── Public API ─────────────────────────────────────────────────────────────
export async function notify(payload: NotificationPayload): Promise<void> {
const prefs = getUserPrefs(payload.userId);
const prefKey = EVENT_PREF_MAP[payload.event];
if (prefKey && !prefs[prefKey]) return;
const lang = getUserLanguage(payload.userId);
const { title, body } = getEventText(lang, payload.event, payload.params);
const email = getUserEmail(payload.userId);
if (email) await sendEmail(email, title, body, payload.userId);
if (prefs.notify_webhook) await sendWebhook({ event: payload.event, title, body, tripName: payload.params.trip });
}
export async function notifyTripMembers(tripId: number, actorUserId: number, event: EventType, params: Record<string, string>): Promise<void> {
const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(tripId) as { user_id: number } | undefined;
if (!trip) return;
const members = db.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(tripId) as { user_id: number }[];
const allIds = [trip.user_id, ...members.map(m => m.user_id)].filter(id => id !== actorUserId);
const unique = [...new Set(allIds)];
for (const userId of unique) {
await notify({ userId, event, params });
}
}
export async function testSmtp(to: string): Promise<{ success: boolean; error?: string }> {
try {
const sent = await sendEmail(to, 'Test Notification', 'This is a test email from TREK. If you received this, your SMTP configuration is working correctly.');
return sent ? { success: true } : { success: false, error: 'SMTP not configured' };
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
}

View File

@@ -15,6 +15,7 @@ export interface User {
last_login?: string | null;
mfa_enabled?: number | boolean;
mfa_secret?: string | null;
mfa_backup_codes?: string | null;
created_at?: string;
updated_at?: string;
}

View File

@@ -55,12 +55,18 @@ function setupWebSocket(server: http.Server): void {
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: number };
user = db.prepare(
'SELECT id, username, email, role FROM users WHERE id = ?'
'SELECT id, username, email, role, mfa_enabled FROM users WHERE id = ?'
).get(decoded.id) as User | undefined;
if (!user) {
nws.close(4001, 'User not found');
return;
}
const requireMfa = (db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined)?.value === 'true';
const mfaOk = user.mfa_enabled === 1 || user.mfa_enabled === true;
if (requireMfa && !mfaOk) {
nws.close(4403, 'MFA required');
return;
}
} catch (err: unknown) {
nws.close(4001, 'Invalid or expired token');
return;