222 lines
8.2 KiB
TypeScript
222 lines
8.2 KiB
TypeScript
import 'dotenv/config';
|
|
import express, { Request, Response, NextFunction } from 'express';
|
|
import cors from 'cors';
|
|
import helmet from 'helmet';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
|
|
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', parseInt(process.env.TRUST_PROXY as string) || 1);
|
|
}
|
|
|
|
// Create upload directories on startup
|
|
const uploadsDir = path.join(__dirname, '../uploads');
|
|
const photosDir = path.join(uploadsDir, 'photos');
|
|
const filesDir = path.join(uploadsDir, 'files');
|
|
const coversDir = path.join(uploadsDir, 'covers');
|
|
const backupsDir = path.join(__dirname, '../data/backups');
|
|
const tmpDir = path.join(__dirname, '../data/tmp');
|
|
|
|
[uploadsDir, photosDir, filesDir, coversDir, backupsDir, tmpDir].forEach(dir => {
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
});
|
|
|
|
// Middleware
|
|
const allowedOrigins = process.env.ALLOWED_ORIGINS
|
|
? process.env.ALLOWED_ORIGINS.split(',')
|
|
: null;
|
|
|
|
let corsOrigin: cors.CorsOptions['origin'];
|
|
if (allowedOrigins) {
|
|
// Explicit whitelist from env var
|
|
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') {
|
|
// Production: same-origin only (Express serves the static client)
|
|
corsOrigin = false;
|
|
} else {
|
|
// Development: allow all origins (needed for Vite dev server)
|
|
corsOrigin = true;
|
|
}
|
|
|
|
const shouldForceHttps = process.env.FORCE_HTTPS === 'true';
|
|
|
|
app.use(cors({
|
|
origin: corsOrigin,
|
|
credentials: true
|
|
}));
|
|
app.use(helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: ["'self'", "'unsafe-inline'"],
|
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"],
|
|
imgSrc: ["'self'", "data:", "blob:", "https:", "http:"],
|
|
connectSrc: ["'self'", "ws:", "wss:", "https:", "http:"],
|
|
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
|
|
objectSrc: ["'self'"],
|
|
frameSrc: ["'self'"],
|
|
frameAncestors: ["'self'"],
|
|
upgradeInsecureRequests: shouldForceHttps ? [] : null
|
|
}
|
|
},
|
|
crossOriginEmbedderPolicy: false,
|
|
hsts: shouldForceHttps ? { maxAge: 31536000, includeSubDomains: false } : false,
|
|
}));
|
|
|
|
// Redirect HTTP to HTTPS (opt-in via FORCE_HTTPS=true)
|
|
if (shouldForceHttps) {
|
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
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 }));
|
|
|
|
// Avatars are public (shown on login, sharing screens)
|
|
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
|
|
|
|
// All other uploads require authentication
|
|
app.get('/uploads/:type/:filename', (req: Request, res: Response) => {
|
|
const { type, filename } = req.params;
|
|
const allowedTypes = ['covers', 'files', 'photos'];
|
|
if (!allowedTypes.includes(type)) return res.status(404).send('Not found');
|
|
|
|
// Prevent path traversal
|
|
const safeName = path.basename(filename);
|
|
const filePath = path.join(__dirname, '../uploads', type, safeName);
|
|
const resolved = path.resolve(filePath);
|
|
if (!resolved.startsWith(path.resolve(__dirname, '../uploads', type))) {
|
|
return res.status(403).send('Forbidden');
|
|
}
|
|
|
|
if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
|
|
res.sendFile(resolved);
|
|
});
|
|
|
|
// Routes
|
|
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 tagsRoutes from './routes/tags';
|
|
import categoriesRoutes from './routes/categories';
|
|
import adminRoutes from './routes/admin';
|
|
import mapsRoutes from './routes/maps';
|
|
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';
|
|
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/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', assignmentsRoutes);
|
|
app.use('/api/tags', tagsRoutes);
|
|
app.use('/api/categories', categoriesRoutes);
|
|
app.use('/api/admin', adminRoutes);
|
|
|
|
// Public addons endpoint (authenticated but not admin-only)
|
|
import { authenticate as addonAuth } from './middleware/auth';
|
|
import { db as addonDb } from './db/database';
|
|
import { Addon } from './types';
|
|
app.get('/api/addons', addonAuth, (req: Request, res: Response) => {
|
|
const addons = addonDb.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'>[];
|
|
res.json({ addons: addons.map(a => ({ ...a, enabled: !!a.enabled })) });
|
|
});
|
|
|
|
// Addon routes
|
|
import vacayRoutes from './routes/vacay';
|
|
app.use('/api/addons/vacay', vacayRoutes);
|
|
import atlasRoutes from './routes/atlas';
|
|
app.use('/api/addons/atlas', atlasRoutes);
|
|
import immichRoutes from './routes/immich';
|
|
app.use('/api/integrations/immich', immichRoutes);
|
|
|
|
app.use('/api/maps', mapsRoutes);
|
|
app.use('/api/weather', weatherRoutes);
|
|
app.use('/api/settings', settingsRoutes);
|
|
app.use('/api/backup', backupRoutes);
|
|
|
|
// MCP endpoint (Streamable HTTP transport, per-user auth)
|
|
import { mcpHandler, closeMcpSessions } from './mcp';
|
|
app.all('/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.get('*', (req: Request, res: Response) => {
|
|
res.sendFile(path.join(publicPath, 'index.html'));
|
|
});
|
|
}
|
|
|
|
// Global error handler
|
|
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
|
console.error('Unhandled error:', err);
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
});
|
|
|
|
import * as scheduler from './scheduler';
|
|
|
|
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'}`);
|
|
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.');
|
|
}
|
|
scheduler.start();
|
|
scheduler.startDemoReset();
|
|
import('./websocket').then(({ setupWebSocket }) => {
|
|
setupWebSocket(server);
|
|
});
|
|
});
|
|
|
|
// Graceful shutdown
|
|
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');
|
|
closeDb();
|
|
console.log('Shutdown complete');
|
|
process.exit(0);
|
|
});
|
|
// Force exit after 10s if connections don't close
|
|
setTimeout(() => {
|
|
console.error('Forced shutdown after timeout');
|
|
process.exit(1);
|
|
}, 10000);
|
|
}
|
|
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
|
|
export default app;
|