257 lines
8.7 KiB
JavaScript
257 lines
8.7 KiB
JavaScript
/* eslint-disable no-unused-vars */
|
|
'use strict';
|
|
|
|
// Ref: http://javascript.tutorialhorizon.com/2014/09/20/organizing-your-expressjs-routes-in-separate-files/
|
|
|
|
const express = require('express');
|
|
require('express-async-errors');
|
|
const rateLimit = require('express-rate-limit')
|
|
|
|
const debug = require('debug')('agm:server'),
|
|
compression = require('compression'),
|
|
path = require('path'),
|
|
fs = require('fs-extra'),
|
|
http = require('http'),
|
|
https1 = require('https'),
|
|
http2 = require('node:http2'), // Native HTTP/2 server (Express expects HTTP/1.1 req/res; do not advertise h2 unless using a compat layer)
|
|
app = express(),
|
|
env = require('./helpers/env'),
|
|
{ registerFatalHandlers, createServerIgnore } = require('./helpers/process_fatal_handlers'),
|
|
dbConnect = require('./helpers/db/connect.js'),
|
|
{ checkUser } = require('./middlewares/app_validator.js'),
|
|
{ ErrorHandler } = require('./middlewares/error_handler.js'),
|
|
errorHandler = require('error-handler').errorHandler;
|
|
|
|
http.globalAgent.maxSockets = Infinity;
|
|
const MAX_REQ_BDY_MB = env.MAX_REQ_BDY_MB || '150mb'
|
|
|
|
// Load ENV vars specified in the .env file (within the same folder)
|
|
debug("Is in Production: ", env.PRODUCTION);
|
|
app.isProd = env.PRODUCTION;
|
|
|
|
process.setMaxListeners(0);
|
|
|
|
// DISABLED (intentionally): error-handler's process hooks use key-file-storage (read/modify/write)
|
|
// which has produced corrupted *.rlog JSON under concurrent failures. It also calls process.exit(1)
|
|
// for uncaughtException/unhandledRejection, which caused crash loops when the log file was malformed.
|
|
// We rely on stdout/PM2 logs + our own guarded process handlers below.
|
|
// errorHandler && (errorHandler.registerUnCaughtProcessErrorsHandler(process, errorLogPath));
|
|
|
|
// Register guarded process-level fatal handlers (atomic .rlog + optional email + optional exit)
|
|
registerFatalHandlers(process, {
|
|
env,
|
|
debug,
|
|
kindPrefix: 'server',
|
|
reportFilePath: env.FATAL_REPORT_FILE,
|
|
ignore: createServerIgnore(),
|
|
});
|
|
|
|
app.set('trust proxy', env.APP_RATE_TRUST_PROXIES /* number of proxies between user and server */);
|
|
|
|
if (!env.PRODUCTION && (env.INV_IMG_VIR_DIR && env.INV_UPLOAD_DIR)) {
|
|
// Map static resources, cached for 8 hours
|
|
app.use(env.INV_IMG_VIR_DIR, express.static(env.INV_UPLOAD_DIR, { maxAge: 31557600 }));
|
|
}
|
|
|
|
// Serve static files from public directory (e.g., DLQ monitor HTML)
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
// Also serve under "/public" prefix to match links like /public/dlq-monitor.html
|
|
app.use('/public', express.static(path.join(__dirname, 'public')));
|
|
|
|
// Global middleware to handle request/response errors gracefully
|
|
app.use((req, res, next) => {
|
|
// Handle request errors (client disconnect, etc.)
|
|
req.on('error', (err) => {
|
|
debug('Request error (client disconnect):', err.message);
|
|
});
|
|
// Explicitly handle aborted requests and tear down the socket
|
|
req.on('aborted', () => {
|
|
try {
|
|
if (!res.headersSent) {
|
|
res.status(499).end(); // 499 Client Closed Request (nginx convention)
|
|
}
|
|
} catch {}
|
|
try {
|
|
if (res && typeof res.destroy === 'function') res.destroy();
|
|
else if (req && typeof req.destroy === 'function') req.destroy();
|
|
} catch {}
|
|
});
|
|
|
|
// Handle response errors
|
|
res.on('error', (err) => {
|
|
debug('Response error:', err.message);
|
|
});
|
|
|
|
next();
|
|
});
|
|
|
|
// for parsing application/x-www-form-urlencoded
|
|
app.use(express.urlencoded({ limit: MAX_REQ_BDY_MB, extended: true, parameterLimit: 100000 }));
|
|
|
|
// Rate limiting middleware. Define a rate limiter
|
|
const apiLimiter = rateLimit({
|
|
windowMs: (env.APP_RATE_MINS || 15) * 60 * 1000, // 15 minutes
|
|
max: env.APP_RATE_REQS || 100, // Limit each IP to 100 requests per windowMs
|
|
skipFailedRequests: env.APP_RATE_SKIPFAIL || true, // Skip failed requests
|
|
message: {
|
|
error: "Too many requests, please try again later."
|
|
}
|
|
});
|
|
|
|
// Apply the rate limiter to all routes in this router
|
|
app.use(apiLimiter);
|
|
|
|
// Handle webhook events from Stripe
|
|
require('./routes/subscription_webhooks')(express, app);
|
|
|
|
function shouldCompress(req, res) {
|
|
if (req.headers['x-no-compression']) {
|
|
return false; // don't compress responses with this request header
|
|
}
|
|
// fallback to standard filter function
|
|
return compression.filter(req, res);
|
|
}
|
|
// Enable the compression middleware
|
|
app.use(compression({ filter: shouldCompress }));
|
|
|
|
// Parsers for POST JSON data
|
|
app.use(express.json({ limit: MAX_REQ_BDY_MB }));
|
|
|
|
// Allow all CORS requests
|
|
// Ref at http://restlet.com/company/blog/2015/12/15/understanding-and-using-cors
|
|
app.use(function (req, res, next) {
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Origin, Methods, X-Requested-With, Content-Type, Content-Disposition, Accept');
|
|
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, PATCH, DELETE, OPTIONS');
|
|
next();
|
|
});
|
|
|
|
// Fast-path CORS preflight to avoid unnecessary fallthrough
|
|
app.use(function (req, res, next) {
|
|
if (req.method === 'OPTIONS') return res.status(204).end();
|
|
next();
|
|
});
|
|
|
|
async function setupRoutes() {
|
|
// const resLogger = async (req, res, next) => {
|
|
// debug('Request:', req.url);
|
|
// next();
|
|
// };
|
|
|
|
// Handle routes with middelware functions
|
|
app.use(checkUser/*, resLogger*/);
|
|
|
|
// REGISTER OUR ROUTES
|
|
require('./routes')(app);
|
|
require('./routes/dlq')(app); // Global DLQ management routes (all queues)
|
|
|
|
app.use(ErrorHandler);
|
|
|
|
// Universal 404 for any method to avoid finalhandler on http2
|
|
app.use((req, res) => {
|
|
if (res.headersSent) return;
|
|
// For assets, send minimal text to ensure sockets close
|
|
res.status(404).type('text/plain').end('Not Found');
|
|
});
|
|
|
|
// Avoid logging aborted requests
|
|
// Ref: https://github.com/nodejs/help/issues/2155
|
|
app.use((err, req, res, next) => {
|
|
if (err && err.code === 'ECONNABORTED') {
|
|
res.status(400).end(); // Don't process this error any further to avoid its logging
|
|
} else
|
|
next(err);
|
|
});
|
|
|
|
// Centralized error handler to always finalize a response
|
|
app.use((err, req, res, next) => {
|
|
debug('App error handled:', err && (err.message || err));
|
|
if (res.headersSent) return;
|
|
|
|
try {
|
|
res.status(500).send({ error: 'Internal Server Error' });
|
|
} catch {
|
|
// Response failed, destroy socket
|
|
try {
|
|
if (res && typeof res.destroy === 'function') res.destroy();
|
|
else if (req && typeof req.destroy === 'function') req.destroy();
|
|
} catch { }
|
|
}
|
|
});
|
|
|
|
}
|
|
|
|
/**
|
|
* Preload some common libs, ussually written in ESM modules that require asynchronously loaded or import..
|
|
* instead of using requires in CommonJS ones.
|
|
*/
|
|
async function preloadLibs() {
|
|
let mod = await import('@mickeyjohn/geodesy/utm.js');
|
|
app.locals.UTM = mod.default;
|
|
app.locals.LatLonUTM = mod.LatLon; // More accuracy, for converting from ll to utm
|
|
app.locals.Dms = mod.Dms;
|
|
|
|
mod = await import('@mickeyjohn/geodesy/latlon-spherical.js');
|
|
app.locals.LatLonSP = mod.default;
|
|
}
|
|
|
|
async function ensureFolders() {
|
|
try {
|
|
await fs.ensureDir(env.INV_UPLOAD_DIR);
|
|
await fs.ensureDir(env.UNZIP_DIR);
|
|
await fs.ensureDir(env.REPORT_DIR);
|
|
await fs.ensureDir(env.TEMP_DIR);
|
|
await fs.ensureDir(env.AREAS_UPLOAD_DIR);
|
|
} catch (error) {
|
|
debug(error);
|
|
}
|
|
}
|
|
|
|
const port = env.AGM_PORT || '4000';
|
|
|
|
const serverFactory = () => {
|
|
const tlsOpts = { key: fs.readFileSync(env.SSL_KEY), cert: fs.readFileSync(env.SSL_CERT) };
|
|
if (env.HTTP2_ENABLED) {
|
|
// WARNING: Express does not speak native HTTP/2 streams.
|
|
// If you advertise h2, browsers may negotiate HTTP/2 and requests can hang.
|
|
// Use a reverse proxy (nginx) to terminate HTTP/2 and proxy HTTP/1.1 upstream.
|
|
const alpn = env.HTTP2_ADVERTISE_H2 ? ['h2', 'http/1.1'] : ['http/1.1'];
|
|
return http2.createSecureServer({ ...tlsOpts, allowHTTP1: true, ALPNProtocols: alpn }, app);
|
|
}
|
|
// Default to stable HTTPS/1.1 to avoid Express/finalhandler issues under HTTP/2
|
|
return https1.createServer(tlsOpts, app);
|
|
};
|
|
|
|
serverFactory()
|
|
.listen(port, async (error) => {
|
|
const onAppErr = (err) => {
|
|
debug(err);
|
|
process.exit(1);
|
|
}
|
|
if (error) return onAppErr(error);
|
|
|
|
try {
|
|
await ensureFolders();
|
|
await preloadLibs();
|
|
|
|
const conn = await dbConnect();
|
|
// Set up error handler for future connection issues
|
|
conn.on('error', onAppErr);
|
|
// Since connection is already established, execute the setup directly
|
|
debug('✅-> MongoDB connected');
|
|
|
|
await setupRoutes();
|
|
|
|
// Start Job Importer queue consumer
|
|
const jobQueuer = require('./helpers/job_queue').getInstance();
|
|
jobQueuer.start();
|
|
|
|
const protoLabel = env.HTTP2_ENABLED
|
|
? (env.HTTP2_ADVERTISE_H2 ? 'HTTPS-v2' : 'HTTPS (HTTP/1.1 only)')
|
|
: 'HTTPS';
|
|
debug(`${protoLabel} AgMission Server is listening on port ${port}`);
|
|
|
|
} catch (error) {
|
|
onAppErr(error);
|
|
}
|
|
}); |