Allow consecutive scans without refresh by resetting trackers and counting vision rate limits once per card. Add a fixed card guide, widen detection bounds, and correct object-cover overlay math. Co-authored-by: Cursor <cursoragent@cursor.com>
124 lines
3.6 KiB
JavaScript
124 lines
3.6 KiB
JavaScript
import Redis from 'ioredis';
|
|
import { RateLimiterRedis } from 'rate-limiter-flexible';
|
|
|
|
const LIMITER_CONFIG = {
|
|
auth: { limit: 5, durationSec: 15 * 60, prefix: 'deckhearth:auth' },
|
|
search: { limit: 60, durationSec: 60, prefix: 'deckhearth:search' },
|
|
upload: { limit: 10, durationSec: 60 * 60, prefix: 'deckhearth:upload' },
|
|
generate: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:generate' },
|
|
import: { limit: 5, durationSec: 60 * 60, prefix: 'deckhearth:import' },
|
|
// One camera verify may escalate L0→L2; vision path is the expensive step.
|
|
scan: { limit: 15, durationSec: 60, prefix: 'deckhearth:scan' },
|
|
};
|
|
|
|
let cached = null;
|
|
|
|
function init() {
|
|
const url = process.env.REDIS_URL;
|
|
if (url) {
|
|
const storeClient = new Redis(url, {
|
|
maxRetriesPerRequest: 1,
|
|
enableOfflineQueue: false,
|
|
});
|
|
const instances = new Map();
|
|
for (const [name, cfg] of Object.entries(LIMITER_CONFIG)) {
|
|
instances.set(
|
|
name,
|
|
new RateLimiterRedis({
|
|
storeClient,
|
|
keyPrefix: cfg.prefix,
|
|
points: cfg.limit,
|
|
duration: cfg.durationSec,
|
|
})
|
|
);
|
|
}
|
|
return { mode: 'live', instances };
|
|
}
|
|
|
|
if (process.env.NODE_ENV === 'production') {
|
|
throw new Error(
|
|
'[rate-limit] REDIS_URL is not configured. Set REDIS_URL to the homelab Redis URL (CT 102) before serving traffic.'
|
|
);
|
|
}
|
|
|
|
console.warn('[rate-limit] REDIS_URL not set — rate limiting disabled (dev/test only)');
|
|
return { mode: 'noop' };
|
|
}
|
|
|
|
function extractIpIdentifier(req) {
|
|
const xff = req.headers?.['x-forwarded-for'];
|
|
const firstHop = Array.isArray(xff) ? xff[0] : xff?.split(',')[0]?.trim();
|
|
return firstHop || req.socket?.remoteAddress || 'anonymous';
|
|
}
|
|
|
|
function extractUserIdentifier(userId) {
|
|
if (
|
|
userId === null ||
|
|
userId === undefined ||
|
|
userId === '' ||
|
|
(typeof userId === 'number' && Number.isNaN(userId))
|
|
) {
|
|
throw new Error(
|
|
'[rate-limit] extractUserIdentifier called without an authenticated userId. Place the rate-limit gate AFTER the auth check, never before.'
|
|
);
|
|
}
|
|
return `user:${userId}`;
|
|
}
|
|
|
|
async function check(className, identifier) {
|
|
if (!cached) {
|
|
cached = init();
|
|
}
|
|
|
|
if (cached.mode === 'noop') {
|
|
return { allowed: true, remaining: Infinity, reset: 0 };
|
|
}
|
|
|
|
const limiter = cached.instances.get(className);
|
|
if (!limiter) {
|
|
throw new Error(`[rate-limit] Unknown limiter class: ${className}`);
|
|
}
|
|
|
|
try {
|
|
const result = await limiter.consume(identifier);
|
|
return {
|
|
allowed: true,
|
|
remaining: result.remainingPoints,
|
|
reset: Date.now() + result.msBeforeNext,
|
|
};
|
|
} catch (rej) {
|
|
if (rej && typeof rej.msBeforeNext === 'number') {
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
reset: Date.now() + rej.msBeforeNext,
|
|
};
|
|
}
|
|
console.error('[rate-limit]', rej);
|
|
return { allowed: true, remaining: Infinity, reset: 0 };
|
|
}
|
|
}
|
|
|
|
export async function checkAuthRateLimit(req) {
|
|
return check('auth', extractIpIdentifier(req));
|
|
}
|
|
|
|
export async function checkSearchRateLimit(req) {
|
|
return check('search', extractIpIdentifier(req));
|
|
}
|
|
|
|
export async function checkUploadRateLimit(req, userId) {
|
|
return check('upload', extractUserIdentifier(userId));
|
|
}
|
|
|
|
export async function checkGenerateRateLimit(req, userId) {
|
|
return check('generate', extractUserIdentifier(userId));
|
|
}
|
|
|
|
export async function checkImportRateLimit(req, userId) {
|
|
return check('import', extractUserIdentifier(userId));
|
|
}
|
|
|
|
export async function checkScanRateLimit(req, userId) {
|
|
return check('scan', extractUserIdentifier(userId));
|
|
}
|