const express = require('express') const path = require('path') const fs = require('fs') const cors = require('cors') const helmet = require('helmet') const morgan = require('morgan') const cookieParser = require('cookie-parser') require('dotenv').config() const apiRouter = require('./router/api.router') const createLogger = require('./utils/logger') const httpLog = createLogger('http') const errLog = createLogger('error') const app = express() // Behind Pangolin: trust the first proxy so req.secure (for the cookie flag), // req.ip (activity log / rate limiting) reflect the X-Forwarded-* headers. app.set('trust proxy', 1) // Security headers. CSP is left off here and will be tuned for the React SPA in // the frontend phase; the rest of helmet's protections stay enabled. app.use( helmet({ contentSecurityPolicy: false, crossOriginResourcePolicy: { policy: 'cross-origin' }, }), ) // CORS only when a separate client origin is configured (local Vite dev). In // production the SPA is same-origin, so no CORS is needed. if (process.env.CLIENT_ORIGIN) { app.use(cors({ origin: process.env.CLIENT_ORIGIN, credentials: true })) } // Access logs: real client IP (via trust proxy), the authenticated admin (if any), // method, URL, status, response time, and size. Bodies/credentials are never logged. morgan.token('user', (req) => (req.user && req.user.username) || '-') const accessFormat = ':remote-addr :user :method :url :status :response-time ms - :res[content-length] bytes' app.use(morgan(accessFormat, { stream: { write: (line) => httpLog.info(line.trim()) } })) app.use(express.json({ limit: '2mb' })) app.use(cookieParser()) // ── Paths ───────────────────────────────────────────────────────────── const SERVER_ROOT = path.join(__dirname, '..') const REPO_ROOT = path.join(SERVER_ROOT, '..') const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(SERVER_ROOT, 'uploads') const CLIENT_DIST = path.join(REPO_ROOT, 'client', 'dist') fs.mkdirSync(UPLOAD_DIR, { recursive: true }) // Uploaded images — always served, even during maintenance. Force nosniff so a // stored file is never interpreted as anything other than its declared type // (defense in depth alongside helmet's global X-Content-Type-Options, and in // case that global config is ever changed). app.use( '/uploads', express.static(UPLOAD_DIR, { setHeaders: (res) => res.set('X-Content-Type-Options', 'nosniff'), }), ) // ── API ─────────────────────────────────────────────────────────────── app.get('/api/health', (req, res) => res.json({ status: 'ok' })) app.use('/api', apiRouter) app.use('/api', (req, res) => res.status(404).json({ message: 'Not found' })) // ── Client SPA ──────────────────────────────────────────────────────── // Serve the built React app if present; otherwise show a placeholder so the // server is usable API-only before the frontend phase. if (fs.existsSync(path.join(CLIENT_DIST, 'index.html'))) { app.use(express.static(CLIENT_DIST)) app.get('*', (req, res) => res.sendFile(path.join(CLIENT_DIST, 'index.html'))) } else { app.get('*', (req, res) => res .type('html') .send( '

UOMysticmoon API

The web client has not been built yet. ' + 'The API is available under /api/v1.

', ), ) } // ── Error handler ───────────────────────────────────────────────────── // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { const status = err.status || (err.name === 'MulterError' ? 400 : 500) // Log the stack for server faults; client (4xx) errors stay terse. errLog.error( `${req.method} ${req.originalUrl} -> ${status} ${err.message}`, status >= 500 ? { stack: err.stack } : undefined, ) res.status(status).json({ message: err.message || 'Internal Server Error' }) }) module.exports = app