webspresso 0.0.97 → 0.0.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/ADMIN.md +153 -5
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/CLI.md +195 -1
- package/.agents/PLUGINS.md +153 -9
- package/.agents/QUEUE.md +43 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/index.js +12 -7
- package/core/orm/model.js +49 -7
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/manager.js +123 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +660 -10
- package/index.js +44 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/audit-log/admin-component.js +11 -0
- package/plugins/audit-log/api-handlers.js +34 -0
- package/plugins/audit-log/index.js +129 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/index.js +4 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +11 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +3 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/file-router.js +210 -62
- package/src/helpers.js +958 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/server.js +629 -63
package/plugins/swagger.js
CHANGED
|
@@ -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, '<')
|
|
24
|
+
.replace(/>/g, '>')
|
|
25
|
+
.replace(/"/g, '"')
|
|
26
|
+
.replace(/'/g, ''');
|
|
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
|
|
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.
|
|
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:
|
|
101
|
-
version:
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/plugins/upload/index.js
CHANGED
|
@@ -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
|
+
};
|
package/src/api/api-loader.js
CHANGED
|
@@ -116,7 +116,9 @@ function createApiHandler(descriptor, context) {
|
|
|
116
116
|
return next(loadErr);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
apiDef = typeof apiModule === 'function'
|
|
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') {
|