webspresso 0.0.90 → 0.0.93
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 +98 -2509
- 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/seed.js +7 -1
- package/bin/commands/service.js +199 -0
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/middleware.js +9 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +63 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +310 -0
- package/core/compression/negotiate.js +165 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- 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/complexity.js +120 -0
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +83 -12
- package/core/orm/model.js +13 -2
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +317 -0
- package/core/queue/adapters/memory.js +176 -0
- package/core/queue/adapters/redis.js +357 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +413 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/core/validation/index.js +70 -0
- package/index.d.ts +924 -53
- package/index.js +110 -1
- package/package.json +30 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +57 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +155 -23
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/data-exchange/export-xlsx.js +28 -2
- package/plugins/data-exchange/import.js +55 -16
- package/plugins/data-exchange/index.js +19 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +33 -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 +139 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +366 -130
- package/src/helpers.js +20 -2
- package/src/njk-frontmatter.js +24 -14
- package/src/plugin-manager.js +124 -4
- package/src/server.js +332 -54
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +141 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +290 -0
- package/src/services/validator.js +230 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Basic Auth Plugin — HTTP Basic Authentication (RFC 7617) for Webspresso
|
|
3
|
+
* Zero external dependencies (uses native Node.js crypto for timing-safe equality)
|
|
4
|
+
* @module plugins/basic-auth
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Compare two strings in constant time to prevent timing attacks.
|
|
11
|
+
* Uses dummy buffer comparison if lengths differ to avoid leaking length via response timing.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} a
|
|
14
|
+
* @param {string} b
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
function safeCompare(a, b) {
|
|
18
|
+
if (typeof a !== 'string' || typeof b !== 'string') {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
23
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
24
|
+
|
|
25
|
+
if (bufA.length !== bufB.length) {
|
|
26
|
+
// Perform dummy timing-safe comparison with itself to preserve constant execution time
|
|
27
|
+
crypto.timingSafeEqual(bufA, bufA);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse an HTTP Authorization header for Basic credentials.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} header - e.g. "Basic dXNlcjpwYXNz"
|
|
38
|
+
* @returns {{ username: string, password: string } | null}
|
|
39
|
+
*/
|
|
40
|
+
function parseBasicAuthHeader(header) {
|
|
41
|
+
if (!header || typeof header !== 'string') {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parts = header.trim().split(/\s+/);
|
|
46
|
+
if (parts.length !== 2 || parts[0].toLowerCase() !== 'basic') {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const decoded = Buffer.from(parts[1], 'base64').toString('utf8');
|
|
52
|
+
const colonIndex = decoded.indexOf(':');
|
|
53
|
+
if (colonIndex === -1) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const username = decoded.slice(0, colonIndex);
|
|
58
|
+
const password = decoded.slice(colonIndex + 1);
|
|
59
|
+
return { username, password };
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Extract credentials from request Authorization header or proxy headers
|
|
67
|
+
*
|
|
68
|
+
* @param {import('express').Request} req
|
|
69
|
+
* @returns {{ username: string, password: string } | null}
|
|
70
|
+
*/
|
|
71
|
+
function extractCredentials(req) {
|
|
72
|
+
const header = req.headers.authorization || req.headers.Authorization;
|
|
73
|
+
return parseBasicAuthHeader(header);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if request matches path prefix list
|
|
78
|
+
*
|
|
79
|
+
* @param {string} path
|
|
80
|
+
* @param {string|string[]|boolean} routes
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
function shouldApplyToRoute(path, routes) {
|
|
84
|
+
if (routes === true || routes == null) return true;
|
|
85
|
+
const list = Array.isArray(routes) ? routes : [routes];
|
|
86
|
+
return list.some((prefix) => path.startsWith(prefix));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Check if request path should be skipped
|
|
91
|
+
*
|
|
92
|
+
* @param {import('express').Request} req
|
|
93
|
+
* @param {string[]} skipPaths
|
|
94
|
+
* @param {Function} [skipFn]
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
function isSkipped(req, skipPaths = [], skipFn) {
|
|
98
|
+
if (typeof skipFn === 'function' && skipFn(req)) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
const p = req.path || req.url || '';
|
|
102
|
+
if (Array.isArray(skipPaths) && skipPaths.some((prefix) => p.startsWith(prefix))) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Send 401 Unauthorized response with optional Basic challenge header
|
|
110
|
+
*
|
|
111
|
+
* @param {import('express').Request} req
|
|
112
|
+
* @param {import('express').Response} res
|
|
113
|
+
* @param {Object} config
|
|
114
|
+
*/
|
|
115
|
+
function sendUnauthorized(req, res, config) {
|
|
116
|
+
res.statusCode = 401;
|
|
117
|
+
|
|
118
|
+
if (config.challenge !== false) {
|
|
119
|
+
const realm = String(config.realm || 'Restricted Area')
|
|
120
|
+
.split(/[\r\n]/)[0]
|
|
121
|
+
.replace(/"/g, '\\"');
|
|
122
|
+
res.setHeader('WWW-Authenticate', `Basic realm="${realm}"`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (typeof config.unauthorizedResponse === 'function') {
|
|
126
|
+
return config.unauthorizedResponse(req, res);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (config.unauthorizedResponse && typeof config.unauthorizedResponse === 'object') {
|
|
130
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
131
|
+
return res.end(JSON.stringify(config.unauthorizedResponse));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof config.unauthorizedResponse === 'string') {
|
|
135
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
136
|
+
return res.end(config.unauthorizedResponse);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const acceptsJson = req.headers.accept && req.headers.accept.includes('application/json');
|
|
140
|
+
if (acceptsJson) {
|
|
141
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
142
|
+
return res.end(JSON.stringify({ error: 'Unauthorized', message: 'Authentication required.' }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
146
|
+
res.end('Unauthorized');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Create Basic Auth Express middleware
|
|
151
|
+
*
|
|
152
|
+
* @param {Object} [options]
|
|
153
|
+
* @param {Record<string, string>} [options.users] - Static username-password map
|
|
154
|
+
* @param {Function} [options.verify] - Custom verification function (username, password, req) => boolean | object | Promise<boolean | object>
|
|
155
|
+
* @param {string} [options.realm='Restricted Area'] - Realm name in WWW-Authenticate header
|
|
156
|
+
* @param {boolean} [options.challenge=true] - Whether to send WWW-Authenticate challenge header
|
|
157
|
+
* @param {string|string[]|boolean} [options.routes=true] - Route prefixes to protect (used in global mode)
|
|
158
|
+
* @param {string[]} [options.skipPaths] - Array of path prefixes to skip authentication
|
|
159
|
+
* @param {Function} [options.skip] - Custom predicate function (req) => boolean
|
|
160
|
+
* @param {Function|string|Object} [options.unauthorizedResponse] - Custom 401 response
|
|
161
|
+
* @param {Function} [options.onAuthenticated] - Callback hook (req, user) => void
|
|
162
|
+
* @returns {Function} Express middleware handler
|
|
163
|
+
*/
|
|
164
|
+
function createBasicAuthMiddleware(options = {}) {
|
|
165
|
+
const config = {
|
|
166
|
+
realm: 'Restricted Area',
|
|
167
|
+
challenge: true,
|
|
168
|
+
routes: true,
|
|
169
|
+
skipPaths: [],
|
|
170
|
+
...options,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return async (req, res, next) => {
|
|
174
|
+
const reqPath = req.path || req.url || '/';
|
|
175
|
+
|
|
176
|
+
if (!shouldApplyToRoute(reqPath, config.routes)) {
|
|
177
|
+
return next();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (isSkipped(req, config.skipPaths, config.skip)) {
|
|
181
|
+
return next();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const credentials = extractCredentials(req);
|
|
185
|
+
if (!credentials) {
|
|
186
|
+
return sendUnauthorized(req, res, config);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { username, password } = credentials;
|
|
190
|
+
let authResult = false;
|
|
191
|
+
|
|
192
|
+
if (typeof config.verify === 'function') {
|
|
193
|
+
try {
|
|
194
|
+
authResult = await config.verify(username, password, req);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return next(err);
|
|
197
|
+
}
|
|
198
|
+
} else if (config.users && typeof config.users === 'object') {
|
|
199
|
+
const expectedPassword = config.users[username];
|
|
200
|
+
if (typeof expectedPassword === 'string') {
|
|
201
|
+
authResult = safeCompare(password, expectedPassword);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!authResult) {
|
|
206
|
+
return sendUnauthorized(req, res, config);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Attach authentication context to request
|
|
210
|
+
req.basicAuth = { username };
|
|
211
|
+
req.auth = req.auth || {};
|
|
212
|
+
const userObj = typeof authResult === 'object' && authResult !== null
|
|
213
|
+
? authResult
|
|
214
|
+
: { username, type: 'basic' };
|
|
215
|
+
|
|
216
|
+
req.auth.user = userObj;
|
|
217
|
+
if (!req.user) {
|
|
218
|
+
req.user = userObj;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (typeof config.onAuthenticated === 'function') {
|
|
222
|
+
try {
|
|
223
|
+
config.onAuthenticated(req, userObj);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return next(err);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
next();
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Webspresso Basic Auth Plugin Factory
|
|
235
|
+
*
|
|
236
|
+
* @param {Object} [options] - Plugin configuration options
|
|
237
|
+
* @param {boolean} [options.global=false] - Whether to apply basic auth as a global middleware on the app
|
|
238
|
+
* @param {Record<string, string>} [options.users] - Default static username-password dictionary
|
|
239
|
+
* @param {Function} [options.verify] - Default custom verification function
|
|
240
|
+
* @param {string} [options.realm='Restricted Area'] - Default realm name
|
|
241
|
+
* @param {boolean} [options.challenge=true] - Default challenge header flag
|
|
242
|
+
* @param {string|string[]|boolean} [options.routes=true] - Route prefixes (when global: true)
|
|
243
|
+
* @param {string[]} [options.skipPaths] - Default skip path prefixes
|
|
244
|
+
* @param {Function} [options.skip] - Default skip predicate function
|
|
245
|
+
* @param {Function|string|Object} [options.unauthorizedResponse] - Custom 401 response
|
|
246
|
+
* @param {Function} [options.onAuthenticated] - Callback hook on successful login
|
|
247
|
+
* @returns {Object} Webspresso Plugin Definition
|
|
248
|
+
*/
|
|
249
|
+
function basicAuthPlugin(options = {}) {
|
|
250
|
+
const { global: applyGlobal = false, ...pluginDefaults } = options;
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
name: 'basic-auth',
|
|
254
|
+
version: '1.0.0',
|
|
255
|
+
description: 'HTTP Basic Authentication (RFC 7617) plugin with named middleware and global guard',
|
|
256
|
+
options,
|
|
257
|
+
|
|
258
|
+
api: {
|
|
259
|
+
createMiddleware: (routeOpts = {}) =>
|
|
260
|
+
createBasicAuthMiddleware({ ...pluginDefaults, ...routeOpts }),
|
|
261
|
+
parseHeader: parseBasicAuthHeader,
|
|
262
|
+
safeCompare,
|
|
263
|
+
extractCredentials,
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
register(ctx) {
|
|
267
|
+
// Register named middleware factory for file routes: middleware: ['basicAuth'] or [['basicAuth', { ... }]]
|
|
268
|
+
if (ctx.middlewares) {
|
|
269
|
+
ctx.middlewares.basicAuth = (routeOpts = {}) =>
|
|
270
|
+
createBasicAuthMiddleware({ ...pluginDefaults, ...routeOpts });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Mount global middleware if global: true
|
|
274
|
+
if (applyGlobal && ctx.app) {
|
|
275
|
+
const globalMw = createBasicAuthMiddleware(options);
|
|
276
|
+
ctx.app.use(globalMw);
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
basicAuthPlugin.basicAuthPlugin = basicAuthPlugin;
|
|
283
|
+
basicAuthPlugin.createBasicAuthMiddleware = createBasicAuthMiddleware;
|
|
284
|
+
basicAuthPlugin.parseBasicAuthHeader = parseBasicAuthHeader;
|
|
285
|
+
basicAuthPlugin.safeCompare = safeCompare;
|
|
286
|
+
basicAuthPlugin.extractCredentials = extractCredentials;
|
|
287
|
+
|
|
288
|
+
module.exports = basicAuthPlugin;
|
|
289
|
+
module.exports.basicAuthPlugin = basicAuthPlugin;
|
|
290
|
+
module.exports.createBasicAuthMiddleware = createBasicAuthMiddleware;
|
|
291
|
+
module.exports.parseBasicAuthHeader = parseBasicAuthHeader;
|
|
292
|
+
module.exports.safeCompare = safeCompare;
|
|
293
|
+
module.exports.extractCredentials = extractCredentials;
|
|
@@ -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,8 +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 = Array.from(model.columns.keys()).filter((c) => !hiddenSet.has(c));
|
|
43
|
+
const columns = getModelExportColumns(model, clean);
|
|
19
44
|
|
|
20
45
|
const wb = new ExcelJS.Workbook();
|
|
21
46
|
const ws = wb.addWorksheet('Export', {
|
|
@@ -83,4 +108,5 @@ function createExportXlsxHandler(opts) {
|
|
|
83
108
|
module.exports = {
|
|
84
109
|
buildXlsxBuffer,
|
|
85
110
|
createExportXlsxHandler,
|
|
111
|
+
getModelExportColumns,
|
|
86
112
|
};
|
|
@@ -10,7 +10,18 @@ const { buildHeaderMapping, dataRowsToObjects } = require('./parse-table');
|
|
|
10
10
|
|
|
11
11
|
function allowedImportColumns(model) {
|
|
12
12
|
const hidden = new Set(model.hidden || []);
|
|
13
|
-
|
|
13
|
+
const cols = new Set();
|
|
14
|
+
if (model.columns && typeof model.columns.keys === 'function') {
|
|
15
|
+
for (const c of model.columns.keys()) {
|
|
16
|
+
if (!hidden.has(c)) cols.add(c);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (model.schema && model.schema.shape) {
|
|
20
|
+
for (const c of Object.keys(model.schema.shape)) {
|
|
21
|
+
if (!hidden.has(c)) cols.add(c);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return Array.from(cols);
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
/**
|
|
@@ -28,7 +39,15 @@ function coerceCell(raw, meta, column) {
|
|
|
28
39
|
raw = t;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
|
-
if (!meta || !meta.type)
|
|
42
|
+
if (!meta || !meta.type) {
|
|
43
|
+
if (typeof raw === 'string' && /^-?\d+(\.\d+)?$/.test(raw)) {
|
|
44
|
+
const num = Number(raw);
|
|
45
|
+
if (!Number.isNaN(num)) return num;
|
|
46
|
+
}
|
|
47
|
+
if (raw === 'true' || raw === 'yes') return true;
|
|
48
|
+
if (raw === 'false' || raw === 'no') return false;
|
|
49
|
+
return raw;
|
|
50
|
+
}
|
|
32
51
|
|
|
33
52
|
switch (meta.type) {
|
|
34
53
|
case 'bigint':
|
|
@@ -52,6 +71,14 @@ function coerceCell(raw, meta, column) {
|
|
|
52
71
|
if (s === 'false' || s === '0' || s === 'no') return false;
|
|
53
72
|
throw new Error(`Invalid boolean for ${column}`);
|
|
54
73
|
}
|
|
74
|
+
case 'date':
|
|
75
|
+
case 'datetime':
|
|
76
|
+
case 'timestamp': {
|
|
77
|
+
if (raw instanceof Date) return raw;
|
|
78
|
+
const d = new Date(raw);
|
|
79
|
+
if (Number.isNaN(d.getTime())) throw new Error(`Invalid date for ${column}`);
|
|
80
|
+
return raw;
|
|
81
|
+
}
|
|
55
82
|
case 'json': {
|
|
56
83
|
if (typeof raw === 'object') return raw;
|
|
57
84
|
try {
|
|
@@ -70,7 +97,7 @@ function buildPayloadForRow(model, data, mode) {
|
|
|
70
97
|
const payload = {};
|
|
71
98
|
for (const [col, raw] of Object.entries(data)) {
|
|
72
99
|
if (!allowed.has(col)) continue;
|
|
73
|
-
const meta = model.columns.get(col);
|
|
100
|
+
const meta = model.columns?.get ? model.columns.get(col) : undefined;
|
|
74
101
|
let v;
|
|
75
102
|
try {
|
|
76
103
|
v = coerceCell(raw, meta, col);
|
|
@@ -81,14 +108,14 @@ function buildPayloadForRow(model, data, mode) {
|
|
|
81
108
|
payload[col] = v;
|
|
82
109
|
}
|
|
83
110
|
|
|
84
|
-
const pk = model.primaryKey;
|
|
85
|
-
const pkMeta = model.columns.get(pk);
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
(payload[pk] === undefined || payload[pk] === null || payload[pk] === '')
|
|
90
|
-
|
|
91
|
-
|
|
111
|
+
const pk = model.primaryKey || 'id';
|
|
112
|
+
const pkMeta = model.columns?.get ? model.columns.get(pk) : undefined;
|
|
113
|
+
if (mode === 'insert') {
|
|
114
|
+
// In insert mode, remove auto-increment / auto-generated primary key
|
|
115
|
+
// so new records are created without unique constraint collision
|
|
116
|
+
if (pkMeta?.autoIncrement || pkMeta?.auto === 'create' || payload[pk] === undefined || payload[pk] === null || payload[pk] === '') {
|
|
117
|
+
delete payload[pk];
|
|
118
|
+
}
|
|
92
119
|
}
|
|
93
120
|
|
|
94
121
|
return payload;
|
|
@@ -125,15 +152,26 @@ async function parseXlsxToRows(buffer) {
|
|
|
125
152
|
}
|
|
126
153
|
|
|
127
154
|
function cellValueToPlain(cell) {
|
|
155
|
+
if (!cell) return '';
|
|
128
156
|
const v = cell.value;
|
|
129
157
|
if (v === null || v === undefined) return '';
|
|
130
158
|
if (v instanceof Date) return v.toISOString();
|
|
131
|
-
if (typeof v === 'object'
|
|
159
|
+
if (typeof v === 'object') {
|
|
160
|
+
if (v.error) return '';
|
|
132
161
|
if ('text' in v && typeof v.text === 'string') return v.text;
|
|
133
162
|
if ('richText' in v && Array.isArray(v.richText)) {
|
|
134
|
-
return v.richText.map((t) => t.text
|
|
163
|
+
return v.richText.map((t) => (t && t.text) ? t.text : '').join('');
|
|
164
|
+
}
|
|
165
|
+
if ('result' in v && v.result !== undefined) {
|
|
166
|
+
return cellValueToPlain({ value: v.result });
|
|
167
|
+
}
|
|
168
|
+
if ('hyperlink' in v) {
|
|
169
|
+
return v.text || v.hyperlink || '';
|
|
170
|
+
}
|
|
171
|
+
if (typeof v.toString === 'function' && v.toString !== Object.prototype.toString) {
|
|
172
|
+
return v.toString();
|
|
135
173
|
}
|
|
136
|
-
|
|
174
|
+
return '';
|
|
137
175
|
}
|
|
138
176
|
return v;
|
|
139
177
|
}
|
|
@@ -177,7 +215,8 @@ function createImportHandler(opts) {
|
|
|
177
215
|
let upsertKey = req.query.upsertKey || req.body?.upsertKey || model.primaryKey;
|
|
178
216
|
upsertKey = String(upsertKey);
|
|
179
217
|
|
|
180
|
-
|
|
218
|
+
const allowedCols = allowedImportColumns(model);
|
|
219
|
+
if (!allowedCols.includes(upsertKey)) {
|
|
181
220
|
return res.status(400).json({ error: `upsertKey "${upsertKey}" is not a column` });
|
|
182
221
|
}
|
|
183
222
|
if (model.hidden?.includes(upsertKey)) {
|
|
@@ -236,7 +275,7 @@ function createImportHandler(opts) {
|
|
|
236
275
|
continue;
|
|
237
276
|
}
|
|
238
277
|
|
|
239
|
-
const keyMeta = model.columns.get(upsertKey);
|
|
278
|
+
const keyMeta = model.columns?.get ? model.columns.get(upsertKey) : undefined;
|
|
240
279
|
let keyVal;
|
|
241
280
|
try {
|
|
242
281
|
keyVal = coerceCell(data[upsertKey], keyMeta, upsertKey);
|
|
@@ -33,8 +33,23 @@ function dataExchangePlugin(options = {}) {
|
|
|
33
33
|
description: 'Admin spreadsheet import (CSV/XLSX) and Excel export',
|
|
34
34
|
enabled,
|
|
35
35
|
|
|
36
|
+
register(ctx) {
|
|
37
|
+
if (!enabled) return;
|
|
38
|
+
const db = dbOption ?? ctx.db ?? ctx.app?.get?.('db') ?? null;
|
|
39
|
+
if (ctx.app && ctx.app.serviceRegistry) {
|
|
40
|
+
const { createExchangeServices } = require('../../src/services/builtins/exchange');
|
|
41
|
+
const exchangeServices = createExchangeServices({ db });
|
|
42
|
+
for (const [name, def] of Object.entries(exchangeServices)) {
|
|
43
|
+
if (!ctx.app.serviceRegistry.has(name)) {
|
|
44
|
+
ctx.app.serviceRegistry.register(name, def);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
|
|
36
50
|
onRoutesReady(ctx) {
|
|
37
51
|
if (!enabled) return;
|
|
52
|
+
if (ctx.app && ctx.app._dataExchangeRoutesRegistered) return;
|
|
38
53
|
|
|
39
54
|
const db = dbOption ?? ctx.db;
|
|
40
55
|
if (!db) {
|
|
@@ -73,6 +88,10 @@ function dataExchangePlugin(options = {}) {
|
|
|
73
88
|
},
|
|
74
89
|
});
|
|
75
90
|
}
|
|
91
|
+
|
|
92
|
+
if (ctx.app) {
|
|
93
|
+
ctx.app._dataExchangeRoutesRegistered = true;
|
|
94
|
+
}
|
|
76
95
|
},
|
|
77
96
|
};
|
|
78
97
|
}
|
|
@@ -15,18 +15,27 @@ function normalizeHeaderCell(h) {
|
|
|
15
15
|
* @returns {(string|null)[]} index → model column or null if unknown
|
|
16
16
|
*/
|
|
17
17
|
function buildHeaderMapping(headerRow, allowedKeys) {
|
|
18
|
-
const
|
|
18
|
+
const exactLowerToKey = new Map();
|
|
19
|
+
const strippedToKey = new Map();
|
|
19
20
|
for (const k of allowedKeys) {
|
|
20
|
-
|
|
21
|
+
const kStr = String(k);
|
|
22
|
+
exactLowerToKey.set(kStr.toLowerCase().replace(/\s+/g, '_'), k);
|
|
23
|
+
strippedToKey.set(kStr.toLowerCase().replace(/[_\s-]+/g, ''), k);
|
|
21
24
|
}
|
|
22
25
|
const mapping = [];
|
|
23
26
|
for (let i = 0; i < headerRow.length; i++) {
|
|
24
|
-
const
|
|
25
|
-
if (!
|
|
27
|
+
const raw = normalizeHeaderCell(headerRow[i]);
|
|
28
|
+
if (!raw) {
|
|
26
29
|
mapping.push(null);
|
|
27
30
|
continue;
|
|
28
31
|
}
|
|
29
|
-
|
|
32
|
+
const norm = raw.toLowerCase().replace(/\s+/g, '_');
|
|
33
|
+
let matched = exactLowerToKey.get(norm);
|
|
34
|
+
if (!matched) {
|
|
35
|
+
const stripped = raw.toLowerCase().replace(/[_\s-]+/g, '');
|
|
36
|
+
matched = strippedToKey.get(stripped);
|
|
37
|
+
}
|
|
38
|
+
mapping.push(matched ?? null);
|
|
30
39
|
}
|
|
31
40
|
return mapping;
|
|
32
41
|
}
|
package/plugins/email/index.js
CHANGED
|
@@ -106,7 +106,20 @@ function emailPlugin(options = {}) {
|
|
|
106
106
|
},
|
|
107
107
|
|
|
108
108
|
register(ctx) {
|
|
109
|
-
|
|
109
|
+
if (ctx.app && ctx.app.serviceRegistry) {
|
|
110
|
+
const { createMailServices } = require('../../src/services/builtins/mail');
|
|
111
|
+
const mailServices = createMailServices({
|
|
112
|
+
emailService,
|
|
113
|
+
registry,
|
|
114
|
+
db,
|
|
115
|
+
tableName,
|
|
116
|
+
});
|
|
117
|
+
for (const [name, def] of Object.entries(mailServices)) {
|
|
118
|
+
if (!ctx.app.serviceRegistry.has(name)) {
|
|
119
|
+
ctx.app.serviceRegistry.register(name, def);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
110
123
|
},
|
|
111
124
|
|
|
112
125
|
onRoutesReady(ctx) {
|
package/plugins/index.js
CHANGED
|
@@ -25,6 +25,21 @@ const emailPlugin = require('./email');
|
|
|
25
25
|
const contentPlugin = require('./content');
|
|
26
26
|
const csrfPlugin = require('./csrf');
|
|
27
27
|
const corsPlugin = require('./cors');
|
|
28
|
+
const { basicAuthPlugin, createBasicAuthMiddleware } = require('./basic-auth');
|
|
29
|
+
const {
|
|
30
|
+
realtimePlugin,
|
|
31
|
+
realtime,
|
|
32
|
+
createRealtime,
|
|
33
|
+
websocket,
|
|
34
|
+
createWebSocketAdapter,
|
|
35
|
+
sse,
|
|
36
|
+
createSseAdapter,
|
|
37
|
+
socketIo,
|
|
38
|
+
createSocketIoAdapter,
|
|
39
|
+
redis,
|
|
40
|
+
createRedisAdapter,
|
|
41
|
+
} = require('./realtime');
|
|
42
|
+
const { mcpPlugin, createMcpServer, McpServer } = require('./mcp');
|
|
28
43
|
|
|
29
44
|
module.exports = {
|
|
30
45
|
sitemapPlugin,
|
|
@@ -49,5 +64,23 @@ module.exports = {
|
|
|
49
64
|
contentPlugin,
|
|
50
65
|
csrfPlugin,
|
|
51
66
|
corsPlugin,
|
|
67
|
+
basicAuthPlugin,
|
|
68
|
+
createBasicAuthMiddleware,
|
|
69
|
+
realtimePlugin,
|
|
70
|
+
realtime,
|
|
71
|
+
createRealtime,
|
|
72
|
+
websocket,
|
|
73
|
+
createWebSocketAdapter,
|
|
74
|
+
sse,
|
|
75
|
+
createSseAdapter,
|
|
76
|
+
socketIo,
|
|
77
|
+
createSocketIoAdapter,
|
|
78
|
+
redis,
|
|
79
|
+
createRedisAdapter,
|
|
80
|
+
queuePlugin: require('./queue'),
|
|
81
|
+
mcpPlugin,
|
|
82
|
+
createMcpServer,
|
|
83
|
+
McpServer,
|
|
52
84
|
};
|
|
53
85
|
|
|
86
|
+
|