39948-vm/backend/src/index.js

275 lines
7.9 KiB
JavaScript

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;