mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
2a37eeccb3
* fix(packing): resolve avatar URL path in bag and category assignees (#854) packingService was returning raw avatar filenames from the DB instead of the full /uploads/avatars/<filename> path, causing broken profile images for users with uploaded avatars. * fix(budget): use Map.get() to fix category rename no-op (#855) * fix(security): relax Referrer-Policy and document HSTS_INCLUDE_SUBDOMAINS (#862) (#863) - Change Helmet default from no-referrer to strict-origin-when-cross-origin so browsers send the origin on cross-origin requests, allowing Google Maps API key restrictions by HTTP referrer to work correctly - Document HSTS_INCLUDE_SUBDOMAINS in all deployment artifacts: .env.example, docker-compose.yml, README.md, unraid-template.xml, charts/values.yaml, charts/configmap.yaml, wiki/Environment-Variables.md * fix(planner): prefetch budget items on trip page mount (#864) Loads budgetItems alongside reservations when TripPlannerPage mounts so the Budget category dropdown in ReservationModal and TransportModal shows pre-existing categories on first open, regardless of whether the Budget tab has been visited. Closes #861 * fix(reservations): prevent Invalid Date when end time is set without end date (#866) When reservation_end_time held a bare time string ("HH:MM"), fmtDate() produced Invalid Date on the reservation card. - Modal: when end date is blank but end time is filled, construct a same-day ISO datetime using the start date (prevents time-only strings from ever being persisted) - Panel: derive endDatePart via regex so date-only end values ("YYYY-MM-DD") still show the multi-day range, while bare time strings are skipped and handled correctly by the existing time column logic Closes #860 * fix(planner): format reservation end time instead of rendering raw ISO string (#867) Closes #859 * fix(planner): wire Route toggle into mobile day sidebar (#850) (#868) The per-booking Route icon was missing on mobile because the mobile DayPlanSidebar invocation in TripPlannerPage didn't pass visibleConnectionIds or onToggleConnection. Mobile PWA users couldn't activate reservation map overlays without forcing desktop mode. Also corrects the Map-Features wiki: fixes the setting name ("Booking route labels" not "Show connection labels"), documents the route_calculation requirement for travel-time pills, and explains that overlays are off by default and must be toggled per reservation.
384 lines
17 KiB
TypeScript
384 lines
17 KiB
TypeScript
import express, { Request, Response, NextFunction } from 'express';
|
|
import cors from 'cors';
|
|
import helmet from 'helmet';
|
|
import cookieParser from 'cookie-parser';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
|
|
import { logDebug, logWarn, logError } from './services/auditLog';
|
|
import { enforceGlobalMfaPolicy } from './middleware/mfaPolicy';
|
|
import { authenticate, verifyJwtAndLoadUser } from './middleware/auth';
|
|
import { db } from './db/database';
|
|
|
|
import authRoutes from './routes/auth';
|
|
import tripsRoutes from './routes/trips';
|
|
import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days';
|
|
import placesRoutes from './routes/places';
|
|
import assignmentsRoutes from './routes/assignments';
|
|
import packingRoutes from './routes/packing';
|
|
import todoRoutes from './routes/todo';
|
|
import tagsRoutes from './routes/tags';
|
|
import categoriesRoutes from './routes/categories';
|
|
import adminRoutes from './routes/admin';
|
|
import mapsRoutes from './routes/maps';
|
|
import airportsRoutes from './routes/airports';
|
|
import filesRoutes from './routes/files';
|
|
import reservationsRoutes from './routes/reservations';
|
|
import dayNotesRoutes from './routes/dayNotes';
|
|
import weatherRoutes from './routes/weather';
|
|
import settingsRoutes from './routes/settings';
|
|
import budgetRoutes from './routes/budget';
|
|
import collabRoutes from './routes/collab';
|
|
import backupRoutes from './routes/backup';
|
|
import oidcRoutes from './routes/oidc';
|
|
import { oauthPublicRouter, oauthApiRouter } from './routes/oauth';
|
|
import vacayRoutes from './routes/vacay';
|
|
import atlasRoutes from './routes/atlas';
|
|
import memoriesRoutes from './routes/memories/unified';
|
|
import photoRoutes from './routes/photos';
|
|
import notificationRoutes from './routes/notifications';
|
|
import shareRoutes from './routes/share';
|
|
import journeyRoutes from './routes/journey';
|
|
import journeyPublicRoutes from './routes/journeyPublic';
|
|
import publicConfigRoutes from './routes/publicConfig';
|
|
import systemNoticesRoutes from './routes/systemNotices';
|
|
import { mcpHandler } from './mcp';
|
|
import { Addon } from './types';
|
|
import { getPhotoProviderConfig } from './services/memories/helpersService';
|
|
import { getCollabFeatures } from './services/adminService';
|
|
import { isAddonEnabled } from './services/adminService';
|
|
import { ADDON_IDS } from './addons';
|
|
|
|
export function createApp(): express.Application {
|
|
const app = express();
|
|
|
|
// Trust first proxy (nginx/Docker) for correct req.ip
|
|
if (process.env.NODE_ENV === 'production' || process.env.TRUST_PROXY) {
|
|
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
|
|
}
|
|
|
|
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
|
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
|
|
: null;
|
|
|
|
let corsOrigin: cors.CorsOptions['origin'];
|
|
if (allowedOrigins) {
|
|
corsOrigin = (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => {
|
|
if (!origin || allowedOrigins.includes(origin)) callback(null, true);
|
|
else callback(new Error('Not allowed by CORS'));
|
|
};
|
|
} else if (process.env.NODE_ENV === 'production') {
|
|
corsOrigin = false;
|
|
} else {
|
|
corsOrigin = true;
|
|
}
|
|
|
|
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
|
// HSTS is worth enabling any time we're serving production traffic,
|
|
// not only when FORCE_HTTPS is set. Self-hosters behind Traefik /
|
|
// Caddy / Cloudflare Tunnel typically leave FORCE_HTTPS unset (the
|
|
// proxy handles the redirect for them), and the previous "HSTS off by
|
|
// default" meant those instances never advertised HSTS at all.
|
|
//
|
|
// `includeSubDomains` stays OFF by default on purpose: an instance
|
|
// running on an apex domain would otherwise force HTTPS on every
|
|
// sibling subdomain the same operator may still be running over plain
|
|
// HTTP. Operators who want the stricter policy opt in with
|
|
// `HSTS_INCLUDE_SUBDOMAINS=true`.
|
|
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
|
|
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true';
|
|
|
|
// RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config
|
|
app.use(
|
|
['/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource'],
|
|
cors({ origin: '*', credentials: false }),
|
|
);
|
|
app.use(cors({ origin: corsOrigin, credentials: true }));
|
|
app.use(helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
|
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
|
connectSrc: [
|
|
"'self'", "ws:", "wss:",
|
|
"https://nominatim.openstreetmap.org", "https://overpass-api.de",
|
|
"https://places.googleapis.com", "https://api.openweathermap.org",
|
|
"https://en.wikipedia.org", "https://commons.wikimedia.org",
|
|
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
|
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
|
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
|
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
|
|
"https://router.project-osrm.org/route/v1/",
|
|
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
|
|
],
|
|
workerSrc: ["'self'", "blob:"],
|
|
childSrc: ["'self'", "blob:"],
|
|
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
|
objectSrc: ["'none'"],
|
|
frameSrc: ["'none'"],
|
|
frameAncestors: ["'self'"],
|
|
upgradeInsecureRequests: shouldForceHttps ? [] : null
|
|
}
|
|
},
|
|
crossOriginEmbedderPolicy: false,
|
|
hsts: hstsActive ? { maxAge: 31536000, includeSubDomains: hstsIncludeSubdomains } : false,
|
|
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
}));
|
|
|
|
if (shouldForceHttps) {
|
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
if (req.path === '/api/health') return next();
|
|
if (req.secure || req.headers['x-forwarded-proto'] === 'https') return next();
|
|
res.redirect(301, 'https://' + req.headers.host + req.url);
|
|
});
|
|
}
|
|
|
|
app.use(express.json({ limit: '100kb' }));
|
|
app.use(express.urlencoded({ extended: true }));
|
|
app.use(cookieParser());
|
|
app.use(enforceGlobalMfaPolicy);
|
|
|
|
// Request logging with sensitive field redaction
|
|
{
|
|
const SENSITIVE_KEYS = new Set(['password', 'new_password', 'current_password', 'token', 'jwt', 'authorization', 'cookie', 'client_secret', 'mfa_token', 'code', 'smtp_pass']);
|
|
const redact = (value: unknown): unknown => {
|
|
if (!value || typeof value !== 'object') return value;
|
|
if (Array.isArray(value)) return (value as unknown[]).map(redact);
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
out[k] = SENSITIVE_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v);
|
|
}
|
|
return out;
|
|
};
|
|
|
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
if (req.path === '/api/health') return next();
|
|
const startedAt = Date.now();
|
|
res.on('finish', () => {
|
|
const ms = Date.now() - startedAt;
|
|
if (res.statusCode >= 500) {
|
|
logError(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
|
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
|
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
|
} else if (res.statusCode >= 400) {
|
|
logWarn(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}`);
|
|
}
|
|
const q = Object.keys(req.query).length ? ` query=${JSON.stringify(redact(req.query))}` : '';
|
|
const b = req.body && Object.keys(req.body).length ? ` body=${JSON.stringify(redact(req.body))}` : '';
|
|
logDebug(`${req.method} ${req.path} ${res.statusCode} ${ms}ms ip=${req.ip}${q}${b}`);
|
|
});
|
|
next();
|
|
});
|
|
}
|
|
|
|
// Static: avatars, covers, and journey photos.
|
|
//
|
|
// Security model (audit SEC-M9): these paths are unauthenticated by
|
|
// design. All filenames are server-chosen UUID v4 (see `uuid()` in
|
|
// the multer storage config for avatars / covers / journey uploads),
|
|
// which gives each asset >122 bits of namespace entropy — not
|
|
// guessable via enumeration. An attacker would need to have already
|
|
// seen the URL (email, shared journey, etc.) to request the file.
|
|
//
|
|
// Moving these behind auth would also break:
|
|
// - Unauthenticated trip-card rendering on public share links
|
|
// - Journey public-share pages (/public/journey/:token)
|
|
// - Email-embedded avatars
|
|
//
|
|
// The `/uploads/photos/...` route below is DIFFERENT: photo URLs are
|
|
// not embedded in unauthenticated UI contexts, so that endpoint IS
|
|
// gated (session JWT with pv, or a share token scoped to the photo's
|
|
// trip).
|
|
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
|
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
|
|
app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey')));
|
|
|
|
// Photos require either a valid logged-in session (via JWT with the
|
|
// password_version gate) OR a share token that covers the SPECIFIC
|
|
// photo's trip. Previously any share token for any trip could request
|
|
// any photo filename by UUID — fine in practice because UUIDs are
|
|
// unguessable, but the auth model was wrong.
|
|
app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
|
|
const safeName = path.basename(req.params.filename);
|
|
const filePath = path.join(__dirname, '../uploads/photos', safeName);
|
|
const resolved = path.resolve(filePath);
|
|
if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) {
|
|
return res.status(403).send('Forbidden');
|
|
}
|
|
// existsSync here is cheap and avoids a sendFile error frame; kept
|
|
// sync because the handler is already short-lived.
|
|
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
|
|
|
|
const authHeader = req.headers.authorization;
|
|
const rawToken = (req.query.token as string) || (authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null);
|
|
if (!rawToken) return res.status(401).send('Authentication required');
|
|
|
|
// JWT session path (with pv check).
|
|
const user = verifyJwtAndLoadUser(rawToken);
|
|
if (user) return res.sendFile(resolved);
|
|
|
|
// Share-token path: require the token to cover the exact trip the
|
|
// photo belongs to. Expired tokens fall through to 401.
|
|
const photo = db.prepare('SELECT trip_id FROM photos WHERE filename = ?').get(safeName) as { trip_id: number } | undefined;
|
|
if (!photo) return res.status(401).send('Authentication required');
|
|
|
|
const share = db.prepare(
|
|
"SELECT trip_id FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
|
).get(rawToken) as { trip_id: number } | undefined;
|
|
if (!share || share.trip_id !== photo.trip_id) {
|
|
return res.status(401).send('Authentication required');
|
|
}
|
|
res.sendFile(resolved);
|
|
});
|
|
|
|
// Block direct access to /uploads/files
|
|
app.use('/uploads/files', (_req: Request, res: Response) => {
|
|
res.status(401).send('Authentication required');
|
|
});
|
|
|
|
// API Routes
|
|
app.use('/api/auth', authRoutes);
|
|
app.use('/api/auth/oidc', oidcRoutes);
|
|
app.use('/api/trips', tripsRoutes);
|
|
app.use('/api/trips/:tripId/days', daysRoutes);
|
|
app.use('/api/trips/:tripId/accommodations', accommodationsRoutes);
|
|
app.use('/api/trips/:tripId/places', placesRoutes);
|
|
app.use('/api/trips/:tripId/packing', packingRoutes);
|
|
app.use('/api/trips/:tripId/todo', todoRoutes);
|
|
app.use('/api/trips/:tripId/files', filesRoutes);
|
|
app.use('/api/trips/:tripId/budget', budgetRoutes);
|
|
app.use('/api/trips/:tripId/collab', collabRoutes);
|
|
app.use('/api/trips/:tripId/reservations', reservationsRoutes);
|
|
app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes);
|
|
app.get('/api/health', (_req: Request, res: Response) => res.json({ status: 'ok' }));
|
|
app.use('/api/config', publicConfigRoutes);
|
|
app.use('/api', assignmentsRoutes);
|
|
app.use('/api/tags', tagsRoutes);
|
|
app.use('/api/categories', categoriesRoutes);
|
|
app.use('/api/admin', adminRoutes);
|
|
|
|
// Addons list endpoint
|
|
app.get('/api/addons', authenticate, (_req: Request, res: Response) => {
|
|
const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
|
|
const providers = db.prepare(`
|
|
SELECT id, name, icon, enabled, sort_order
|
|
FROM photo_providers
|
|
WHERE enabled = 1
|
|
ORDER BY sort_order, id
|
|
`).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
|
|
const fields = db.prepare(`
|
|
SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
|
|
FROM photo_provider_fields
|
|
ORDER BY sort_order, id
|
|
`).all() as Array<{
|
|
provider_id: string;
|
|
field_key: string;
|
|
label: string;
|
|
input_type: string;
|
|
placeholder?: string | null;
|
|
hint?: string | null;
|
|
required: number;
|
|
secret: number;
|
|
settings_key?: string | null;
|
|
payload_key?: string | null;
|
|
sort_order: number;
|
|
}>;
|
|
|
|
const fieldsByProvider = new Map<string, typeof fields>();
|
|
for (const field of fields) {
|
|
const arr = fieldsByProvider.get(field.provider_id) || [];
|
|
arr.push(field);
|
|
fieldsByProvider.set(field.provider_id, arr);
|
|
}
|
|
|
|
res.json({
|
|
collabFeatures: getCollabFeatures(),
|
|
addons: [
|
|
...addons.map(a => ({ ...a, enabled: !!a.enabled })),
|
|
...providers.map(p => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
type: 'photo_provider',
|
|
icon: p.icon,
|
|
enabled: !!p.enabled,
|
|
config: getPhotoProviderConfig(p.id),
|
|
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
|
|
key: f.field_key,
|
|
label: f.label,
|
|
input_type: f.input_type,
|
|
placeholder: f.placeholder || '',
|
|
hint: f.hint || null,
|
|
required: !!f.required,
|
|
secret: !!f.secret,
|
|
settings_key: f.settings_key || null,
|
|
payload_key: f.payload_key || null,
|
|
sort_order: f.sort_order,
|
|
})),
|
|
})),
|
|
],
|
|
});
|
|
});
|
|
|
|
// Addon routes
|
|
app.use('/api/addons/vacay', vacayRoutes);
|
|
app.use('/api/addons/atlas', atlasRoutes);
|
|
app.use('/api/journeys', (req, res, next) => {
|
|
if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' });
|
|
next();
|
|
}, journeyRoutes);
|
|
app.use('/api/public/journey', journeyPublicRoutes);
|
|
app.use('/api/integrations/memories', memoriesRoutes);
|
|
app.use('/api/photos', photoRoutes);
|
|
app.use('/api/maps', mapsRoutes);
|
|
app.use('/api/airports', airportsRoutes);
|
|
app.use('/api/weather', weatherRoutes);
|
|
app.use('/api/settings', settingsRoutes);
|
|
app.use('/api/system-notices', systemNoticesRoutes);
|
|
app.use('/api/backup', backupRoutes);
|
|
app.use('/api/notifications', notificationRoutes);
|
|
app.use('/api', shareRoutes);
|
|
|
|
// OAuth 2.1 — public endpoints (/.well-known, /oauth/token, /oauth/revoke)
|
|
app.use('/', oauthPublicRouter);
|
|
// OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*)
|
|
app.use('/api/oauth', oauthApiRouter);
|
|
|
|
// MCP endpoint
|
|
app.post('/mcp', mcpHandler);
|
|
app.get('/mcp', mcpHandler);
|
|
app.delete('/mcp', mcpHandler);
|
|
|
|
// Production static file serving
|
|
if (process.env.NODE_ENV === 'production') {
|
|
const publicPath = path.join(__dirname, '../public');
|
|
app.use(express.static(publicPath, {
|
|
setHeaders: (res, filePath) => {
|
|
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'));
|
|
});
|
|
}
|
|
|
|
// Global error handler
|
|
app.use((err: Error & { status?: number; statusCode?: number }, _req: Request, res: Response, _next: NextFunction) => {
|
|
if (process.env.NODE_ENV === 'production') {
|
|
console.error('Unhandled error:', err.message);
|
|
} else {
|
|
console.error('Unhandled error:', err);
|
|
}
|
|
const status = err.statusCode || err.status || 500;
|
|
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
|
|
const message = status < 500 ? err.message : 'Internal server error';
|
|
res.status(status).json({ error: message });
|
|
});
|
|
|
|
return app;
|
|
}
|