36118b4a4f
- Set express trust proxy=1 so req.ip is the real client (one Dokku nginx hop). Drop the X-Forwarded-For fallback — it was client-spoofable. - Add a global redeem rate limit (30/min across all IPs) so rotating proxies can't outpace the per-IP limiter. - Every failed redeem now decrements life from all active pair codes via a shared failures counter. After 5 wrong guesses during a code's 5-minute TTL, the owner's real code gets burned and they must re-mint. This makes the 20-bit entropy of a 6-digit code defensible: an attacker doesn't know which code is real and can't probe long enough to find it before triggering the burn.
83 lines
3.1 KiB
TypeScript
83 lines
3.1 KiB
TypeScript
import 'dotenv/config';
|
|
import express from 'express';
|
|
import cors from 'cors';
|
|
import helmet from 'helmet';
|
|
import cookieParser from 'cookie-parser';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { existsSync } from 'node:fs';
|
|
import { env } from './config/env.js';
|
|
import { logger } from './lib/logger.js';
|
|
import chatRoutes from './routes/chat.js';
|
|
import tripsRoutes from './routes/trips.js';
|
|
import teslaRoutes from './routes/tesla.js';
|
|
import ownerRoutes from './routes/owner.js';
|
|
import { warnIfMisconfigured as warnOwnerAuth } from './lib/ownerAuth.js';
|
|
import { createOptionalAuth } from './lib/auth.js';
|
|
|
|
const app = express();
|
|
|
|
// We sit behind one Nginx hop (Dokku's per-app vhost). Trust exactly one
|
|
// proxy so req.ip reflects the real client and the rate limiters below
|
|
// can't be bypassed via a spoofed X-Forwarded-For header.
|
|
app.set('trust proxy', 1);
|
|
|
|
app.use(helmet({ contentSecurityPolicy: false }));
|
|
app.use(cors({ origin: env.appUrl, credentials: true }));
|
|
app.use(express.json({ limit: '2mb' }));
|
|
app.use(cookieParser());
|
|
|
|
app.use((req, _res, next) => {
|
|
if (req.url !== '/health') logger.info({ method: req.method, url: req.url }, 'request');
|
|
next();
|
|
});
|
|
|
|
app.get('/health', (_req, res) => {
|
|
res.json({ status: 'ok', service: 'tesla-roadtrip', time: new Date().toISOString() });
|
|
});
|
|
|
|
const auth = createOptionalAuth();
|
|
if (auth) {
|
|
app.use(auth.middleware());
|
|
app.use(auth.routes());
|
|
logger.info('Auth middleware mounted (AUTH_SECRET present)');
|
|
} else {
|
|
logger.info('Auth disabled — set AUTH_SECRET to enable user accounts');
|
|
}
|
|
|
|
// Owner auth + Tesla integration. Tesla routes are owner-gated except the
|
|
// public .well-known partner-key path. Owner routes handle login/logout.
|
|
warnOwnerAuth();
|
|
app.use(ownerRoutes);
|
|
app.use(teslaRoutes);
|
|
|
|
app.use('/api', chatRoutes);
|
|
app.use('/api/trips', tripsRoutes);
|
|
|
|
// ─── Static client (production only) ─────────────────────────────────────────
|
|
// In dev, Vite serves the client on :5173. In production (Dokku), the built
|
|
// client lands in client/dist via `npm run build` and we serve it from here.
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const clientDist = path.resolve(__dirname, '../../client/dist');
|
|
if (existsSync(clientDist)) {
|
|
app.use(express.static(clientDist, { index: false, maxAge: '1h' }));
|
|
app.get(/.*/, (req, res, next) => {
|
|
// Don't shadow API or well-known paths.
|
|
if (req.path.startsWith('/api') || req.path.startsWith('/.well-known')) return next();
|
|
res.sendFile(path.join(clientDist, 'index.html'));
|
|
});
|
|
logger.info({ clientDist }, 'Serving built client');
|
|
} else {
|
|
logger.info('No client/dist found — relying on Vite dev server');
|
|
}
|
|
|
|
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
logger.error({ err }, 'Unhandled error');
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
});
|
|
|
|
app.listen(env.port, () => {
|
|
logger.info(`Tesla Roadtrip server running on port ${env.port}`);
|
|
});
|