@intranefr/superbackend 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -0
- package/README.md +11 -0
- package/index.js +23 -0
- package/package.json +7 -2
- package/src/admin/endpointRegistry.js +120 -0
- package/src/controllers/admin.controller.js +22 -5
- package/src/controllers/adminBlockDefinitions.controller.js +127 -0
- package/src/controllers/adminBlockDefinitionsAi.controller.js +54 -0
- package/src/controllers/adminCache.controller.js +342 -0
- package/src/controllers/adminContextBlockDefinitions.controller.js +141 -0
- package/src/controllers/adminCrons.controller.js +388 -0
- package/src/controllers/adminDbBrowser.controller.js +124 -0
- package/src/controllers/adminEjsVirtual.controller.js +13 -3
- package/src/controllers/adminHeadless.controller.js +9 -2
- package/src/controllers/adminHealthChecks.controller.js +570 -0
- package/src/controllers/adminI18n.controller.js +51 -29
- package/src/controllers/adminLlm.controller.js +126 -2
- package/src/controllers/adminPages.controller.js +720 -0
- package/src/controllers/adminPagesContextBlocksAi.controller.js +54 -0
- package/src/controllers/adminProxy.controller.js +113 -0
- package/src/controllers/adminRateLimits.controller.js +138 -0
- package/src/controllers/adminRbac.controller.js +803 -0
- package/src/controllers/adminScripts.controller.js +93 -2
- package/src/controllers/adminSeoConfig.controller.js +71 -48
- package/src/controllers/blogAdmin.controller.js +279 -0
- package/src/controllers/blogAiAdmin.controller.js +224 -0
- package/src/controllers/blogAutomationAdmin.controller.js +141 -0
- package/src/controllers/blogInternal.controller.js +26 -0
- package/src/controllers/blogPublic.controller.js +89 -0
- package/src/controllers/fileManager.controller.js +190 -0
- package/src/controllers/fileManagerStoragePolicy.controller.js +23 -0
- package/src/controllers/healthChecksPublic.controller.js +196 -0
- package/src/controllers/metrics.controller.js +64 -4
- package/src/controllers/orgAdmin.controller.js +80 -0
- package/src/middleware/internalCronAuth.js +29 -0
- package/src/middleware/rbac.js +62 -0
- package/src/middleware.js +756 -48
- package/src/models/BlockDefinition.js +27 -0
- package/src/models/BlogAutomationLock.js +14 -0
- package/src/models/BlogAutomationRun.js +39 -0
- package/src/models/BlogPost.js +42 -0
- package/src/models/CacheEntry.js +26 -0
- package/src/models/ConsoleEntry.js +32 -0
- package/src/models/ConsoleLog.js +23 -0
- package/src/models/ContextBlockDefinition.js +33 -0
- package/src/models/CronExecution.js +47 -0
- package/src/models/CronJob.js +70 -0
- package/src/models/ExternalDbConnection.js +49 -0
- package/src/models/FileEntry.js +22 -0
- package/src/models/HealthAutoHealAttempt.js +57 -0
- package/src/models/HealthCheck.js +132 -0
- package/src/models/HealthCheckRun.js +51 -0
- package/src/models/HealthIncident.js +49 -0
- package/src/models/Page.js +95 -0
- package/src/models/PageCollection.js +42 -0
- package/src/models/ProxyEntry.js +66 -0
- package/src/models/RateLimitCounter.js +19 -0
- package/src/models/RateLimitMetricBucket.js +20 -0
- package/src/models/RbacGrant.js +25 -0
- package/src/models/RbacGroup.js +16 -0
- package/src/models/RbacGroupMember.js +13 -0
- package/src/models/RbacGroupRole.js +13 -0
- package/src/models/RbacRole.js +25 -0
- package/src/models/RbacUserRole.js +13 -0
- package/src/routes/adminBlog.routes.js +21 -0
- package/src/routes/adminBlogAi.routes.js +16 -0
- package/src/routes/adminBlogAutomation.routes.js +27 -0
- package/src/routes/adminCache.routes.js +20 -0
- package/src/routes/adminConsoleManager.routes.js +302 -0
- package/src/routes/adminCrons.routes.js +25 -0
- package/src/routes/adminDbBrowser.routes.js +65 -0
- package/src/routes/adminEjsVirtual.routes.js +2 -1
- package/src/routes/adminHeadless.routes.js +2 -1
- package/src/routes/adminHealthChecks.routes.js +28 -0
- package/src/routes/adminI18n.routes.js +4 -3
- package/src/routes/adminLlm.routes.js +4 -2
- package/src/routes/adminPages.routes.js +55 -0
- package/src/routes/adminProxy.routes.js +15 -0
- package/src/routes/adminRateLimits.routes.js +17 -0
- package/src/routes/adminRbac.routes.js +38 -0
- package/src/routes/adminSeoConfig.routes.js +5 -4
- package/src/routes/adminUiComponents.routes.js +2 -1
- package/src/routes/blogInternal.routes.js +14 -0
- package/src/routes/blogPublic.routes.js +9 -0
- package/src/routes/fileManager.routes.js +62 -0
- package/src/routes/fileManagerStoragePolicy.routes.js +9 -0
- package/src/routes/healthChecksPublic.routes.js +9 -0
- package/src/routes/log.routes.js +43 -60
- package/src/routes/metrics.routes.js +4 -2
- package/src/routes/orgAdmin.routes.js +1 -0
- package/src/routes/pages.routes.js +123 -0
- package/src/routes/proxy.routes.js +46 -0
- package/src/routes/rbac.routes.js +47 -0
- package/src/routes/webhook.routes.js +2 -1
- package/src/routes/workflows.routes.js +4 -0
- package/src/services/blockDefinitionsAi.service.js +247 -0
- package/src/services/blog.service.js +99 -0
- package/src/services/blogAutomation.service.js +978 -0
- package/src/services/blogCronsBootstrap.service.js +184 -0
- package/src/services/blogPublishing.service.js +58 -0
- package/src/services/cacheLayer.service.js +696 -0
- package/src/services/consoleManager.service.js +700 -0
- package/src/services/consoleOverride.service.js +6 -1
- package/src/services/cronScheduler.service.js +350 -0
- package/src/services/dbBrowser.service.js +536 -0
- package/src/services/ejsVirtual.service.js +102 -32
- package/src/services/fileManager.service.js +475 -0
- package/src/services/fileManagerStoragePolicy.service.js +285 -0
- package/src/services/healthChecks.service.js +650 -0
- package/src/services/healthChecksBootstrap.service.js +109 -0
- package/src/services/healthChecksScheduler.service.js +106 -0
- package/src/services/llmDefaults.service.js +190 -0
- package/src/services/migrationAssets/s3.js +2 -2
- package/src/services/pages.service.js +602 -0
- package/src/services/pagesContext.service.js +331 -0
- package/src/services/pagesContextBlocksAi.service.js +349 -0
- package/src/services/proxy.service.js +535 -0
- package/src/services/rateLimiter.service.js +623 -0
- package/src/services/rbac.service.js +212 -0
- package/src/services/scriptsRunner.service.js +1 -1
- package/src/services/uiComponentsAi.service.js +6 -19
- package/src/services/workflow.service.js +23 -8
- package/src/utils/orgRoles.js +14 -0
- package/src/utils/rbac/engine.js +60 -0
- package/src/utils/rbac/rightsRegistry.js +29 -0
- package/views/admin-blog-automation.ejs +877 -0
- package/views/admin-blog-edit.ejs +542 -0
- package/views/admin-blog.ejs +399 -0
- package/views/admin-cache.ejs +681 -0
- package/views/admin-console-manager.ejs +680 -0
- package/views/admin-crons.ejs +645 -0
- package/views/admin-db-browser.ejs +445 -0
- package/views/admin-ejs-virtual.ejs +16 -10
- package/views/admin-file-manager.ejs +942 -0
- package/views/admin-health-checks.ejs +725 -0
- package/views/admin-i18n.ejs +59 -5
- package/views/admin-llm.ejs +99 -1
- package/views/admin-organizations.ejs +163 -1
- package/views/admin-pages.ejs +2424 -0
- package/views/admin-proxy.ejs +491 -0
- package/views/admin-rate-limiter.ejs +625 -0
- package/views/admin-rbac.ejs +1331 -0
- package/views/admin-scripts.ejs +1 -1
- package/views/admin-seo-config.ejs +61 -7
- package/views/admin-ui-components.ejs +57 -25
- package/views/admin-workflows.ejs +7 -7
- package/views/file-manager.ejs +866 -0
- package/views/pages/blocks/contact.ejs +27 -0
- package/views/pages/blocks/cta.ejs +18 -0
- package/views/pages/blocks/faq.ejs +20 -0
- package/views/pages/blocks/features.ejs +19 -0
- package/views/pages/blocks/hero.ejs +13 -0
- package/views/pages/blocks/html.ejs +5 -0
- package/views/pages/blocks/image.ejs +14 -0
- package/views/pages/blocks/testimonials.ejs +26 -0
- package/views/pages/blocks/text.ejs +10 -0
- package/views/pages/layouts/default.ejs +51 -0
- package/views/pages/layouts/minimal.ejs +42 -0
- package/views/pages/layouts/sidebar.ejs +54 -0
- package/views/pages/partials/footer.ejs +13 -0
- package/views/pages/partials/header.ejs +12 -0
- package/views/pages/partials/sidebar.ejs +8 -0
- package/views/pages/runtime/page.ejs +10 -0
- package/views/pages/templates/article.ejs +20 -0
- package/views/pages/templates/default.ejs +12 -0
- package/views/pages/templates/landing.ejs +14 -0
- package/views/pages/templates/listing.ejs +15 -0
- package/views/partials/admin-image-upload-modal.ejs +221 -0
- package/views/partials/dashboard/nav-items.ejs +11 -0
- package/views/partials/llm-provider-model-picker.ejs +183 -0
- package/src/routes/llmUi.routes.js +0 -26
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const mongoose = require('mongoose');
|
|
3
|
+
|
|
4
|
+
const JsonConfig = require('../models/JsonConfig');
|
|
5
|
+
const RateLimitCounter = require('../models/RateLimitCounter');
|
|
6
|
+
const RateLimitMetricBucket = require('../models/RateLimitMetricBucket');
|
|
7
|
+
|
|
8
|
+
const { parseJsonOrThrow, clearJsonConfigCache } = require('./jsonConfigs.service');
|
|
9
|
+
const { verifyAccessToken } = require('../utils/jwt');
|
|
10
|
+
|
|
11
|
+
const RATE_LIMITS_KEY = 'rate-limits';
|
|
12
|
+
|
|
13
|
+
const registry = new Map();
|
|
14
|
+
const bootstrapState = new Map();
|
|
15
|
+
|
|
16
|
+
function deepMerge(base, override) {
|
|
17
|
+
if (!override || typeof override !== 'object') return base;
|
|
18
|
+
if (!base || typeof base !== 'object') return override;
|
|
19
|
+
if (Array.isArray(base) || Array.isArray(override)) return override;
|
|
20
|
+
|
|
21
|
+
const out = { ...base };
|
|
22
|
+
for (const [k, v] of Object.entries(override)) {
|
|
23
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && base[k] && typeof base[k] === 'object' && !Array.isArray(base[k])) {
|
|
24
|
+
out[k] = deepMerge(base[k], v);
|
|
25
|
+
} else {
|
|
26
|
+
out[k] = v;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sha256(value) {
|
|
33
|
+
return crypto.createHash('sha256').update(String(value || ''), 'utf8').digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getClientIp(req) {
|
|
37
|
+
const fromExpress = req.ip;
|
|
38
|
+
if (fromExpress) return String(fromExpress);
|
|
39
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
40
|
+
if (forwarded) return String(forwarded).split(',')[0].trim();
|
|
41
|
+
return 'unknown';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function tryExtractUserIdFromBearer(req) {
|
|
45
|
+
try {
|
|
46
|
+
if (req.user?._id) return String(req.user._id);
|
|
47
|
+
|
|
48
|
+
const authHeader = req.headers.authorization;
|
|
49
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) return null;
|
|
50
|
+
const token = authHeader.slice(7).trim();
|
|
51
|
+
if (!token) return null;
|
|
52
|
+
|
|
53
|
+
const decoded = verifyAccessToken(token);
|
|
54
|
+
if (!decoded?.userId) return null;
|
|
55
|
+
return String(decoded.userId);
|
|
56
|
+
} catch (_) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function ensureRateLimitsJsonConfigExists() {
|
|
62
|
+
const existing = await JsonConfig.findOne({ $or: [{ slug: RATE_LIMITS_KEY }, { alias: RATE_LIMITS_KEY }] });
|
|
63
|
+
if (existing) return existing;
|
|
64
|
+
|
|
65
|
+
const defaultConfig = {
|
|
66
|
+
version: 1,
|
|
67
|
+
defaults: {
|
|
68
|
+
enabled: false,
|
|
69
|
+
mode: 'reportOnly',
|
|
70
|
+
algorithm: 'fixedWindow',
|
|
71
|
+
limit: { max: 600, windowMs: 60000 },
|
|
72
|
+
identity: { type: 'userIdOrIp' },
|
|
73
|
+
metrics: { enabled: true, bucketMs: 60000, retentionDays: 14 },
|
|
74
|
+
store: { ttlBufferMs: 60000, failOpen: true },
|
|
75
|
+
},
|
|
76
|
+
limiters: {},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const doc = await JsonConfig.create({
|
|
80
|
+
title: 'Rate Limits',
|
|
81
|
+
slug: RATE_LIMITS_KEY,
|
|
82
|
+
alias: RATE_LIMITS_KEY,
|
|
83
|
+
publicEnabled: false,
|
|
84
|
+
cacheTtlSeconds: 0,
|
|
85
|
+
jsonRaw: JSON.stringify(defaultConfig, null, 2),
|
|
86
|
+
jsonHash: sha256(JSON.stringify(defaultConfig)),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
clearJsonConfigCache(RATE_LIMITS_KEY);
|
|
90
|
+
return doc;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function getRateLimitsConfigDoc() {
|
|
94
|
+
const doc = await ensureRateLimitsJsonConfigExists();
|
|
95
|
+
return doc;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function getRateLimitsConfigData() {
|
|
99
|
+
const doc = await ensureRateLimitsJsonConfigExists();
|
|
100
|
+
const jsonRaw = String(doc.jsonRaw || '');
|
|
101
|
+
const data = parseJsonOrThrow(jsonRaw);
|
|
102
|
+
return { doc, data };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeLimiterId(limiterId) {
|
|
106
|
+
const id = String(limiterId || '').trim();
|
|
107
|
+
if (!id) throw new Error('limiterId is required');
|
|
108
|
+
return id;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function registerLimiter(limiterId, { label, integration, inferredMountPath } = {}) {
|
|
112
|
+
const id = normalizeLimiterId(limiterId);
|
|
113
|
+
|
|
114
|
+
const existing = registry.get(id) || { id, label: id, integration: null };
|
|
115
|
+
const next = { ...existing };
|
|
116
|
+
|
|
117
|
+
if (label) next.label = String(label);
|
|
118
|
+
|
|
119
|
+
if (integration && typeof integration === 'object' && !Array.isArray(integration)) {
|
|
120
|
+
next.integration = { ...(existing.integration || {}), ...integration };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (inferredMountPath && (!next.integration || !next.integration.mountPath)) {
|
|
124
|
+
next.integration = { ...(next.integration || {}), mountPath: String(inferredMountPath) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
registry.set(id, next);
|
|
128
|
+
return next;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function ensureLimiterOverrideExists(limiterId) {
|
|
132
|
+
const id = normalizeLimiterId(limiterId);
|
|
133
|
+
|
|
134
|
+
const state = bootstrapState.get(id) || { inFlight: null, done: false, scheduled: false };
|
|
135
|
+
if (state.done) return;
|
|
136
|
+
if (state.inFlight) return state.inFlight;
|
|
137
|
+
|
|
138
|
+
if (mongoose.connection.readyState !== 1) {
|
|
139
|
+
if (!state.scheduled) {
|
|
140
|
+
state.scheduled = true;
|
|
141
|
+
bootstrapState.set(id, state);
|
|
142
|
+
if (mongoose.connection && typeof mongoose.connection.once === 'function') {
|
|
143
|
+
mongoose.connection.once('connected', () => {
|
|
144
|
+
ensureLimiterOverrideExists(id);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const p = (async () => {
|
|
152
|
+
const { doc, data } = await getRateLimitsConfigData();
|
|
153
|
+
const existing = data?.limiters && data.limiters[id];
|
|
154
|
+
if (existing && typeof existing === 'object' && !Array.isArray(existing)) {
|
|
155
|
+
state.done = true;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const updated = {
|
|
160
|
+
version: Number(data?.version || 1) || 1,
|
|
161
|
+
defaults: data?.defaults || {},
|
|
162
|
+
limiters: { ...(data?.limiters || {}), [id]: { enabled: false } },
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
doc.jsonRaw = JSON.stringify(updated, null, 2);
|
|
166
|
+
doc.jsonHash = sha256(doc.jsonRaw);
|
|
167
|
+
await doc.save();
|
|
168
|
+
clearJsonConfigCache(RATE_LIMITS_KEY);
|
|
169
|
+
|
|
170
|
+
state.done = true;
|
|
171
|
+
})()
|
|
172
|
+
.catch((error) => {
|
|
173
|
+
console.error('Error bootstrapping rate limiter config:', error);
|
|
174
|
+
})
|
|
175
|
+
.finally(() => {
|
|
176
|
+
state.inFlight = null;
|
|
177
|
+
bootstrapState.set(id, state);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
state.inFlight = p;
|
|
181
|
+
bootstrapState.set(id, state);
|
|
182
|
+
return p;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function baseDefaults() {
|
|
186
|
+
return {
|
|
187
|
+
enabled: false,
|
|
188
|
+
mode: 'reportOnly',
|
|
189
|
+
algorithm: 'fixedWindow',
|
|
190
|
+
limit: { max: 60, windowMs: 60000 },
|
|
191
|
+
identity: { type: 'userIdOrIp' },
|
|
192
|
+
metrics: { enabled: true, bucketMs: 60000, retentionDays: 14 },
|
|
193
|
+
store: { ttlBufferMs: 60000, failOpen: true },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function resolveEffectiveConfig({ registryConfig, globalDefaults, limiterOverride }) {
|
|
198
|
+
const merged = deepMerge(deepMerge(deepMerge(baseDefaults(), registryConfig || {}), globalDefaults || {}), limiterOverride || {});
|
|
199
|
+
|
|
200
|
+
if (merged.limit) {
|
|
201
|
+
merged.limit.max = Math.max(0, Number(merged.limit.max || 0) || 0);
|
|
202
|
+
merged.limit.windowMs = Math.max(1, Number(merged.limit.windowMs || 1) || 1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (merged.metrics) {
|
|
206
|
+
merged.metrics.bucketMs = Math.max(1000, Number(merged.metrics.bucketMs || 60000) || 60000);
|
|
207
|
+
merged.metrics.retentionDays = Math.max(1, Number(merged.metrics.retentionDays || 14) || 14);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!merged.store) merged.store = {};
|
|
211
|
+
merged.store.ttlBufferMs = Math.max(0, Number(merged.store.ttlBufferMs || 0) || 0);
|
|
212
|
+
merged.store.failOpen = merged.store.failOpen !== false;
|
|
213
|
+
|
|
214
|
+
if (!merged.identity) merged.identity = { type: 'userIdOrIp' };
|
|
215
|
+
|
|
216
|
+
return merged;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function computeIdentityKey(identityCfg, { req, identity } = {}) {
|
|
220
|
+
const cfg = identityCfg || { type: 'userIdOrIp' };
|
|
221
|
+
const id = identity && typeof identity === 'object' ? identity : {};
|
|
222
|
+
|
|
223
|
+
const userId = id.userId || tryExtractUserIdFromBearer(req);
|
|
224
|
+
const ip = id.ip || getClientIp(req);
|
|
225
|
+
const orgId = id.orgId || (req.org?._id ? String(req.org._id) : null);
|
|
226
|
+
|
|
227
|
+
if (id.identityKey) return String(id.identityKey);
|
|
228
|
+
|
|
229
|
+
const type = String(cfg.type || 'userIdOrIp');
|
|
230
|
+
|
|
231
|
+
if (type === 'userId') return userId ? `user:${userId}` : `ip:${ip}`;
|
|
232
|
+
if (type === 'ip') return `ip:${ip}`;
|
|
233
|
+
if (type === 'orgId') return orgId ? `org:${orgId}` : (userId ? `user:${userId}` : `ip:${ip}`);
|
|
234
|
+
|
|
235
|
+
if (type === 'header') {
|
|
236
|
+
const headerName = String(cfg.headerName || '').toLowerCase();
|
|
237
|
+
const headerValue = headerName ? req.get(headerName) : null;
|
|
238
|
+
if (headerValue) return `header:${headerName}:${String(headerValue)}`;
|
|
239
|
+
return userId ? `user:${userId}` : `ip:${ip}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return userId ? `user:${userId}` : `ip:${ip}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function computeWindowStart(now, windowMs) {
|
|
246
|
+
const ms = Number(windowMs || 60000) || 60000;
|
|
247
|
+
return new Date(Math.floor(now.getTime() / ms) * ms);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function recordMetrics({ limiterId, bucketStart, allowed, blocked, checked, retentionDays }) {
|
|
251
|
+
const ttlDays = Math.max(1, Number(retentionDays || 14) || 14);
|
|
252
|
+
const expiresAt = new Date(bucketStart.getTime() + ttlDays * 24 * 60 * 60 * 1000);
|
|
253
|
+
|
|
254
|
+
await RateLimitMetricBucket.findOneAndUpdate(
|
|
255
|
+
{ limiterId, bucketStart },
|
|
256
|
+
{
|
|
257
|
+
$inc: {
|
|
258
|
+
checked: checked ? 1 : 0,
|
|
259
|
+
allowed: allowed ? 1 : 0,
|
|
260
|
+
blocked: blocked ? 1 : 0,
|
|
261
|
+
},
|
|
262
|
+
$setOnInsert: { expiresAt },
|
|
263
|
+
},
|
|
264
|
+
{ upsert: true },
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function check(limiterId, { req, identity } = {}) {
|
|
269
|
+
const id = normalizeLimiterId(limiterId);
|
|
270
|
+
registerLimiter(id);
|
|
271
|
+
|
|
272
|
+
ensureLimiterOverrideExists(id);
|
|
273
|
+
|
|
274
|
+
const failOpen = true;
|
|
275
|
+
|
|
276
|
+
if (mongoose.connection.readyState !== 1) {
|
|
277
|
+
if (failOpen) {
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
limiterId: String(id),
|
|
281
|
+
allowed: true,
|
|
282
|
+
enforced: false,
|
|
283
|
+
reason: 'DB_NOT_CONNECTED',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
ok: false,
|
|
288
|
+
limiterId: String(id),
|
|
289
|
+
allowed: false,
|
|
290
|
+
enforced: true,
|
|
291
|
+
reason: 'DB_NOT_CONNECTED',
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let configDoc;
|
|
296
|
+
let configData;
|
|
297
|
+
try {
|
|
298
|
+
const cfg = await getRateLimitsConfigData();
|
|
299
|
+
configDoc = cfg.doc;
|
|
300
|
+
configData = cfg.data;
|
|
301
|
+
} catch (e) {
|
|
302
|
+
if (failOpen) {
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
limiterId: String(id),
|
|
306
|
+
allowed: true,
|
|
307
|
+
enforced: false,
|
|
308
|
+
reason: 'CONFIG_ERROR',
|
|
309
|
+
error: e.message,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
limiterId: String(id),
|
|
315
|
+
allowed: false,
|
|
316
|
+
enforced: true,
|
|
317
|
+
reason: 'CONFIG_ERROR',
|
|
318
|
+
error: e.message,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const globalDefaults = configData?.defaults || {};
|
|
323
|
+
const hasOverride = configData?.limiters && Object.prototype.hasOwnProperty.call(configData.limiters, String(id));
|
|
324
|
+
const limiterOverrideRaw = hasOverride ? configData.limiters[String(id)] : null;
|
|
325
|
+
const limiterOverride = limiterOverrideRaw && typeof limiterOverrideRaw === 'object' && !Array.isArray(limiterOverrideRaw)
|
|
326
|
+
? limiterOverrideRaw
|
|
327
|
+
: { enabled: false };
|
|
328
|
+
|
|
329
|
+
const effective = resolveEffectiveConfig({
|
|
330
|
+
registryConfig: {},
|
|
331
|
+
globalDefaults,
|
|
332
|
+
limiterOverride,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const enabled = effective.enabled !== false;
|
|
336
|
+
const mode = String(effective.mode || 'reportOnly');
|
|
337
|
+
|
|
338
|
+
if (!enabled || mode === 'disabled') {
|
|
339
|
+
return {
|
|
340
|
+
ok: true,
|
|
341
|
+
limiterId: String(id),
|
|
342
|
+
allowed: true,
|
|
343
|
+
enforced: false,
|
|
344
|
+
reason: 'DISABLED',
|
|
345
|
+
config: effective,
|
|
346
|
+
configDoc: configDoc?._id ? String(configDoc._id) : null,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const effectiveFailOpen = effective.store?.failOpen !== false;
|
|
351
|
+
|
|
352
|
+
const now = new Date();
|
|
353
|
+
const max = Number(effective.limit?.max || 0) || 0;
|
|
354
|
+
const windowMs = Number(effective.limit?.windowMs || 60000) || 60000;
|
|
355
|
+
const windowStart = computeWindowStart(now, windowMs);
|
|
356
|
+
const ttlBufferMs = Number(effective.store?.ttlBufferMs || 0) || 0;
|
|
357
|
+
|
|
358
|
+
const identityKey = computeIdentityKey(effective.identity, { req, identity });
|
|
359
|
+
|
|
360
|
+
let count;
|
|
361
|
+
try {
|
|
362
|
+
const expiresAt = new Date(windowStart.getTime() + windowMs + ttlBufferMs);
|
|
363
|
+
const updated = await RateLimitCounter.findOneAndUpdate(
|
|
364
|
+
{ limiterId: String(limiterId), identityKey: String(identityKey), windowStart },
|
|
365
|
+
{
|
|
366
|
+
$inc: { count: 1 },
|
|
367
|
+
$setOnInsert: { expiresAt },
|
|
368
|
+
},
|
|
369
|
+
{ upsert: true, new: true },
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
count = Number(updated?.count || 0) || 0;
|
|
373
|
+
} catch (e) {
|
|
374
|
+
if (effectiveFailOpen) {
|
|
375
|
+
return {
|
|
376
|
+
ok: false,
|
|
377
|
+
limiterId: String(id),
|
|
378
|
+
allowed: true,
|
|
379
|
+
enforced: false,
|
|
380
|
+
reason: 'STORE_ERROR',
|
|
381
|
+
error: e.message,
|
|
382
|
+
config: effective,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
limiterId: String(id),
|
|
389
|
+
allowed: false,
|
|
390
|
+
enforced: true,
|
|
391
|
+
reason: 'STORE_ERROR',
|
|
392
|
+
error: e.message,
|
|
393
|
+
config: effective,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const allowed = max <= 0 ? true : count <= max;
|
|
398
|
+
const enforced = mode === 'enforce';
|
|
399
|
+
|
|
400
|
+
if (effective.metrics?.enabled !== false) {
|
|
401
|
+
const bucketMs = Number(effective.metrics?.bucketMs || 60000) || 60000;
|
|
402
|
+
const bucketStart = new Date(Math.floor(now.getTime() / bucketMs) * bucketMs);
|
|
403
|
+
try {
|
|
404
|
+
await recordMetrics({
|
|
405
|
+
limiterId: String(id),
|
|
406
|
+
bucketStart,
|
|
407
|
+
allowed,
|
|
408
|
+
blocked: !allowed,
|
|
409
|
+
checked: true,
|
|
410
|
+
retentionDays: effective.metrics?.retentionDays,
|
|
411
|
+
});
|
|
412
|
+
} catch (_) {
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const remaining = max <= 0 ? null : Math.max(0, max - count);
|
|
417
|
+
const retryAfterMs = allowed ? 0 : Math.max(0, windowStart.getTime() + windowMs - now.getTime());
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
ok: true,
|
|
421
|
+
limiterId: String(id),
|
|
422
|
+
allowed: enforced ? allowed : true,
|
|
423
|
+
enforced,
|
|
424
|
+
mode,
|
|
425
|
+
limit: max,
|
|
426
|
+
remaining,
|
|
427
|
+
retryAfterMs,
|
|
428
|
+
windowStart: windowStart.toISOString(),
|
|
429
|
+
windowMs,
|
|
430
|
+
identityKey,
|
|
431
|
+
config: effective,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function limit(limiterId, opts = {}) {
|
|
436
|
+
const getIdentity = typeof opts.getIdentity === 'function' ? opts.getIdentity : null;
|
|
437
|
+
const label = opts && typeof opts === 'object' ? opts.label : null;
|
|
438
|
+
const integration = opts && typeof opts === 'object' ? opts.integration : null;
|
|
439
|
+
|
|
440
|
+
const id = normalizeLimiterId(limiterId);
|
|
441
|
+
registerLimiter(id, { label, integration });
|
|
442
|
+
ensureLimiterOverrideExists(id);
|
|
443
|
+
|
|
444
|
+
let bootstrapAttemptedInRequest = false;
|
|
445
|
+
|
|
446
|
+
return async (req, res, next) => {
|
|
447
|
+
try {
|
|
448
|
+
if (!bootstrapAttemptedInRequest) {
|
|
449
|
+
bootstrapAttemptedInRequest = true;
|
|
450
|
+
const inferredMountPath = req?.baseUrl || req?.path || null;
|
|
451
|
+
registerLimiter(id, { inferredMountPath });
|
|
452
|
+
await ensureLimiterOverrideExists(id);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const identity = getIdentity ? getIdentity(req) : null;
|
|
456
|
+
const result = await check(id, { req, identity });
|
|
457
|
+
|
|
458
|
+
if (typeof result.limit === 'number') {
|
|
459
|
+
res.setHeader('X-RateLimit-Limit', String(result.limit));
|
|
460
|
+
}
|
|
461
|
+
if (result.remaining !== null && result.remaining !== undefined) {
|
|
462
|
+
res.setHeader('X-RateLimit-Remaining', String(result.remaining));
|
|
463
|
+
}
|
|
464
|
+
if (result.retryAfterMs && result.retryAfterMs > 0) {
|
|
465
|
+
res.setHeader('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
|
|
466
|
+
}
|
|
467
|
+
if (result.mode) {
|
|
468
|
+
res.setHeader('X-RateLimit-Mode', String(result.mode));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (!result.allowed) {
|
|
472
|
+
return res.status(429).json({ error: 'Too many requests' });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return next();
|
|
476
|
+
} catch (e) {
|
|
477
|
+
return next(e);
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function list() {
|
|
483
|
+
const { data } = await getRateLimitsConfigData();
|
|
484
|
+
const globalDefaults = data?.defaults || {};
|
|
485
|
+
const limiters = data?.limiters || {};
|
|
486
|
+
|
|
487
|
+
const ids = new Set([...Array.from(registry.keys()), ...Object.keys(limiters)]);
|
|
488
|
+
|
|
489
|
+
const items = [];
|
|
490
|
+
for (const id of ids) {
|
|
491
|
+
const entry = registry.get(id) || { id, label: id, integration: null };
|
|
492
|
+
const hasOverride = Object.prototype.hasOwnProperty.call(limiters, id);
|
|
493
|
+
const overrideRaw = hasOverride ? limiters[id] : null;
|
|
494
|
+
const override = overrideRaw && typeof overrideRaw === 'object' && !Array.isArray(overrideRaw) ? overrideRaw : (hasOverride ? {} : null);
|
|
495
|
+
const effectiveOverride = override || { enabled: false };
|
|
496
|
+
const effective = resolveEffectiveConfig({ registryConfig: {}, globalDefaults, limiterOverride: effectiveOverride });
|
|
497
|
+
items.push({
|
|
498
|
+
id,
|
|
499
|
+
label: entry.label,
|
|
500
|
+
integration: entry.integration,
|
|
501
|
+
registryConfig: {},
|
|
502
|
+
override,
|
|
503
|
+
effective,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
items.sort((a, b) => a.id.localeCompare(b.id));
|
|
508
|
+
return items;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async function bulkSetEnabled({ enabled, ids, all } = {}) {
|
|
512
|
+
const { doc, data } = await getRateLimitsConfigData();
|
|
513
|
+
const limiters = { ...(data?.limiters || {}) };
|
|
514
|
+
|
|
515
|
+
const targetIds = all
|
|
516
|
+
? Array.from(new Set([...Object.keys(limiters), ...Array.from(registry.keys())]))
|
|
517
|
+
: (Array.isArray(ids) ? ids.map((x) => String(x)) : []);
|
|
518
|
+
|
|
519
|
+
for (const rawId of targetIds) {
|
|
520
|
+
const id = normalizeLimiterId(rawId);
|
|
521
|
+
const current = limiters[id] && typeof limiters[id] === 'object' && !Array.isArray(limiters[id]) ? limiters[id] : {};
|
|
522
|
+
limiters[id] = { ...current, enabled: Boolean(enabled) };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const updated = {
|
|
526
|
+
version: Number(data?.version || 1) || 1,
|
|
527
|
+
defaults: data?.defaults || {},
|
|
528
|
+
limiters,
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
doc.jsonRaw = JSON.stringify(updated, null, 2);
|
|
532
|
+
doc.jsonHash = sha256(doc.jsonRaw);
|
|
533
|
+
await doc.save();
|
|
534
|
+
clearJsonConfigCache(RATE_LIMITS_KEY);
|
|
535
|
+
|
|
536
|
+
return updated;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function setLimiterOverride(limiterId, override) {
|
|
540
|
+
const { doc, data } = await getRateLimitsConfigData();
|
|
541
|
+
const id = String(limiterId);
|
|
542
|
+
const next = typeof override === 'object' && override ? override : {};
|
|
543
|
+
|
|
544
|
+
const updated = {
|
|
545
|
+
version: Number(data?.version || 1) || 1,
|
|
546
|
+
defaults: data?.defaults || {},
|
|
547
|
+
limiters: { ...(data?.limiters || {}), [id]: next },
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
doc.jsonRaw = JSON.stringify(updated, null, 2);
|
|
551
|
+
doc.jsonHash = sha256(doc.jsonRaw);
|
|
552
|
+
await doc.save();
|
|
553
|
+
clearJsonConfigCache(RATE_LIMITS_KEY);
|
|
554
|
+
|
|
555
|
+
return updated;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function resetLimiterOverride(limiterId) {
|
|
559
|
+
const { doc, data } = await getRateLimitsConfigData();
|
|
560
|
+
const id = String(limiterId);
|
|
561
|
+
const limiters = { ...(data?.limiters || {}) };
|
|
562
|
+
limiters[id] = { enabled: false };
|
|
563
|
+
|
|
564
|
+
const updated = {
|
|
565
|
+
version: Number(data?.version || 1) || 1,
|
|
566
|
+
defaults: data?.defaults || {},
|
|
567
|
+
limiters,
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
doc.jsonRaw = JSON.stringify(updated, null, 2);
|
|
571
|
+
doc.jsonHash = sha256(doc.jsonRaw);
|
|
572
|
+
await doc.save();
|
|
573
|
+
clearJsonConfigCache(RATE_LIMITS_KEY);
|
|
574
|
+
|
|
575
|
+
return updated;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function updateRawConfig({ jsonRaw }) {
|
|
579
|
+
const { doc } = await getRateLimitsConfigData();
|
|
580
|
+
parseJsonOrThrow(jsonRaw);
|
|
581
|
+
doc.jsonRaw = String(jsonRaw);
|
|
582
|
+
doc.jsonHash = sha256(doc.jsonRaw);
|
|
583
|
+
await doc.save();
|
|
584
|
+
clearJsonConfigCache(RATE_LIMITS_KEY);
|
|
585
|
+
return doc.toObject();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function queryMetrics({ start, end } = {}) {
|
|
589
|
+
const endDate = end ? new Date(end) : new Date();
|
|
590
|
+
const startDate = start ? new Date(start) : new Date(endDate.getTime() - 24 * 60 * 60 * 1000);
|
|
591
|
+
|
|
592
|
+
const items = await RateLimitMetricBucket.find({
|
|
593
|
+
bucketStart: { $gte: startDate, $lte: endDate },
|
|
594
|
+
}).lean();
|
|
595
|
+
|
|
596
|
+
const totals = {};
|
|
597
|
+
for (const row of items) {
|
|
598
|
+
const id = String(row.limiterId);
|
|
599
|
+
if (!totals[id]) totals[id] = { checked: 0, allowed: 0, blocked: 0 };
|
|
600
|
+
totals[id].checked += Number(row.checked || 0) || 0;
|
|
601
|
+
totals[id].allowed += Number(row.allowed || 0) || 0;
|
|
602
|
+
totals[id].blocked += Number(row.blocked || 0) || 0;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
range: { start: startDate.toISOString(), end: endDate.toISOString() },
|
|
607
|
+
totals,
|
|
608
|
+
buckets: items,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
module.exports = {
|
|
613
|
+
limit,
|
|
614
|
+
check,
|
|
615
|
+
list,
|
|
616
|
+
getRateLimitsConfigDoc,
|
|
617
|
+
getRateLimitsConfigData,
|
|
618
|
+
updateRawConfig,
|
|
619
|
+
setLimiterOverride,
|
|
620
|
+
resetLimiterOverride,
|
|
621
|
+
bulkSetEnabled,
|
|
622
|
+
queryMetrics,
|
|
623
|
+
};
|