const express = require('express'); const cors = require('cors'); const helmet = require('helmet'); const app = express(); const passport = require('passport'); const path = require('path'); const fs = require('fs'); const bodyParser = require('body-parser'); const config = require('./config'); const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); const { logger, requestLogger } = require('./utils/logger'); const { uploadLimiter, downloadLimiter, searchLimiter, aiLimiter, } = require('./middlewares/rateLimiter'); const authRoutes = require('./routes/auth'); const fileRoutes = require('./routes/file'); const searchRoutes = require('./routes/search'); const sqlRoutes = require('./routes/sql'); const openaiRoutes = require('./routes/openai'); const usersRoutes = require('./routes/users'); const rolesRoutes = require('./routes/roles'); const permissionsRoutes = require('./routes/permissions'); const projectsRoutes = require('./routes/projects'); const project_membershipsRoutes = require('./routes/project_memberships'); const assetsRoutes = require('./routes/assets'); const asset_variantsRoutes = require('./routes/asset_variants'); const presigned_url_requestsRoutes = require('./routes/presigned_url_requests'); const tour_pagesRoutes = require('./routes/tour_pages'); const project_audio_tracksRoutes = require('./routes/project_audio_tracks'); const publish_eventsRoutes = require('./routes/publish_events'); const pwa_cachesRoutes = require('./routes/pwa_caches'); const access_logsRoutes = require('./routes/access_logs'); const element_type_defaultsRoutes = require('./routes/element_type_defaults'); const project_element_defaultsRoutes = require('./routes/project_element_defaults'); const publishRoutes = require('./routes/publish'); const runtimeContextRoutes = require('./routes/runtime-context'); const { runtimeContextMiddleware } = require('./middlewares/runtime-context'); const { blockNonPublicRuntimeListEndpoints, sanitizePublicRuntimeListResponse, } = require('./middlewares/runtime-public'); const getBaseUrl = (url) => { if (!url) return ''; return url.endsWith('/api') ? url.slice(0, -4) : url; }; const options = { definition: { openapi: '3.0.0', info: { version: '1.0.0', title: 'Tour Builder Platform', description: 'Tour Builder Platform Online REST API for Testing and Prototyping application. You can perform all major operations with your entities - create, delete and etc.', }, servers: [ { url: getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || config.swaggerUrl, description: 'Development server', }, ], components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', }, }, responses: { UnauthorizedError: { description: 'Access token is missing or invalid', }, }, }, security: [ { bearerAuth: [], }, ], }, apis: ['./src/routes/*.js'], }; const specs = swaggerJsDoc(options); app.use( '/api-docs', function (req, res, next) { swaggerUI.host = getBaseUrl(process.env.NEXT_PUBLIC_BACK_API) || req.get('host'); next(); }, swaggerUI.serve, swaggerUI.setup(specs), ); app.enable('trust proxy'); app.use( helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false, }), ); app.use(cors({ origin: true })); require('./auth/auth'); // Request logger applied early so all routes are logged app.use(requestLogger); // Initialize passport JWT auth early (before file routes) const jwtAuth = passport.authenticate('jwt', { session: false }); // Mount file routes BEFORE body-parser to avoid JSON parsing on binary uploads // These routes handle their own body parsing (JSON for init/finalize, raw streams for chunks) // Use downloadLimiter for download/presign (high traffic), uploadLimiter for uploads (strict) app.use('/api/file/download', downloadLimiter); app.use('/api/file/presign', downloadLimiter); app.use('/api/file/upload', uploadLimiter); app.use('/api/file/upload-sessions', uploadLimiter); app.use('/api/file', fileRoutes); // Body parser for all other routes app.use(bodyParser.json({ limit: '1mb' })); app.use(bodyParser.urlencoded({ extended: true, limit: '1mb' })); app.use(runtimeContextMiddleware); const requireRuntimeReadOrAuth = (req, res, next) => { const headerEnvironment = req.runtimeContext?.headerEnvironment; const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method); const hasAuthHeader = Boolean(req.headers.authorization); // Only production is public. Stage requires authentication (workspace for review). const isPublicEnvironment = headerEnvironment === 'production'; if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) { req.isRuntimePublicRequest = true; return next(); } req.isRuntimePublicRequest = false; return jwtAuth(req, res, next); }; // Health check endpoint (no auth required) app.get('/api/health', async (req, res) => { const db = require('./db/models'); const health = { status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime(), environment: process.env.NODE_ENV || 'development', }; try { await db.sequelize.authenticate(); health.database = 'connected'; } catch (error) { health.status = 'degraded'; health.database = 'disconnected'; health.databaseError = error.message; } const statusCode = health.status === 'ok' ? 200 : 503; res.status(statusCode).json(health); }); app.use('/api/auth', authRoutes); app.use('/api/runtime-context', runtimeContextRoutes); app.use('/api/users', jwtAuth, usersRoutes); app.use('/api/roles', jwtAuth, rolesRoutes); app.use('/api/permissions', jwtAuth, permissionsRoutes); const mountRuntimeEntityRoute = (path, entityName, router) => { app.use( path, requireRuntimeReadOrAuth, blockNonPublicRuntimeListEndpoints, sanitizePublicRuntimeListResponse(entityName), router, ); }; mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes); app.use('/api/project_memberships', jwtAuth, project_membershipsRoutes); app.use('/api/assets', jwtAuth, assetsRoutes); app.use('/api/asset_variants', jwtAuth, asset_variantsRoutes); app.use('/api/presigned_url_requests', jwtAuth, presigned_url_requestsRoutes); mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes); mountRuntimeEntityRoute( '/api/project_audio_tracks', 'project_audio_tracks', project_audio_tracksRoutes, ); app.use('/api/publish_events', jwtAuth, publish_eventsRoutes); app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes); app.use('/api/access_logs', jwtAuth, access_logsRoutes); app.use('/api/element-type-defaults', jwtAuth, element_type_defaultsRoutes); // Backwards compatibility alias for old API endpoint app.use('/api/ui-elements', jwtAuth, element_type_defaultsRoutes); app.use( '/api/project-element-defaults', jwtAuth, project_element_defaultsRoutes, ); app.use('/api/publish', jwtAuth, publishRoutes); app.use('/api/openai', jwtAuth, aiLimiter, openaiRoutes); app.use('/api/ai', jwtAuth, aiLimiter, openaiRoutes); app.use('/api/search', jwtAuth, searchLimiter, searchRoutes); app.use('/api/sql', jwtAuth, sqlRoutes); const publicDir = path.join(__dirname, '../public'); if (fs.existsSync(publicDir)) { app.use('/', express.static(publicDir)); app.get('*', function (request, response) { response.sendFile(path.resolve(publicDir, 'index.html')); }); } // Generic error handler app.use((err, req, res, _next) => { if (!res.headersSent) { logger.error({ err, url: req.url, method: req.method }, 'Unhandled error'); res.status(500).json({ message: 'Internal server error' }); } }); const PORT = process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080; app.listen(PORT, () => { logger.info( { port: PORT, env: process.env.NODE_ENV || 'development' }, 'Server started', ); }); module.exports = app;