Skip to content
Merged
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
15 changes: 15 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,21 @@ const globalApiLimit = buildRateLimit({
keyPrefix: "api",
});

// #451 — Global rate limit applied to ALL requests, not just /api/v1.
// This prevents abuse of non-API routes (health, docs, etc.) as well.
const globalRateLimit = buildRateLimit({
windowSeconds: Math.floor(config.rateLimit.windowMs / 1000),
max: config.rateLimit.max * 2, // More generous for non-API routes
keyPrefix: "global",
});

// #450 — Request validation middleware: rejects requests with invalid
// Content-Type for POST/PUT/PATCH, and validates JSON body size limits.
const requestValidationMiddleware = require("./middleware/requestValidation");

app.use(globalRateLimit);
app.use(requestValidationMiddleware);

// Resolve any presented API key first so the per-key limiter can meter it,
// then fall through to the IP-keyed limiter for unauthenticated callers.
// Authentication itself is still enforced per-route by requireApiKey.
Expand Down
46 changes: 46 additions & 0 deletions src/middleware/requestValidation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use strict';

const AppError = require('../errors/AppError');

/**
* #450 — Request validation middleware.
*
* Validates incoming requests before they reach route handlers:
* - Rejects POST/PUT/PATCH without Content-Type or with unsupported types
* - Validates Content-Type is application/json for JSON endpoints
* - Enforces max body size (handled by express.json but validated here for clarity)
*
* This catches malformed requests early and returns consistent 400 errors.
*/
function requestValidationMiddleware(req, res, next) {
const method = req.method.toUpperCase();

// Validate Content-Type for methods that typically have a body
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = req.headers['content-type'];

if (!contentType) {
return next(new AppError(
'VALIDATION_ERROR',
'Content-Type header is required for this request',
400,
{ method, hint: 'Use Content-Type: application/json' },
));
}

// Only accept JSON content type
const normalizedContentType = contentType.split(';')[0].trim().toLowerCase();
if (normalizedContentType !== 'application/json') {
return next(new AppError(
'VALIDATION_ERROR',
`Unsupported Content-Type: ${contentType}`,
400,
{ method, content_type: contentType, expected: 'application/json' },
));
}
}

next();
}

module.exports = requestValidationMiddleware;
102 changes: 102 additions & 0 deletions src/services/dbCache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
'use strict';

const cache = require('./cache');
const logger = require('../logger');

/**
* #452 — Database query caching layer.
*
* Provides a simple wrapper around database queries with Redis caching
* to reduce redundant queries. Cache keys are generated from the query
* and parameters, with configurable TTL.
*
* Usage:
* const result = await cachedQuery('SELECT * FROM users WHERE id = $1', [userId], { ttlSeconds: 60 });
*/

const DEFAULT_TTL_SECONDS = 60;
const MAX_CACHE_KEY_LENGTH = 256;

/**
* Generate a cache key from query and parameters.
* Uses a deterministic hash to keep keys short.
*/
function generateCacheKey(query, params) {
const crypto = require('crypto');
const content = JSON.stringify({ query, params });
const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 32);
return `dbq:${hash}`;
}

/**
* Execute a query with caching support.
* Returns cached result if available, otherwise executes the query
* and caches the result.
*
* @param {string} query - SQL query string
* @param {Array} params - Query parameters
* @param {Object} options - Cache options
* @param {number} options.ttlSeconds - Cache TTL in seconds (default: 60)
* @param {boolean} options.skipCache - Skip cache and force fresh query
* @param {Function} options.executor - Query executor function (required)
* @returns {Promise<*>} Query result
*/
async function cachedQuery(query, params = [], options = {}) {
const {
ttlSeconds = DEFAULT_TTL_SECONDS,
skipCache = false,
executor,
} = options;

if (!executor) {
throw new Error('cachedQuery: executor function is required');
}

const cacheKey = generateCacheKey(query, params);

// Try cache first
if (!skipCache) {
try {
const cached = await cache.get(cacheKey);
if (cached !== null && cached !== undefined) {
logger.debug('Database query cache hit', { cacheKey });
return JSON.parse(cached);
}
} catch (err) {
// Cache miss or Redis unavailable — continue to query
logger.debug('Database query cache read failed', { cacheKey, error: err.message });
}
}

// Execute the query
const result = await executor(query, params);

// Cache the result
if (!skipCache) {
try {
await cache.set(cacheKey, JSON.stringify(result), ttlSeconds);
logger.debug('Database query cached', { cacheKey, ttlSeconds });
} catch (err) {
logger.warn('Database query cache write failed', { cacheKey, error: err.message });
}
}

return result;
}

/**
* Invalidate cached query results matching a pattern.
* Note: This is a best-effort invalidation — Redis doesn't support
* pattern-based key deletion efficiently without SCAN.
*/
async function invalidateQueryCache(queryPattern) {
logger.info('Database query cache invalidation requested', { pattern: queryPattern });
// In production, you would use SCAN + DEL with a pattern
// For now, this is a placeholder that logs the request
}

module.exports = {
cachedQuery,
invalidateQueryCache,
generateCacheKey,
};
Loading