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.
Files changed (121) hide show
  1. package/.agents/ADMIN.md +153 -5
  2. package/.agents/ARCHITECTURE.md +10 -0
  3. package/.agents/CLI.md +195 -1
  4. package/.agents/PLUGINS.md +153 -9
  5. package/.agents/QUEUE.md +43 -0
  6. package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
  7. package/.agents/skills/webspresso-framework/SKILL.md +56 -0
  8. package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
  9. package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
  10. package/.agents/skills/webspresso-services/SKILL.md +101 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/admin-password.js +2 -1
  14. package/bin/commands/admin-setup.js +1 -1
  15. package/bin/commands/agents-init.js +51 -11
  16. package/bin/commands/analytics-prune.js +57 -0
  17. package/bin/commands/api.js +207 -15
  18. package/bin/commands/audit-prune.js +1 -1
  19. package/bin/commands/build.js +99 -0
  20. package/bin/commands/dev.js +64 -35
  21. package/bin/commands/doctor.js +26 -0
  22. package/bin/commands/email-prune.js +47 -0
  23. package/bin/commands/favicon-generate.js +1 -1
  24. package/bin/commands/make-job.js +86 -0
  25. package/bin/commands/make-model.js +250 -0
  26. package/bin/commands/new.js +45 -28
  27. package/bin/commands/openapi.js +102 -0
  28. package/bin/commands/orm-map.js +5 -4
  29. package/bin/commands/page.js +6 -3
  30. package/bin/commands/polar-migrate.js +1 -1
  31. package/bin/commands/queue-prune.js +59 -0
  32. package/bin/commands/routes.js +52 -0
  33. package/bin/commands/seed.js +1 -1
  34. package/bin/commands/service.js +6 -3
  35. package/bin/commands/typegen.js +106 -0
  36. package/bin/commands/upload-prune.js +79 -0
  37. package/bin/webspresso.js +22 -0
  38. package/core/applySchema.js +13 -6
  39. package/core/auth/hash.js +31 -17
  40. package/core/compression/middleware.js +23 -17
  41. package/core/openapi/build-from-api-routes.js +160 -29
  42. package/core/orm/cache/fingerprint.js +15 -1
  43. package/core/orm/cache/memory-provider.js +27 -2
  44. package/core/orm/index.js +12 -7
  45. package/core/orm/model.js +49 -7
  46. package/core/queue/adapters/database.js +94 -0
  47. package/core/queue/adapters/memory.js +62 -0
  48. package/core/queue/adapters/redis.js +80 -0
  49. package/core/queue/manager.js +123 -0
  50. package/core/ssr/stream.js +11 -0
  51. package/index.d.ts +660 -10
  52. package/index.js +44 -96
  53. package/package.json +32 -12
  54. package/plugins/admin-panel/api.js +279 -17
  55. package/plugins/admin-panel/app.js +124 -31
  56. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  57. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  58. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
  59. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  60. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  61. package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
  62. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  63. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  64. package/plugins/admin-panel/core/admin-module.js +13 -7
  65. package/plugins/admin-panel/core/api-extensions.js +34 -2
  66. package/plugins/admin-panel/core/registry.js +96 -16
  67. package/plugins/admin-panel/index.js +96 -0
  68. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  69. package/plugins/admin-panel/modules/admin-users.js +16 -1
  70. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  71. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  72. package/plugins/admin-panel/modules/dashboard.js +283 -0
  73. package/plugins/admin-panel/modules/menu.js +56 -18
  74. package/plugins/admin-panel/modules/user-management.js +24 -6
  75. package/plugins/audit-log/admin-component.js +11 -0
  76. package/plugins/audit-log/api-handlers.js +34 -0
  77. package/plugins/audit-log/index.js +129 -3
  78. package/plugins/audit-log/purge.js +60 -10
  79. package/plugins/basic-auth/index.js +3 -1
  80. package/plugins/content/client/inline-edit.js +12 -12
  81. package/plugins/cors.js +6 -7
  82. package/plugins/dashboard/app.js +152 -27
  83. package/plugins/dashboard/index.js +143 -39
  84. package/plugins/dashboard/styles.js +78 -0
  85. package/plugins/data-exchange/export-xlsx.js +9 -2
  86. package/plugins/data-exchange/import.js +9 -2
  87. package/plugins/data-exchange/index.js +6 -3
  88. package/plugins/email/index.js +132 -2
  89. package/plugins/email/purge.js +60 -10
  90. package/plugins/file-manager/core.js +1 -1
  91. package/plugins/file-manager/security.js +33 -23
  92. package/plugins/index.js +49 -87
  93. package/plugins/mcp/transports/sse.js +4 -2
  94. package/plugins/orm-cache-admin/index.js +4 -2
  95. package/plugins/queue/index.js +11 -0
  96. package/plugins/schema-explorer/admin-component.js +714 -0
  97. package/plugins/schema-explorer/relations-graph.js +205 -0
  98. package/plugins/schema-explorer.js +248 -69
  99. package/plugins/site-analytics/admin-component.js +30 -8
  100. package/plugins/site-analytics/api-handlers.js +42 -0
  101. package/plugins/site-analytics/index.js +147 -2
  102. package/plugins/site-analytics/purge.js +168 -0
  103. package/plugins/swagger.js +261 -13
  104. package/plugins/upload/index.js +26 -0
  105. package/plugins/upload/orphan-pruner.js +292 -0
  106. package/src/api/api-loader.js +3 -1
  107. package/src/bundle/index.js +517 -0
  108. package/src/bundle/manifest.js +165 -0
  109. package/src/dev/banner.js +296 -0
  110. package/src/dev/colors.js +70 -0
  111. package/src/dev/index.js +62 -0
  112. package/src/dev/profiler.js +354 -0
  113. package/src/dev/typegen.js +497 -0
  114. package/src/dev-reload/index.js +611 -0
  115. package/src/file-router.js +213 -63
  116. package/src/helpers.js +958 -98
  117. package/src/njk-frontmatter.js +12 -0
  118. package/src/pages/page-loader.js +61 -11
  119. package/src/server.js +629 -63
  120. package/src/services/discovery.js +6 -1
  121. package/src/services/index.js +6 -13
@@ -1,5 +1,8 @@
1
+ 'use strict';
2
+
1
3
  /**
2
- * Swagger / OpenAPI plugin — HTTP API docs from file-based routes + Zod, optional ORM schemas
4
+ * Swagger / Scalar / OpenAPI plugin — HTTP API docs from file-based routes + Zod, optional ORM schemas
5
+ * @module plugins/swagger
3
6
  */
4
7
 
5
8
  const path = require('path');
@@ -9,17 +12,44 @@ const { buildOpenApiDocument } = require('../core/openapi/build-from-api-routes'
9
12
  const PKG = require(path.join(__dirname, '..', 'package.json'));
10
13
 
11
14
  /**
15
+ * HTML Escaping
16
+ * @param {string} str
17
+ * @returns {string}
18
+ */
19
+ function escapeHtml(str) {
20
+ if (!str) return '';
21
+ return String(str)
22
+ .replace(/&/g, '&')
23
+ .replace(/</g, '&lt;')
24
+ .replace(/>/g, '&gt;')
25
+ .replace(/"/g, '&quot;')
26
+ .replace(/'/g, '&#039;');
27
+ }
28
+
29
+ /**
30
+ * Generates Swagger UI HTML page
12
31
  * @param {string} specUrl - Absolute path on same origin (e.g. /_swagger/openapi.json)
32
+ * @param {Object} [options]
33
+ * @returns {string}
13
34
  */
14
- function buildSwaggerUiHtml(specUrl) {
35
+ function buildSwaggerUiHtml(specUrl, options = {}) {
36
+ const title = escapeHtml(options.title || 'API Documentation');
37
+ const docExpansion = JSON.stringify(options.docExpansion || 'list');
38
+ const filter = JSON.stringify(options.filter !== false);
15
39
  const urlJson = JSON.stringify(specUrl);
40
+
16
41
  return `<!DOCTYPE html>
17
42
  <html lang="en">
18
43
  <head>
19
44
  <meta charset="UTF-8" />
20
45
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
21
- <title>API documentation</title>
46
+ <title>${title}</title>
22
47
  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css" crossorigin />
48
+ <style>
49
+ body { margin: 0; padding: 0; background: #fafafa; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
50
+ .swagger-ui .topbar { display: none; }
51
+ .swagger-ui .information-container { margin-bottom: 20px; }
52
+ </style>
23
53
  </head>
24
54
  <body>
25
55
  <div id="swagger-ui"></div>
@@ -29,7 +59,11 @@ function buildSwaggerUiHtml(specUrl) {
29
59
  SwaggerUIBundle({
30
60
  url: ${urlJson},
31
61
  dom_id: '#swagger-ui',
32
- deepLinking: true
62
+ deepLinking: true,
63
+ persistAuthorization: true,
64
+ displayRequestDuration: true,
65
+ docExpansion: ${docExpansion},
66
+ filter: ${filter}
33
67
  });
34
68
  };
35
69
  </script>
@@ -38,20 +72,140 @@ function buildSwaggerUiHtml(specUrl) {
38
72
  }
39
73
 
40
74
  /**
75
+ * Generates modern Scalar API Reference HTML page
76
+ * @param {string} specUrl - Absolute path on same origin (e.g. /_swagger/openapi.json)
77
+ * @param {Object} [options]
78
+ * @returns {string}
79
+ */
80
+ function buildScalarUiHtml(specUrl, options = {}) {
81
+ const title = escapeHtml(options.title || 'API Reference');
82
+ const theme = options.theme || 'default';
83
+ const darkMode = options.darkMode !== false;
84
+ const layout = options.layout || 'modern';
85
+ const showSidebar = options.showSidebar !== false;
86
+ const scalarOptions = options.scalarOptions || {};
87
+
88
+ const configuration = {
89
+ spec: {
90
+ url: specUrl,
91
+ },
92
+ theme,
93
+ darkMode,
94
+ layout,
95
+ showSidebar,
96
+ ...scalarOptions,
97
+ };
98
+
99
+ if (options.proxyUrl) {
100
+ configuration.proxyUrl = options.proxyUrl;
101
+ }
102
+
103
+ const configJson = JSON.stringify(configuration).replace(/</g, '\\u003c');
104
+
105
+ return `<!DOCTYPE html>
106
+ <html lang="en">
107
+ <head>
108
+ <meta charset="UTF-8" />
109
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
110
+ <title>${title}</title>
111
+ <style>
112
+ body {
113
+ margin: 0;
114
+ padding: 0;
115
+ height: 100vh;
116
+ overflow: hidden;
117
+ }
118
+ </style>
119
+ </head>
120
+ <body>
121
+ <script
122
+ id="api-reference"
123
+ type="application/json">
124
+ ${configJson}
125
+ </script>
126
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
127
+ </body>
128
+ </html>`;
129
+ }
130
+
131
+ /**
132
+ * Generates Admin Panel client component to embed API docs
133
+ * @param {Object} opts
134
+ * @returns {string}
135
+ */
136
+ function generateApiDocsAdminComponent({ uiPath, jsonPath, title }) {
137
+ const safeTitle = JSON.stringify(title || 'API Documentation');
138
+ const safeUi = JSON.stringify(uiPath);
139
+ const safeJson = JSON.stringify(jsonPath);
140
+
141
+ return `
142
+ (function() {
143
+ var uiUrl = ${safeUi};
144
+ var jsonUrl = ${safeJson};
145
+ var pageTitle = ${safeTitle};
146
+
147
+ return {
148
+ view: function() {
149
+ return m('div.flex.flex-col', { style: { minHeight: 'calc(100vh - 140px)' } }, [
150
+ m('div.flex.items-center.justify-between.p-4.bg-white.border.border-gray-200.rounded-lg.mb-4.shadow-sm', [
151
+ m('div', [
152
+ m('h2.text-lg.font-bold.text-gray-900', pageTitle),
153
+ m('p.text-xs.text-gray-500', 'Interactive OpenAPI documentation & endpoint playground')
154
+ ]),
155
+ m('div.flex.items-center.space-x-2', [
156
+ m('a.inline-flex.items-center.px-3.py-1.5.text-xs.font-medium.rounded.border.border-gray-300.bg-white.text-gray-700.hover:bg-gray-50', {
157
+ href: jsonUrl,
158
+ target: '_blank',
159
+ rel: 'noopener noreferrer'
160
+ }, 'View OpenAPI JSON ↗'),
161
+ m('a.inline-flex.items-center.px-3.py-1.5.text-xs.font-medium.rounded.bg-indigo-600.text-white.hover:bg-indigo-700', {
162
+ href: uiUrl,
163
+ target: '_blank',
164
+ rel: 'noopener noreferrer'
165
+ }, 'Open Full Screen ↗')
166
+ ])
167
+ ]),
168
+ m('div.flex-1.bg-white.border.border-gray-200.rounded-lg.overflow-hidden.shadow-sm', {
169
+ style: { minHeight: '750px' }
170
+ }, [
171
+ m('iframe', {
172
+ src: uiUrl,
173
+ style: { width: '100%', height: '100%', minHeight: '750px', border: 'none' }
174
+ })
175
+ ])
176
+ ]);
177
+ }
178
+ };
179
+ })();
180
+ `;
181
+ }
182
+
183
+ /**
184
+ * Swagger / Scalar / OpenAPI Plugin
185
+ *
41
186
  * @param {Object} [options]
42
187
  * @param {string} [options.path='/_swagger'] - Base path; JSON at `{path}/openapi.json`, UI at `{path}`
188
+ * @param {'swagger'|'scalar'} [options.ui='swagger'] - Default UI playground engine ('scalar' or 'swagger')
43
189
  * @param {boolean} [options.enabled] - Default: development only
44
190
  * @param {Function} [options.authorize] - (req) => boolean
45
- * @param {boolean} [options.includeOrmSchemas] - Merge ORM components.schemas
191
+ * @param {boolean} [options.includeOrmSchemas=false] - Merge ORM components.schemas
46
192
  * @param {string[]} [options.ormExclude] - Model names to exclude when includeOrmSchemas
47
193
  * @param {string} [options.title] - OpenAPI info.title
48
194
  * @param {string} [options.version] - OpenAPI info.version
49
195
  * @param {string} [options.description] - OpenAPI info.description
50
196
  * @param {string} [options.serverUrl] - Override servers[0].url (default: BASE_URL or http://localhost:3000)
197
+ * @param {string} [options.theme='default'] - Theme for Scalar UI
198
+ * @param {boolean} [options.darkMode=true] - Dark mode default for Scalar UI
199
+ * @param {boolean} [options.admin=true] - Auto-register in Admin Panel if admin-panel plugin is present
200
+ * @param {string} [options.adminTitle='API Docs'] - Admin menu/page title
201
+ * @param {number} [options.adminOrder=92] - Admin menu sort order
202
+ * @param {Object} [options.scalarOptions] - Custom options passed to Scalar configuration
203
+ * @param {Object} [options.swaggerOptions] - Custom options passed to Swagger UI
51
204
  */
52
205
  function swaggerPlugin(options = {}) {
53
206
  const {
54
207
  path: basePath = '/_swagger',
208
+ ui = 'swagger',
55
209
  enabled,
56
210
  authorize,
57
211
  includeOrmSchemas = false,
@@ -60,15 +214,31 @@ function swaggerPlugin(options = {}) {
60
214
  version,
61
215
  description,
62
216
  serverUrl,
217
+ theme = 'default',
218
+ darkMode = true,
219
+ admin = true,
220
+ adminTitle,
221
+ adminOrder = 92,
222
+ scalarOptions = {},
223
+ swaggerOptions = {},
63
224
  } = options;
64
225
 
65
226
  const normalizedBase = `/${trimUrlPathSlashes(basePath)}`;
66
227
  const jsonPath = `${normalizedBase}/openapi.json`;
67
228
  const uiPath = normalizedBase;
229
+ const swaggerSubPath = `${normalizedBase}/swagger`;
230
+ const scalarSubPath = `${normalizedBase}/scalar`;
231
+
232
+ const docTitle = title || PKG.name || 'API';
233
+ const docVersion = version || PKG.version || '1.0.0';
234
+ const docDescription =
235
+ description || 'Generated from pages/api routes and optional Zod schema exports.';
236
+
237
+ const isScalarPrimary = String(ui).toLowerCase() === 'scalar';
68
238
 
69
239
  return {
70
240
  name: 'swagger',
71
- version: '1.0.0',
241
+ version: '1.2.0',
72
242
 
73
243
  onRoutesReady(ctx) {
74
244
  const isDev = process.env.NODE_ENV !== 'production';
@@ -88,6 +258,7 @@ function swaggerPlugin(options = {}) {
88
258
 
89
259
  const server = serverUrl || process.env.BASE_URL || 'http://localhost:3000';
90
260
 
261
+ // 1. OpenAPI 3 JSON Endpoint
91
262
  ctx.addRoute('get', jsonPath, (req, res) => {
92
263
  if (forbid(req, res)) return;
93
264
  try {
@@ -97,11 +268,9 @@ function swaggerPlugin(options = {}) {
97
268
  includeOrmSchemas,
98
269
  ormExclude,
99
270
  info: {
100
- title: title || PKG.name || 'API',
101
- version: version || PKG.version || '1.0.0',
102
- description:
103
- description ||
104
- 'Generated from pages/api routes and optional Zod schema exports.',
271
+ title: docTitle,
272
+ version: docVersion,
273
+ description: docDescription,
105
274
  },
106
275
  servers: [{ url: server.replace(/\/$/, '') }],
107
276
  });
@@ -112,16 +281,95 @@ function swaggerPlugin(options = {}) {
112
281
  }
113
282
  });
114
283
 
284
+ // 2. Swagger UI Explicit Endpoint
285
+ ctx.addRoute('get', swaggerSubPath, (req, res) => {
286
+ if (forbid(req, res)) return;
287
+ res.type('html').send(
288
+ buildSwaggerUiHtml(jsonPath, {
289
+ title: `${docTitle} - Swagger UI`,
290
+ ...swaggerOptions,
291
+ })
292
+ );
293
+ });
294
+
295
+ // 3. Scalar UI Explicit Endpoint
296
+ ctx.addRoute('get', scalarSubPath, (req, res) => {
297
+ if (forbid(req, res)) return;
298
+ res.type('html').send(
299
+ buildScalarUiHtml(jsonPath, {
300
+ title: `${docTitle} - Scalar API Reference`,
301
+ theme,
302
+ darkMode,
303
+ scalarOptions,
304
+ })
305
+ );
306
+ });
307
+
308
+ // 4. Primary UI Endpoint (Swagger or Scalar based on options.ui)
115
309
  ctx.addRoute('get', uiPath, (req, res) => {
116
310
  if (forbid(req, res)) return;
117
- res.type('html').send(buildSwaggerUiHtml(jsonPath));
311
+ if (isScalarPrimary) {
312
+ res.type('html').send(
313
+ buildScalarUiHtml(jsonPath, {
314
+ title: `${docTitle} - API Reference`,
315
+ theme,
316
+ darkMode,
317
+ scalarOptions,
318
+ })
319
+ );
320
+ } else {
321
+ res.type('html').send(
322
+ buildSwaggerUiHtml(jsonPath, {
323
+ title: `${docTitle} - API Documentation`,
324
+ ...swaggerOptions,
325
+ })
326
+ );
327
+ }
118
328
  });
119
329
 
330
+ // 5. Admin Panel Integration
331
+ const adminApi = typeof ctx.usePlugin === 'function' ? ctx.usePlugin('admin-panel') : null;
332
+ if (adminApi && admin !== false) {
333
+ const pageTitle = adminTitle || title || 'API Docs';
334
+ try {
335
+ adminApi.registerModule({
336
+ id: 'api-docs',
337
+ title: pageTitle,
338
+ pages: [{
339
+ id: 'api-docs',
340
+ title: pageTitle,
341
+ path: '/api-docs',
342
+ icon: 'tool',
343
+ component: generateApiDocsAdminComponent({
344
+ uiPath,
345
+ jsonPath,
346
+ title: pageTitle,
347
+ }),
348
+ }],
349
+ menu: [{
350
+ id: 'api-docs',
351
+ label: pageTitle,
352
+ path: '/api-docs',
353
+ icon: 'tool',
354
+ order: adminOrder,
355
+ }],
356
+ });
357
+ } catch (adminErr) {
358
+ console.warn('[swagger] Failed to register admin module:', adminErr.message);
359
+ }
360
+ }
361
+
120
362
  if (isDev) {
121
- console.log(` Swagger UI: ${uiPath} (OpenAPI: ${jsonPath})`);
363
+ const primaryEngine = isScalarPrimary ? 'Scalar' : 'Swagger';
364
+ console.log(` API Docs (${primaryEngine}): ${uiPath} (OpenAPI: ${jsonPath})`);
122
365
  }
123
366
  },
124
367
  };
125
368
  }
126
369
 
127
370
  module.exports = swaggerPlugin;
371
+ module.exports.swaggerPlugin = swaggerPlugin;
372
+ module.exports.openapiPlugin = swaggerPlugin;
373
+ module.exports.apiDocsPlugin = swaggerPlugin;
374
+ module.exports.buildSwaggerUiHtml = buildSwaggerUiHtml;
375
+ module.exports.buildScalarUiHtml = buildScalarUiHtml;
@@ -126,6 +126,25 @@ function uploadPlugin(options = {}) {
126
126
  version: '1.0.0',
127
127
  description: 'Multipart file uploads with pluggable storage',
128
128
 
129
+ api: {
130
+ scanOrphanedUploads(opts = {}) {
131
+ const { scanOrphanedUploads } = require('./orphan-pruner');
132
+ return scanOrphanedUploads({
133
+ destDir: local?.destDir,
134
+ publicBasePath: local?.publicBasePath,
135
+ ...opts,
136
+ });
137
+ },
138
+ pruneOrphanedUploads(opts = {}) {
139
+ const { pruneOrphanedUploads } = require('./orphan-pruner');
140
+ return pruneOrphanedUploads({
141
+ destDir: local?.destDir,
142
+ publicBasePath: local?.publicBasePath,
143
+ ...opts,
144
+ });
145
+ },
146
+ },
147
+
129
148
  register(ctx) {
130
149
  if (ctx.app && ctx.app.serviceRegistry) {
131
150
  const { createMediaServices } = require('../../src/services/builtins/media');
@@ -221,7 +240,14 @@ function uploadPlugin(options = {}) {
221
240
  };
222
241
  }
223
242
 
243
+ const {
244
+ scanOrphanedUploads,
245
+ pruneOrphanedUploads,
246
+ } = require('./orphan-pruner');
247
+
224
248
  module.exports = {
225
249
  uploadPlugin,
226
250
  createLocalFileProvider,
251
+ scanOrphanedUploads,
252
+ pruneOrphanedUploads,
227
253
  };
@@ -0,0 +1,292 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Orphaned Uploads Scanner & Pruner
5
+ * Identifies and deletes unreferenced uploaded files from disk.
6
+ * Zero external dependencies.
7
+ * @module plugins/upload/orphan-pruner
8
+ */
9
+
10
+ const fs = require('fs/promises');
11
+ const fsSync = require('fs');
12
+ const path = require('path');
13
+ const { normalizePublicBase } = require('./local-file-provider');
14
+
15
+ /**
16
+ * Format bytes into human readable string
17
+ * @param {number} bytes
18
+ * @returns {string}
19
+ */
20
+ function formatBytes(bytes) {
21
+ if (bytes === 0) return '0 B';
22
+ const k = 1024;
23
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
24
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
25
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
26
+ }
27
+
28
+ /**
29
+ * Recursively find all files in a directory
30
+ * @param {string} dir
31
+ * @param {string} [baseDir]
32
+ * @returns {Promise<Array<{ relativePath: string, absolutePath: string, size: number, mtime: Date }>>}
33
+ */
34
+ async function scanDirectoryFiles(dir, baseDir = dir) {
35
+ const results = [];
36
+ try {
37
+ const entries = await fs.readdir(dir, { withFileTypes: true });
38
+ for (const entry of entries) {
39
+ if (entry.name.startsWith('.')) continue; // ignore hidden files
40
+ const fullPath = path.join(dir, entry.name);
41
+ if (entry.isDirectory()) {
42
+ const subFiles = await scanDirectoryFiles(fullPath, baseDir);
43
+ results.push(...subFiles);
44
+ } else if (entry.isFile()) {
45
+ try {
46
+ const st = await fs.stat(fullPath);
47
+ const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
48
+ results.push({
49
+ relativePath,
50
+ absolutePath: fullPath,
51
+ size: st.size,
52
+ mtime: st.mtime,
53
+ });
54
+ } catch (e) {
55
+ // File might have been removed concurrently, ignore
56
+ }
57
+ }
58
+ }
59
+ } catch (e) {
60
+ // Directory not accessible, ignore
61
+ }
62
+ return results;
63
+ }
64
+
65
+ /**
66
+ * Extract all referenced file names and URL fragments from database
67
+ * @param {Object} db - Database instance (with knex and optional models)
68
+ * @returns {Promise<Set<string>>}
69
+ */
70
+ async function extractDbFileReferences(db) {
71
+ const references = new Set();
72
+ if (!db || !db.knex) return references;
73
+
74
+ const { getAllModels } = require('../../core/orm/model');
75
+ const allModels = db.getAllModels ? db.getAllModels() : Array.from(getAllModels().values());
76
+ const modelList = Array.isArray(allModels) ? allModels : Array.from(allModels.values());
77
+
78
+ const tablesToScan = new Set();
79
+ for (const model of modelList) {
80
+ if (model.table) tablesToScan.add(model.table);
81
+ }
82
+
83
+ // Common additional tables
84
+ const extraTables = ['admin_users', 'files', 'media', 'attachments', 'content_items'];
85
+ for (const t of extraTables) {
86
+ try {
87
+ if (await db.knex.schema.hasTable(t)) {
88
+ tablesToScan.add(t);
89
+ }
90
+ } catch (e) {}
91
+ }
92
+
93
+ function addStringReference(val) {
94
+ if (typeof val !== 'string' || !val) return;
95
+ const str = val.trim();
96
+ if (!str) return;
97
+
98
+ // Direct path or filename
99
+ references.add(str);
100
+ references.add(path.basename(str));
101
+
102
+ // Normalized url without leading /
103
+ references.add(str.replace(/^\/+/, ''));
104
+
105
+ // Extract potential url/filename tokens inside rich-text or JSON
106
+ const matches = str.match(/[a-zA-Z0-9_\-\.\/]+\.[a-zA-Z0-9]{2,8}/g);
107
+ if (matches) {
108
+ for (const m of matches) {
109
+ references.add(m);
110
+ references.add(path.basename(m));
111
+ references.add(m.replace(/^\/+/, ''));
112
+ }
113
+ }
114
+ }
115
+
116
+ function scanValue(val) {
117
+ if (val == null) return;
118
+ if (typeof val === 'string') {
119
+ addStringReference(val);
120
+ } else if (Array.isArray(val)) {
121
+ for (const item of val) scanValue(item);
122
+ } else if (typeof val === 'object') {
123
+ for (const k of Object.keys(val)) {
124
+ scanValue(val[k]);
125
+ }
126
+ }
127
+ }
128
+
129
+ for (const table of tablesToScan) {
130
+ try {
131
+ const exists = await db.knex.schema.hasTable(table);
132
+ if (!exists) continue;
133
+
134
+ const rows = await db.knex(table).select();
135
+ for (const row of rows) {
136
+ if (!row || typeof row !== 'object') continue;
137
+ for (const [key, value] of Object.entries(row)) {
138
+ // If value is a JSON string, try to parse
139
+ if (typeof value === 'string' && (value.startsWith('{') || value.startsWith('['))) {
140
+ try {
141
+ const parsed = JSON.parse(value);
142
+ scanValue(parsed);
143
+ } catch (e) {
144
+ addStringReference(value);
145
+ }
146
+ } else {
147
+ scanValue(value);
148
+ }
149
+ }
150
+ }
151
+ } catch (e) {
152
+ // Table scan failed, ignore
153
+ }
154
+ }
155
+
156
+ return references;
157
+ }
158
+
159
+ /**
160
+ * Scan for orphaned upload files
161
+ * @param {Object} options
162
+ * @param {Object} [options.db] - Database instance
163
+ * @param {string} [options.destDir] - Uploads directory on disk
164
+ * @param {string} [options.publicBasePath='/uploads'] - Public URL base path
165
+ * @returns {Promise<Object>}
166
+ */
167
+ async function scanOrphanedUploads(options = {}) {
168
+ const { db, publicBasePath = '/uploads' } = options;
169
+ const destDir = path.resolve(
170
+ options.destDir || path.join(process.cwd(), 'public', 'uploads')
171
+ );
172
+
173
+ let diskFiles = [];
174
+ if (fsSync.existsSync(destDir)) {
175
+ diskFiles = await scanDirectoryFiles(destDir);
176
+ }
177
+
178
+ let totalBytes = 0;
179
+ for (const f of diskFiles) {
180
+ totalBytes += f.size;
181
+ }
182
+
183
+ const dbReferences = await extractDbFileReferences(db);
184
+ const normalizedBase = normalizePublicBase(publicBasePath);
185
+
186
+ const orphanedFiles = [];
187
+ const referencedFiles = [];
188
+
189
+ for (const file of diskFiles) {
190
+ const baseName = path.basename(file.relativePath);
191
+ const fullPublicUrl = `${normalizedBase}/${file.relativePath}`.replace(/\/{2,}/g, '/');
192
+
193
+ // Check if filename, relative path, or public url is referenced in DB
194
+ const isReferenced =
195
+ dbReferences.has(file.relativePath) ||
196
+ dbReferences.has(baseName) ||
197
+ dbReferences.has(fullPublicUrl) ||
198
+ dbReferences.has(fullPublicUrl.replace(/^\/+/, ''));
199
+
200
+ if (isReferenced) {
201
+ referencedFiles.push(file);
202
+ } else {
203
+ orphanedFiles.push({
204
+ ...file,
205
+ publicUrl: fullPublicUrl,
206
+ });
207
+ }
208
+ }
209
+
210
+ let orphanedBytes = 0;
211
+ for (const f of orphanedFiles) {
212
+ orphanedBytes += f.size;
213
+ }
214
+
215
+ return {
216
+ destDir,
217
+ totalFiles: diskFiles.length,
218
+ totalBytes,
219
+ totalBytesFormatted: formatBytes(totalBytes),
220
+ referencedCount: referencedFiles.length,
221
+ orphanedCount: orphanedFiles.length,
222
+ orphanedBytes,
223
+ orphanedBytesFormatted: formatBytes(orphanedBytes),
224
+ orphanedFiles,
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Prune orphaned uploads from disk
230
+ * @param {Object} options
231
+ * @param {Object} [options.db] - Database instance
232
+ * @param {string} [options.destDir] - Uploads directory on disk
233
+ * @param {string} [options.publicBasePath='/uploads'] - Public URL base path
234
+ * @param {boolean} [options.dryRun=false] - Only scan without deleting
235
+ * @returns {Promise<Object>}
236
+ */
237
+ async function pruneOrphanedUploads(options = {}) {
238
+ const dryRun = Boolean(options.dryRun);
239
+ const scan = await scanOrphanedUploads(options);
240
+
241
+ if (dryRun || scan.orphanedCount === 0) {
242
+ return {
243
+ ...scan,
244
+ dryRun,
245
+ deletedCount: 0,
246
+ deletedBytes: 0,
247
+ deletedBytesFormatted: formatBytes(0),
248
+ deletedFiles: [],
249
+ };
250
+ }
251
+
252
+ const deletedFiles = [];
253
+ let deletedBytes = 0;
254
+
255
+ for (const file of scan.orphanedFiles) {
256
+ try {
257
+ await fs.unlink(file.absolutePath);
258
+ deletedFiles.push(file.relativePath);
259
+ deletedBytes += file.size;
260
+
261
+ // Try to clean up empty parent directories
262
+ const parentDir = path.dirname(file.absolutePath);
263
+ if (parentDir !== scan.destDir) {
264
+ try {
265
+ const contents = await fs.readdir(parentDir);
266
+ if (contents.length === 0) {
267
+ await fs.rmdir(parentDir);
268
+ }
269
+ } catch (e) {}
270
+ }
271
+ } catch (e) {
272
+ // Deletion error, ignore
273
+ }
274
+ }
275
+
276
+ return {
277
+ ...scan,
278
+ dryRun: false,
279
+ deletedCount: deletedFiles.length,
280
+ deletedBytes,
281
+ deletedBytesFormatted: formatBytes(deletedBytes),
282
+ deletedFiles,
283
+ };
284
+ }
285
+
286
+ module.exports = {
287
+ formatBytes,
288
+ scanDirectoryFiles,
289
+ extractDbFileReferences,
290
+ scanOrphanedUploads,
291
+ pruneOrphanedUploads,
292
+ };
@@ -116,7 +116,9 @@ function createApiHandler(descriptor, context) {
116
116
  return next(loadErr);
117
117
  }
118
118
 
119
- apiDef = typeof apiModule === 'function' ? { handler: apiModule } : apiModule.default || apiModule;
119
+ apiDef = typeof apiModule === 'function'
120
+ ? Object.assign({ handler: apiModule }, apiModule)
121
+ : (apiModule.default || apiModule);
120
122
  handlerFn = apiDef.handler || (typeof apiModule === 'function' ? apiModule : null);
121
123
 
122
124
  if (typeof handlerFn !== 'function') {