Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 23 additions & 127 deletions api/api.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
const http = require('http');
const https = require('https');
const fs = require('fs');
const formidable = require('formidable');
const countlyConfig = require('./config', 'dont-enclose');
const plugins = require('../plugins/pluginManager.ts');
const log = require('./utils/log.js')('core:api');
const common = require('./utils/common.js');
const {processRequest} = require('./utils/requestProcessor');
const {createApiApp} = require('./express/app');
const frontendConfig = require('../frontend/express/config.js');
const {WriteBatcher, ReadBatcher, InsertBatcher} = require('./parts/data/batcher.js');
const QueryRunner = require('./parts/data/QueryRunner.js');
Expand Down Expand Up @@ -187,35 +187,36 @@ plugins.connectToAllDatabases().then(function() {

plugins.dispatch("/master", {}); // init hook

// Rate limiting as Express middleware (same logic as before)
const rateLimitWindow = parseInt(plugins.getConfig("security").api_rate_limit_window, 10) || 0;
const rateLimitRequests = parseInt(plugins.getConfig("security").api_rate_limit_requests, 10) || 0;
const rateLimiterInstance = new RateLimiterMemory({ points: rateLimitRequests, duration: rateLimitWindow });
const requiresRateLimiting = rateLimitWindow > 0 && rateLimitRequests > 0;
const omit = /^\/i(\/bulk)?(\?|$)/; // omit /i endpoint from rate limiting

/**
* Rate Limiting Middleware
* @param {Function} next - The next middleware function
* @returns {Function} - The wrapped middleware function with rate limiting
* Express rate limiting middleware
* @param {object} req - Express request
* @param {object} res - Express response
* @param {Function} next - next middleware
*/
const rateLimit = (next) => {
if (!requiresRateLimiting) {
return next;
const rateLimitMiddleware = (req, res, next) => {
if (!requiresRateLimiting || omit.test(req.url)) {
return next();
}
return (req, res) => {
if (omit.test(req.url)) {
return next(req, res);
}
const ip = common.getIpAddress(req);
rateLimiterInstance
.consume(ip)
.then(() => next(req, res))
.catch(() => {
log.w(`Rate limit exceeded for IP: ${ip}`);
common.returnMessage({ req, res, qstring: {} }, 429, "Too Many Requests");
});
};
const ip = common.getIpAddress(req);
rateLimiterInstance
.consume(ip)
.then(() => next())
.catch(() => {
log.w(`Rate limit exceeded for IP: ${ip}`);
common.returnMessage({req, res, qstring: {}}, 429, "Too Many Requests");
});
};

// Create the Express app with the full middleware stack
const app = createApiApp({countlyConfig, processRequest, plugins, rateLimitMiddleware});

const serverOptions = {
port: common.config.api.port,
host: common.config.api.host || ''
Expand All @@ -230,10 +231,10 @@ plugins.connectToAllDatabases().then(function() {
if (common.config.api.ssl.ca) {
sslOptions.ca = fs.readFileSync(common.config.api.ssl.ca);
}
server = https.createServer(sslOptions, rateLimit(handleRequest));
server = https.createServer(sslOptions, app);
}
else {
server = http.createServer(rateLimit(handleRequest));
server = http.createServer(app);
}

server.listen(serverOptions.port, serverOptions.host, () => {
Expand All @@ -249,108 +250,3 @@ plugins.connectToAllDatabases().then(function() {
log.e('Database connection failed:', error);
process.exit(1);
});

/**
* Handle incoming HTTP/HTTPS requests
* @param {http.IncomingMessage} req - The request object
* @param {http.ServerResponse} res - The response object
*/
function handleRequest(req, res) {
const params = {
qstring: {},
res: res,
req: req
};

res.setHeader('Connection', 'keep-alive');
res.setHeader('Keep-Alive', 'timeout=5, max=1000');

if (req.method.toLowerCase() === 'post') {
const formidableOptions = { multiples: true };
if (countlyConfig.api.maxUploadFileSize) {
formidableOptions.maxFileSize = countlyConfig.api.maxUploadFileSize;
}

const form = new formidable.IncomingForm(formidableOptions);
if (/crash_symbols\/(add_symbol|upload_symbol)/.test(req.url)) {
req.body = [];
req.on('data', (data) => {
req.body.push(data);
});
}
else {
req.body = '';
req.on('data', (data) => {
req.body += data;
});
}

let multiFormData = false;
// Check if we have 'multipart/form-data'
if (req.headers['content-type']?.startsWith('multipart/form-data')) {
multiFormData = true;
}

form.parse(req, (err, fields, files) => {
//handle bakcwards compatability with formiddble v1
for (let i in files) {
if (Array.isArray(files[i])) {
files[i].forEach((file) => {
if (file.filepath) {
file.path = file.filepath;
}
if (file.mimetype) {
file.type = file.mimetype;
}
if (file.originalFilename) {
file.name = file.originalFilename;
}
});
}
else {
if (files[i].filepath) {
files[i].path = files[i].filepath;
}
if (files[i].mimetype) {
files[i].type = files[i].mimetype;
}
if (files[i].originalFilename) {
files[i].name = files[i].originalFilename;
}
}
}
params.files = files;
if (multiFormData) {
let formDataUrl = [];
for (const i in fields) {
params.qstring[i] = fields[i];
formDataUrl.push(`${i}=${fields[i]}`);
}
params.formDataUrl = formDataUrl.join('&');
}
else {
for (const i in fields) {
params.qstring[i] = fields[i];
}
}
if (!params.apiPath) {
processRequest(params);
}
});
}
else if (req.method.toLowerCase() === 'options') {
const headers = {};
headers["Access-Control-Allow-Origin"] = "*";
headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS";
headers["Access-Control-Allow-Headers"] = "countly-token, Content-Type";
res.writeHead(200, headers);
res.end();
}
//attempt process GET request
else if (req.method.toLowerCase() === 'get') {
processRequest(params);
}
else {
common.returnMessage(params, 405, "Method not allowed");
}
}
22 changes: 22 additions & 0 deletions api/config.sample.js
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,28 @@ var countlyConfig = {
*/
encryption: {},

/**
* JWT (JSON Web Token) authentication configuration
* Provides stateless API authentication using access and refresh tokens
*
* IMPORTANT: Set COUNTLY_JWT_SECRET environment variable with a secure secret (minimum 32 characters)
* JWT authentication will not work without this secret configured.
*
* @type {object}
* @property {string} secret - JWT signing secret. MUST be set via COUNTLY_JWT_SECRET env variable (min 32 chars)
* @property {number} [accessTokenExpiry=900] - Access token expiration in seconds (default: 15 minutes)
* @property {number} [refreshTokenExpiry=604800] - Refresh token expiration in seconds (default: 7 days)
* @property {string} [issuer=countly] - Token issuer claim
* @property {string} [algorithm=HS256] - JWT signing algorithm
*/
jwt: {
secret: '', // Set via COUNTLY_JWT_SECRET environment variable
accessTokenExpiry: 900, // 15 minutes
refreshTokenExpiry: 604800, // 7 days
issuer: 'countly',
algorithm: 'HS256'
},

/**
* Specifies where to store files. Value "fs" means file system or basically storing files on hard drive. Another currently supported option is "gridfs" storing files in MongoDB database using GridFS. By default fallback to "fs";
* @type {string} [default=fs]
Expand Down
82 changes: 82 additions & 0 deletions api/express/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Express app factory for the Countly API server.
* Creates and configures an Express application that replaces the raw
* http.createServer() handler, while preserving all existing behavior
* through a middleware stack that bridges to the legacy request processor.
* @module api/express/app
*/

const express = require('express');

/**
* Create and configure the Express app for the API server
* @param {object} options - Configuration options
* @param {object} options.countlyConfig - Countly API config
* @param {Function} options.processRequest - Legacy processRequest function
* @param {object} options.plugins - Plugin manager instance
* @param {Function} options.rateLimitMiddleware - Express rate limiting middleware
* @returns {express.Application} Configured Express app
*/
function createApiApp({countlyConfig, processRequest, plugins, rateLimitMiddleware}) {
const app = express();

app.enable('trust proxy');

// Disable express default headers we don't need
app.disable('x-powered-by');
app.disable('etag');

// CORS for OPTIONS requests (replaces handleRequest OPTIONS block)
app.options('*', (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "countly-token, Content-Type, Authorization");
res.status(200).end();
});

// Body parser - formidable-based, replicates handleRequest POST parsing
app.use(require('./bodyParser').createBodyParser(countlyConfig));

// Params middleware - builds req.countlyParams from parsed request
app.use(require('./params'));

// Rate limiting (same logic as before, now as Express middleware)
app.use(rateLimitMiddleware);

// Core routes migrated from requestProcessor.js switch/case
app.use(require('../routes/jwt.ts').default);
app.use(require('../routes/ping'));
app.use(require('../routes/token'));
app.use(require('../routes/notes'));
app.use(require('../routes/cms'));
app.use(require('../routes/date_presets'));
app.use(require('../routes/version'));
app.use(require('../routes/sdk'));
app.use(require('../routes/users'));
app.use(require('../routes/apps'));
app.use(require('../routes/event_groups'));
app.use(require('../routes/tasks'));
app.use(require('../routes/render'));
app.use(require('../routes/app_users'));
app.use(require('../routes/events'));
app.use(require('../routes/system'));
app.use(require('../routes/export'));
app.use(require('../routes/analytics'));
// /o with method-based dispatch - must come after specific /o/* routes
app.use(require('../routes/data'));

// Express router for plugin routes registered via plugins.apiRoute()
const apiRouter = express.Router();
plugins.mountApiRoutes(apiRouter);
app.use(apiRouter);

// Store router on app for later access (e.g., adding routes after init)
app.set('apiRouter', apiRouter);

// Legacy bridge - catch-all that delegates to processRequest
app.use(require('./legacyBridge').createLegacyBridge(processRequest));

return app;
}

module.exports = {createApiApp};
Loading
Loading