|
|
const { v4: uuidv4 } = require('uuid') |
|
|
const config = require('../../config/config') |
|
|
const apiKeyService = require('../services/apiKeyService') |
|
|
const userService = require('../services/userService') |
|
|
const logger = require('../utils/logger') |
|
|
const redis = require('../models/redis') |
|
|
|
|
|
const ClientValidator = require('../validators/clientValidator') |
|
|
|
|
|
const FALLBACK_CONCURRENCY_CONFIG = { |
|
|
leaseSeconds: 300, |
|
|
renewIntervalSeconds: 30, |
|
|
cleanupGraceSeconds: 30 |
|
|
} |
|
|
|
|
|
const resolveConcurrencyConfig = () => { |
|
|
if (typeof redis._getConcurrencyConfig === 'function') { |
|
|
return redis._getConcurrencyConfig() |
|
|
} |
|
|
|
|
|
const raw = { |
|
|
...FALLBACK_CONCURRENCY_CONFIG, |
|
|
...(config.concurrency || {}) |
|
|
} |
|
|
|
|
|
const toNumber = (value, fallback) => { |
|
|
const parsed = Number(value) |
|
|
if (!Number.isFinite(parsed)) { |
|
|
return fallback |
|
|
} |
|
|
return parsed |
|
|
} |
|
|
|
|
|
const leaseSeconds = Math.max( |
|
|
toNumber(raw.leaseSeconds, FALLBACK_CONCURRENCY_CONFIG.leaseSeconds), |
|
|
30 |
|
|
) |
|
|
|
|
|
let renewIntervalSeconds |
|
|
if (raw.renewIntervalSeconds === 0 || raw.renewIntervalSeconds === '0') { |
|
|
renewIntervalSeconds = 0 |
|
|
} else { |
|
|
renewIntervalSeconds = Math.max( |
|
|
toNumber(raw.renewIntervalSeconds, FALLBACK_CONCURRENCY_CONFIG.renewIntervalSeconds), |
|
|
0 |
|
|
) |
|
|
} |
|
|
|
|
|
const cleanupGraceSeconds = Math.max( |
|
|
toNumber(raw.cleanupGraceSeconds, FALLBACK_CONCURRENCY_CONFIG.cleanupGraceSeconds), |
|
|
0 |
|
|
) |
|
|
|
|
|
return { |
|
|
leaseSeconds, |
|
|
renewIntervalSeconds, |
|
|
cleanupGraceSeconds |
|
|
} |
|
|
} |
|
|
|
|
|
const TOKEN_COUNT_PATHS = new Set([ |
|
|
'/v1/messages/count_tokens', |
|
|
'/api/v1/messages/count_tokens', |
|
|
'/claude/v1/messages/count_tokens' |
|
|
]) |
|
|
|
|
|
function extractApiKey(req) { |
|
|
const candidates = [ |
|
|
req.headers['x-api-key'], |
|
|
req.headers['x-goog-api-key'], |
|
|
req.headers['authorization'], |
|
|
req.headers['api-key'], |
|
|
req.query?.key |
|
|
] |
|
|
|
|
|
for (const candidate of candidates) { |
|
|
let value = candidate |
|
|
|
|
|
if (Array.isArray(value)) { |
|
|
value = value.find((item) => typeof item === 'string' && item.trim()) |
|
|
} |
|
|
|
|
|
if (typeof value !== 'string') { |
|
|
continue |
|
|
} |
|
|
|
|
|
let trimmed = value.trim() |
|
|
if (!trimmed) { |
|
|
continue |
|
|
} |
|
|
|
|
|
if (/^Bearer\s+/i.test(trimmed)) { |
|
|
trimmed = trimmed.replace(/^Bearer\s+/i, '').trim() |
|
|
if (!trimmed) { |
|
|
continue |
|
|
} |
|
|
} |
|
|
|
|
|
return trimmed |
|
|
} |
|
|
|
|
|
return '' |
|
|
} |
|
|
|
|
|
function normalizeRequestPath(value) { |
|
|
if (!value) { |
|
|
return '/' |
|
|
} |
|
|
const lower = value.split('?')[0].toLowerCase() |
|
|
const collapsed = lower.replace(/\/{2,}/g, '/') |
|
|
if (collapsed.length > 1 && collapsed.endsWith('/')) { |
|
|
return collapsed.slice(0, -1) |
|
|
} |
|
|
return collapsed || '/' |
|
|
} |
|
|
|
|
|
function isTokenCountRequest(req) { |
|
|
const combined = normalizeRequestPath(`${req.baseUrl || ''}${req.path || ''}`) |
|
|
if (TOKEN_COUNT_PATHS.has(combined)) { |
|
|
return true |
|
|
} |
|
|
const original = normalizeRequestPath(req.originalUrl || '') |
|
|
if (TOKEN_COUNT_PATHS.has(original)) { |
|
|
return true |
|
|
} |
|
|
return false |
|
|
} |
|
|
|
|
|
|
|
|
const authenticateApiKey = async (req, res, next) => { |
|
|
const startTime = Date.now() |
|
|
|
|
|
try { |
|
|
|
|
|
const apiKey = extractApiKey(req) |
|
|
|
|
|
if (apiKey) { |
|
|
req.headers['x-api-key'] = apiKey |
|
|
} |
|
|
|
|
|
if (!apiKey) { |
|
|
logger.security(`🔒 Missing API key attempt from ${req.ip || 'unknown'}`) |
|
|
return res.status(401).json({ |
|
|
error: 'Missing API key', |
|
|
message: |
|
|
'Please provide an API key in the x-api-key, x-goog-api-key, or Authorization header' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { |
|
|
logger.security(`🔒 Invalid API key format from ${req.ip || 'unknown'}`) |
|
|
return res.status(401).json({ |
|
|
error: 'Invalid API key format', |
|
|
message: 'API key format is invalid' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
const validation = await apiKeyService.validateApiKey(apiKey) |
|
|
|
|
|
if (!validation.valid) { |
|
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' |
|
|
logger.security(`🔒 Invalid API key attempt: ${validation.error} from ${clientIP}`) |
|
|
return res.status(401).json({ |
|
|
error: 'Invalid API key', |
|
|
message: validation.error |
|
|
}) |
|
|
} |
|
|
|
|
|
const skipKeyRestrictions = isTokenCountRequest(req) |
|
|
|
|
|
|
|
|
if ( |
|
|
!skipKeyRestrictions && |
|
|
validation.keyData.enableClientRestriction && |
|
|
validation.keyData.allowedClients?.length > 0 |
|
|
) { |
|
|
|
|
|
const validationResult = ClientValidator.validateRequest( |
|
|
validation.keyData.allowedClients, |
|
|
req |
|
|
) |
|
|
|
|
|
if (!validationResult.allowed) { |
|
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' |
|
|
logger.security( |
|
|
`🚫 Client restriction failed for key: ${validation.keyData.id} (${validation.keyData.name}) from ${clientIP}` |
|
|
) |
|
|
return res.status(403).json({ |
|
|
error: 'Client not allowed', |
|
|
message: 'Your client is not authorized to use this API key', |
|
|
allowedClients: validation.keyData.allowedClients, |
|
|
userAgent: validationResult.userAgent |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
logger.api( |
|
|
`✅ Client validated: ${validationResult.clientName} (${validationResult.matchedClient}) for key: ${validation.keyData.id} (${validation.keyData.name})` |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
const concurrencyLimit = validation.keyData.concurrencyLimit || 0 |
|
|
if (!skipKeyRestrictions && concurrencyLimit > 0) { |
|
|
const { leaseSeconds: configLeaseSeconds, renewIntervalSeconds: configRenewIntervalSeconds } = |
|
|
resolveConcurrencyConfig() |
|
|
const leaseSeconds = Math.max(Number(configLeaseSeconds) || 300, 30) |
|
|
let renewIntervalSeconds = configRenewIntervalSeconds |
|
|
if (renewIntervalSeconds > 0) { |
|
|
const maxSafeRenew = Math.max(leaseSeconds - 5, 15) |
|
|
renewIntervalSeconds = Math.min(Math.max(renewIntervalSeconds, 15), maxSafeRenew) |
|
|
} else { |
|
|
renewIntervalSeconds = 0 |
|
|
} |
|
|
const requestId = uuidv4() |
|
|
|
|
|
const currentConcurrency = await redis.incrConcurrency( |
|
|
validation.keyData.id, |
|
|
requestId, |
|
|
leaseSeconds |
|
|
) |
|
|
logger.api( |
|
|
`📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}` |
|
|
) |
|
|
|
|
|
if (currentConcurrency > concurrencyLimit) { |
|
|
|
|
|
await redis.decrConcurrency(validation.keyData.id, requestId) |
|
|
logger.security( |
|
|
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${ |
|
|
validation.keyData.name |
|
|
}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}` |
|
|
) |
|
|
return res.status(429).json({ |
|
|
error: 'Concurrency limit exceeded', |
|
|
message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`, |
|
|
currentConcurrency: currentConcurrency - 1, |
|
|
concurrencyLimit |
|
|
}) |
|
|
} |
|
|
|
|
|
const renewIntervalMs = |
|
|
renewIntervalSeconds > 0 ? Math.max(renewIntervalSeconds * 1000, 15000) : 0 |
|
|
|
|
|
|
|
|
let concurrencyDecremented = false |
|
|
let leaseRenewInterval = null |
|
|
|
|
|
if (renewIntervalMs > 0) { |
|
|
leaseRenewInterval = setInterval(() => { |
|
|
redis |
|
|
.refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds) |
|
|
.catch((error) => { |
|
|
logger.error( |
|
|
`Failed to refresh concurrency lease for key ${validation.keyData.id}:`, |
|
|
error |
|
|
) |
|
|
}) |
|
|
}, renewIntervalMs) |
|
|
|
|
|
if (typeof leaseRenewInterval.unref === 'function') { |
|
|
leaseRenewInterval.unref() |
|
|
} |
|
|
} |
|
|
|
|
|
const decrementConcurrency = async () => { |
|
|
if (!concurrencyDecremented) { |
|
|
concurrencyDecremented = true |
|
|
if (leaseRenewInterval) { |
|
|
clearInterval(leaseRenewInterval) |
|
|
leaseRenewInterval = null |
|
|
} |
|
|
try { |
|
|
const newCount = await redis.decrConcurrency(validation.keyData.id, requestId) |
|
|
logger.api( |
|
|
`📉 Decremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}` |
|
|
) |
|
|
} catch (error) { |
|
|
logger.error(`Failed to decrement concurrency for key ${validation.keyData.id}:`, error) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
res.once('close', () => { |
|
|
logger.api( |
|
|
`🔌 Response closed for key: ${validation.keyData.id} (${validation.keyData.name})` |
|
|
) |
|
|
decrementConcurrency() |
|
|
}) |
|
|
|
|
|
|
|
|
req.once('close', () => { |
|
|
logger.api( |
|
|
`🔌 Request closed for key: ${validation.keyData.id} (${validation.keyData.name})` |
|
|
) |
|
|
decrementConcurrency() |
|
|
}) |
|
|
|
|
|
req.once('aborted', () => { |
|
|
logger.warn( |
|
|
`⚠️ Request aborted for key: ${validation.keyData.id} (${validation.keyData.name})` |
|
|
) |
|
|
decrementConcurrency() |
|
|
}) |
|
|
|
|
|
req.once('error', (error) => { |
|
|
logger.error( |
|
|
`❌ Request error for key ${validation.keyData.id} (${validation.keyData.name}):`, |
|
|
error |
|
|
) |
|
|
decrementConcurrency() |
|
|
}) |
|
|
|
|
|
res.once('error', (error) => { |
|
|
logger.error( |
|
|
`❌ Response error for key ${validation.keyData.id} (${validation.keyData.name}):`, |
|
|
error |
|
|
) |
|
|
decrementConcurrency() |
|
|
}) |
|
|
|
|
|
|
|
|
res.once('finish', () => { |
|
|
logger.api( |
|
|
`✅ Response finished for key: ${validation.keyData.id} (${validation.keyData.name})` |
|
|
) |
|
|
decrementConcurrency() |
|
|
}) |
|
|
|
|
|
|
|
|
req.concurrencyInfo = { |
|
|
apiKeyId: validation.keyData.id, |
|
|
apiKeyName: validation.keyData.name, |
|
|
requestId, |
|
|
decrementConcurrency |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const rateLimitWindow = validation.keyData.rateLimitWindow || 0 |
|
|
const rateLimitRequests = validation.keyData.rateLimitRequests || 0 |
|
|
const rateLimitCost = validation.keyData.rateLimitCost || 0 |
|
|
|
|
|
|
|
|
const hasRateLimits = |
|
|
rateLimitWindow > 0 && |
|
|
(rateLimitRequests > 0 || validation.keyData.tokenLimit > 0 || rateLimitCost > 0) |
|
|
|
|
|
if (hasRateLimits) { |
|
|
const windowStartKey = `rate_limit:window_start:${validation.keyData.id}` |
|
|
const requestCountKey = `rate_limit:requests:${validation.keyData.id}` |
|
|
const tokenCountKey = `rate_limit:tokens:${validation.keyData.id}` |
|
|
const costCountKey = `rate_limit:cost:${validation.keyData.id}` |
|
|
|
|
|
const now = Date.now() |
|
|
const windowDuration = rateLimitWindow * 60 * 1000 |
|
|
|
|
|
|
|
|
let windowStart = await redis.getClient().get(windowStartKey) |
|
|
|
|
|
if (!windowStart) { |
|
|
|
|
|
await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) |
|
|
await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) |
|
|
await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) |
|
|
await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) |
|
|
windowStart = now |
|
|
} else { |
|
|
windowStart = parseInt(windowStart) |
|
|
|
|
|
|
|
|
if (now - windowStart >= windowDuration) { |
|
|
|
|
|
await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) |
|
|
await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) |
|
|
await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) |
|
|
await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) |
|
|
windowStart = now |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const currentRequests = parseInt((await redis.getClient().get(requestCountKey)) || '0') |
|
|
const currentTokens = parseInt((await redis.getClient().get(tokenCountKey)) || '0') |
|
|
const currentCost = parseFloat((await redis.getClient().get(costCountKey)) || '0') |
|
|
|
|
|
|
|
|
if (rateLimitRequests > 0 && currentRequests >= rateLimitRequests) { |
|
|
const resetTime = new Date(windowStart + windowDuration) |
|
|
const remainingMinutes = Math.ceil((resetTime - now) / 60000) |
|
|
|
|
|
logger.security( |
|
|
`🚦 Rate limit exceeded (requests) for key: ${validation.keyData.id} (${validation.keyData.name}), requests: ${currentRequests}/${rateLimitRequests}` |
|
|
) |
|
|
|
|
|
return res.status(429).json({ |
|
|
error: 'Rate limit exceeded', |
|
|
message: `已达到请求次数限制 (${rateLimitRequests} 次),将在 ${remainingMinutes} 分钟后重置`, |
|
|
currentRequests, |
|
|
requestLimit: rateLimitRequests, |
|
|
resetAt: resetTime.toISOString(), |
|
|
remainingMinutes |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
const tokenLimit = parseInt(validation.keyData.tokenLimit) |
|
|
if (tokenLimit > 0) { |
|
|
|
|
|
if (currentTokens >= tokenLimit) { |
|
|
const resetTime = new Date(windowStart + windowDuration) |
|
|
const remainingMinutes = Math.ceil((resetTime - now) / 60000) |
|
|
|
|
|
logger.security( |
|
|
`🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}` |
|
|
) |
|
|
|
|
|
return res.status(429).json({ |
|
|
error: 'Rate limit exceeded', |
|
|
message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`, |
|
|
currentTokens, |
|
|
tokenLimit, |
|
|
resetAt: resetTime.toISOString(), |
|
|
remainingMinutes |
|
|
}) |
|
|
} |
|
|
} else if (rateLimitCost > 0) { |
|
|
|
|
|
if (currentCost >= rateLimitCost) { |
|
|
const resetTime = new Date(windowStart + windowDuration) |
|
|
const remainingMinutes = Math.ceil((resetTime - now) / 60000) |
|
|
|
|
|
logger.security( |
|
|
`💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${ |
|
|
validation.keyData.name |
|
|
}), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}` |
|
|
) |
|
|
|
|
|
return res.status(429).json({ |
|
|
error: 'Rate limit exceeded', |
|
|
message: `已达到费用限制 ($${rateLimitCost}),将在 ${remainingMinutes} 分钟后重置`, |
|
|
currentCost, |
|
|
costLimit: rateLimitCost, |
|
|
resetAt: resetTime.toISOString(), |
|
|
remainingMinutes |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
await redis.getClient().incr(requestCountKey) |
|
|
|
|
|
|
|
|
req.rateLimitInfo = { |
|
|
windowStart, |
|
|
windowDuration, |
|
|
requestCountKey, |
|
|
tokenCountKey, |
|
|
costCountKey, |
|
|
currentRequests: currentRequests + 1, |
|
|
currentTokens, |
|
|
currentCost, |
|
|
rateLimitRequests, |
|
|
tokenLimit, |
|
|
rateLimitCost |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const dailyCostLimit = validation.keyData.dailyCostLimit || 0 |
|
|
if (dailyCostLimit > 0) { |
|
|
const dailyCost = validation.keyData.dailyCost || 0 |
|
|
|
|
|
if (dailyCost >= dailyCostLimit) { |
|
|
logger.security( |
|
|
`💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${ |
|
|
validation.keyData.name |
|
|
}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` |
|
|
) |
|
|
|
|
|
return res.status(429).json({ |
|
|
error: 'Daily cost limit exceeded', |
|
|
message: `已达到每日费用限制 ($${dailyCostLimit})`, |
|
|
currentCost: dailyCost, |
|
|
costLimit: dailyCostLimit, |
|
|
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
logger.api( |
|
|
`💰 Cost usage for key: ${validation.keyData.id} (${ |
|
|
validation.keyData.name |
|
|
}), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
const totalCostLimit = validation.keyData.totalCostLimit || 0 |
|
|
if (totalCostLimit > 0) { |
|
|
const totalCost = validation.keyData.totalCost || 0 |
|
|
|
|
|
if (totalCost >= totalCostLimit) { |
|
|
logger.security( |
|
|
`💰 Total cost limit exceeded for key: ${validation.keyData.id} (${ |
|
|
validation.keyData.name |
|
|
}), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}` |
|
|
) |
|
|
|
|
|
return res.status(429).json({ |
|
|
error: 'Total cost limit exceeded', |
|
|
message: `已达到总费用限制 ($${totalCostLimit})`, |
|
|
currentCost: totalCost, |
|
|
costLimit: totalCostLimit |
|
|
}) |
|
|
} |
|
|
|
|
|
logger.api( |
|
|
`💰 Total cost usage for key: ${validation.keyData.id} (${ |
|
|
validation.keyData.name |
|
|
}), current: $${totalCost.toFixed(2)}/$${totalCostLimit}` |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0 |
|
|
if (weeklyOpusCostLimit > 0) { |
|
|
|
|
|
const requestBody = req.body || {} |
|
|
const model = requestBody.model || '' |
|
|
|
|
|
|
|
|
if (model && model.toLowerCase().includes('claude-opus')) { |
|
|
const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0 |
|
|
|
|
|
if (weeklyOpusCost >= weeklyOpusCostLimit) { |
|
|
logger.security( |
|
|
`💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${ |
|
|
validation.keyData.name |
|
|
}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` |
|
|
) |
|
|
|
|
|
|
|
|
const now = new Date() |
|
|
const dayOfWeek = now.getDay() |
|
|
const daysUntilMonday = dayOfWeek === 0 ? 1 : (8 - dayOfWeek) % 7 || 7 |
|
|
const resetDate = new Date(now) |
|
|
resetDate.setDate(now.getDate() + daysUntilMonday) |
|
|
resetDate.setHours(0, 0, 0, 0) |
|
|
|
|
|
return res.status(429).json({ |
|
|
error: 'Weekly Opus cost limit exceeded', |
|
|
message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`, |
|
|
currentCost: weeklyOpusCost, |
|
|
costLimit: weeklyOpusCostLimit, |
|
|
resetAt: resetDate.toISOString() |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
logger.api( |
|
|
`💰 Opus weekly cost usage for key: ${validation.keyData.id} (${ |
|
|
validation.keyData.name |
|
|
}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` |
|
|
) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
req.apiKey = { |
|
|
id: validation.keyData.id, |
|
|
name: validation.keyData.name, |
|
|
tokenLimit: validation.keyData.tokenLimit, |
|
|
claudeAccountId: validation.keyData.claudeAccountId, |
|
|
claudeConsoleAccountId: validation.keyData.claudeConsoleAccountId, |
|
|
geminiAccountId: validation.keyData.geminiAccountId, |
|
|
openaiAccountId: validation.keyData.openaiAccountId, |
|
|
bedrockAccountId: validation.keyData.bedrockAccountId, |
|
|
droidAccountId: validation.keyData.droidAccountId, |
|
|
permissions: validation.keyData.permissions, |
|
|
concurrencyLimit: validation.keyData.concurrencyLimit, |
|
|
rateLimitWindow: validation.keyData.rateLimitWindow, |
|
|
rateLimitRequests: validation.keyData.rateLimitRequests, |
|
|
rateLimitCost: validation.keyData.rateLimitCost, |
|
|
enableModelRestriction: validation.keyData.enableModelRestriction, |
|
|
restrictedModels: validation.keyData.restrictedModels, |
|
|
enableClientRestriction: validation.keyData.enableClientRestriction, |
|
|
allowedClients: validation.keyData.allowedClients, |
|
|
dailyCostLimit: validation.keyData.dailyCostLimit, |
|
|
dailyCost: validation.keyData.dailyCost, |
|
|
totalCostLimit: validation.keyData.totalCostLimit, |
|
|
totalCost: validation.keyData.totalCost, |
|
|
usage: validation.keyData.usage |
|
|
} |
|
|
req.usage = validation.keyData.usage |
|
|
|
|
|
const authDuration = Date.now() - startTime |
|
|
const userAgent = req.headers['user-agent'] || 'No User-Agent' |
|
|
logger.api( |
|
|
`🔓 Authenticated request from key: ${validation.keyData.name} (${validation.keyData.id}) in ${authDuration}ms` |
|
|
) |
|
|
logger.api(` User-Agent: "${userAgent}"`) |
|
|
|
|
|
return next() |
|
|
} catch (error) { |
|
|
const authDuration = Date.now() - startTime |
|
|
logger.error(`❌ Authentication middleware error (${authDuration}ms):`, { |
|
|
error: error.message, |
|
|
stack: error.stack, |
|
|
ip: req.ip, |
|
|
userAgent: req.get('User-Agent'), |
|
|
url: req.originalUrl |
|
|
}) |
|
|
|
|
|
return res.status(500).json({ |
|
|
error: 'Authentication error', |
|
|
message: 'Internal server error during authentication' |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const authenticateAdmin = async (req, res, next) => { |
|
|
const startTime = Date.now() |
|
|
|
|
|
try { |
|
|
|
|
|
const token = |
|
|
req.headers['authorization']?.replace(/^Bearer\s+/i, '') || |
|
|
req.cookies?.adminToken || |
|
|
req.headers['x-admin-token'] |
|
|
|
|
|
if (!token) { |
|
|
logger.security(`🔒 Missing admin token attempt from ${req.ip || 'unknown'}`) |
|
|
return res.status(401).json({ |
|
|
error: 'Missing admin token', |
|
|
message: 'Please provide an admin token' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
if (typeof token !== 'string' || token.length < 32 || token.length > 512) { |
|
|
logger.security(`🔒 Invalid admin token format from ${req.ip || 'unknown'}`) |
|
|
return res.status(401).json({ |
|
|
error: 'Invalid admin token format', |
|
|
message: 'Admin token format is invalid' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
const adminSession = await Promise.race([ |
|
|
redis.getSession(token), |
|
|
new Promise((_, reject) => |
|
|
setTimeout(() => reject(new Error('Session lookup timeout')), 5000) |
|
|
) |
|
|
]) |
|
|
|
|
|
if (!adminSession || Object.keys(adminSession).length === 0) { |
|
|
logger.security(`🔒 Invalid admin token attempt from ${req.ip || 'unknown'}`) |
|
|
return res.status(401).json({ |
|
|
error: 'Invalid admin token', |
|
|
message: 'Invalid or expired admin session' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
const now = new Date() |
|
|
const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime) |
|
|
const inactiveDuration = now - lastActivity |
|
|
const maxInactivity = 24 * 60 * 60 * 1000 |
|
|
|
|
|
if (inactiveDuration > maxInactivity) { |
|
|
logger.security( |
|
|
`🔒 Expired admin session for ${adminSession.username} from ${req.ip || 'unknown'}` |
|
|
) |
|
|
await redis.deleteSession(token) |
|
|
return res.status(401).json({ |
|
|
error: 'Session expired', |
|
|
message: 'Admin session has expired due to inactivity' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
redis |
|
|
.setSession( |
|
|
token, |
|
|
{ |
|
|
...adminSession, |
|
|
lastActivity: now.toISOString() |
|
|
}, |
|
|
86400 |
|
|
) |
|
|
.catch((error) => { |
|
|
logger.error('Failed to update admin session activity:', error) |
|
|
}) |
|
|
|
|
|
|
|
|
req.admin = { |
|
|
id: adminSession.adminId || 'admin', |
|
|
username: adminSession.username, |
|
|
sessionId: token, |
|
|
loginTime: adminSession.loginTime |
|
|
} |
|
|
|
|
|
const authDuration = Date.now() - startTime |
|
|
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`) |
|
|
|
|
|
return next() |
|
|
} catch (error) { |
|
|
const authDuration = Date.now() - startTime |
|
|
logger.error(`❌ Admin authentication error (${authDuration}ms):`, { |
|
|
error: error.message, |
|
|
ip: req.ip, |
|
|
userAgent: req.get('User-Agent'), |
|
|
url: req.originalUrl |
|
|
}) |
|
|
|
|
|
return res.status(500).json({ |
|
|
error: 'Authentication error', |
|
|
message: 'Internal server error during admin authentication' |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const authenticateUser = async (req, res, next) => { |
|
|
const startTime = Date.now() |
|
|
|
|
|
try { |
|
|
|
|
|
const sessionToken = |
|
|
req.headers['authorization']?.replace(/^Bearer\s+/i, '') || |
|
|
req.cookies?.userToken || |
|
|
req.headers['x-user-token'] |
|
|
|
|
|
if (!sessionToken) { |
|
|
logger.security(`🔒 Missing user session token attempt from ${req.ip || 'unknown'}`) |
|
|
return res.status(401).json({ |
|
|
error: 'Missing user session token', |
|
|
message: 'Please login to access this resource' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
if (typeof sessionToken !== 'string' || sessionToken.length < 32 || sessionToken.length > 128) { |
|
|
logger.security(`🔒 Invalid user session token format from ${req.ip || 'unknown'}`) |
|
|
return res.status(401).json({ |
|
|
error: 'Invalid session token format', |
|
|
message: 'Session token format is invalid' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
const sessionValidation = await userService.validateUserSession(sessionToken) |
|
|
|
|
|
if (!sessionValidation) { |
|
|
logger.security(`🔒 Invalid user session token attempt from ${req.ip || 'unknown'}`) |
|
|
return res.status(401).json({ |
|
|
error: 'Invalid session token', |
|
|
message: 'Invalid or expired user session' |
|
|
}) |
|
|
} |
|
|
|
|
|
const { session, user } = sessionValidation |
|
|
|
|
|
|
|
|
if (!user.isActive) { |
|
|
logger.security( |
|
|
`🔒 Disabled user login attempt: ${user.username} from ${req.ip || 'unknown'}` |
|
|
) |
|
|
return res.status(403).json({ |
|
|
error: 'Account disabled', |
|
|
message: 'Your account has been disabled. Please contact administrator.' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
req.user = { |
|
|
id: user.id, |
|
|
username: user.username, |
|
|
email: user.email, |
|
|
displayName: user.displayName, |
|
|
firstName: user.firstName, |
|
|
lastName: user.lastName, |
|
|
role: user.role, |
|
|
sessionToken, |
|
|
sessionCreatedAt: session.createdAt |
|
|
} |
|
|
|
|
|
const authDuration = Date.now() - startTime |
|
|
logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`) |
|
|
|
|
|
return next() |
|
|
} catch (error) { |
|
|
const authDuration = Date.now() - startTime |
|
|
logger.error(`❌ User authentication error (${authDuration}ms):`, { |
|
|
error: error.message, |
|
|
ip: req.ip, |
|
|
userAgent: req.get('User-Agent'), |
|
|
url: req.originalUrl |
|
|
}) |
|
|
|
|
|
return res.status(500).json({ |
|
|
error: 'Authentication error', |
|
|
message: 'Internal server error during user authentication' |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const authenticateUserOrAdmin = async (req, res, next) => { |
|
|
const startTime = Date.now() |
|
|
|
|
|
try { |
|
|
|
|
|
const adminToken = |
|
|
req.headers['authorization']?.replace(/^Bearer\s+/i, '') || |
|
|
req.cookies?.adminToken || |
|
|
req.headers['x-admin-token'] |
|
|
|
|
|
|
|
|
const userToken = |
|
|
req.headers['x-user-token'] || |
|
|
req.cookies?.userToken || |
|
|
(!adminToken ? req.headers['authorization']?.replace(/^Bearer\s+/i, '') : null) |
|
|
|
|
|
|
|
|
if (adminToken) { |
|
|
try { |
|
|
const adminSession = await redis.getSession(adminToken) |
|
|
if (adminSession && Object.keys(adminSession).length > 0) { |
|
|
req.admin = { |
|
|
id: adminSession.adminId || 'admin', |
|
|
username: adminSession.username, |
|
|
sessionId: adminToken, |
|
|
loginTime: adminSession.loginTime |
|
|
} |
|
|
req.userType = 'admin' |
|
|
|
|
|
const authDuration = Date.now() - startTime |
|
|
logger.security(`🔐 Admin authenticated: ${adminSession.username} in ${authDuration}ms`) |
|
|
return next() |
|
|
} |
|
|
} catch (error) { |
|
|
logger.debug('Admin authentication failed, trying user authentication:', error.message) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (userToken) { |
|
|
try { |
|
|
const sessionValidation = await userService.validateUserSession(userToken) |
|
|
if (sessionValidation) { |
|
|
const { session, user } = sessionValidation |
|
|
|
|
|
if (user.isActive) { |
|
|
req.user = { |
|
|
id: user.id, |
|
|
username: user.username, |
|
|
email: user.email, |
|
|
displayName: user.displayName, |
|
|
firstName: user.firstName, |
|
|
lastName: user.lastName, |
|
|
role: user.role, |
|
|
sessionToken: userToken, |
|
|
sessionCreatedAt: session.createdAt |
|
|
} |
|
|
req.userType = 'user' |
|
|
|
|
|
const authDuration = Date.now() - startTime |
|
|
logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`) |
|
|
return next() |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
logger.debug('User authentication failed:', error.message) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
logger.security(`🔒 Authentication failed from ${req.ip || 'unknown'}`) |
|
|
return res.status(401).json({ |
|
|
error: 'Authentication required', |
|
|
message: 'Please login as user or admin to access this resource' |
|
|
}) |
|
|
} catch (error) { |
|
|
const authDuration = Date.now() - startTime |
|
|
logger.error(`❌ User/Admin authentication error (${authDuration}ms):`, { |
|
|
error: error.message, |
|
|
ip: req.ip, |
|
|
userAgent: req.get('User-Agent'), |
|
|
url: req.originalUrl |
|
|
}) |
|
|
|
|
|
return res.status(500).json({ |
|
|
error: 'Authentication error', |
|
|
message: 'Internal server error during authentication' |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const requireRole = (allowedRoles) => (req, res, next) => { |
|
|
|
|
|
if (req.admin) { |
|
|
return next() |
|
|
} |
|
|
|
|
|
|
|
|
if (req.user) { |
|
|
const userRole = req.user.role |
|
|
const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles] |
|
|
|
|
|
if (allowed.includes(userRole)) { |
|
|
return next() |
|
|
} else { |
|
|
logger.security( |
|
|
`🚫 Access denied for user ${req.user.username} (role: ${userRole}) to ${req.originalUrl}` |
|
|
) |
|
|
return res.status(403).json({ |
|
|
error: 'Insufficient permissions', |
|
|
message: `This resource requires one of the following roles: ${allowed.join(', ')}` |
|
|
}) |
|
|
} |
|
|
} |
|
|
|
|
|
return res.status(401).json({ |
|
|
error: 'Authentication required', |
|
|
message: 'Please login to access this resource' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
const requireAdmin = (req, res, next) => { |
|
|
if (req.admin) { |
|
|
return next() |
|
|
} |
|
|
|
|
|
|
|
|
if (req.user && req.user.role === 'admin') { |
|
|
return next() |
|
|
} |
|
|
|
|
|
logger.security( |
|
|
`🚫 Admin access denied for ${req.user?.username || 'unknown'} from ${req.ip || 'unknown'}` |
|
|
) |
|
|
return res.status(403).json({ |
|
|
error: 'Admin access required', |
|
|
message: 'This resource requires administrator privileges' |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const corsMiddleware = (req, res, next) => { |
|
|
const { origin } = req.headers |
|
|
|
|
|
|
|
|
const allowedOrigins = [ |
|
|
'http://localhost:3000', |
|
|
'https://localhost:3000', |
|
|
'http://127.0.0.1:3000', |
|
|
'https://127.0.0.1:3000' |
|
|
] |
|
|
|
|
|
|
|
|
const isChromeExtension = origin && origin.startsWith('chrome-extension://') |
|
|
|
|
|
|
|
|
if (allowedOrigins.includes(origin) || !origin || isChromeExtension) { |
|
|
res.header('Access-Control-Allow-Origin', origin || '*') |
|
|
} |
|
|
|
|
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') |
|
|
res.header( |
|
|
'Access-Control-Allow-Headers', |
|
|
[ |
|
|
'Origin', |
|
|
'X-Requested-With', |
|
|
'Content-Type', |
|
|
'Accept', |
|
|
'Authorization', |
|
|
'x-api-key', |
|
|
'x-goog-api-key', |
|
|
'api-key', |
|
|
'x-admin-token', |
|
|
'anthropic-version', |
|
|
'anthropic-dangerous-direct-browser-access' |
|
|
].join(', ') |
|
|
) |
|
|
|
|
|
res.header('Access-Control-Expose-Headers', ['X-Request-ID', 'Content-Type'].join(', ')) |
|
|
|
|
|
res.header('Access-Control-Max-Age', '86400') |
|
|
res.header('Access-Control-Allow-Credentials', 'true') |
|
|
|
|
|
if (req.method === 'OPTIONS') { |
|
|
res.status(204).end() |
|
|
} else { |
|
|
next() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const requestLogger = (req, res, next) => { |
|
|
const start = Date.now() |
|
|
const requestId = Math.random().toString(36).substring(2, 15) |
|
|
|
|
|
|
|
|
req.requestId = requestId |
|
|
res.setHeader('X-Request-ID', requestId) |
|
|
|
|
|
|
|
|
const clientIP = req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || 'unknown' |
|
|
const userAgent = req.get('User-Agent') || 'unknown' |
|
|
const referer = req.get('Referer') || 'none' |
|
|
|
|
|
|
|
|
if (req.originalUrl !== '/health') { |
|
|
|
|
|
logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`) |
|
|
} |
|
|
|
|
|
res.on('finish', () => { |
|
|
const duration = Date.now() - start |
|
|
const contentLength = res.get('Content-Length') || '0' |
|
|
|
|
|
|
|
|
const logMetadata = { |
|
|
requestId, |
|
|
method: req.method, |
|
|
url: req.originalUrl, |
|
|
status: res.statusCode, |
|
|
duration, |
|
|
contentLength, |
|
|
ip: clientIP, |
|
|
userAgent, |
|
|
referer |
|
|
} |
|
|
|
|
|
|
|
|
if (res.statusCode >= 500) { |
|
|
logger.error( |
|
|
`◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, |
|
|
logMetadata |
|
|
) |
|
|
} else if (res.statusCode >= 400) { |
|
|
logger.warn( |
|
|
`◀️ [${requestId}] ${req.method} ${req.originalUrl} | ${res.statusCode} | ${duration}ms | ${contentLength}B`, |
|
|
logMetadata |
|
|
) |
|
|
} else if (req.originalUrl !== '/health') { |
|
|
logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata) |
|
|
} |
|
|
|
|
|
|
|
|
if (req.apiKey) { |
|
|
logger.api( |
|
|
`📱 [${requestId}] Request from ${req.apiKey.name} (${req.apiKey.id}) | ${duration}ms` |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
if (duration > 5000) { |
|
|
logger.warn( |
|
|
`🐌 [${requestId}] Slow request detected: ${duration}ms for ${req.method} ${req.originalUrl}` |
|
|
) |
|
|
} |
|
|
}) |
|
|
|
|
|
res.on('error', (error) => { |
|
|
const duration = Date.now() - start |
|
|
logger.error(`💥 [${requestId}] Response error after ${duration}ms:`, error) |
|
|
}) |
|
|
|
|
|
next() |
|
|
} |
|
|
|
|
|
|
|
|
const securityMiddleware = (req, res, next) => { |
|
|
|
|
|
res.setHeader('X-Content-Type-Options', 'nosniff') |
|
|
res.setHeader('X-Frame-Options', 'DENY') |
|
|
res.setHeader('X-XSS-Protection', '1; mode=block') |
|
|
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') |
|
|
|
|
|
|
|
|
res.setHeader('X-DNS-Prefetch-Control', 'off') |
|
|
res.setHeader('X-Download-Options', 'noopen') |
|
|
res.setHeader('X-Permitted-Cross-Domain-Policies', 'none') |
|
|
|
|
|
|
|
|
const host = req.get('host') || '' |
|
|
const isLocalhost = |
|
|
host.includes('localhost') || host.includes('127.0.0.1') || host.includes('0.0.0.0') |
|
|
const isHttps = req.secure || req.headers['x-forwarded-proto'] === 'https' |
|
|
|
|
|
if (isLocalhost || isHttps) { |
|
|
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') |
|
|
res.setHeader('Cross-Origin-Resource-Policy', 'same-origin') |
|
|
res.setHeader('Origin-Agent-Cluster', '?1') |
|
|
} |
|
|
|
|
|
|
|
|
if (req.path.startsWith('/web') || req.path === '/') { |
|
|
res.setHeader( |
|
|
'Content-Security-Policy', |
|
|
[ |
|
|
"default-src 'self'", |
|
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://cdn.bootcdn.net", |
|
|
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.bootcdn.net", |
|
|
"font-src 'self' https://cdnjs.cloudflare.com https://cdn.bootcdn.net", |
|
|
"img-src 'self' data:", |
|
|
"connect-src 'self'", |
|
|
"frame-ancestors 'none'", |
|
|
"base-uri 'self'", |
|
|
"form-action 'self'" |
|
|
].join('; ') |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
if (req.secure || req.headers['x-forwarded-proto'] === 'https') { |
|
|
res.setHeader('Strict-Transport-Security', 'max-age=15552000; includeSubDomains') |
|
|
} |
|
|
|
|
|
|
|
|
res.removeHeader('X-Powered-By') |
|
|
res.removeHeader('Server') |
|
|
|
|
|
|
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') |
|
|
res.setHeader('Pragma', 'no-cache') |
|
|
res.setHeader('Expires', '0') |
|
|
|
|
|
next() |
|
|
} |
|
|
|
|
|
|
|
|
const errorHandler = (error, req, res, _next) => { |
|
|
const requestId = req.requestId || 'unknown' |
|
|
const isDevelopment = process.env.NODE_ENV === 'development' |
|
|
|
|
|
|
|
|
logger.error(`💥 [${requestId}] Unhandled error:`, { |
|
|
error: error.message, |
|
|
stack: error.stack, |
|
|
url: req.originalUrl, |
|
|
method: req.method, |
|
|
ip: req.ip || 'unknown', |
|
|
userAgent: req.get('User-Agent') || 'unknown', |
|
|
apiKey: req.apiKey ? req.apiKey.id : 'none', |
|
|
admin: req.admin ? req.admin.username : 'none' |
|
|
}) |
|
|
|
|
|
|
|
|
let statusCode = 500 |
|
|
let errorMessage = 'Internal Server Error' |
|
|
let userMessage = 'Something went wrong' |
|
|
|
|
|
if (error.status && error.status >= 400 && error.status < 600) { |
|
|
statusCode = error.status |
|
|
} |
|
|
|
|
|
|
|
|
switch (error.name) { |
|
|
case 'ValidationError': |
|
|
statusCode = 400 |
|
|
errorMessage = 'Validation Error' |
|
|
userMessage = 'Invalid input data' |
|
|
break |
|
|
case 'CastError': |
|
|
statusCode = 400 |
|
|
errorMessage = 'Cast Error' |
|
|
userMessage = 'Invalid data format' |
|
|
break |
|
|
case 'MongoError': |
|
|
case 'RedisError': |
|
|
statusCode = 503 |
|
|
errorMessage = 'Database Error' |
|
|
userMessage = 'Database temporarily unavailable' |
|
|
break |
|
|
case 'TimeoutError': |
|
|
statusCode = 408 |
|
|
errorMessage = 'Request Timeout' |
|
|
userMessage = 'Request took too long to process' |
|
|
break |
|
|
default: |
|
|
if (error.message && !isDevelopment) { |
|
|
|
|
|
if (error.message.includes('ECONNREFUSED')) { |
|
|
userMessage = 'Service temporarily unavailable' |
|
|
} else if (error.message.includes('timeout')) { |
|
|
userMessage = 'Request timeout' |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
res.setHeader('X-Request-ID', requestId) |
|
|
|
|
|
|
|
|
const errorResponse = { |
|
|
error: errorMessage, |
|
|
message: isDevelopment ? error.message : userMessage, |
|
|
requestId, |
|
|
timestamp: new Date().toISOString() |
|
|
} |
|
|
|
|
|
|
|
|
if (isDevelopment) { |
|
|
errorResponse.stack = error.stack |
|
|
errorResponse.url = req.originalUrl |
|
|
errorResponse.method = req.method |
|
|
} |
|
|
|
|
|
res.status(statusCode).json(errorResponse) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const globalRateLimit = async (req, res, next) => |
|
|
|
|
|
next() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const requestSizeLimit = (req, res, next) => { |
|
|
const maxSize = 60 * 1024 * 1024 |
|
|
const contentLength = parseInt(req.headers['content-length'] || '0') |
|
|
|
|
|
if (contentLength > maxSize) { |
|
|
logger.security(`🚨 Request too large: ${contentLength} bytes from ${req.ip}`) |
|
|
return res.status(413).json({ |
|
|
error: 'Payload Too Large', |
|
|
message: 'Request body size exceeds limit', |
|
|
limit: '10MB' |
|
|
}) |
|
|
} |
|
|
|
|
|
return next() |
|
|
} |
|
|
|
|
|
module.exports = { |
|
|
authenticateApiKey, |
|
|
authenticateAdmin, |
|
|
authenticateUser, |
|
|
authenticateUserOrAdmin, |
|
|
requireRole, |
|
|
requireAdmin, |
|
|
corsMiddleware, |
|
|
requestLogger, |
|
|
securityMiddleware, |
|
|
errorHandler, |
|
|
globalRateLimit, |
|
|
requestSizeLimit |
|
|
} |
|
|
|