webspresso 0.0.74 → 0.0.75

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.
Files changed (60) hide show
  1. package/README.md +41 -3
  2. package/bin/commands/orm-map.js +139 -0
  3. package/bin/commands/skill.js +22 -8
  4. package/bin/utils/orm-map-html.js +689 -0
  5. package/bin/utils/orm-map-load.js +85 -0
  6. package/bin/utils/orm-map-snapshot.js +179 -0
  7. package/bin/utils/resolve-webspresso-orm.js +23 -0
  8. package/bin/webspresso.js +2 -0
  9. package/core/auth/manager.js +14 -1
  10. package/core/kernel/app.js +96 -0
  11. package/core/kernel/base-repository.js +143 -0
  12. package/core/kernel/events.js +101 -0
  13. package/core/kernel/flow.js +22 -0
  14. package/core/kernel/index.js +17 -0
  15. package/core/kernel/plugin.js +23 -0
  16. package/core/kernel/plugins/sample-seo.js +26 -0
  17. package/core/kernel/run-demo.js +58 -0
  18. package/core/kernel/view.js +167 -0
  19. package/core/openapi/build-from-api-routes.js +8 -2
  20. package/core/orm/model.js +3 -1
  21. package/core/url-path-normalize.js +30 -0
  22. package/index.d.ts +168 -1
  23. package/index.js +20 -2
  24. package/package.json +11 -1
  25. package/plugins/admin-panel/api.js +43 -15
  26. package/plugins/admin-panel/client/README.md +39 -0
  27. package/plugins/admin-panel/client/load-parts.js +74 -0
  28. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  29. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  30. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  31. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  32. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  33. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  34. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  35. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  36. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  37. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  38. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  39. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  40. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  41. package/plugins/admin-panel/components.js +4 -2640
  42. package/plugins/admin-panel/core/api-extensions.js +100 -10
  43. package/plugins/admin-panel/index.js +3 -0
  44. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  45. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  46. package/plugins/admin-panel/modules/dashboard.js +3 -2
  47. package/plugins/admin-panel/modules/user-management.js +90 -20
  48. package/plugins/index.js +4 -0
  49. package/plugins/rate-limit/index.js +178 -0
  50. package/plugins/redirect/index.js +204 -0
  51. package/plugins/rest-resources/index.js +2 -1
  52. package/plugins/swagger.js +2 -1
  53. package/plugins/upload/local-file-provider.js +6 -2
  54. package/src/file-router.js +191 -50
  55. package/src/njk-frontmatter.js +156 -0
  56. package/src/plugin-manager.js +4 -2
  57. package/src/server.js +26 -9
  58. package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
  59. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  60. package/templates/skills/webspresso-usage/SKILL.md +29 -278
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Rate limit plugin — registers named `rateLimit` middleware for file routes + optional global limiter
3
+ * @module plugins/rate-limit
4
+ */
5
+
6
+ const PLUGIN_ONLY_KEYS = new Set(['global', 'globalOverrides', 'globalSkipPaths']);
7
+
8
+ const DEFAULT_BASE = {
9
+ windowMs: 60_000,
10
+ limit: 100,
11
+ legacyHeaders: false,
12
+ standardHeaders: 'draft-7',
13
+ message: { error: 'Too many requests, please try again later.' },
14
+ };
15
+
16
+ /**
17
+ * Load express-rate-limit (peer). Throws if missing or too old for ipKeyGenerator.
18
+ * @returns {{ rateLimit: Function, ipKeyGenerator: Function }}
19
+ */
20
+ function loadPeer() {
21
+ let mod;
22
+ try {
23
+ // eslint-disable-next-line global-require, import/no-extraneous-dependencies
24
+ mod = require('express-rate-limit');
25
+ } catch (e) {
26
+ const err = new Error(
27
+ 'rate-limit plugin: install peer dependency `express-rate-limit` (npm install express-rate-limit)'
28
+ );
29
+ err.cause = e;
30
+ throw err;
31
+ }
32
+ const rateLimit = mod.rateLimit || mod.default;
33
+ const { ipKeyGenerator } = mod;
34
+ if (typeof rateLimit !== 'function') {
35
+ throw new Error('rate-limit plugin: express-rate-limit export rateLimit not found');
36
+ }
37
+ if (typeof ipKeyGenerator !== 'function') {
38
+ throw new Error(
39
+ 'rate-limit plugin: express-rate-limit must be >= 8 (ipKeyGenerator export required)'
40
+ );
41
+ }
42
+ return { rateLimit, ipKeyGenerator };
43
+ }
44
+
45
+ /**
46
+ * Strip plugin-only options before passing to express-rate-limit.
47
+ * @param {Record<string, unknown>} opts
48
+ */
49
+ function pickLimiterOptions(opts) {
50
+ const out = { ...opts };
51
+ for (const k of PLUGIN_ONLY_KEYS) {
52
+ delete out[k];
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Merge layers into express-rate-limit options. Default key uses ipKeyGenerator(req.ip, subnet).
59
+ * Custom keyGenerator and top-level ipv6Subnet are mutually exclusive in v8+ validation.
60
+ *
61
+ * @param {Function} ipKeyGenerator
62
+ * @param {...Record<string, unknown>} layers
63
+ */
64
+ function resolveLimiterConfig(ipKeyGenerator, ...layers) {
65
+ /** @type {Record<string, unknown>} */
66
+ const merged = Object.assign({}, ...layers);
67
+ const keyGenerator = merged.keyGenerator;
68
+ const ipv6Subnet = merged.ipv6Subnet;
69
+ const rest = { ...merged };
70
+ delete rest.keyGenerator;
71
+ delete rest.ipv6Subnet;
72
+
73
+ if (typeof keyGenerator === 'function') {
74
+ return { ...rest, keyGenerator };
75
+ }
76
+
77
+ const subnet = ipv6Subnet !== undefined ? ipv6Subnet : 56;
78
+ return {
79
+ ...rest,
80
+ keyGenerator: (req, res) => ipKeyGenerator(req.ip, subnet),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * @param {import('express').RequestHandler|undefined} userSkip
86
+ * @param {import('express').RequestHandler} builtin
87
+ */
88
+ function combineSkip(userSkip, builtin) {
89
+ if (!userSkip) return builtin;
90
+ return (req, res) => userSkip(req, res) || builtin(req, res);
91
+ }
92
+
93
+ /**
94
+ * @param {string[]} extraPathPrefixes
95
+ * @returns {import('express').RequestHandler}
96
+ */
97
+ function createDefaultGlobalSkip(extraPathPrefixes = []) {
98
+ const extras = (Array.isArray(extraPathPrefixes) ? extraPathPrefixes : []).filter(Boolean);
99
+ /** @type {string[]} */
100
+ const prefixes = [
101
+ '/__webspresso/client-runtime',
102
+ '/_webspresso',
103
+ ...extras,
104
+ ];
105
+ return (req, res) => {
106
+ const p = req.path || '';
107
+ if (prefixes.some((x) => p.startsWith(x))) return true;
108
+ if (p === '/health' || p === '/robots.txt' || p === '/favicon.ico') return true;
109
+ return false;
110
+ };
111
+ }
112
+
113
+ /**
114
+ * @param {object} [options] — express-rate-limit options plus plugin keys
115
+ * @param {boolean} [options.global=false] — mount a global limiter on ctx.app
116
+ * @param {object} [options.globalOverrides] — shallow merge applied only for the global limiter (after factory defaults)
117
+ * @param {string[]} [options.globalSkipPaths] — extra path prefixes where global limiter skips counting
118
+ */
119
+ function rateLimitPlugin(options = {}) {
120
+ const {
121
+ global: applyGlobal = false,
122
+ globalOverrides = null,
123
+ globalSkipPaths = [],
124
+ ...rest
125
+ } = options;
126
+
127
+ const factoryDefaults = pickLimiterOptions(rest);
128
+
129
+ const plugin = {
130
+ name: 'rate-limit',
131
+ version: '1.0.0',
132
+ description: 'Named rateLimit middleware (express-rate-limit) for file routes; optional global limiter',
133
+ _options: options,
134
+
135
+ api: {
136
+ /**
137
+ * Build limiter options object (for setupRoutes / tests).
138
+ * @param {Record<string, unknown>} [routeOpts]
139
+ */
140
+ createLimiterOptions(routeOpts = {}) {
141
+ const { ipKeyGenerator } = loadPeer();
142
+ const baseDefaults = { ...DEFAULT_BASE, ...factoryDefaults };
143
+ return resolveLimiterConfig(ipKeyGenerator, baseDefaults, routeOpts);
144
+ },
145
+ },
146
+
147
+ register(ctx) {
148
+ const { rateLimit, ipKeyGenerator } = loadPeer();
149
+
150
+ const baseDefaults = { ...DEFAULT_BASE, ...factoryDefaults };
151
+
152
+ ctx.middlewares.rateLimit = (routeOpts = {}) =>
153
+ rateLimit(resolveLimiterConfig(ipKeyGenerator, baseDefaults, routeOpts));
154
+
155
+ if (applyGlobal) {
156
+ const globalBuiltins = createDefaultGlobalSkip(globalSkipPaths);
157
+ const globalResolved = resolveLimiterConfig(
158
+ ipKeyGenerator,
159
+ baseDefaults,
160
+ globalOverrides && typeof globalOverrides === 'object' ? globalOverrides : {}
161
+ );
162
+ const UserSkip = globalResolved.skip;
163
+ const middleware = rateLimit({
164
+ ...globalResolved,
165
+ skip: combineSkip(
166
+ typeof UserSkip === 'function' ? UserSkip : undefined,
167
+ globalBuiltins
168
+ ),
169
+ });
170
+ ctx.app.use(middleware);
171
+ }
172
+ },
173
+ };
174
+
175
+ return plugin;
176
+ }
177
+
178
+ module.exports = { rateLimitPlugin };
@@ -0,0 +1,204 @@
1
+ /**
2
+ * HTTP redirect plugin — runs in register() before file-based routes.
3
+ * @module plugins/redirect
4
+ */
5
+
6
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
7
+
8
+ /**
9
+ * @param {string} to
10
+ * @returns {boolean}
11
+ */
12
+ function isExternalTarget(to) {
13
+ if (!to || typeof to !== 'string') return false;
14
+ const t = to.trim();
15
+ return /^https?:\/\//i.test(t) || t.startsWith('//');
16
+ }
17
+
18
+ /**
19
+ * @param {'strip'|'add'|false|undefined} mode
20
+ * @param {string} path
21
+ * @returns {string}
22
+ */
23
+ function normalizePathForTrailingSlash(mode, path) {
24
+ if (!path || mode === false || mode == null) return path;
25
+ if (path === '/') return path;
26
+ if (mode === 'strip') {
27
+ return path.endsWith('/') ? path.slice(0, -1) || '/' : path;
28
+ }
29
+ if (mode === 'add') {
30
+ return path.endsWith('/') ? path : `${path}/`;
31
+ }
32
+ return path;
33
+ }
34
+
35
+ /**
36
+ * @param {object} rule
37
+ * @param {object} pluginOpts
38
+ * @returns {object|null} compiled rule or null if invalid / disallowed external
39
+ */
40
+ function compileRule(rule, pluginOpts) {
41
+ if (!rule || typeof rule.to !== 'string' || rule.to.trim() === '') {
42
+ console.warn('[redirect] Skipping rule: missing `to`');
43
+ return null;
44
+ }
45
+
46
+ const to = rule.to.trim();
47
+ const external = isExternalTarget(to);
48
+ if (external && !pluginOpts.allowExternal) {
49
+ console.warn('[redirect] Skipping rule: external `to` requires allowExternal: true', rule.from);
50
+ return null;
51
+ }
52
+
53
+ let status = rule.status != null ? Number(rule.status) : pluginOpts.defaultStatus;
54
+ if (!REDIRECT_STATUSES.has(status)) {
55
+ status = pluginOpts.defaultStatus;
56
+ }
57
+
58
+ let matchAnyMethod = false;
59
+ /** @type {Set<string>|null} */
60
+ let methodSet = null;
61
+ if (rule.methods === '*') {
62
+ matchAnyMethod = true;
63
+ } else if (Array.isArray(rule.methods) && rule.methods.length > 0) {
64
+ methodSet = new Set(rule.methods.map((m) => String(m).toUpperCase()));
65
+ } else {
66
+ methodSet = new Set(pluginOpts.defaultMethods.map((m) => String(m).toUpperCase()));
67
+ }
68
+
69
+ const trailing = pluginOpts.trailingSlash;
70
+
71
+ if (typeof rule.from === 'string') {
72
+ const fromRaw = rule.from.trim();
73
+ const fromNorm = normalizePathForTrailingSlash(trailing, fromRaw);
74
+ return {
75
+ kind: 'string',
76
+ fromNorm,
77
+ fromRaw,
78
+ to,
79
+ status,
80
+ external,
81
+ matchAnyMethod,
82
+ methodSet,
83
+ trailing,
84
+ };
85
+ }
86
+
87
+ if (rule.from instanceof RegExp) {
88
+ return {
89
+ kind: 'regex',
90
+ from: rule.from,
91
+ to,
92
+ status,
93
+ external,
94
+ matchAnyMethod,
95
+ methodSet,
96
+ trailing,
97
+ };
98
+ }
99
+
100
+ console.warn('[redirect] Skipping rule: `from` must be string or RegExp');
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * @param {object} compiled
106
+ * @param {string} pathForMatch
107
+ * @param {string} rawPath
108
+ * @returns {boolean}
109
+ */
110
+ function pathMatches(compiled, pathForMatch, rawPath) {
111
+ if (compiled.kind === 'string') {
112
+ if (pathForMatch === compiled.fromNorm) return true;
113
+ if (!compiled.trailing) {
114
+ const a = rawPath.replace(/\/$/, '') || '/';
115
+ const b = compiled.fromRaw.replace(/\/$/, '') || '/';
116
+ if (a === b) return true;
117
+ }
118
+ return false;
119
+ }
120
+ return compiled.from.test(pathForMatch);
121
+ }
122
+
123
+ /**
124
+ * @param {object} compiled
125
+ * @param {string} method
126
+ * @returns {boolean}
127
+ */
128
+ function methodMatches(compiled, method) {
129
+ const m = method.toUpperCase();
130
+ if (compiled.matchAnyMethod) return true;
131
+ if (compiled.methodSet) return compiled.methodSet.has(m);
132
+ return false;
133
+ }
134
+
135
+ /**
136
+ * @param {object} options
137
+ * @param {Array<{from: string|RegExp, to: string, status?: number, methods?: string[]|'*'}>} [options.rules]
138
+ * @param {number} [options.defaultStatus=302]
139
+ * @param {boolean} [options.preserveQuery=true]
140
+ * @param {boolean} [options.allowExternal=false]
141
+ * @param {'strip'|'add'|false} [options.trailingSlash=false]
142
+ * @param {string[]} [options.defaultMethods=['GET','HEAD']]
143
+ * @returns {{ name: string, version: string, description: string, register: Function }}
144
+ */
145
+ function redirectPlugin(options = {}) {
146
+ const {
147
+ rules = [],
148
+ defaultStatus = 302,
149
+ preserveQuery = true,
150
+ allowExternal = false,
151
+ trailingSlash = false,
152
+ defaultMethods = ['GET', 'HEAD'],
153
+ } = options;
154
+
155
+ const pluginOpts = {
156
+ defaultStatus: REDIRECT_STATUSES.has(Number(defaultStatus)) ? Number(defaultStatus) : 302,
157
+ preserveQuery,
158
+ allowExternal,
159
+ trailingSlash,
160
+ defaultMethods: Array.isArray(defaultMethods) && defaultMethods.length > 0 ? defaultMethods : ['GET', 'HEAD'],
161
+ };
162
+
163
+ const compiled = [];
164
+ for (const rule of rules) {
165
+ const c = compileRule(rule, pluginOpts);
166
+ if (c) compiled.push(c);
167
+ }
168
+
169
+ function redirectMiddleware(req, res, next) {
170
+ const rawPath = req.path || '/';
171
+ const pathForMatch = normalizePathForTrailingSlash(pluginOpts.trailingSlash, rawPath);
172
+ const method = req.method || 'GET';
173
+
174
+ for (const c of compiled) {
175
+ if (!methodMatches(c, method)) continue;
176
+ if (!pathMatches(c, pathForMatch, rawPath)) continue;
177
+
178
+ let location = c.to;
179
+ if (pluginOpts.preserveQuery && !location.includes('?')) {
180
+ const full = req.originalUrl || req.url || '';
181
+ const qi = full.indexOf('?');
182
+ if (qi !== -1) {
183
+ location += full.slice(qi);
184
+ }
185
+ }
186
+ res.redirect(c.status, location);
187
+ return;
188
+ }
189
+ next();
190
+ }
191
+
192
+ return {
193
+ name: 'redirect',
194
+ version: '1.0.0',
195
+ description: 'Configurable HTTP redirects before file-based routes',
196
+
197
+ register(ctx) {
198
+ if (compiled.length === 0) return;
199
+ ctx.app.use(redirectMiddleware);
200
+ },
201
+ };
202
+ }
203
+
204
+ module.exports = { redirectPlugin };
@@ -6,6 +6,7 @@
6
6
  const { attachDbMiddleware } = require('../../src/app-context');
7
7
  const { getAllModels } = require('../../core/orm/model');
8
8
  const { omit } = require('../../core/orm/utils');
9
+ const { trimUrlPathSlashes } = require('../../core/url-path-normalize');
9
10
 
10
11
  const RESERVED_QUERY_KEYS = new Set(['page', 'perPage', 'sort', 'order', 'include', 'trashed']);
11
12
 
@@ -150,7 +151,7 @@ function resolveExposedModels(db, opts) {
150
151
  }
151
152
 
152
153
  function normalizeBasePath(p) {
153
- return `/${String(p).replace(/^\/+|\/+$/g, '')}`;
154
+ return `/${trimUrlPathSlashes(p)}`;
154
155
  }
155
156
 
156
157
  function applySoftDeleteScope(query, countQuery, model, trashed) {
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  const path = require('path');
6
+ const { trimUrlPathSlashes } = require('../core/url-path-normalize');
6
7
  const { buildOpenApiDocument } = require('../core/openapi/build-from-api-routes');
7
8
 
8
9
  const PKG = require(path.join(__dirname, '..', 'package.json'));
@@ -61,7 +62,7 @@ function swaggerPlugin(options = {}) {
61
62
  serverUrl,
62
63
  } = options;
63
64
 
64
- const normalizedBase = `/${String(basePath).replace(/^\/+|\/+$/g, '')}`;
65
+ const normalizedBase = `/${trimUrlPathSlashes(basePath)}`;
65
66
  const jsonPath = `${normalizedBase}/openapi.json`;
66
67
  const uiPath = normalizedBase;
67
68
 
@@ -6,6 +6,7 @@
6
6
  const fs = require('fs/promises');
7
7
  const path = require('path');
8
8
  const { randomBytes } = require('crypto');
9
+ const { trimUrlPathSlashes } = require('../../core/url-path-normalize');
9
10
 
10
11
  /**
11
12
  * Common MIME → canonical extension (lowercase, with leading dot).
@@ -38,8 +39,11 @@ const MIME_TO_EXT = {
38
39
  * @returns {string}
39
40
  */
40
41
  function normalizePublicBase(publicBasePath) {
41
- let p = (publicBasePath == null || publicBasePath === '' ? '/uploads' : String(publicBasePath)).trim();
42
- p = p.replace(/\/+$/, '');
42
+ let p =
43
+ publicBasePath == null || publicBasePath === ''
44
+ ? '/uploads'
45
+ : String(publicBasePath).trim();
46
+ p = trimUrlPathSlashes(p);
43
47
  if (!p.startsWith('/')) {
44
48
  p = `/${p}`;
45
49
  }