webspresso 0.0.103 → 0.0.105
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/.agents/AGENTS.md +6 -0
- package/.agents/ARCHITECTURE.md +23 -0
- package/.agents/CACHE.md +147 -0
- package/.agents/CLI.md +1 -1
- package/.agents/EARLY_HINTS.md +120 -0
- package/.agents/HEALTH.md +118 -0
- package/.agents/LIMITS.md +109 -0
- package/.agents/ORM.md +155 -1
- package/.agents/PLUGINS.md +28 -0
- package/.agents/RESILIENCE.md +235 -0
- package/AGENTS.md +6 -0
- package/README.md +2 -2
- package/bin/commands/add-docker.js +6 -6
- package/bin/commands/dev.js +4 -3
- package/bin/commands/new.js +5 -5
- package/bin/commands/openapi.js +1 -1
- package/bin/commands/start.js +1 -1
- package/bin/utils/orm-map-html.js +2 -8
- package/bin/utils/project.js +2 -2
- package/core/auth/hash.js +5 -4
- package/core/auth/jwt.js +6 -57
- package/core/auth/magic-link.js +4 -5
- package/core/auth/manager.js +3 -5
- package/core/auth/oauth.js +45 -30
- package/core/auth/totp.js +5 -16
- package/core/auth/user-agent.js +1 -21
- package/core/cache/byte-size.js +123 -0
- package/core/cache/index.js +21 -0
- package/core/cache/lru.js +477 -0
- package/core/cache/tagged-lru.js +346 -0
- package/core/commands/registry.js +6 -20
- package/core/content/renderer.js +1 -13
- package/core/early-hints/index.js +267 -0
- package/core/errors/domain.js +48 -0
- package/core/errors/http.js +31 -0
- package/core/errors/index.js +10 -0
- package/core/errors/middleware.js +2 -8
- package/core/errors/normalize.js +88 -2
- package/core/events/discovery.js +6 -24
- package/core/events/dispatcher.js +2 -7
- package/core/forms/form-builder.js +1 -9
- package/core/health/index.js +41 -0
- package/core/health/middleware.js +213 -0
- package/core/health/normalize.js +140 -0
- package/core/health/probes.js +203 -0
- package/core/limits/index.js +34 -0
- package/core/limits/middleware.js +317 -0
- package/core/limits/normalize.js +222 -0
- package/core/orm/batch.js +70 -0
- package/core/orm/cache/fingerprint.js +2 -2
- package/core/orm/cache/memory-provider.js +27 -110
- package/core/orm/index.js +76 -8
- package/core/orm/json-fields.js +22 -8
- package/core/orm/model.js +10 -0
- package/core/orm/query-builder.js +63 -9
- package/core/orm/query-filters.js +393 -0
- package/core/orm/repository.js +318 -19
- package/core/orm/schema-helpers.js +8 -10
- package/core/orm/scopes.js +321 -1
- package/core/orm/transaction.js +10 -2
- package/core/orm/utils.js +4 -86
- package/core/queue/adapters/database.js +4 -8
- package/core/queue/manager.js +55 -41
- package/core/resilience/circuit-breaker.js +391 -0
- package/core/resilience/errors.js +63 -0
- package/core/resilience/index.js +53 -0
- package/core/resilience/registry.js +97 -0
- package/core/resilience/retry.js +203 -0
- package/core/schema-cache.js +9 -2
- package/core/shutdown/http-adapter.js +4 -1
- package/core/shutdown/shutdown-manager.js +132 -31
- package/core/utils/array.js +12 -0
- package/core/utils/base64url.js +8 -0
- package/core/utils/crypto.js +191 -0
- package/core/utils/ensure-leading-slash.js +6 -0
- package/core/utils/escape-html.js +7 -0
- package/core/utils/escape-regexp.js +7 -0
- package/core/utils/fetch.js +121 -0
- package/core/utils/fs.js +144 -0
- package/core/utils/get-client-ip.js +6 -0
- package/core/utils/hmac.js +6 -0
- package/core/utils/html.js +72 -0
- package/core/utils/ip.js +86 -0
- package/core/utils/json.js +98 -0
- package/core/utils/object.js +106 -0
- package/core/utils/random.js +9 -0
- package/core/utils/regex.js +77 -0
- package/core/utils/safe-compare.js +3 -0
- package/core/utils/sha256.js +6 -0
- package/core/utils/string.js +87 -0
- package/core/utils/strip-trailing-slash.js +6 -0
- package/core/utils/time.js +96 -0
- package/core/utils/timeout.js +348 -0
- package/core/utils/url.js +106 -0
- package/core/utils/walk.js +7 -0
- package/index.d.ts +627 -2
- package/index.js +158 -0
- package/package.json +1 -2
- package/plugins/admin-panel/auth.js +15 -7
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +82 -10
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +4 -2
- package/plugins/admin-panel/client/parts/08-record-list.js +3 -2
- package/plugins/admin-panel/client/parts/10-export-registry.js +6 -0
- package/plugins/admin-panel/core/api-extensions.js +3 -80
- package/plugins/admin-panel/index.js +1 -1
- package/plugins/admin-panel/lib/login-rate-limiter.js +15 -13
- package/plugins/admin-panel/modules/menu.js +1 -1
- package/plugins/api-key/crypto.js +3 -26
- package/plugins/api-key/scopes.js +5 -7
- package/plugins/audit-log/api-handlers.js +8 -7
- package/plugins/audit-log/middleware.js +11 -2
- package/plugins/audit-log/parse.js +3 -3
- package/plugins/basic-auth/index.js +2 -26
- package/plugins/cors.js +2 -2
- package/plugins/csrf/index.js +7 -23
- package/plugins/dashboard/app.js +2 -2
- package/plugins/dashboard/index.js +5 -5
- package/plugins/data-exchange/record-selection.js +2 -5
- package/plugins/email/api-handlers.js +5 -18
- package/plugins/email/auth-bridge.js +3 -2
- package/plugins/file-manager/admin-component.js +3 -1
- package/plugins/file-manager/core.js +34 -4
- package/plugins/health-check.js +2 -1
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +5 -6
- package/plugins/mcp/auth.js +1 -13
- package/plugins/mcp/discovery.js +18 -17
- package/plugins/mcp/index.js +23 -1
- package/plugins/mcp/transports/sse.js +10 -1
- package/plugins/orm-cache-admin/admin-component.js +3 -2
- package/plugins/polar/src/api.js +11 -3
- package/plugins/polar/src/webhooks.js +7 -13
- package/plugins/rate-limit/index.js +3 -1
- package/plugins/recaptcha/helpers.js +1 -7
- package/plugins/recaptcha/verify.js +37 -23
- package/plugins/redirect/index.js +57 -7
- package/plugins/rest-resources/index.js +1 -207
- package/plugins/rls/index.js +170 -0
- package/plugins/schema-explorer/admin-component.js +4 -2
- package/plugins/schema-explorer.js +40 -29
- package/plugins/seo-checker/index.js +5 -2
- package/plugins/site-analytics/tracking.js +21 -18
- package/plugins/sitemap.js +21 -17
- package/plugins/swagger.js +13 -17
- package/plugins/upload/index.js +4 -3
- package/plugins/upload/orphan-pruner.js +4 -3
- package/plugins/webhooks/crypto.js +6 -28
- package/plugins/webhooks/dispatcher.js +14 -9
- package/plugins/webhooks/index.js +6 -0
- package/plugins/webhooks/matcher.js +28 -17
- package/plugins/webhooks/sender.js +40 -13
- package/src/app-context.js +41 -5
- package/src/dev/banner.js +6 -2
- package/src/dev-reload/index.js +47 -6
- package/src/file-router.js +1 -3
- package/src/helpers.js +180 -33
- package/src/modules/module-discovery.js +1 -9
- package/src/pages/page-loader.js +53 -1
- package/src/plugin-manager.js +90 -17
- package/src/server.js +137 -26
- package/src/services/discovery.js +19 -40
- package/src/services/executor.js +86 -25
- package/src/services/memoize.js +3 -25
- package/src/services/registry.js +6 -0
- package/src/template/precompile.js +8 -19
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso RLS Plugin — Dynamic Model Row-Level Security
|
|
3
|
+
* Automatically bridges request authentication (req.user, req.auth, req.tenantId)
|
|
4
|
+
* to ORM repositories with role-based bypass and route-level overrides.
|
|
5
|
+
* Zero external dependencies.
|
|
6
|
+
* @module plugins/rls
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default user extractor from HTTP request
|
|
11
|
+
* @param {import('express').Request} req
|
|
12
|
+
* @returns {Object|null}
|
|
13
|
+
*/
|
|
14
|
+
function defaultGetUser(req) {
|
|
15
|
+
if (!req) return null;
|
|
16
|
+
return req.user || req.adminUser || (req.auth && req.auth.user) || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Default tenant extractor from HTTP request
|
|
21
|
+
* @param {import('express').Request} req
|
|
22
|
+
* @param {Object|null} [user]
|
|
23
|
+
* @returns {unknown|null}
|
|
24
|
+
*/
|
|
25
|
+
function defaultGetTenantId(req, user) {
|
|
26
|
+
if (!req) return null;
|
|
27
|
+
if (req.tenantId !== undefined && req.tenantId !== null) return req.tenantId;
|
|
28
|
+
if (req.tenant !== undefined && req.tenant !== null) return req.tenant;
|
|
29
|
+
if (user && typeof user === 'object') {
|
|
30
|
+
if (user.tenant_id !== undefined && user.tenant_id !== null) return user.tenant_id;
|
|
31
|
+
if (user.tenantId !== undefined && user.tenantId !== null) return user.tenantId;
|
|
32
|
+
if (user.organization_id !== undefined && user.organization_id !== null) return user.organization_id;
|
|
33
|
+
if (user.organizationId !== undefined && user.organizationId !== null) return user.organizationId;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create Express middleware for Row Level Security
|
|
40
|
+
* Wraps req.db methods to automatically inject authenticated user and tenant context
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} [options]
|
|
43
|
+
* @param {string[]|Function|false} [options.bypassRoles=['admin', 'superadmin']] - Roles that bypass RLS
|
|
44
|
+
* @param {Function} [options.getUser] - Custom user extractor (req) => user
|
|
45
|
+
* @param {Function} [options.getTenantId] - Custom tenant extractor (req, user) => tenantId
|
|
46
|
+
* @param {boolean} [options.enabled=true] - Whether RLS middleware is active
|
|
47
|
+
* @returns {Function} Express middleware handler
|
|
48
|
+
*/
|
|
49
|
+
function createRlsMiddleware(options = {}) {
|
|
50
|
+
const {
|
|
51
|
+
bypassRoles = ['admin', 'superadmin'],
|
|
52
|
+
getUser = defaultGetUser,
|
|
53
|
+
getTenantId = defaultGetTenantId,
|
|
54
|
+
enabled = true,
|
|
55
|
+
} = options;
|
|
56
|
+
|
|
57
|
+
return (req, res, next) => {
|
|
58
|
+
if (!enabled) return next();
|
|
59
|
+
|
|
60
|
+
const user = typeof getUser === 'function' ? getUser(req) : null;
|
|
61
|
+
const tenantId = typeof getTenantId === 'function' ? getTenantId(req, user) : null;
|
|
62
|
+
|
|
63
|
+
// Check if user has global bypass role
|
|
64
|
+
let isBypassed = false;
|
|
65
|
+
if (user && bypassRoles) {
|
|
66
|
+
if (typeof bypassRoles === 'function') {
|
|
67
|
+
isBypassed = Boolean(bypassRoles(user, req));
|
|
68
|
+
} else if (Array.isArray(bypassRoles)) {
|
|
69
|
+
const uRole = user.role || user.roleName || user.type;
|
|
70
|
+
const uRoles = Array.isArray(user.roles) ? user.roles : (uRole ? [uRole] : []);
|
|
71
|
+
isBypassed = bypassRoles.some((r) => uRoles.includes(r));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Wrap req.db if it exists on the request
|
|
76
|
+
if (req.db && typeof req.db.getRepository === 'function' && !req.db._rlsWrapped) {
|
|
77
|
+
const origGetRepo = req.db.getRepository.bind(req.db);
|
|
78
|
+
const origQuery = req.db.query ? req.db.query.bind(req.db) : null;
|
|
79
|
+
const origCreateRepo = req.db.createRepository ? req.db.createRepository.bind(req.db) : null;
|
|
80
|
+
|
|
81
|
+
const reqDb = Object.create(req.db);
|
|
82
|
+
reqDb._rlsWrapped = true;
|
|
83
|
+
|
|
84
|
+
reqDb.getRepository = (modelName, customContext = {}) => {
|
|
85
|
+
const ctx = {
|
|
86
|
+
user,
|
|
87
|
+
tenantId: customContext.tenantId !== undefined ? customContext.tenantId : tenantId,
|
|
88
|
+
rlsDisabled: isBypassed || customContext.rlsDisabled === true,
|
|
89
|
+
...customContext,
|
|
90
|
+
};
|
|
91
|
+
return origGetRepo(modelName, ctx);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (origCreateRepo) {
|
|
95
|
+
reqDb.createRepository = (model, customContext = {}) => {
|
|
96
|
+
const ctx = {
|
|
97
|
+
user,
|
|
98
|
+
tenantId: customContext.tenantId !== undefined ? customContext.tenantId : tenantId,
|
|
99
|
+
rlsDisabled: isBypassed || customContext.rlsDisabled === true,
|
|
100
|
+
...customContext,
|
|
101
|
+
};
|
|
102
|
+
return origCreateRepo(model, ctx);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (origQuery) {
|
|
107
|
+
reqDb.query = (modelName, customContext = {}) => {
|
|
108
|
+
const ctx = {
|
|
109
|
+
user,
|
|
110
|
+
tenantId: customContext.tenantId !== undefined ? customContext.tenantId : tenantId,
|
|
111
|
+
rlsDisabled: isBypassed || customContext.rlsDisabled === true,
|
|
112
|
+
...customContext,
|
|
113
|
+
};
|
|
114
|
+
return origQuery(modelName, ctx);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
req.db = reqDb;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
next();
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Webspresso RLS Plugin Factory
|
|
127
|
+
*
|
|
128
|
+
* @param {Object} [options] - Plugin configuration options
|
|
129
|
+
* @param {boolean} [options.global=true] - Mount global middleware on app
|
|
130
|
+
* @param {string[]|Function|false} [options.bypassRoles=['admin', 'superadmin']] - Global roles bypassing RLS
|
|
131
|
+
* @param {Function} [options.getUser] - Custom user extractor
|
|
132
|
+
* @param {Function} [options.getTenantId] - Custom tenant extractor
|
|
133
|
+
* @param {boolean} [options.enabled=true] - Enabled flag
|
|
134
|
+
* @returns {Object} Webspresso Plugin Definition
|
|
135
|
+
*/
|
|
136
|
+
function rlsPlugin(options = {}) {
|
|
137
|
+
const { global: applyGlobal = true, ...pluginDefaults } = options;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
name: 'rls',
|
|
141
|
+
version: '1.0.0',
|
|
142
|
+
description: 'Dynamic Model Row-Level Security (RLS) with automatic request context binding',
|
|
143
|
+
options,
|
|
144
|
+
|
|
145
|
+
api: {
|
|
146
|
+
createMiddleware: (routeOpts = {}) =>
|
|
147
|
+
createRlsMiddleware({ ...pluginDefaults, ...routeOpts }),
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
register(ctx) {
|
|
151
|
+
// Named middleware for route definitions: middleware: ['rls']
|
|
152
|
+
if (ctx.middlewares) {
|
|
153
|
+
ctx.middlewares.rls = (routeOpts = {}) =>
|
|
154
|
+
createRlsMiddleware({ ...pluginDefaults, ...routeOpts });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Global middleware (mounted by default unless global: false)
|
|
158
|
+
if (applyGlobal && ctx.app) {
|
|
159
|
+
ctx.app.use(createRlsMiddleware(options));
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
rlsPlugin.rlsPlugin = rlsPlugin;
|
|
166
|
+
rlsPlugin.createRlsMiddleware = createRlsMiddleware;
|
|
167
|
+
|
|
168
|
+
module.exports = rlsPlugin;
|
|
169
|
+
module.exports.rlsPlugin = rlsPlugin;
|
|
170
|
+
module.exports.createRlsMiddleware = createRlsMiddleware;
|
|
@@ -20,10 +20,12 @@ function generateSchemaExplorerAdminComponent(options = {}) {
|
|
|
20
20
|
function getSchemaApi(method, path) {
|
|
21
21
|
var adminPath = window.__ADMIN_PATH__ || '/_admin';
|
|
22
22
|
var url = adminPath + '/api${apiPrefix}' + (path || '');
|
|
23
|
-
|
|
23
|
+
var fetchFn = (typeof window !== 'undefined' && window.Admin && window.Admin.fetch) ? window.Admin.fetch : fetch;
|
|
24
|
+
return fetchFn(url, {
|
|
24
25
|
method: method || 'GET',
|
|
25
26
|
credentials: 'include',
|
|
26
|
-
headers: { 'Accept': 'application/json' }
|
|
27
|
+
headers: { 'Accept': 'application/json' },
|
|
28
|
+
timeout: 30000,
|
|
27
29
|
}).then(function(res) {
|
|
28
30
|
if (!res.ok) {
|
|
29
31
|
return res.json().catch(function() { return { error: res.statusText }; }).then(function(err) {
|
|
@@ -59,6 +59,25 @@ function schemaExplorerPlugin(options = {}) {
|
|
|
59
59
|
|
|
60
60
|
const pageTitle = title || adminTitle || menuLabel;
|
|
61
61
|
|
|
62
|
+
let cachedGraph = null;
|
|
63
|
+
let cachedGraphTime = 0;
|
|
64
|
+
const GRAPH_CACHE_TTL = 5000; // 5 seconds in dev, infinite in prod
|
|
65
|
+
|
|
66
|
+
function getEnrichedGraph() {
|
|
67
|
+
const isProd = process.env.NODE_ENV === 'production';
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
if (cachedGraph && (isProd || (now - cachedGraphTime) < GRAPH_CACHE_TTL)) {
|
|
70
|
+
return cachedGraph;
|
|
71
|
+
}
|
|
72
|
+
const rawModels = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
|
|
73
|
+
const relationships = extractAllRelationships(rawModels);
|
|
74
|
+
const enriched = enrichModelsWithGraph(rawModels, relationships);
|
|
75
|
+
const mermaid = generateMermaidErDiagram(rawModels, relationships);
|
|
76
|
+
cachedGraph = { rawModels, relationships, enriched, mermaid };
|
|
77
|
+
cachedGraphTime = now;
|
|
78
|
+
return cachedGraph;
|
|
79
|
+
}
|
|
80
|
+
|
|
62
81
|
return {
|
|
63
82
|
name: 'schema-explorer',
|
|
64
83
|
version: '1.0.0',
|
|
@@ -66,6 +85,14 @@ function schemaExplorerPlugin(options = {}) {
|
|
|
66
85
|
|
|
67
86
|
// Expose API for other plugins & programmatic consumption
|
|
68
87
|
api: {
|
|
88
|
+
/**
|
|
89
|
+
* Invalidate schema explorer cache
|
|
90
|
+
*/
|
|
91
|
+
invalidateCache() {
|
|
92
|
+
cachedGraph = null;
|
|
93
|
+
cachedGraphTime = 0;
|
|
94
|
+
},
|
|
95
|
+
|
|
69
96
|
/**
|
|
70
97
|
* Get all models metadata
|
|
71
98
|
* @returns {Object[]}
|
|
@@ -79,9 +106,7 @@ function schemaExplorerPlugin(options = {}) {
|
|
|
79
106
|
* @returns {Object[]}
|
|
80
107
|
*/
|
|
81
108
|
getEnrichedModels() {
|
|
82
|
-
|
|
83
|
-
const rels = extractAllRelationships(raw);
|
|
84
|
-
return enrichModelsWithGraph(raw, rels);
|
|
109
|
+
return getEnrichedGraph().enriched;
|
|
85
110
|
},
|
|
86
111
|
|
|
87
112
|
/**
|
|
@@ -109,8 +134,7 @@ function schemaExplorerPlugin(options = {}) {
|
|
|
109
134
|
* @returns {Object[]}
|
|
110
135
|
*/
|
|
111
136
|
getRelationships() {
|
|
112
|
-
|
|
113
|
-
return extractAllRelationships(raw);
|
|
137
|
+
return getEnrichedGraph().relationships;
|
|
114
138
|
},
|
|
115
139
|
|
|
116
140
|
/**
|
|
@@ -118,9 +142,7 @@ function schemaExplorerPlugin(options = {}) {
|
|
|
118
142
|
* @returns {string}
|
|
119
143
|
*/
|
|
120
144
|
getMermaid() {
|
|
121
|
-
|
|
122
|
-
const rels = extractAllRelationships(raw);
|
|
123
|
-
return generateMermaidErDiagram(raw, rels);
|
|
145
|
+
return getEnrichedGraph().mermaid;
|
|
124
146
|
},
|
|
125
147
|
|
|
126
148
|
extractAllRelationships,
|
|
@@ -250,21 +272,17 @@ function schemaExplorerPlugin(options = {}) {
|
|
|
250
272
|
method: 'get',
|
|
251
273
|
path: '',
|
|
252
274
|
handler: (req, res) => {
|
|
253
|
-
const
|
|
254
|
-
const relationships = extractAllRelationships(rawModels);
|
|
255
|
-
const models = enrichModelsWithGraph(rawModels, relationships);
|
|
256
|
-
const mermaid = generateMermaidErDiagram(rawModels, relationships);
|
|
257
|
-
|
|
275
|
+
const graph = getEnrichedGraph();
|
|
258
276
|
res.json({
|
|
259
277
|
meta: {
|
|
260
278
|
version: '1.0.0',
|
|
261
279
|
generatedAt: new Date().toISOString(),
|
|
262
|
-
modelCount:
|
|
263
|
-
relationCount: relationships.length,
|
|
280
|
+
modelCount: graph.enriched.length,
|
|
281
|
+
relationCount: graph.relationships.length,
|
|
264
282
|
},
|
|
265
|
-
models,
|
|
266
|
-
relationships,
|
|
267
|
-
mermaid,
|
|
283
|
+
models: graph.enriched,
|
|
284
|
+
relationships: graph.relationships,
|
|
285
|
+
mermaid: graph.mermaid,
|
|
268
286
|
});
|
|
269
287
|
},
|
|
270
288
|
},
|
|
@@ -272,19 +290,14 @@ function schemaExplorerPlugin(options = {}) {
|
|
|
272
290
|
method: 'get',
|
|
273
291
|
path: '/relations',
|
|
274
292
|
handler: (req, res) => {
|
|
275
|
-
|
|
276
|
-
const relationships = extractAllRelationships(rawModels);
|
|
277
|
-
res.json({ relationships });
|
|
293
|
+
res.json({ relationships: getEnrichedGraph().relationships });
|
|
278
294
|
},
|
|
279
295
|
},
|
|
280
296
|
{
|
|
281
297
|
method: 'get',
|
|
282
298
|
path: '/mermaid',
|
|
283
299
|
handler: (req, res) => {
|
|
284
|
-
|
|
285
|
-
const relationships = extractAllRelationships(rawModels);
|
|
286
|
-
const mermaid = generateMermaidErDiagram(rawModels, relationships);
|
|
287
|
-
res.json({ mermaid });
|
|
300
|
+
res.json({ mermaid: getEnrichedGraph().mermaid });
|
|
288
301
|
},
|
|
289
302
|
},
|
|
290
303
|
{
|
|
@@ -298,10 +311,8 @@ function schemaExplorerPlugin(options = {}) {
|
|
|
298
311
|
return res.status(404).json({ error: `Model "${modelName}" not found` });
|
|
299
312
|
}
|
|
300
313
|
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
const models = enrichModelsWithGraph(rawModels, relationships);
|
|
304
|
-
const found = models.find(m => m.name === modelName);
|
|
314
|
+
const graph = getEnrichedGraph();
|
|
315
|
+
const found = graph.enriched.find(m => m.name === modelName);
|
|
305
316
|
|
|
306
317
|
res.json({ model: found });
|
|
307
318
|
},
|
|
@@ -50,12 +50,15 @@ function seoCheckerPlugin(options = {}) {
|
|
|
50
50
|
// Inject panel styles into head
|
|
51
51
|
ctx.injectHead(generatePanelStyles(), { priority: 100 });
|
|
52
52
|
|
|
53
|
+
const safeSettings = JSON.stringify(mergedSettings).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
|
|
54
|
+
const safeChecks = JSON.stringify(checkDefinitions).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
|
|
55
|
+
|
|
53
56
|
// Inject panel HTML and analyzer script into body
|
|
54
57
|
ctx.injectBody(`
|
|
55
58
|
${generatePanelHtml(checkDefinitions)}
|
|
56
59
|
<script>
|
|
57
|
-
window.__SEO_CHECKER_SETTINGS__ = ${
|
|
58
|
-
window.__SEO_CHECKER_CHECKS__ = ${
|
|
60
|
+
window.__SEO_CHECKER_SETTINGS__ = ${safeSettings};
|
|
61
|
+
window.__SEO_CHECKER_CHECKS__ = ${safeChecks};
|
|
59
62
|
</script>
|
|
60
63
|
<script>${analyzerScript}</script>
|
|
61
64
|
`);
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* @module plugins/site-analytics/tracking
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const { sha256 } = require('../../core/utils/crypto');
|
|
8
|
+
const { getClientIp: resolveClientIp } = require('../../core/utils/ip');
|
|
8
9
|
const { detectBot } = require('./bot-patterns');
|
|
9
10
|
const { detectCountry } = require('./geo');
|
|
10
11
|
|
|
@@ -75,24 +76,20 @@ function isPermanentDbError(e) {
|
|
|
75
76
|
* Hash a string with SHA-256 (truncated to 16 hex chars for compactness)
|
|
76
77
|
*/
|
|
77
78
|
function quickHash(str) {
|
|
78
|
-
return
|
|
79
|
+
return sha256(str).slice(0, 16);
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
83
|
* Get client IP from request, handling proxies
|
|
83
84
|
*/
|
|
84
85
|
function getClientIp(req) {
|
|
85
|
-
|
|
86
|
-
if (forwarded) {
|
|
87
|
-
return forwarded.split(',')[0].trim();
|
|
88
|
-
}
|
|
89
|
-
return req.headers['x-real-ip'] || req.ip || req.connection?.remoteAddress || '';
|
|
86
|
+
return resolveClientIp(req, '');
|
|
90
87
|
}
|
|
91
88
|
|
|
92
89
|
/**
|
|
93
90
|
* Create tracking middleware
|
|
94
|
-
* @param {Object} options
|
|
95
|
-
* @param {Object} options.knex - Knex instance
|
|
91
|
+
* @param {Object} [options={}]
|
|
92
|
+
* @param {Object} [options.knex] - Knex instance
|
|
96
93
|
* @param {string[]} [options.excludePaths] - Paths to exclude from tracking
|
|
97
94
|
* @param {boolean} [options.trackBots=true] - Whether to record bot visits
|
|
98
95
|
* @param {string} [options.tableName='analytics_page_views'] - DB table name
|
|
@@ -103,7 +100,8 @@ function getClientIp(req) {
|
|
|
103
100
|
* @param {number} [options.maxQueueSize=5000] - Queue memory cap to prevent OOM
|
|
104
101
|
* @param {number} [options.errorLogIntervalMs=60000] - Rate limit window for batch failure logs
|
|
105
102
|
*/
|
|
106
|
-
function createTrackingMiddleware(options) {
|
|
103
|
+
function createTrackingMiddleware(options = {}) {
|
|
104
|
+
const opts = options || {};
|
|
107
105
|
const {
|
|
108
106
|
knex,
|
|
109
107
|
excludePaths = [],
|
|
@@ -115,7 +113,7 @@ function createTrackingMiddleware(options) {
|
|
|
115
113
|
maxBackoffMs = 60000,
|
|
116
114
|
maxQueueSize = 5000,
|
|
117
115
|
errorLogIntervalMs = 60000,
|
|
118
|
-
} =
|
|
116
|
+
} = opts;
|
|
119
117
|
|
|
120
118
|
const allExcludes = [...DEFAULT_EXCLUDE, ...excludePaths];
|
|
121
119
|
let tableReady = false;
|
|
@@ -213,7 +211,7 @@ function createTrackingMiddleware(options) {
|
|
|
213
211
|
|
|
214
212
|
async function flushQueue() {
|
|
215
213
|
if (flushPromise) return flushPromise;
|
|
216
|
-
if (queue.length === 0) return;
|
|
214
|
+
if (queue.length === 0 || !knex) return;
|
|
217
215
|
|
|
218
216
|
const now = Date.now();
|
|
219
217
|
if (now < nextAllowedFlushTime) {
|
|
@@ -299,16 +297,17 @@ function createTrackingMiddleware(options) {
|
|
|
299
297
|
if (periodicFlushInterval.unref) periodicFlushInterval.unref();
|
|
300
298
|
|
|
301
299
|
async function trackingMiddleware(req, res, next) {
|
|
302
|
-
next();
|
|
300
|
+
if (typeof next === 'function') next();
|
|
303
301
|
|
|
304
|
-
if (req.method !== 'GET') return;
|
|
302
|
+
if (!req || req.method !== 'GET') return;
|
|
305
303
|
|
|
306
|
-
const path = req.path;
|
|
307
|
-
if (STATIC_EXT.test(path)) return;
|
|
304
|
+
const path = req.path || req.url || '';
|
|
305
|
+
if (!path || STATIC_EXT.test(path)) return;
|
|
308
306
|
if (allExcludes.some(prefix => path.startsWith(prefix))) return;
|
|
309
307
|
|
|
310
308
|
try {
|
|
311
|
-
const
|
|
309
|
+
const headers = req.headers || {};
|
|
310
|
+
const userAgent = headers['user-agent'] || '';
|
|
312
311
|
const ip = getClientIp(req);
|
|
313
312
|
const { isBot, botName } = detectBot(userAgent);
|
|
314
313
|
|
|
@@ -329,7 +328,7 @@ function createTrackingMiddleware(options) {
|
|
|
329
328
|
: getSessionId(visitorId);
|
|
330
329
|
|
|
331
330
|
const country = detectCountry(req);
|
|
332
|
-
const referrer =
|
|
331
|
+
const referrer = headers['referer'] || headers['referrer'] || null;
|
|
333
332
|
|
|
334
333
|
if (queue.length < maxQueueSize) {
|
|
335
334
|
queue.push({
|
|
@@ -385,6 +384,10 @@ function createTrackingMiddleware(options) {
|
|
|
385
384
|
}
|
|
386
385
|
clearInterval(sessionCleanupInterval);
|
|
387
386
|
clearInterval(periodicFlushInterval);
|
|
387
|
+
if (queue.length > 0 && knex) {
|
|
388
|
+
return flushQueue().catch(() => {});
|
|
389
|
+
}
|
|
390
|
+
return Promise.resolve();
|
|
388
391
|
};
|
|
389
392
|
|
|
390
393
|
return trackingMiddleware;
|
package/plugins/sitemap.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const { matchPattern } = require('../src/plugin-manager');
|
|
7
|
+
const { stripTrailingSlash } = require('../core/utils/url');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Escape XML special characters
|
|
@@ -140,7 +141,7 @@ function generateRobotsTxt(hostname, options = {}) {
|
|
|
140
141
|
*/
|
|
141
142
|
function sitemapPlugin(options = {}) {
|
|
142
143
|
const {
|
|
143
|
-
hostname = process.env.BASE_URL || 'http://localhost:
|
|
144
|
+
hostname = process.env.BASE_URL || 'http://localhost:7331',
|
|
144
145
|
exclude = ['/api/*'],
|
|
145
146
|
changefreq = 'weekly',
|
|
146
147
|
priority = 0.8,
|
|
@@ -157,8 +158,9 @@ function sitemapPlugin(options = {}) {
|
|
|
157
158
|
const dynamicExclusions = [...exclude];
|
|
158
159
|
const registeredSources = [...dynamicSources];
|
|
159
160
|
|
|
160
|
-
// Cached URLs (for dynamic sources)
|
|
161
|
+
// Cached URLs and pre-rendered XML (for dynamic sources)
|
|
161
162
|
let cachedUrls = null;
|
|
163
|
+
let cachedXml = null;
|
|
162
164
|
let cacheTime = null;
|
|
163
165
|
const cacheMaxAge = options.cacheMaxAge || 5 * 60 * 1000; // 5 minutes default
|
|
164
166
|
|
|
@@ -228,7 +230,7 @@ function sitemapPlugin(options = {}) {
|
|
|
228
230
|
*/
|
|
229
231
|
async function buildAllUrls(routes, dbInstance) {
|
|
230
232
|
const urls = [];
|
|
231
|
-
const hostnameNormalized = hostname
|
|
233
|
+
const hostnameNormalized = stripTrailingSlash(hostname);
|
|
232
234
|
|
|
233
235
|
// Filter static routes for sitemap
|
|
234
236
|
const sitemapRoutes = routes
|
|
@@ -326,7 +328,8 @@ function sitemapPlugin(options = {}) {
|
|
|
326
328
|
*/
|
|
327
329
|
addUrl(path, opts = {}) {
|
|
328
330
|
dynamicUrls.push({ path, ...opts });
|
|
329
|
-
cachedUrls = null;
|
|
331
|
+
cachedUrls = null;
|
|
332
|
+
cachedXml = null;
|
|
330
333
|
},
|
|
331
334
|
|
|
332
335
|
/**
|
|
@@ -335,7 +338,8 @@ function sitemapPlugin(options = {}) {
|
|
|
335
338
|
*/
|
|
336
339
|
exclude(pattern) {
|
|
337
340
|
dynamicExclusions.push(pattern);
|
|
338
|
-
cachedUrls = null;
|
|
341
|
+
cachedUrls = null;
|
|
342
|
+
cachedXml = null;
|
|
339
343
|
},
|
|
340
344
|
|
|
341
345
|
/**
|
|
@@ -347,7 +351,8 @@ function sitemapPlugin(options = {}) {
|
|
|
347
351
|
throw new Error('urlPattern is required for dynamic source');
|
|
348
352
|
}
|
|
349
353
|
registeredSources.push(source);
|
|
350
|
-
cachedUrls = null;
|
|
354
|
+
cachedUrls = null;
|
|
355
|
+
cachedXml = null;
|
|
351
356
|
},
|
|
352
357
|
|
|
353
358
|
/**
|
|
@@ -369,6 +374,7 @@ function sitemapPlugin(options = {}) {
|
|
|
369
374
|
*/
|
|
370
375
|
invalidateCache() {
|
|
371
376
|
cachedUrls = null;
|
|
377
|
+
cachedXml = null;
|
|
372
378
|
cacheTime = null;
|
|
373
379
|
}
|
|
374
380
|
},
|
|
@@ -384,28 +390,26 @@ function sitemapPlugin(options = {}) {
|
|
|
384
390
|
try {
|
|
385
391
|
// Check cache
|
|
386
392
|
const now = Date.now();
|
|
387
|
-
if (
|
|
393
|
+
if (cachedXml && cacheTime && (now - cacheTime) < cacheMaxAge) {
|
|
388
394
|
res.type('application/xml');
|
|
389
|
-
return res.send(
|
|
390
|
-
hostname,
|
|
391
|
-
defaultChangefreq: changefreq,
|
|
392
|
-
defaultPriority: priority
|
|
393
|
-
}));
|
|
395
|
+
return res.send(cachedXml);
|
|
394
396
|
}
|
|
395
397
|
|
|
396
398
|
// Build URLs
|
|
397
399
|
const urls = await buildAllUrls(ctx.routes, dbInstance);
|
|
400
|
+
const xml = generateSitemapXml(urls, {
|
|
401
|
+
hostname,
|
|
402
|
+
defaultChangefreq: changefreq,
|
|
403
|
+
defaultPriority: priority
|
|
404
|
+
});
|
|
398
405
|
|
|
399
406
|
// Cache results
|
|
400
407
|
cachedUrls = urls;
|
|
408
|
+
cachedXml = xml;
|
|
401
409
|
cacheTime = now;
|
|
402
410
|
|
|
403
411
|
res.type('application/xml');
|
|
404
|
-
res.send(
|
|
405
|
-
hostname,
|
|
406
|
-
defaultChangefreq: changefreq,
|
|
407
|
-
defaultPriority: priority
|
|
408
|
-
}));
|
|
412
|
+
res.send(xml);
|
|
409
413
|
} catch (err) {
|
|
410
414
|
console.error('Sitemap generation error:', err);
|
|
411
415
|
res.status(500).send('Error generating sitemap');
|
package/plugins/swagger.js
CHANGED
|
@@ -11,20 +11,8 @@ const { buildOpenApiDocument } = require('../core/openapi/build-from-api-routes'
|
|
|
11
11
|
|
|
12
12
|
const PKG = require(path.join(__dirname, '..', 'package.json'));
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* @param {string} str
|
|
17
|
-
* @returns {string}
|
|
18
|
-
*/
|
|
19
|
-
function escapeHtml(str) {
|
|
20
|
-
if (!str) return '';
|
|
21
|
-
return String(str)
|
|
22
|
-
.replace(/&/g, '&')
|
|
23
|
-
.replace(/</g, '<')
|
|
24
|
-
.replace(/>/g, '>')
|
|
25
|
-
.replace(/"/g, '"')
|
|
26
|
-
.replace(/'/g, ''');
|
|
27
|
-
}
|
|
14
|
+
const { escapeHtml } = require('../core/utils/html');
|
|
15
|
+
const { stripTrailingSlash } = require('../core/utils/url');
|
|
28
16
|
|
|
29
17
|
/**
|
|
30
18
|
* Generates Swagger UI HTML page
|
|
@@ -193,7 +181,7 @@ function generateApiDocsAdminComponent({ uiPath, jsonPath, title }) {
|
|
|
193
181
|
* @param {string} [options.title] - OpenAPI info.title
|
|
194
182
|
* @param {string} [options.version] - OpenAPI info.version
|
|
195
183
|
* @param {string} [options.description] - OpenAPI info.description
|
|
196
|
-
* @param {string} [options.serverUrl] - Override servers[0].url (default: BASE_URL or http://localhost:
|
|
184
|
+
* @param {string} [options.serverUrl] - Override servers[0].url (default: BASE_URL or http://localhost:7331)
|
|
197
185
|
* @param {string} [options.theme='default'] - Theme for Scalar UI
|
|
198
186
|
* @param {boolean} [options.darkMode=true] - Dark mode default for Scalar UI
|
|
199
187
|
* @param {boolean} [options.admin=true] - Auto-register in Admin Panel if admin-panel plugin is present
|
|
@@ -256,12 +244,17 @@ function swaggerPlugin(options = {}) {
|
|
|
256
244
|
return false;
|
|
257
245
|
};
|
|
258
246
|
|
|
259
|
-
const server = serverUrl || process.env.BASE_URL || 'http://localhost:
|
|
247
|
+
const server = serverUrl || process.env.BASE_URL || 'http://localhost:7331';
|
|
248
|
+
|
|
249
|
+
let cachedDoc = null;
|
|
260
250
|
|
|
261
251
|
// 1. OpenAPI 3 JSON Endpoint
|
|
262
252
|
ctx.addRoute('get', jsonPath, (req, res) => {
|
|
263
253
|
if (forbid(req, res)) return;
|
|
264
254
|
try {
|
|
255
|
+
if (cachedDoc && process.env.NODE_ENV === 'production') {
|
|
256
|
+
return res.json(cachedDoc);
|
|
257
|
+
}
|
|
265
258
|
const doc = buildOpenApiDocument({
|
|
266
259
|
routes: ctx.routes || [],
|
|
267
260
|
pagesDir: ctx.options.pagesDir,
|
|
@@ -272,8 +265,11 @@ function swaggerPlugin(options = {}) {
|
|
|
272
265
|
version: docVersion,
|
|
273
266
|
description: docDescription,
|
|
274
267
|
},
|
|
275
|
-
servers: [{ url: server
|
|
268
|
+
servers: [{ url: stripTrailingSlash(server) }],
|
|
276
269
|
});
|
|
270
|
+
if (process.env.NODE_ENV === 'production') {
|
|
271
|
+
cachedDoc = doc;
|
|
272
|
+
}
|
|
277
273
|
res.json(doc);
|
|
278
274
|
} catch (err) {
|
|
279
275
|
console.warn('[swagger] OpenAPI generation failed:', err.message);
|
package/plugins/upload/index.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
const multer = require('multer');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const { createLocalFileProvider } = require('./local-file-provider');
|
|
9
|
+
const { ensureLeadingSlash } = require('../../core/utils/url');
|
|
10
|
+
const { ensureArray } = require('../../core/utils/object');
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* @typedef {import('express').Request} ExpressRequest
|
|
@@ -37,8 +39,7 @@ const { createLocalFileProvider } = require('./local-file-provider');
|
|
|
37
39
|
* @returns {string}
|
|
38
40
|
*/
|
|
39
41
|
function normalizeRoutePath(p) {
|
|
40
|
-
|
|
41
|
-
return s.startsWith('/') ? s : `/${s}`;
|
|
42
|
+
return ensureLeadingSlash((p || '/api/upload').trim());
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -118,7 +119,7 @@ function uploadPlugin(options = {}) {
|
|
|
118
119
|
publicBasePath: local?.publicBasePath,
|
|
119
120
|
});
|
|
120
121
|
|
|
121
|
-
const mw = (
|
|
122
|
+
const mw = ensureArray(middleware).filter(Boolean);
|
|
122
123
|
const parseMultipart = createMulterMiddleware(maxBytes, fieldName, multiple, maxFiles);
|
|
123
124
|
|
|
124
125
|
return {
|
|
@@ -11,6 +11,7 @@ const fs = require('fs/promises');
|
|
|
11
11
|
const fsSync = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const { normalizePublicBase } = require('./local-file-provider');
|
|
14
|
+
const { safeJsonParse } = require('../../core/utils/json');
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Format bytes into human readable string
|
|
@@ -137,10 +138,10 @@ async function extractDbFileReferences(db) {
|
|
|
137
138
|
for (const [key, value] of Object.entries(row)) {
|
|
138
139
|
// If value is a JSON string, try to parse
|
|
139
140
|
if (typeof value === 'string' && (value.startsWith('{') || value.startsWith('['))) {
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
const parsed = safeJsonParse(value, null);
|
|
142
|
+
if (parsed) {
|
|
142
143
|
scanValue(parsed);
|
|
143
|
-
}
|
|
144
|
+
} else {
|
|
144
145
|
addStringReference(value);
|
|
145
146
|
}
|
|
146
147
|
} else {
|