webspresso 0.0.91 → 0.0.94
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/README.md +103 -2864
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/polar-migrate.js +50 -0
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +8 -2
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/tokens.js +16 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +17 -5
- package/core/compression/compression-stream.js +1 -1
- package/core/compression/middleware.js +9 -0
- package/core/compression/negotiate.js +23 -11
- package/core/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/cache/layer.js +1 -1
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/index.js +42 -3
- package/core/orm/model.js +11 -2
- package/core/orm/transaction.js +3 -1
- package/core/queue/adapters/database.js +46 -13
- package/core/queue/adapters/memory.js +21 -0
- package/core/queue/adapters/redis.js +21 -0
- package/core/queue/manager.js +46 -20
- package/core/ssr/stream.js +58 -14
- package/core/validation/index.js +70 -0
- package/index.d.ts +99 -5
- package/index.js +7 -1
- package/package.json +9 -1
- package/plugins/admin-panel/auth.js +10 -0
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
- package/plugins/admin-panel/index.js +65 -6
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/content/index.js +5 -3
- package/plugins/data-exchange/export-xlsx.js +28 -6
- package/plugins/data-exchange/import.js +120 -58
- package/plugins/data-exchange/index.js +5 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/index.js +6 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +156 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- package/plugins/polar/.env.example +12 -0
- package/plugins/polar/CHANGELOG.md +38 -0
- package/plugins/polar/README.md +158 -0
- package/plugins/polar/index.js +144 -0
- package/plugins/polar/src/api.js +54 -0
- package/plugins/polar/src/checkout.js +111 -0
- package/plugins/polar/src/config.js +88 -0
- package/plugins/polar/src/env.schema.js +36 -0
- package/plugins/polar/src/middleware.js +37 -0
- package/plugins/polar/src/migration.js +58 -0
- package/plugins/polar/src/nunjucks-filters.js +56 -0
- package/plugins/polar/src/portal.js +51 -0
- package/plugins/polar/src/rate-limit.js +70 -0
- package/plugins/polar/src/routes.js +212 -0
- package/plugins/polar/src/sync.js +138 -0
- package/plugins/polar/src/urls.js +117 -0
- package/plugins/polar/src/user-resolver.js +195 -0
- package/plugins/polar/src/webhooks.js +248 -0
- package/src/file-router.js +203 -125
- package/src/helpers.js +60 -13
- package/src/njk-frontmatter.js +24 -14
- package/src/server.js +30 -12
- package/src/services/discovery.js +2 -1
- package/src/services/registry.js +2 -0
- package/src/services/validator.js +2 -53
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +7 -4
package/index.d.ts
CHANGED
|
@@ -34,7 +34,21 @@ export interface CreateAppOptions {
|
|
|
34
34
|
version?: string;
|
|
35
35
|
manifestPath?: string;
|
|
36
36
|
prefix?: string;
|
|
37
|
-
|
|
37
|
+
publicDir?: string;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* If truthy, route load() return values for stylesheets and scripts are promoted to pageHead
|
|
41
|
+
*/
|
|
42
|
+
pageAssets?:
|
|
43
|
+
| boolean
|
|
44
|
+
| {
|
|
45
|
+
enabled?: boolean;
|
|
46
|
+
stylesheets?: boolean;
|
|
47
|
+
scripts?: boolean;
|
|
48
|
+
};
|
|
49
|
+
/** Trust proxy configuration (default: 1) */
|
|
50
|
+
trustProxy?: boolean | number | string;
|
|
51
|
+
/**
|
|
38
52
|
* Custom 404 / 500 / 503 handlers. File-based route errors are forwarded with `next(err)` and hit `serverError` / `timeout`.
|
|
39
53
|
* When `serverError` / `timeout` is a template path, it is not used for paths under `/api` (default JSON instead).
|
|
40
54
|
*/
|
|
@@ -56,6 +70,7 @@ export interface CreateAppOptions {
|
|
|
56
70
|
port?: number;
|
|
57
71
|
shutdown?: ShutdownOptions;
|
|
58
72
|
compression?: boolean | CompressionOptions;
|
|
73
|
+
trustProxy?: boolean | number | string;
|
|
59
74
|
[key: string]: unknown;
|
|
60
75
|
};
|
|
61
76
|
/** Opt-in Alpine / swup assets under `/__webspresso/client-runtime/*`. Env: WEBSPRESSO_ALPINE, WEBSPRESSO_SWUP. */
|
|
@@ -76,7 +91,14 @@ export interface ServiceContext {
|
|
|
76
91
|
req?: Request;
|
|
77
92
|
res?: Response;
|
|
78
93
|
db?: DatabaseInstance | null;
|
|
79
|
-
|
|
94
|
+
fsy?: Record<string, unknown>;
|
|
95
|
+
locale?: string;
|
|
96
|
+
t?: unknown;
|
|
97
|
+
service?: {
|
|
98
|
+
<T = unknown>(name: string, input?: unknown, options?: unknown): Promise<T>;
|
|
99
|
+
invalidate?: (name: string, input?: unknown, ctx?: unknown) => boolean;
|
|
100
|
+
clearCache?: (name?: string) => boolean;
|
|
101
|
+
};
|
|
80
102
|
[key: string]: unknown;
|
|
81
103
|
}
|
|
82
104
|
|
|
@@ -89,9 +111,16 @@ export interface ServiceDefinition<TInput = unknown, TOutput = unknown> {
|
|
|
89
111
|
schema?: import('zod').ZodType<any> | Record<string, unknown> | ((input: unknown) => unknown) | null;
|
|
90
112
|
handler: ServiceHandler<TInput, TOutput>;
|
|
91
113
|
metadata?: Record<string, unknown>;
|
|
92
|
-
auth?: boolean | string | string[];
|
|
93
|
-
cache?:
|
|
94
|
-
|
|
114
|
+
auth?: boolean | string | string[] | ((user: unknown, ctx: ServiceContext) => boolean | Promise<boolean>);
|
|
115
|
+
cache?:
|
|
116
|
+
| boolean
|
|
117
|
+
| string
|
|
118
|
+
| number
|
|
119
|
+
| {
|
|
120
|
+
ttl?: string | number;
|
|
121
|
+
key?: string | ((input: unknown, ctx: ServiceContext) => string);
|
|
122
|
+
};
|
|
123
|
+
timeout?: number | string;
|
|
95
124
|
transaction?: boolean;
|
|
96
125
|
[key: string]: unknown;
|
|
97
126
|
}
|
|
@@ -274,6 +303,7 @@ export interface WebspressoApplication extends Application {
|
|
|
274
303
|
server?: unknown;
|
|
275
304
|
readonly isShuttingDown: boolean;
|
|
276
305
|
shutdownManager: ShutdownManager;
|
|
306
|
+
serviceRegistry: ServiceRegistry;
|
|
277
307
|
onShutdown(fn: () => Promise<void> | void): this;
|
|
278
308
|
close(reason?: string): Promise<void>;
|
|
279
309
|
enableShutdownHooks(): this;
|
|
@@ -1583,4 +1613,68 @@ export const ssr: {
|
|
|
1583
1613
|
renderStream: typeof renderStream;
|
|
1584
1614
|
};
|
|
1585
1615
|
|
|
1616
|
+
// --- Model Context Protocol (MCP) Plugin ---
|
|
1617
|
+
|
|
1618
|
+
export interface McpPluginOptions {
|
|
1619
|
+
path?: string;
|
|
1620
|
+
enabled?: boolean;
|
|
1621
|
+
auth?: {
|
|
1622
|
+
token?: string;
|
|
1623
|
+
localhostOnly?: boolean;
|
|
1624
|
+
verify?: (req: import('express').Request) => boolean | Promise<boolean>;
|
|
1625
|
+
};
|
|
1626
|
+
services?: boolean | {
|
|
1627
|
+
include?: string[];
|
|
1628
|
+
exclude?: string[];
|
|
1629
|
+
readOnly?: boolean;
|
|
1630
|
+
role?: string | string[];
|
|
1631
|
+
};
|
|
1632
|
+
orm?: boolean | {
|
|
1633
|
+
readOnly?: boolean;
|
|
1634
|
+
models?: string[];
|
|
1635
|
+
};
|
|
1636
|
+
system?: boolean;
|
|
1637
|
+
tools?: Array<{
|
|
1638
|
+
name: string;
|
|
1639
|
+
description?: string;
|
|
1640
|
+
inputSchema?: Record<string, unknown>;
|
|
1641
|
+
handler: (args: any, ctx: any) => Promise<any> | any;
|
|
1642
|
+
}>;
|
|
1643
|
+
resources?: Array<{
|
|
1644
|
+
uri?: string;
|
|
1645
|
+
uriTemplate?: string;
|
|
1646
|
+
name?: string;
|
|
1647
|
+
description?: string;
|
|
1648
|
+
mimeType?: string;
|
|
1649
|
+
handler: (uri: string, paramsOrCtx: any, ctx?: any) => Promise<any> | any;
|
|
1650
|
+
}>;
|
|
1651
|
+
prompts?: Array<{
|
|
1652
|
+
name: string;
|
|
1653
|
+
description?: string;
|
|
1654
|
+
arguments?: Array<{ name: string; description?: string; required?: boolean }>;
|
|
1655
|
+
handler: (args: any, ctx: any) => Promise<any> | any;
|
|
1656
|
+
}>;
|
|
1657
|
+
discovery?: boolean;
|
|
1658
|
+
name?: string;
|
|
1659
|
+
version?: string;
|
|
1660
|
+
instructions?: string;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
export class McpServer {
|
|
1664
|
+
name: string;
|
|
1665
|
+
version: string;
|
|
1666
|
+
instructions: string;
|
|
1667
|
+
constructor(options?: { name?: string; version?: string; instructions?: string; context?: Record<string, unknown> });
|
|
1668
|
+
registerTool(toolDef: any): this;
|
|
1669
|
+
registerTools(toolDefs: any[]): this;
|
|
1670
|
+
unregisterTool(name: string): boolean;
|
|
1671
|
+
registerResource(resourceDef: any): this;
|
|
1672
|
+
registerResourceTemplate(templateDef: any): this;
|
|
1673
|
+
registerPrompt(promptDef: any): this;
|
|
1674
|
+
handleMessage(message: any, callContext?: any): Promise<any>;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
export function mcpPlugin(options?: McpPluginOptions): WebspressoPlugin;
|
|
1678
|
+
export function createMcpServer(options?: McpPluginOptions): McpServer;
|
|
1679
|
+
|
|
1586
1680
|
|
package/index.js
CHANGED
|
@@ -55,7 +55,7 @@ const {
|
|
|
55
55
|
// ORM exports (lazy loaded)
|
|
56
56
|
const orm = require('./core/orm');
|
|
57
57
|
|
|
58
|
-
/**
|
|
58
|
+
/** Standalone experimental application kernel (event bus, plugin shell, view resolver, flows). Distinct from framework SSR `createApp`. */
|
|
59
59
|
const kernel = require('./core/kernel');
|
|
60
60
|
|
|
61
61
|
// Built-in plugins
|
|
@@ -87,6 +87,9 @@ const {
|
|
|
87
87
|
socketIo,
|
|
88
88
|
redis,
|
|
89
89
|
createRedisAdapter,
|
|
90
|
+
mcpPlugin,
|
|
91
|
+
createMcpServer,
|
|
92
|
+
McpServer,
|
|
90
93
|
} = require('./plugins');
|
|
91
94
|
|
|
92
95
|
const content = require('./core/content');
|
|
@@ -237,6 +240,9 @@ module.exports = {
|
|
|
237
240
|
queue: require('./core/queue'),
|
|
238
241
|
createQueueManager: require('./core/queue').createQueueManager,
|
|
239
242
|
QueueManager: require('./core/queue').QueueManager,
|
|
243
|
+
mcpPlugin,
|
|
244
|
+
createMcpServer,
|
|
245
|
+
McpServer,
|
|
240
246
|
|
|
241
247
|
// Schema-driven CMS core (framework-agnostic)
|
|
242
248
|
content,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.94",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"bench:ci:local": "npm run bench:baseline && npm run bench:ci",
|
|
43
43
|
"check:types": "tsc --project tests/ts-smoke/tsconfig.json",
|
|
44
44
|
"rebuild:native": "npm rebuild better-sqlite3 bcrypt sharp",
|
|
45
|
+
"docs:dev": "docboot dev ./docs",
|
|
46
|
+
"docs:build": "docboot build ./docs",
|
|
47
|
+
"docs:serve": "docboot serve dist",
|
|
48
|
+
"docs:doctor": "docboot doctor ./docs",
|
|
45
49
|
"release": "release-it"
|
|
46
50
|
},
|
|
47
51
|
"keywords": [
|
|
@@ -134,6 +138,7 @@
|
|
|
134
138
|
"@vitest/coverage-v8": "^3.0.0",
|
|
135
139
|
"better-sqlite3": "^11.10.0",
|
|
136
140
|
"chokidar": "^3.5.3",
|
|
141
|
+
"docboot": "^0.3.0",
|
|
137
142
|
"dotenv": "^16.3.1",
|
|
138
143
|
"express-rate-limit": "^8.4.1",
|
|
139
144
|
"release-it": "^20.2.1",
|
|
@@ -149,5 +154,8 @@
|
|
|
149
154
|
"tar": "^7.5.11",
|
|
150
155
|
"undici": "^6.24.0",
|
|
151
156
|
"uuid": "^11.1.1"
|
|
157
|
+
},
|
|
158
|
+
"allowScripts": {
|
|
159
|
+
"better-sqlite3@11.10.0": true
|
|
152
160
|
}
|
|
153
161
|
}
|
|
@@ -68,6 +68,9 @@ async function verifyPassword(password, hash, compare) {
|
|
|
68
68
|
return await compare(password, hash);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// Precomputed dummy bcrypt hash (cost factor 10) to mitigate timing attacks / user enumeration
|
|
72
|
+
const DUMMY_HASH = '$2b$10$wKzS8Vp0qXn8mY2bQv5eUu6R.kZbN8vA8Y7o7u4d1w2x3y4z5a6b7';
|
|
73
|
+
|
|
71
74
|
/**
|
|
72
75
|
* Login user and set session
|
|
73
76
|
* @param {Object} adminUserRepo - AdminUser repository
|
|
@@ -81,6 +84,13 @@ async function login(adminUserRepo, email, password, compare) {
|
|
|
81
84
|
const user = await adminUserRepo.query().where('email', email).first();
|
|
82
85
|
|
|
83
86
|
if (!user || !user.active) {
|
|
87
|
+
if (typeof compare === 'function') {
|
|
88
|
+
try {
|
|
89
|
+
await Promise.resolve(compare(password, DUMMY_HASH));
|
|
90
|
+
} catch {
|
|
91
|
+
// Ignore timing dummy comparison errors
|
|
92
|
+
}
|
|
93
|
+
}
|
|
84
94
|
return null;
|
|
85
95
|
}
|
|
86
96
|
|
|
@@ -59,10 +59,15 @@ async function downloadDataExchangeXlsx(modelName, payload) {
|
|
|
59
59
|
const blob = await res.blob();
|
|
60
60
|
var url = URL.createObjectURL(blob);
|
|
61
61
|
var a = document.createElement('a');
|
|
62
|
+
a.style.display = 'none';
|
|
62
63
|
a.href = url;
|
|
63
64
|
a.download = modelName + '-export.xlsx';
|
|
65
|
+
document.body.appendChild(a);
|
|
64
66
|
a.click();
|
|
65
|
-
|
|
67
|
+
setTimeout(function () {
|
|
68
|
+
if (a.parentNode) a.parentNode.removeChild(a);
|
|
69
|
+
URL.revokeObjectURL(url);
|
|
70
|
+
}, 1000);
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
// Helper: Capitalize first letter of each word
|
|
@@ -396,10 +396,15 @@ function getFieldRenderer(col, modelMeta) {
|
|
|
396
396
|
const custom = window.__customFieldRenderers && window.__customFieldRenderers[col.customField.type];
|
|
397
397
|
if (custom) {
|
|
398
398
|
return (col, value, onChange, readonly, error) => {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
399
|
+
try {
|
|
400
|
+
if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
|
|
401
|
+
return m(custom.edit, { col, value, onChange, readonly, error });
|
|
402
|
+
} else if (typeof custom.edit === 'function') {
|
|
403
|
+
return custom.edit(value, onChange, col, readonly, error);
|
|
404
|
+
}
|
|
405
|
+
} catch (err) {
|
|
406
|
+
console.error('[Admin Panel] Error rendering custom field edit for "' + col.customField.type + '":', err);
|
|
407
|
+
return m('div.text-red-500.text-xs', 'Edit render error: ' + (err.message || String(err)));
|
|
403
408
|
}
|
|
404
409
|
};
|
|
405
410
|
}
|
|
@@ -409,10 +414,15 @@ function getFieldRenderer(col, modelMeta) {
|
|
|
409
414
|
if (col.type && window.__customFieldRenderers && window.__customFieldRenderers[col.type]) {
|
|
410
415
|
const custom = window.__customFieldRenderers[col.type];
|
|
411
416
|
return (col, value, onChange, readonly, error) => {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
417
|
+
try {
|
|
418
|
+
if (custom.edit && typeof custom.edit === 'object' && custom.edit.view) {
|
|
419
|
+
return m(custom.edit, { col, value, onChange, readonly, error });
|
|
420
|
+
} else if (typeof custom.edit === 'function') {
|
|
421
|
+
return custom.edit(value, onChange, col, readonly, error);
|
|
422
|
+
}
|
|
423
|
+
} catch (err) {
|
|
424
|
+
console.error('[Admin Panel] Error rendering custom field edit for "' + col.type + '":', err);
|
|
425
|
+
return m('div.text-red-500.text-xs', 'Edit render error: ' + (err.message || String(err)));
|
|
416
426
|
}
|
|
417
427
|
};
|
|
418
428
|
}
|
|
@@ -73,10 +73,15 @@ function formatCellValue(value, col, record = null) {
|
|
|
73
73
|
const customRendererType = (col && col.customField && col.customField.type) || (col && col.type);
|
|
74
74
|
const custom = window.__customFieldRenderers && window.__customFieldRenderers[customRendererType];
|
|
75
75
|
if (custom && custom.display) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
try {
|
|
77
|
+
if (typeof custom.display === 'object' && custom.display.view) {
|
|
78
|
+
return m(custom.display, { value, col, record });
|
|
79
|
+
} else if (typeof custom.display === 'function') {
|
|
80
|
+
return custom.display(value, record);
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error('[Admin Panel] Error rendering custom field display for "' + customRendererType + '":', err);
|
|
84
|
+
return String(value);
|
|
80
85
|
}
|
|
81
86
|
}
|
|
82
87
|
|
|
@@ -325,10 +325,15 @@ const RecordList = {
|
|
|
325
325
|
const blob = new Blob([JSON.stringify(response.data, null, 2)], { type: 'application/json' });
|
|
326
326
|
const url = URL.createObjectURL(blob);
|
|
327
327
|
const a = document.createElement('a');
|
|
328
|
+
a.style.display = 'none';
|
|
328
329
|
a.href = url;
|
|
329
330
|
a.download = modelName + '-export.json';
|
|
331
|
+
document.body.appendChild(a);
|
|
330
332
|
a.click();
|
|
331
|
-
|
|
333
|
+
setTimeout(function () {
|
|
334
|
+
if (a.parentNode) a.parentNode.removeChild(a);
|
|
335
|
+
URL.revokeObjectURL(url);
|
|
336
|
+
}, 1000);
|
|
332
337
|
} catch (err) {
|
|
333
338
|
alert('Error: ' + err.message);
|
|
334
339
|
} finally {
|
|
@@ -351,15 +356,24 @@ const RecordList = {
|
|
|
351
356
|
const payload = state.selectAllMode
|
|
352
357
|
? { selectAll: true, filters: state.filters }
|
|
353
358
|
: { ids: Array.from(state.selectedRecords) };
|
|
359
|
+
if (!state.selectAllMode && (!payload.ids || payload.ids.length === 0)) {
|
|
360
|
+
alert('Please select at least one record to export');
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
354
363
|
const response = await api.post('/extensions/export?model=' + modelName + '&format=csv', payload);
|
|
355
364
|
// Download as file
|
|
356
365
|
const blob = new Blob([response.data], { type: 'text/csv' });
|
|
357
366
|
const url = URL.createObjectURL(blob);
|
|
358
367
|
const a = document.createElement('a');
|
|
368
|
+
a.style.display = 'none';
|
|
359
369
|
a.href = url;
|
|
360
370
|
a.download = modelName + '-export.csv';
|
|
371
|
+
document.body.appendChild(a);
|
|
361
372
|
a.click();
|
|
362
|
-
|
|
373
|
+
setTimeout(function () {
|
|
374
|
+
if (a.parentNode) a.parentNode.removeChild(a);
|
|
375
|
+
URL.revokeObjectURL(url);
|
|
376
|
+
}, 1000);
|
|
363
377
|
} catch (err) {
|
|
364
378
|
alert('Error: ' + err.message);
|
|
365
379
|
} finally {
|
|
@@ -382,6 +396,10 @@ const RecordList = {
|
|
|
382
396
|
const payload = state.selectAllMode
|
|
383
397
|
? { selectAll: true, filters: state.filters }
|
|
384
398
|
: { ids: Array.from(state.selectedRecords) };
|
|
399
|
+
if (!state.selectAllMode && (!payload.ids || payload.ids.length === 0)) {
|
|
400
|
+
alert('Please select at least one record to export');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
385
403
|
if (state.trashedView) payload.trashed = 'only';
|
|
386
404
|
await downloadDataExchangeXlsx(modelName, payload);
|
|
387
405
|
} catch (err) {
|
|
@@ -51,6 +51,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
51
51
|
configure,
|
|
52
52
|
uploadUrl: uploadUrlOption,
|
|
53
53
|
richTextSanitize = true,
|
|
54
|
+
dataExchange: dataExchangeOption = true,
|
|
54
55
|
} = options;
|
|
55
56
|
|
|
56
57
|
const adminUsersConfig = typeof adminUsersOption === 'object' ? adminUsersOption : { enabled: adminUsersOption !== false };
|
|
@@ -190,6 +191,17 @@ function adminPanelPlugin(options = {}) {
|
|
|
190
191
|
}));
|
|
191
192
|
ctx.app._webspressoSessionInitialized = true;
|
|
192
193
|
}
|
|
194
|
+
|
|
195
|
+
// Auto-register built-in exchange services if enabled and serviceRegistry exists
|
|
196
|
+
if (enabled && dataExchangeOption !== false && ctx.app && ctx.app.serviceRegistry) {
|
|
197
|
+
const { createExchangeServices } = require('../../src/services/builtins/exchange');
|
|
198
|
+
const exchangeServices = createExchangeServices({ db });
|
|
199
|
+
for (const [name, def] of Object.entries(exchangeServices)) {
|
|
200
|
+
if (!ctx.app.serviceRegistry.has(name)) {
|
|
201
|
+
ctx.app.serviceRegistry.register(name, def);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
193
205
|
},
|
|
194
206
|
|
|
195
207
|
/**
|
|
@@ -268,7 +280,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
268
280
|
path: adminPath,
|
|
269
281
|
db,
|
|
270
282
|
AdminUser,
|
|
271
|
-
hashPassword: (password, rounds) => bcrypt.hash(password, rounds),
|
|
283
|
+
hashPassword: (password, rounds) => bcrypt.hash(password, process.env.NODE_ENV === 'test' ? 4 : (rounds || 10)),
|
|
272
284
|
comparePassword: (password, hash) => bcrypt.compare(password, hash),
|
|
273
285
|
richTextSanitize,
|
|
274
286
|
});
|
|
@@ -287,7 +299,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
287
299
|
adminUsersHandlers = createAdminUsersApiHandlers({
|
|
288
300
|
db,
|
|
289
301
|
AdminUser,
|
|
290
|
-
hashPassword: (password, rounds) => bcrypt.hash(password, rounds),
|
|
302
|
+
hashPassword: (password, rounds) => bcrypt.hash(password, process.env.NODE_ENV === 'test' ? 4 : (rounds || 10)),
|
|
291
303
|
});
|
|
292
304
|
}
|
|
293
305
|
|
|
@@ -379,6 +391,49 @@ function adminPanelPlugin(options = {}) {
|
|
|
379
391
|
// Query API routes (auth required)
|
|
380
392
|
ctx.addRoute('get', `${adminPath}/api/models/:model/queries/:query`, requireAuth, apiHandlers.queryHandler);
|
|
381
393
|
|
|
394
|
+
// Data exchange (Excel export & CSV/XLSX import) routes
|
|
395
|
+
const dataExchangeConfig = typeof dataExchangeOption === 'object' ? dataExchangeOption : { enabled: dataExchangeOption !== false };
|
|
396
|
+
if (dataExchangeConfig.enabled !== false && !ctx.app?._dataExchangeRoutesRegistered) {
|
|
397
|
+
const { createExportXlsxHandler } = require('../data-exchange/export-xlsx');
|
|
398
|
+
const { createImportHandler } = require('../data-exchange/import');
|
|
399
|
+
const exportHandler = createExportXlsxHandler({ db, maxRows: dataExchangeConfig.maxRows || 10000 });
|
|
400
|
+
const importHandler = createImportHandler({
|
|
401
|
+
db,
|
|
402
|
+
maxRows: dataExchangeConfig.maxRows || 10000,
|
|
403
|
+
maxFileBytes: dataExchangeConfig.maxFileBytes || 10 * 1024 * 1024,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const base = `${adminPath}/api/data-exchange`;
|
|
407
|
+
ctx.addRoute('get', `${base}/export/:model`, requireAuth, exportHandler);
|
|
408
|
+
ctx.addRoute('post', `${base}/export/:model`, requireAuth, exportHandler);
|
|
409
|
+
ctx.addRoute('post', `${base}/import/:model`, requireAuth, importHandler);
|
|
410
|
+
|
|
411
|
+
if (!registry.bulkActions.has('export-xlsx')) {
|
|
412
|
+
registry.registerBulkAction('export-xlsx', {
|
|
413
|
+
label: 'Export as Excel',
|
|
414
|
+
icon: 'download',
|
|
415
|
+
color: 'blue',
|
|
416
|
+
models: '*',
|
|
417
|
+
confirm: false,
|
|
418
|
+
handler: async (records, modelName, { db: d }) => {
|
|
419
|
+
const { getModel } = require('../../core/orm/model');
|
|
420
|
+
const model = d.getModel ? d.getModel(modelName) : getModel(modelName);
|
|
421
|
+
const pk = model?.primaryKey || 'id';
|
|
422
|
+
const ids = records.map((r) => r[pk]).join(',');
|
|
423
|
+
return {
|
|
424
|
+
download: true,
|
|
425
|
+
url: `${adminPath}/api/data-exchange/export/${modelName}?ids=${encodeURIComponent(ids)}`,
|
|
426
|
+
filename: `${modelName}_export.xlsx`,
|
|
427
|
+
};
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (ctx.app) {
|
|
433
|
+
ctx.app._dataExchangeRoutesRegistered = true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
382
437
|
// ==========================================
|
|
383
438
|
// Static Vendor Assets (Offline Zero-CDN)
|
|
384
439
|
// ==========================================
|
|
@@ -476,10 +531,14 @@ function generateAdminPanelHtml(adminPath, registry) {
|
|
|
476
531
|
|
|
477
532
|
const settings = registry.settings;
|
|
478
533
|
const fieldRenderersCode = Array.from(registry.fieldRenderers.entries()).map(([type, r]) => {
|
|
479
|
-
return `
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
534
|
+
return `try {
|
|
535
|
+
window.__customFieldRenderers['${type}'] = {
|
|
536
|
+
display: ${r.display ? serializeJs(r.display) : 'null'},
|
|
537
|
+
edit: ${r.edit ? serializeJs(r.edit) : 'null'}
|
|
538
|
+
};
|
|
539
|
+
} catch (err) {
|
|
540
|
+
console.error('[Admin Panel] Failed to register custom field renderer "${type}":', err);
|
|
541
|
+
}`;
|
|
483
542
|
}).join('\n');
|
|
484
543
|
|
|
485
544
|
return `<!DOCTYPE html>
|
|
@@ -238,11 +238,18 @@ async function executeBulkAction(action, selectedIds, modelName) {
|
|
|
238
238
|
if (result.result?.download) {
|
|
239
239
|
// Trigger download
|
|
240
240
|
const link = document.createElement('a');
|
|
241
|
-
link.
|
|
241
|
+
link.style.display = 'none';
|
|
242
|
+
const rawUrl = result.result.url || '';
|
|
243
|
+
const adminPath = window.__ADMIN_PATH__ || '/_admin';
|
|
244
|
+
link.href = (rawUrl.startsWith('/') && !rawUrl.startsWith(adminPath))
|
|
245
|
+
? adminPath + rawUrl
|
|
246
|
+
: rawUrl;
|
|
242
247
|
link.download = result.result.filename || 'export';
|
|
243
248
|
document.body.appendChild(link);
|
|
244
249
|
link.click();
|
|
245
|
-
|
|
250
|
+
setTimeout(function () {
|
|
251
|
+
if (link.parentNode) link.parentNode.removeChild(link);
|
|
252
|
+
}, 1000);
|
|
246
253
|
return { success: true, message: 'Download started' };
|
|
247
254
|
}
|
|
248
255
|
|
|
@@ -384,7 +384,14 @@ const Widget = {
|
|
|
384
384
|
? m('div.flex.justify-center.py-8', m(Spinner))
|
|
385
385
|
: error
|
|
386
386
|
? m('div.text-red-500.text-sm', error)
|
|
387
|
-
: (
|
|
387
|
+
: (() => {
|
|
388
|
+
try {
|
|
389
|
+
return (WidgetRenderers[widget.id] || WidgetRenderers.default).render(data);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
console.error('[Admin Panel] Error rendering widget "' + widget.id + '":', err);
|
|
392
|
+
return m('div.text-red-500.text-xs', 'Widget render error: ' + (err.message || String(err)));
|
|
393
|
+
}
|
|
394
|
+
})(),
|
|
388
395
|
]),
|
|
389
396
|
]);
|
|
390
397
|
},
|
package/plugins/content/index.js
CHANGED
|
@@ -59,12 +59,14 @@ function contentPlugin(options = {}) {
|
|
|
59
59
|
if (!inlineEdit || !isAdminSession(req, adminPath)) {
|
|
60
60
|
return html;
|
|
61
61
|
}
|
|
62
|
+
const nonce = req.res?.locals?.cspNonce || req.res?.locals?.nonce || req.cspNonce || '';
|
|
63
|
+
const nonceAttr = nonce ? ` nonce="${nonce}"` : '';
|
|
62
64
|
const configScript =
|
|
63
|
-
|
|
65
|
+
`<script${nonceAttr}>window.__WS_CONTENT__=` +
|
|
64
66
|
JSON.stringify({ enabled: true, adminPath }) +
|
|
65
67
|
';</script>';
|
|
66
|
-
const styleTag =
|
|
67
|
-
const scriptTag =
|
|
68
|
+
const styleTag = `<style id="ws-content-inline-css"${nonceAttr}>` + INLINE_EDIT_CSS + '</style>';
|
|
69
|
+
const scriptTag = `<script id="ws-content-inline-edit"${nonceAttr}>` + INLINE_EDIT_JS + '</script>';
|
|
68
70
|
const bundle = configScript + styleTag + scriptTag;
|
|
69
71
|
if (html.includes('</body>')) {
|
|
70
72
|
return html.replace('</body>', bundle + '</body>');
|
|
@@ -7,6 +7,32 @@ const ExcelJS = require('exceljs');
|
|
|
7
7
|
const { sanitizeForOutput } = require('../../core/orm/utils');
|
|
8
8
|
const { resolveExportRecords } = require('./record-selection');
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* @param {import('../../core/orm/types').ModelDefinition} model
|
|
12
|
+
* @param {object[]} records
|
|
13
|
+
* @returns {Promise<Buffer>}
|
|
14
|
+
*/
|
|
15
|
+
function getModelExportColumns(model, cleanRecords = []) {
|
|
16
|
+
const hiddenSet = new Set(model.hidden || []);
|
|
17
|
+
const colSet = new Set();
|
|
18
|
+
if (model.columns && typeof model.columns.keys === 'function') {
|
|
19
|
+
for (const c of model.columns.keys()) {
|
|
20
|
+
if (!hiddenSet.has(c)) colSet.add(c);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (model.schema?.shape) {
|
|
24
|
+
for (const c of Object.keys(model.schema.shape)) {
|
|
25
|
+
if (!hiddenSet.has(c)) colSet.add(c);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (colSet.size === 0 && cleanRecords.length > 0) {
|
|
29
|
+
for (const c of Object.keys(cleanRecords[0])) {
|
|
30
|
+
if (!hiddenSet.has(c)) colSet.add(c);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return Array.from(colSet);
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
/**
|
|
11
37
|
* @param {import('../../core/orm/types').ModelDefinition} model
|
|
12
38
|
* @param {object[]} records
|
|
@@ -14,12 +40,7 @@ const { resolveExportRecords } = require('./record-selection');
|
|
|
14
40
|
*/
|
|
15
41
|
async function buildXlsxBuffer(model, records) {
|
|
16
42
|
const clean = sanitizeForOutput(records, model);
|
|
17
|
-
const
|
|
18
|
-
const columns = (model.columns && model.columns.size > 0)
|
|
19
|
-
? Array.from(model.columns.keys()).filter((c) => !hiddenSet.has(c))
|
|
20
|
-
: (model.schema?.shape
|
|
21
|
-
? Object.keys(model.schema.shape).filter((c) => !hiddenSet.has(c))
|
|
22
|
-
: Object.keys(clean[0] || {}));
|
|
43
|
+
const columns = getModelExportColumns(model, clean);
|
|
23
44
|
|
|
24
45
|
const wb = new ExcelJS.Workbook();
|
|
25
46
|
const ws = wb.addWorksheet('Export', {
|
|
@@ -87,4 +108,5 @@ function createExportXlsxHandler(opts) {
|
|
|
87
108
|
module.exports = {
|
|
88
109
|
buildXlsxBuffer,
|
|
89
110
|
createExportXlsxHandler,
|
|
111
|
+
getModelExportColumns,
|
|
90
112
|
};
|