webspresso 0.0.96 → 0.0.98
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/ADMIN.md +153 -5
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/CLI.md +195 -1
- package/.agents/PLUGINS.md +153 -9
- package/.agents/QUEUE.md +43 -0
- package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
- package/.agents/skills/webspresso-framework/SKILL.md +56 -0
- package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
- package/.agents/skills/webspresso-services/SKILL.md +101 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/admin-password.js +2 -1
- package/bin/commands/admin-setup.js +1 -1
- package/bin/commands/agents-init.js +51 -11
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/audit-prune.js +1 -1
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/favicon-generate.js +1 -1
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/orm-map.js +5 -4
- package/bin/commands/page.js +6 -3
- package/bin/commands/polar-migrate.js +1 -1
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/seed.js +1 -1
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +31 -17
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/index.js +12 -7
- package/core/orm/model.js +49 -7
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/manager.js +123 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +660 -10
- package/index.js +44 -96
- package/package.json +32 -12
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +124 -31
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +34 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/audit-log/admin-component.js +11 -0
- package/plugins/audit-log/api-handlers.js +34 -0
- package/plugins/audit-log/index.js +129 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/basic-auth/index.js +3 -1
- package/plugins/content/client/inline-edit.js +12 -12
- package/plugins/cors.js +6 -7
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/core.js +1 -1
- package/plugins/file-manager/security.js +33 -23
- package/plugins/index.js +49 -87
- package/plugins/mcp/transports/sse.js +4 -2
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +11 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +30 -8
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +3 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/file-router.js +213 -63
- package/src/helpers.js +958 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/server.js +629 -63
- package/src/services/discovery.js +6 -1
- package/src/services/index.js +6 -13
|
@@ -8,4 +8,27 @@ window.__ADMIN_COMPONENTS__ = {
|
|
|
8
8
|
RecordForm,
|
|
9
9
|
api,
|
|
10
10
|
state,
|
|
11
|
+
notify: typeof window !== 'undefined' ? window.notify : undefined,
|
|
12
|
+
confirmModal: typeof window !== 'undefined' ? window.confirmModal : undefined,
|
|
13
|
+
toastState: typeof window !== 'undefined' ? window.toastState : undefined,
|
|
14
|
+
confirmState: typeof window !== 'undefined' ? window.confirmState : undefined,
|
|
15
|
+
AdminUI: typeof window !== 'undefined' ? window.AdminUI : undefined,
|
|
16
|
+
Admin: typeof window !== 'undefined' ? window.Admin : undefined,
|
|
17
|
+
ToastContainer: typeof window !== 'undefined' ? window.ToastContainer : undefined,
|
|
18
|
+
ConfirmModalContainer: typeof window !== 'undefined' ? window.ConfirmModalContainer : undefined,
|
|
11
19
|
};
|
|
20
|
+
|
|
21
|
+
if (typeof window !== 'undefined') {
|
|
22
|
+
window.Admin = window.Admin || {};
|
|
23
|
+
window.Admin.api = api;
|
|
24
|
+
window.Admin.state = state;
|
|
25
|
+
if (typeof window.AdminUI !== 'undefined') {
|
|
26
|
+
window.Admin.ui = window.AdminUI;
|
|
27
|
+
}
|
|
28
|
+
if (typeof window.notify !== 'undefined') {
|
|
29
|
+
window.Admin.toast = window.notify;
|
|
30
|
+
}
|
|
31
|
+
if (typeof window.confirmModal !== 'undefined') {
|
|
32
|
+
window.Admin.confirm = window.confirmModal;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -193,19 +193,22 @@ function registerModule(config, deps) {
|
|
|
193
193
|
menuList = menuList.concat(dirMenuItems);
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
const moduleTitle = config.title;
|
|
197
|
+
|
|
196
198
|
for (const p of pagesList) {
|
|
197
199
|
if (p.menu && !menuList.some(m => m.id === p.id)) {
|
|
200
|
+
const pageLabel = p.title || moduleTitle || p.id;
|
|
198
201
|
if (p.menu === true) {
|
|
199
202
|
menuList.push({
|
|
200
203
|
id: p.id,
|
|
201
|
-
label:
|
|
204
|
+
label: pageLabel,
|
|
202
205
|
path: p.path,
|
|
203
206
|
icon: p.icon,
|
|
204
207
|
});
|
|
205
208
|
} else if (typeof p.menu === 'object') {
|
|
206
209
|
menuList.push({
|
|
207
210
|
id: p.id,
|
|
208
|
-
label:
|
|
211
|
+
label: pageLabel,
|
|
209
212
|
path: p.path,
|
|
210
213
|
icon: p.icon,
|
|
211
214
|
...p.menu,
|
|
@@ -215,7 +218,7 @@ function registerModule(config, deps) {
|
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
if (pagesList.length > 0) {
|
|
218
|
-
registerPages(config.id, pagesList, { registry, adminPath, ctx, optionalAuth, serveAdminPanel });
|
|
221
|
+
registerPages(config.id, pagesList, { registry, adminPath, ctx, optionalAuth, serveAdminPanel }, moduleTitle);
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
if (menuList.length > 0) {
|
|
@@ -246,7 +249,7 @@ function registerPageDir(dirPath, deps) {
|
|
|
246
249
|
}
|
|
247
250
|
}
|
|
248
251
|
|
|
249
|
-
function registerPages(moduleId, pages, deps) {
|
|
252
|
+
function registerPages(moduleId, pages, deps, moduleTitle) {
|
|
250
253
|
if (!Array.isArray(pages)) {
|
|
251
254
|
throw new Error(`Module "${moduleId}": pages must be an array`);
|
|
252
255
|
}
|
|
@@ -257,8 +260,9 @@ function registerPages(moduleId, pages, deps) {
|
|
|
257
260
|
const pageId = page.id || moduleId;
|
|
258
261
|
|
|
259
262
|
let htmlContent = page.html || null;
|
|
263
|
+
let resolvedHtmlPath = null;
|
|
260
264
|
if (page.htmlFile) {
|
|
261
|
-
|
|
265
|
+
resolvedHtmlPath = path.resolve(page.htmlFile);
|
|
262
266
|
if (!fs.existsSync(resolvedHtmlPath)) {
|
|
263
267
|
throw new Error(`Module "${moduleId}", page "${pageId}": htmlFile not found at ${page.htmlFile}`);
|
|
264
268
|
}
|
|
@@ -266,7 +270,8 @@ function registerPages(moduleId, pages, deps) {
|
|
|
266
270
|
}
|
|
267
271
|
|
|
268
272
|
registry.registerPage(pageId, {
|
|
269
|
-
title: page.title,
|
|
273
|
+
title: page.title || moduleTitle,
|
|
274
|
+
documentTitle: page.documentTitle,
|
|
270
275
|
path: page.path,
|
|
271
276
|
icon: page.icon,
|
|
272
277
|
description: page.description,
|
|
@@ -275,6 +280,7 @@ function registerPages(moduleId, pages, deps) {
|
|
|
275
280
|
iframe: page.iframe,
|
|
276
281
|
url: page.url || page.iframeUrl || undefined,
|
|
277
282
|
html: htmlContent || undefined,
|
|
283
|
+
htmlFile: resolvedHtmlPath || (page.htmlFile ? path.resolve(page.htmlFile) : undefined),
|
|
278
284
|
});
|
|
279
285
|
|
|
280
286
|
if (page.componentFile) {
|
|
@@ -284,7 +290,7 @@ function registerPages(moduleId, pages, deps) {
|
|
|
284
290
|
}
|
|
285
291
|
const rawCode = fs.readFileSync(resolvedPath, 'utf8');
|
|
286
292
|
const formattedCode = formatClientComponent(pageId, rawCode);
|
|
287
|
-
registry.registerClientComponent(pageId, formattedCode);
|
|
293
|
+
registry.registerClientComponent(pageId, formattedCode, resolvedPath);
|
|
288
294
|
} else if (page.component) {
|
|
289
295
|
const formattedCode = formatClientComponent(pageId, page.component);
|
|
290
296
|
registry.registerClientComponent(pageId, formattedCode);
|
|
@@ -24,6 +24,10 @@ let npmVersionCache = {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
function fetchLatestNpmVersion(force = false) {
|
|
27
|
+
if ((process.env.NODE_ENV === 'test' || process.env.VITEST) && !force) {
|
|
28
|
+
return Promise.resolve(npmVersionCache.latestVersion || getWebspressoVersion());
|
|
29
|
+
}
|
|
30
|
+
|
|
27
31
|
const now = Date.now();
|
|
28
32
|
if (!force && npmVersionCache.latestVersion && (now - npmVersionCache.checkedAt < 30 * 60 * 1000)) {
|
|
29
33
|
return Promise.resolve(npmVersionCache.latestVersion);
|
|
@@ -336,12 +340,24 @@ function createExtensionApiHandlers(options) {
|
|
|
336
340
|
}
|
|
337
341
|
|
|
338
342
|
// Execute action
|
|
339
|
-
const
|
|
343
|
+
const context = {
|
|
340
344
|
db,
|
|
341
345
|
req,
|
|
342
346
|
user: req.session?.adminUser,
|
|
343
347
|
body: req.body,
|
|
348
|
+
model: modelName,
|
|
349
|
+
modelName,
|
|
350
|
+
name: modelName,
|
|
351
|
+
};
|
|
352
|
+
const modelArg = Object.assign(new String(modelName), context, {
|
|
353
|
+
name: modelName,
|
|
354
|
+
modelName,
|
|
355
|
+
model: modelName,
|
|
356
|
+
valueOf: () => modelName,
|
|
357
|
+
toString: () => modelName,
|
|
358
|
+
[Symbol.toPrimitive]: () => modelName,
|
|
344
359
|
});
|
|
360
|
+
const result = await action.handler(record, modelArg, context);
|
|
345
361
|
|
|
346
362
|
res.json({ success: true, result });
|
|
347
363
|
} catch (error) {
|
|
@@ -414,12 +430,24 @@ function createExtensionApiHandlers(options) {
|
|
|
414
430
|
}
|
|
415
431
|
|
|
416
432
|
// Execute action
|
|
417
|
-
const
|
|
433
|
+
const context = {
|
|
418
434
|
db,
|
|
419
435
|
req,
|
|
420
436
|
user: req.session?.adminUser,
|
|
421
437
|
body: req.body,
|
|
438
|
+
model: modelName,
|
|
439
|
+
modelName,
|
|
440
|
+
name: modelName,
|
|
441
|
+
};
|
|
442
|
+
const modelArg = Object.assign(new String(modelName), context, {
|
|
443
|
+
name: modelName,
|
|
444
|
+
modelName,
|
|
445
|
+
model: modelName,
|
|
446
|
+
valueOf: () => modelName,
|
|
447
|
+
toString: () => modelName,
|
|
448
|
+
[Symbol.toPrimitive]: () => modelName,
|
|
422
449
|
});
|
|
450
|
+
const result = await action.handler(records, modelArg, context);
|
|
423
451
|
|
|
424
452
|
res.json({ success: true, result, affected: records.length });
|
|
425
453
|
} catch (error) {
|
|
@@ -671,6 +699,10 @@ function createExtensionApiHandlers(options) {
|
|
|
671
699
|
}
|
|
672
700
|
}
|
|
673
701
|
|
|
702
|
+
if (registry && typeof registry.runHooks === 'function') {
|
|
703
|
+
await registry.runHooks('onDashboardLoad', { stats, req, user: req.session?.adminUser, db });
|
|
704
|
+
}
|
|
705
|
+
|
|
674
706
|
res.json({ stats });
|
|
675
707
|
} catch (error) {
|
|
676
708
|
res.status(500).json({ error: error.message });
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* @module plugins/admin-panel/core/registry
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* Extension types
|
|
9
11
|
*/
|
|
@@ -46,6 +48,7 @@ class AdminRegistry {
|
|
|
46
48
|
|
|
47
49
|
// Client components: JS code strings for custom page rendering
|
|
48
50
|
this.clientComponents = new Map();
|
|
51
|
+
this.clientComponentFiles = new Map();
|
|
49
52
|
|
|
50
53
|
// Custom scripts & styles for Admin Panel SSR rendering
|
|
51
54
|
this.scripts = [];
|
|
@@ -59,6 +62,8 @@ class AdminRegistry {
|
|
|
59
62
|
afterUpdate: [],
|
|
60
63
|
beforeDelete: [],
|
|
61
64
|
afterDelete: [],
|
|
65
|
+
beforeRestore: [],
|
|
66
|
+
afterRestore: [],
|
|
62
67
|
onRecordView: [],
|
|
63
68
|
onDashboardLoad: [],
|
|
64
69
|
};
|
|
@@ -66,6 +71,7 @@ class AdminRegistry {
|
|
|
66
71
|
// Settings
|
|
67
72
|
this.settings = {
|
|
68
73
|
title: 'Admin Panel',
|
|
74
|
+
titleTemplate: '%s | %b',
|
|
69
75
|
logo: null,
|
|
70
76
|
primaryColor: '#3B82F6',
|
|
71
77
|
perPage: 20,
|
|
@@ -335,17 +341,41 @@ class AdminRegistry {
|
|
|
335
341
|
* @param {string} pageId - Page ID (must match a registered page)
|
|
336
342
|
* @param {string} jsCode - JavaScript code string that defines the component.
|
|
337
343
|
* The code should assign a Mithril component to window.__customPages[pageId].
|
|
344
|
+
* @param {string} [filePath=null] - Absolute file path for dev mode live reloading
|
|
338
345
|
*/
|
|
339
|
-
registerClientComponent(pageId, jsCode) {
|
|
346
|
+
registerClientComponent(pageId, jsCode, filePath = null) {
|
|
340
347
|
this.clientComponents.set(pageId, jsCode);
|
|
348
|
+
if (filePath) {
|
|
349
|
+
this.clientComponentFiles.set(pageId, filePath);
|
|
350
|
+
}
|
|
341
351
|
return this;
|
|
342
352
|
}
|
|
343
353
|
|
|
344
354
|
/**
|
|
345
|
-
* Get all client component code strings concatenated
|
|
355
|
+
* Get all client component code strings concatenated.
|
|
356
|
+
* In development mode (NODE_ENV !== 'production'), dynamically re-reads modified
|
|
357
|
+
* component files from disk to eliminate manual server restarts.
|
|
346
358
|
*/
|
|
347
359
|
getClientComponents() {
|
|
348
|
-
|
|
360
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
361
|
+
const parts = [];
|
|
362
|
+
for (const [pageId, code] of this.clientComponents.entries()) {
|
|
363
|
+
const filePath = this.clientComponentFiles.get(pageId);
|
|
364
|
+
if (isDev && filePath) {
|
|
365
|
+
try {
|
|
366
|
+
if (fs.existsSync(filePath)) {
|
|
367
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
368
|
+
const { formatClientComponent } = require('./admin-module');
|
|
369
|
+
parts.push(formatClientComponent(pageId, raw));
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
} catch (e) {
|
|
373
|
+
console.error(`[Admin Panel] Failed to reload component for "${pageId}":`, e);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
parts.push(code || '');
|
|
377
|
+
}
|
|
378
|
+
return parts.filter(Boolean).join('\n');
|
|
349
379
|
}
|
|
350
380
|
|
|
351
381
|
/**
|
|
@@ -382,6 +412,44 @@ class AdminRegistry {
|
|
|
382
412
|
return this;
|
|
383
413
|
}
|
|
384
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Set base admin panel title
|
|
417
|
+
* @param {string} title - Base title (e.g., 'Acme Admin')
|
|
418
|
+
*/
|
|
419
|
+
setTitle(title) {
|
|
420
|
+
if (typeof title === 'string') {
|
|
421
|
+
this.settings.title = title;
|
|
422
|
+
}
|
|
423
|
+
return this;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Get base admin panel title
|
|
428
|
+
* @returns {string}
|
|
429
|
+
*/
|
|
430
|
+
getTitle() {
|
|
431
|
+
return this.settings.title;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Set admin page title template
|
|
436
|
+
* @param {string} template - Title template (e.g., '%s | %b', '%s — %b', '%s')
|
|
437
|
+
*/
|
|
438
|
+
setTitleTemplate(template) {
|
|
439
|
+
if (typeof template === 'string') {
|
|
440
|
+
this.settings.titleTemplate = template;
|
|
441
|
+
}
|
|
442
|
+
return this;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Get admin page title template
|
|
447
|
+
* @returns {string}
|
|
448
|
+
*/
|
|
449
|
+
getTitleTemplate() {
|
|
450
|
+
return this.settings.titleTemplate;
|
|
451
|
+
}
|
|
452
|
+
|
|
385
453
|
/**
|
|
386
454
|
* Enable user management
|
|
387
455
|
* @param {Object} config - User management configuration
|
|
@@ -471,19 +539,30 @@ class AdminRegistry {
|
|
|
471
539
|
toClientConfig() {
|
|
472
540
|
return {
|
|
473
541
|
settings: this.settings,
|
|
474
|
-
pages: this.getPages().map(p =>
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
542
|
+
pages: this.getPages().map(p => {
|
|
543
|
+
let html = p.html;
|
|
544
|
+
if (p.htmlFile && process.env.NODE_ENV !== 'production') {
|
|
545
|
+
try {
|
|
546
|
+
if (fs.existsSync(p.htmlFile)) {
|
|
547
|
+
html = fs.readFileSync(p.htmlFile, 'utf8');
|
|
548
|
+
}
|
|
549
|
+
} catch (e) {}
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
id: p.id,
|
|
553
|
+
title: p.title,
|
|
554
|
+
documentTitle: p.documentTitle,
|
|
555
|
+
path: p.path,
|
|
556
|
+
icon: p.icon,
|
|
557
|
+
description: p.description,
|
|
558
|
+
permission: p.permission,
|
|
559
|
+
layout: p.layout !== undefined ? p.layout : true,
|
|
560
|
+
iframe: p.iframe,
|
|
561
|
+
url: p.url || p.iframeUrl,
|
|
562
|
+
html,
|
|
563
|
+
hasClientComponent: this.clientComponents.has(p.id),
|
|
564
|
+
};
|
|
565
|
+
}),
|
|
487
566
|
|
|
488
567
|
widgets: this.getWidgets().map(w => ({
|
|
489
568
|
id: w.id,
|
|
@@ -527,6 +606,7 @@ class AdminRegistry {
|
|
|
527
606
|
this.menuGroups.clear();
|
|
528
607
|
this.fieldRenderers.clear();
|
|
529
608
|
this.clientComponents.clear();
|
|
609
|
+
this.clientComponentFiles.clear();
|
|
530
610
|
this.scripts = [];
|
|
531
611
|
this.styles = [];
|
|
532
612
|
Object.keys(this.hooks).forEach(k => this.hooks[k] = []);
|
|
@@ -43,6 +43,8 @@ function adminPanelPlugin(options = {}) {
|
|
|
43
43
|
const {
|
|
44
44
|
path: adminPath = '/_admin',
|
|
45
45
|
enabled = true,
|
|
46
|
+
title,
|
|
47
|
+
titleTemplate,
|
|
46
48
|
sessionSecret,
|
|
47
49
|
db,
|
|
48
50
|
auth,
|
|
@@ -52,6 +54,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
52
54
|
uploadUrl: uploadUrlOption,
|
|
53
55
|
richTextSanitize = true,
|
|
54
56
|
dataExchange: dataExchangeOption = true,
|
|
57
|
+
loginRateLimit,
|
|
55
58
|
} = options;
|
|
56
59
|
|
|
57
60
|
const adminUsersConfig = typeof adminUsersOption === 'object' ? adminUsersOption : { enabled: adminUsersOption !== false };
|
|
@@ -63,6 +66,12 @@ function adminPanelPlugin(options = {}) {
|
|
|
63
66
|
|
|
64
67
|
// Create fresh registry for this plugin instance
|
|
65
68
|
const registry = new AdminRegistry();
|
|
69
|
+
if (title) {
|
|
70
|
+
registry.setTitle(title);
|
|
71
|
+
}
|
|
72
|
+
if (titleTemplate) {
|
|
73
|
+
registry.setTitleTemplate(titleTemplate);
|
|
74
|
+
}
|
|
66
75
|
|
|
67
76
|
// Dependencies are now in main package
|
|
68
77
|
const session = require('express-session');
|
|
@@ -144,8 +153,40 @@ function adminPanelPlugin(options = {}) {
|
|
|
144
153
|
registerStyle(style) {
|
|
145
154
|
return registry.registerStyle(style);
|
|
146
155
|
},
|
|
156
|
+
registerHook(hookName, callback) {
|
|
157
|
+
return registry.registerHook(hookName, callback);
|
|
158
|
+
},
|
|
159
|
+
runHooks(hookName, context) {
|
|
160
|
+
return registry.runHooks(hookName, context);
|
|
161
|
+
},
|
|
162
|
+
registerAction(id, config) {
|
|
163
|
+
return registry.registerAction(id, config);
|
|
164
|
+
},
|
|
165
|
+
registerBulkAction(id, config) {
|
|
166
|
+
return registry.registerBulkAction(id, config);
|
|
167
|
+
},
|
|
168
|
+
setTitle(title) {
|
|
169
|
+
registry.setTitle(title);
|
|
170
|
+
return this;
|
|
171
|
+
},
|
|
172
|
+
getTitle() {
|
|
173
|
+
return registry.getTitle();
|
|
174
|
+
},
|
|
175
|
+
setTitleTemplate(template) {
|
|
176
|
+
registry.setTitleTemplate(template);
|
|
177
|
+
return this;
|
|
178
|
+
},
|
|
179
|
+
getTitleTemplate() {
|
|
180
|
+
return registry.getTitleTemplate();
|
|
181
|
+
},
|
|
182
|
+
resetLoginAttempts(ip) {
|
|
183
|
+
if (this._apiHandlers && typeof this._apiHandlers.resetLoginAttempts === 'function') {
|
|
184
|
+
return this._apiHandlers.resetLoginAttempts(ip);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
147
187
|
|
|
148
188
|
_ctx: null,
|
|
189
|
+
_apiHandlers: null,
|
|
149
190
|
},
|
|
150
191
|
|
|
151
192
|
/**
|
|
@@ -275,15 +316,68 @@ function adminPanelPlugin(options = {}) {
|
|
|
275
316
|
});
|
|
276
317
|
}
|
|
277
318
|
|
|
319
|
+
// Auto-register model-level declared actions and bulkActions
|
|
320
|
+
const { getAllModels: getFrameworkModels } = require('../../core/orm/model');
|
|
321
|
+
const modelMap = new Map();
|
|
322
|
+
if (getFrameworkModels) {
|
|
323
|
+
for (const [name, model] of getFrameworkModels()) {
|
|
324
|
+
modelMap.set(name, model);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (db.getAllModels) {
|
|
328
|
+
const dbModels = db.getAllModels();
|
|
329
|
+
const list = Array.isArray(dbModels) ? dbModels : Array.from(dbModels.values());
|
|
330
|
+
for (const model of list) {
|
|
331
|
+
modelMap.set(model.name, model);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const appModelList = Array.from(modelMap.values());
|
|
335
|
+
for (const model of appModelList) {
|
|
336
|
+
if (model.admin?.actions && Array.isArray(model.admin.actions)) {
|
|
337
|
+
for (const act of model.admin.actions) {
|
|
338
|
+
if (act.id && act.label && act.handler && !registry.actions.has(act.id)) {
|
|
339
|
+
registry.registerAction(act.id, {
|
|
340
|
+
models: [model.name],
|
|
341
|
+
...act,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (model.admin?.bulkActions && Array.isArray(model.admin.bulkActions)) {
|
|
347
|
+
for (const bAct of model.admin.bulkActions) {
|
|
348
|
+
if (bAct.id && bAct.label && bAct.handler && !registry.bulkActions.has(bAct.id)) {
|
|
349
|
+
registry.registerBulkAction(bAct.id, {
|
|
350
|
+
models: [model.name],
|
|
351
|
+
...bAct,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (model.admin?.hooks && typeof model.admin.hooks === 'object') {
|
|
357
|
+
for (const [hookName, fn] of Object.entries(model.admin.hooks)) {
|
|
358
|
+
if (typeof fn === 'function') {
|
|
359
|
+
registry.registerHook(hookName, async (ctx) => {
|
|
360
|
+
if (!ctx.model || ctx.model === model.name) {
|
|
361
|
+
await fn(ctx);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
278
369
|
// Create API handlers
|
|
279
370
|
const apiHandlers = createApiHandlers({
|
|
280
371
|
path: adminPath,
|
|
281
372
|
db,
|
|
373
|
+
registry,
|
|
282
374
|
AdminUser,
|
|
283
375
|
hashPassword: (password, rounds) => bcrypt.hash(password, process.env.NODE_ENV === 'test' ? 4 : (rounds || 10)),
|
|
284
376
|
comparePassword: (password, hash) => bcrypt.compare(password, hash),
|
|
285
377
|
richTextSanitize,
|
|
378
|
+
loginRateLimit,
|
|
286
379
|
});
|
|
380
|
+
this.api._apiHandlers = apiHandlers;
|
|
287
381
|
|
|
288
382
|
const extensionHandlers = createExtensionApiHandlers({
|
|
289
383
|
registry,
|
|
@@ -330,7 +424,9 @@ function adminPanelPlugin(options = {}) {
|
|
|
330
424
|
ctx.addRoute('get', `${adminPath}/api/extensions/widgets/:widgetId/data`, requireAuth, extensionHandlers.widgetDataHandler);
|
|
331
425
|
ctx.addRoute('get', `${adminPath}/api/extensions/dashboard/stats`, requireAuth, extensionHandlers.dashboardStatsHandler);
|
|
332
426
|
ctx.addRoute('post', `${adminPath}/api/extensions/actions/:actionId/:model/:id`, requireAuth, extensionHandlers.actionHandler);
|
|
427
|
+
ctx.addRoute('post', `${adminPath}/api/actions/:actionId/:model/:id`, requireAuth, extensionHandlers.actionHandler);
|
|
333
428
|
ctx.addRoute('post', `${adminPath}/api/extensions/bulk-actions/:actionId/:model`, requireAuth, extensionHandlers.bulkActionHandler);
|
|
429
|
+
ctx.addRoute('post', `${adminPath}/api/bulk-actions/:actionId/:model`, requireAuth, extensionHandlers.bulkActionHandler);
|
|
334
430
|
ctx.addRoute('get', `${adminPath}/api/extensions/bulk-fields/:model`, requireAuth, extensionHandlers.bulkFieldsHandler);
|
|
335
431
|
ctx.addRoute('post', `${adminPath}/api/extensions/bulk-update/:model`, requireAuth, extensionHandlers.bulkUpdateFieldHandler);
|
|
336
432
|
ctx.addRoute('get', `${adminPath}/api/extensions/export/:model`, requireAuth, extensionHandlers.exportHandler);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory brute-force rate limiter for admin login.
|
|
5
|
+
* Zero external dependencies.
|
|
6
|
+
*/
|
|
7
|
+
class LoginRateLimiter {
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
if (options === false) {
|
|
10
|
+
this.enabled = false;
|
|
11
|
+
this.maxAttempts = 5;
|
|
12
|
+
this.windowMs = 15 * 60 * 1000;
|
|
13
|
+
} else {
|
|
14
|
+
const opts = typeof options === 'object' && options !== null ? options : {};
|
|
15
|
+
this.enabled = opts.enabled !== false;
|
|
16
|
+
this.maxAttempts = typeof opts.maxAttempts === 'number' && opts.maxAttempts > 0 ? opts.maxAttempts : 5;
|
|
17
|
+
this.windowMs = typeof opts.windowMs === 'number' && opts.windowMs > 0 ? opts.windowMs : (15 * 60 * 1000);
|
|
18
|
+
}
|
|
19
|
+
this.attempts = new Map();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
getClientIp(req) {
|
|
23
|
+
if (!req) return '127.0.0.1';
|
|
24
|
+
if (req.ip) return String(req.ip);
|
|
25
|
+
if (req.headers && req.headers['x-forwarded-for']) {
|
|
26
|
+
const forwarded = String(req.headers['x-forwarded-for']).split(',')[0].trim();
|
|
27
|
+
if (forwarded) return forwarded;
|
|
28
|
+
}
|
|
29
|
+
if (req.connection && req.connection.remoteAddress) {
|
|
30
|
+
return String(req.connection.remoteAddress);
|
|
31
|
+
}
|
|
32
|
+
if (req.socket && req.socket.remoteAddress) {
|
|
33
|
+
return String(req.socket.remoteAddress);
|
|
34
|
+
}
|
|
35
|
+
return '127.0.0.1';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
isBlocked(req) {
|
|
39
|
+
if (!this.enabled) return { blocked: false, remaining: Infinity, count: 0 };
|
|
40
|
+
const ip = this.getClientIp(req);
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const entry = this.attempts.get(ip);
|
|
43
|
+
if (!entry) return { blocked: false, remaining: this.maxAttempts, count: 0 };
|
|
44
|
+
|
|
45
|
+
if (now >= entry.resetAt) {
|
|
46
|
+
this.attempts.delete(ip);
|
|
47
|
+
return { blocked: false, remaining: this.maxAttempts, count: 0 };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (entry.count >= this.maxAttempts) {
|
|
51
|
+
const remainingMs = Math.max(0, entry.resetAt - now);
|
|
52
|
+
const waitMinutes = Math.max(1, Math.ceil(remainingMs / 60000));
|
|
53
|
+
const retryAfter = Math.max(1, Math.ceil(remainingMs / 1000));
|
|
54
|
+
return {
|
|
55
|
+
blocked: true,
|
|
56
|
+
remaining: 0,
|
|
57
|
+
count: entry.count,
|
|
58
|
+
waitMinutes,
|
|
59
|
+
retryAfter,
|
|
60
|
+
message: `Too many failed login attempts. Please try again in ${waitMinutes} ${waitMinutes === 1 ? 'minute' : 'minutes'}.`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
blocked: false,
|
|
66
|
+
remaining: Math.max(0, this.maxAttempts - entry.count),
|
|
67
|
+
count: entry.count,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
recordFailure(req) {
|
|
72
|
+
if (!this.enabled) return { blocked: false, remaining: Infinity, count: 0 };
|
|
73
|
+
const ip = this.getClientIp(req);
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const entry = this.attempts.get(ip);
|
|
76
|
+
|
|
77
|
+
if (!entry || now >= entry.resetAt) {
|
|
78
|
+
this.attempts.set(ip, {
|
|
79
|
+
count: 1,
|
|
80
|
+
resetAt: now + this.windowMs,
|
|
81
|
+
});
|
|
82
|
+
} else {
|
|
83
|
+
entry.count += 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (this.attempts.size > 500) {
|
|
87
|
+
this.cleanup();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return this.isBlocked(req);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
recordSuccess(req) {
|
|
94
|
+
if (!this.enabled) return;
|
|
95
|
+
const ip = this.getClientIp(req);
|
|
96
|
+
this.attempts.delete(ip);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
reset(ip) {
|
|
100
|
+
if (ip) {
|
|
101
|
+
this.attempts.delete(ip);
|
|
102
|
+
} else {
|
|
103
|
+
this.attempts.clear();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
cleanup() {
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
for (const [ip, entry] of this.attempts.entries()) {
|
|
110
|
+
if (now >= entry.resetAt) {
|
|
111
|
+
this.attempts.delete(ip);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
LoginRateLimiter,
|
|
119
|
+
};
|
|
@@ -497,7 +497,16 @@ const AdminUsersPage = {
|
|
|
497
497
|
};
|
|
498
498
|
|
|
499
499
|
vnode.state.deleteAdmin = async function(admin) {
|
|
500
|
-
|
|
500
|
+
var confirmed = typeof confirmAction === 'function'
|
|
501
|
+
? await confirmAction({
|
|
502
|
+
title: 'Delete Administrator',
|
|
503
|
+
message: 'Are you sure you want to delete administrator "' + (admin.name || admin.email) + '"? This action cannot be undone.',
|
|
504
|
+
danger: true,
|
|
505
|
+
confirmText: 'Delete'
|
|
506
|
+
})
|
|
507
|
+
: confirm('Are you sure you want to delete administrator "' + (admin.name || admin.email) + '"? This action cannot be undone.');
|
|
508
|
+
|
|
509
|
+
if (!confirmed) {
|
|
501
510
|
return;
|
|
502
511
|
}
|
|
503
512
|
vnode.state.loading = true;
|
|
@@ -506,9 +515,15 @@ const AdminUsersPage = {
|
|
|
506
515
|
try {
|
|
507
516
|
await api.delete('/admins/' + encodeURIComponent(admin.id));
|
|
508
517
|
vnode.state.success = 'Administrator deleted successfully';
|
|
518
|
+
if (typeof notifySuccess === 'function') {
|
|
519
|
+
notifySuccess('Administrator deleted successfully');
|
|
520
|
+
}
|
|
509
521
|
await vnode.state.loadAdmins();
|
|
510
522
|
} catch (err) {
|
|
511
523
|
vnode.state.error = err.message || 'Failed to delete administrator';
|
|
524
|
+
if (typeof notifyError === 'function') {
|
|
525
|
+
notifyError(vnode.state.error);
|
|
526
|
+
}
|
|
512
527
|
vnode.state.loading = false;
|
|
513
528
|
m.redraw();
|
|
514
529
|
}
|