webspresso 0.0.67 → 0.0.68
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 +50 -0
- package/bin/commands/doctor.js +23 -0
- package/bin/commands/new.js +106 -15
- package/core/orm/migrations/scaffold.js +6 -0
- package/core/orm/schema-helpers.js +19 -0
- package/core/orm/types.js +1 -1
- package/index.d.ts +30 -0
- package/index.js +15 -1
- package/package.json +3 -1
- package/plugins/admin-panel/api.js +5 -5
- package/plugins/admin-panel/components.js +184 -0
- package/plugins/admin-panel/core/registry.js +1 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +135 -66
- package/plugins/admin-panel/field-renderers/index.js +1 -0
- package/plugins/admin-panel/index.js +8 -0
- package/plugins/index.js +3 -0
- package/plugins/upload/index.js +188 -0
- package/plugins/upload/local-file-provider.js +122 -0
- package/templates/skills/webspresso-usage/SKILL.md +54 -22
|
@@ -1,124 +1,193 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* File Upload Field Renderer
|
|
3
|
-
*
|
|
3
|
+
* POST multipart field "file" to window.__ADMIN_CONFIG__.settings.uploadUrl
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
function getUploadUrlFromAdminConfig() {
|
|
7
|
+
try {
|
|
8
|
+
const cfg = typeof window !== 'undefined' ? window.__ADMIN_CONFIG__ : null;
|
|
9
|
+
const u = cfg && cfg.settings && cfg.settings.uploadUrl;
|
|
10
|
+
return u ? String(u) : '';
|
|
11
|
+
} catch {
|
|
12
|
+
return '';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
6
16
|
module.exports = {
|
|
7
17
|
FileUploadField: {
|
|
8
18
|
oncreate: (vnode) => {
|
|
9
|
-
const { name,
|
|
19
|
+
const { name, meta = {} } = vnode.attrs;
|
|
20
|
+
if (!getUploadUrlFromAdminConfig()) return;
|
|
21
|
+
|
|
10
22
|
const dropZoneId = 'drop-zone-' + name;
|
|
11
|
-
|
|
12
23
|
const dropZone = document.getElementById(dropZoneId);
|
|
13
24
|
if (!dropZone) return;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
|
25
|
+
|
|
26
|
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
|
|
17
27
|
dropZone.addEventListener(eventName, (e) => {
|
|
18
28
|
e.preventDefault();
|
|
19
29
|
e.stopPropagation();
|
|
20
30
|
});
|
|
21
31
|
});
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
['dragenter', 'dragover'].forEach(eventName => {
|
|
32
|
+
|
|
33
|
+
['dragenter', 'dragover'].forEach((eventName) => {
|
|
25
34
|
dropZone.addEventListener(eventName, () => {
|
|
26
|
-
dropZone.classList.add('border-blue-500', 'bg-blue-50');
|
|
35
|
+
dropZone.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
|
|
27
36
|
});
|
|
28
37
|
});
|
|
29
|
-
|
|
30
|
-
['dragleave', 'drop'].forEach(eventName => {
|
|
38
|
+
|
|
39
|
+
['dragleave', 'drop'].forEach((eventName) => {
|
|
31
40
|
dropZone.addEventListener(eventName, () => {
|
|
32
|
-
dropZone.classList.remove('border-blue-500', 'bg-blue-50');
|
|
41
|
+
dropZone.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
|
|
33
42
|
});
|
|
34
43
|
});
|
|
35
|
-
|
|
36
|
-
// Handle dropped files
|
|
44
|
+
|
|
37
45
|
dropZone.addEventListener('drop', (e) => {
|
|
38
46
|
const files = e.dataTransfer.files;
|
|
39
47
|
if (files.length > 0) {
|
|
40
|
-
|
|
48
|
+
handleFileUpload(files[0], vnode, meta);
|
|
41
49
|
}
|
|
42
50
|
});
|
|
43
|
-
|
|
44
|
-
// Handle file input change
|
|
51
|
+
|
|
45
52
|
const fileInput = dropZone.querySelector('input[type=file]');
|
|
46
53
|
if (fileInput) {
|
|
47
54
|
fileInput.addEventListener('change', (e) => {
|
|
48
55
|
if (e.target.files.length > 0) {
|
|
49
|
-
|
|
56
|
+
handleFileUpload(e.target.files[0], vnode, meta);
|
|
50
57
|
}
|
|
51
58
|
});
|
|
52
59
|
}
|
|
53
60
|
},
|
|
54
|
-
|
|
61
|
+
|
|
55
62
|
view: (vnode) => {
|
|
56
63
|
const { name, value = '', meta = {}, required = false } = vnode.attrs;
|
|
57
64
|
const dropZoneId = 'drop-zone-' + name;
|
|
58
|
-
const maxSize = meta.maxSize ||
|
|
65
|
+
const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
|
|
59
66
|
const accept = meta.accept || '*/*';
|
|
60
|
-
|
|
67
|
+
const uploadUrl = getUploadUrlFromAdminConfig();
|
|
68
|
+
|
|
69
|
+
if (!uploadUrl) {
|
|
70
|
+
return m('.mb-4', [
|
|
71
|
+
m(
|
|
72
|
+
'label.block.text-sm.font-medium.mb-2',
|
|
73
|
+
meta.label || name,
|
|
74
|
+
required ? m('span.text-red-500', ' *') : null
|
|
75
|
+
),
|
|
76
|
+
m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
|
|
77
|
+
m('input.w-full.px-3.py-2.border.rounded', {
|
|
78
|
+
type: 'text',
|
|
79
|
+
name,
|
|
80
|
+
value: typeof value === 'string' ? value : '',
|
|
81
|
+
placeholder: 'https://… or /uploads/…',
|
|
82
|
+
required,
|
|
83
|
+
oninput: (e) => {
|
|
84
|
+
if (vnode.attrs.onchange) vnode.attrs.onchange(e.target.value);
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
|
|
61
90
|
return m('.mb-4', [
|
|
62
|
-
m(
|
|
91
|
+
m(
|
|
92
|
+
'label.block.text-sm.font-medium.mb-2',
|
|
63
93
|
meta.label || name,
|
|
64
94
|
required ? m('span.text-red-500', ' *') : null
|
|
65
95
|
),
|
|
66
|
-
m(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
m('p.text-gray-600 dark:text-slate-400.mb-2', 'Drag and drop a file here, or'),
|
|
81
|
-
m('label.text-blue-600.hover:text-blue-800.cursor-pointer', {
|
|
82
|
-
for: 'file-input-' + name
|
|
83
|
-
}, 'browse'),
|
|
84
|
-
]),
|
|
85
|
-
value ? m('.mt-4', [
|
|
86
|
-
m('p.text-sm.text-gray-600', 'Current file: ' + (typeof value === 'string' ? value : value.name || 'uploaded')),
|
|
87
|
-
m('button.text-red-600.hover:text-red-800.text-sm.mt-2', {
|
|
88
|
-
onclick: () => {
|
|
89
|
-
if (vnode.attrs.onchange) {
|
|
90
|
-
vnode.attrs.onchange('');
|
|
96
|
+
m(
|
|
97
|
+
'div.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-8.text-center',
|
|
98
|
+
{
|
|
99
|
+
id: dropZoneId,
|
|
100
|
+
style: 'cursor: pointer;',
|
|
101
|
+
},
|
|
102
|
+
[
|
|
103
|
+
m('input[type=file]', {
|
|
104
|
+
class: 'hidden',
|
|
105
|
+
id: 'file-input-' + name,
|
|
106
|
+
accept,
|
|
107
|
+
onchange: (e) => {
|
|
108
|
+
if (e.target.files.length > 0) {
|
|
109
|
+
handleFileUpload(e.target.files[0], vnode, meta);
|
|
91
110
|
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
m('div', [
|
|
114
|
+
m('p.text-gray-600.dark:text-slate-400.mb-2', 'Drag and drop a file here, or'),
|
|
115
|
+
m(
|
|
116
|
+
'label.text-blue-600.hover:text-blue-800.cursor-pointer',
|
|
117
|
+
{ for: 'file-input-' + name },
|
|
118
|
+
'browse'
|
|
119
|
+
),
|
|
120
|
+
]),
|
|
121
|
+
value
|
|
122
|
+
? m('.mt-4', [
|
|
123
|
+
m(
|
|
124
|
+
'p.text-sm.text-gray-600.break-all',
|
|
125
|
+
'Current: ' + (typeof value === 'string' ? value : value.name || 'uploaded')
|
|
126
|
+
),
|
|
127
|
+
m(
|
|
128
|
+
'button.text-red-600.hover:text-red-800.text-sm.mt-2',
|
|
129
|
+
{
|
|
130
|
+
type: 'button',
|
|
131
|
+
onclick: () => {
|
|
132
|
+
if (vnode.attrs.onchange) vnode.attrs.onchange('');
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
'Remove'
|
|
136
|
+
),
|
|
137
|
+
])
|
|
138
|
+
: null,
|
|
139
|
+
]
|
|
140
|
+
),
|
|
96
141
|
m('input[type=hidden]', {
|
|
97
142
|
name,
|
|
98
143
|
value: typeof value === 'string' ? value : '',
|
|
99
144
|
}),
|
|
145
|
+
m(
|
|
146
|
+
'p.text-xs.text-gray-500.mt-1',
|
|
147
|
+
'Max ' + Math.round(maxSize / 1024 / 1024) + ' MB (server enforces limits)'
|
|
148
|
+
),
|
|
100
149
|
]);
|
|
101
|
-
}
|
|
150
|
+
},
|
|
102
151
|
},
|
|
103
152
|
};
|
|
104
153
|
|
|
105
154
|
/**
|
|
106
|
-
*
|
|
107
|
-
* @param {
|
|
108
|
-
* @param {Object}
|
|
109
|
-
* @param {Object} meta - Field metadata
|
|
155
|
+
* @param {File} file
|
|
156
|
+
* @param {import('mithril').Vnode} vnode
|
|
157
|
+
* @param {Object} meta
|
|
110
158
|
*/
|
|
111
|
-
function
|
|
112
|
-
const maxSize = meta.maxSize ||
|
|
113
|
-
|
|
159
|
+
async function handleFileUpload(file, vnode, meta) {
|
|
160
|
+
const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
|
|
114
161
|
if (file.size > maxSize) {
|
|
115
|
-
alert(`File
|
|
162
|
+
alert(`File too large (max ${Math.round(maxSize / 1024 / 1024)} MB).`);
|
|
116
163
|
return;
|
|
117
164
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
165
|
+
|
|
166
|
+
const uploadUrl = getUploadUrlFromAdminConfig();
|
|
167
|
+
if (!uploadUrl) {
|
|
168
|
+
alert('Upload URL is not configured.');
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const fd = new FormData();
|
|
173
|
+
fd.append('file', file);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
|
|
177
|
+
let data = {};
|
|
178
|
+
try {
|
|
179
|
+
data = await res.json();
|
|
180
|
+
} catch {
|
|
181
|
+
data = {};
|
|
182
|
+
}
|
|
183
|
+
if (!res.ok) {
|
|
184
|
+
alert(data.message || data.error || `Upload failed (${res.status})`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const url = data.url || data.publicUrl || '';
|
|
188
|
+
if (vnode.attrs.onchange) vnode.attrs.onchange(url);
|
|
189
|
+
if (typeof m !== 'undefined' && m.redraw) m.redraw();
|
|
190
|
+
} catch (err) {
|
|
191
|
+
alert(err.message || 'Upload failed');
|
|
123
192
|
}
|
|
124
193
|
}
|
|
@@ -78,6 +78,7 @@ function initializeDefaultRenderers() {
|
|
|
78
78
|
// Custom field types
|
|
79
79
|
registerFieldRenderer('rich-text', richTextRenderer.RichTextField);
|
|
80
80
|
registerFieldRenderer('file-upload', fileUploadRenderer.FileUploadField);
|
|
81
|
+
registerFieldRenderer('file', fileUploadRenderer.FileUploadField);
|
|
81
82
|
|
|
82
83
|
// Relation types
|
|
83
84
|
registerFieldRenderer('belongsTo', relationRenderers.BelongsToField);
|
|
@@ -30,6 +30,7 @@ const { registerModule } = require('./core/admin-module');
|
|
|
30
30
|
* @param {string} [options.userManagement.model='User'] - User model name
|
|
31
31
|
* @param {Object} [options.userManagement.fields] - Field mappings
|
|
32
32
|
* @param {Function} [options.configure] - Configuration callback (registry) => void
|
|
33
|
+
* @param {string} [options.uploadUrl] - POST URL for file uploads (overrides app.get('webspresso.uploadPath') from uploadPlugin)
|
|
33
34
|
* @returns {Object} Plugin definition
|
|
34
35
|
*/
|
|
35
36
|
function adminPanelPlugin(options = {}) {
|
|
@@ -41,6 +42,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
41
42
|
auth,
|
|
42
43
|
userManagement: userMgmtConfig,
|
|
43
44
|
configure,
|
|
45
|
+
uploadUrl: uploadUrlOption,
|
|
44
46
|
} = options;
|
|
45
47
|
|
|
46
48
|
// Validate required options
|
|
@@ -128,6 +130,12 @@ function adminPanelPlugin(options = {}) {
|
|
|
128
130
|
this.api._ctx = ctx;
|
|
129
131
|
const { app } = ctx;
|
|
130
132
|
|
|
133
|
+
const uploadUrlResolved =
|
|
134
|
+
uploadUrlOption || app.get('webspresso.uploadPath') || null;
|
|
135
|
+
if (uploadUrlResolved) {
|
|
136
|
+
registry.configure({ uploadUrl: uploadUrlResolved });
|
|
137
|
+
}
|
|
138
|
+
|
|
131
139
|
// Check if admin_users table exists (migration run)
|
|
132
140
|
db.knex.schema.hasTable('admin_users').then((hasAdminTable) => {
|
|
133
141
|
if (!hasAdminTable) {
|
package/plugins/index.js
CHANGED
|
@@ -16,6 +16,7 @@ const swaggerPlugin = require('./swagger');
|
|
|
16
16
|
const healthCheckPlugin = require('./health-check');
|
|
17
17
|
const restResourcePlugin = require('./rest-resources');
|
|
18
18
|
const ormCacheAdminPlugin = require('./orm-cache-admin');
|
|
19
|
+
const { uploadPlugin, createLocalFileProvider } = require('./upload');
|
|
19
20
|
|
|
20
21
|
module.exports = {
|
|
21
22
|
sitemapPlugin,
|
|
@@ -31,5 +32,7 @@ module.exports = {
|
|
|
31
32
|
healthCheckPlugin,
|
|
32
33
|
restResourcePlugin,
|
|
33
34
|
ormCacheAdminPlugin,
|
|
35
|
+
uploadPlugin,
|
|
36
|
+
createLocalFileProvider,
|
|
34
37
|
};
|
|
35
38
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File upload plugin — multipart parsing, validation, pluggable storage.
|
|
3
|
+
* @module plugins/upload
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const multer = require('multer');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { createLocalFileProvider } = require('./local-file-provider');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {import('express').Request} ExpressRequest
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} UploadPutArgs
|
|
16
|
+
* @property {Buffer} [buffer]
|
|
17
|
+
* @property {import('stream').Readable} [stream]
|
|
18
|
+
* @property {string} originalName
|
|
19
|
+
* @property {string} mimeType
|
|
20
|
+
* @property {number} size
|
|
21
|
+
* @property {ExpressRequest} req
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} UploadPutResult
|
|
26
|
+
* @property {string} publicUrl
|
|
27
|
+
* @property {string} [key]
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} UploadStorageProvider
|
|
32
|
+
* @property {(args: UploadPutArgs) => Promise<UploadPutResult>} put
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} [p]
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function normalizeRoutePath(p) {
|
|
40
|
+
const s = (p || '/api/upload').trim();
|
|
41
|
+
return s.startsWith('/') ? s : `/${s}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} [name]
|
|
46
|
+
* @returns {string} lowercase extension without dot
|
|
47
|
+
*/
|
|
48
|
+
function extensionFromName(name) {
|
|
49
|
+
return path.extname(path.basename(name || '')).replace(/^\./, '').toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {number} maxBytes
|
|
54
|
+
* @param {string} fieldName
|
|
55
|
+
* @returns {import('express').RequestHandler}
|
|
56
|
+
*/
|
|
57
|
+
function createMulterSingleMiddleware(maxBytes, fieldName) {
|
|
58
|
+
const upload = multer({
|
|
59
|
+
storage: multer.memoryStorage(),
|
|
60
|
+
limits: { fileSize: maxBytes, files: 1 },
|
|
61
|
+
});
|
|
62
|
+
return upload.single(fieldName);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {unknown} err
|
|
67
|
+
* @returns {{ status: number, message: string }}
|
|
68
|
+
*/
|
|
69
|
+
function multerErrorResponse(err) {
|
|
70
|
+
if (err instanceof multer.MulterError) {
|
|
71
|
+
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
72
|
+
return { status: 413, message: 'File too large' };
|
|
73
|
+
}
|
|
74
|
+
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
|
75
|
+
return { status: 400, message: 'Invalid upload (too many files or unexpected field)' };
|
|
76
|
+
}
|
|
77
|
+
return { status: 400, message: err.message || 'Upload error' };
|
|
78
|
+
}
|
|
79
|
+
return { status: 400, message: err instanceof Error ? err.message : String(err) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {Object} [options]
|
|
84
|
+
* @param {string} [options.path='/api/upload'] — POST route
|
|
85
|
+
* @param {UploadStorageProvider} [options.provider] — Storage backend; default: local disk
|
|
86
|
+
* @param {Object} [options.local] — Shorthand for createLocalFileProvider when provider omitted
|
|
87
|
+
* @param {string} [options.local.destDir]
|
|
88
|
+
* @param {string} [options.local.publicBasePath]
|
|
89
|
+
* @param {number} [options.maxBytes=10485760] — 10 MiB default
|
|
90
|
+
* @param {string[]|null} [options.mimeAllowlist] — If set, reject other MIME types (415)
|
|
91
|
+
* @param {string[]|null} [options.extensionAllowlist] — Optional extra guard on original extension
|
|
92
|
+
* @param {import('express').RequestHandler|import('express').RequestHandler[]} [options.middleware]
|
|
93
|
+
* @param {string} [options.fieldName='file'] — Multipart field name
|
|
94
|
+
* @returns {Object} Webspresso plugin
|
|
95
|
+
*/
|
|
96
|
+
function uploadPlugin(options = {}) {
|
|
97
|
+
const {
|
|
98
|
+
path: routePath = '/api/upload',
|
|
99
|
+
provider: userProvider,
|
|
100
|
+
local,
|
|
101
|
+
maxBytes = 10 * 1024 * 1024,
|
|
102
|
+
mimeAllowlist = null,
|
|
103
|
+
extensionAllowlist = null,
|
|
104
|
+
middleware = [],
|
|
105
|
+
fieldName = 'file',
|
|
106
|
+
} = options;
|
|
107
|
+
|
|
108
|
+
const normalizedPath = normalizeRoutePath(routePath);
|
|
109
|
+
|
|
110
|
+
const provider =
|
|
111
|
+
userProvider ||
|
|
112
|
+
createLocalFileProvider({
|
|
113
|
+
destDir: local?.destDir,
|
|
114
|
+
publicBasePath: local?.publicBasePath,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const mw = (Array.isArray(middleware) ? middleware : [middleware]).filter(Boolean);
|
|
118
|
+
const parseMultipart = createMulterSingleMiddleware(maxBytes, fieldName);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
name: 'upload',
|
|
122
|
+
version: '1.0.0',
|
|
123
|
+
description: 'Multipart file uploads with pluggable storage',
|
|
124
|
+
|
|
125
|
+
onRoutesReady(ctx) {
|
|
126
|
+
const { app } = ctx;
|
|
127
|
+
app.set('webspresso.uploadPath', normalizedPath);
|
|
128
|
+
|
|
129
|
+
const handler = (req, res) => {
|
|
130
|
+
parseMultipart(req, res, async (err) => {
|
|
131
|
+
if (err) {
|
|
132
|
+
const { status, message } = multerErrorResponse(err);
|
|
133
|
+
return res.status(status).json({ error: message, message });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const file = req.file;
|
|
137
|
+
if (!file || !file.buffer) {
|
|
138
|
+
return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const mime = file.mimetype || 'application/octet-stream';
|
|
142
|
+
const originalName = file.originalname || 'upload';
|
|
143
|
+
const ext = extensionFromName(originalName);
|
|
144
|
+
|
|
145
|
+
if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
|
|
146
|
+
return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
extensionAllowlist &&
|
|
150
|
+
extensionAllowlist.length &&
|
|
151
|
+
(!ext || !extensionAllowlist.includes(ext))
|
|
152
|
+
) {
|
|
153
|
+
return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const result = await provider.put({
|
|
158
|
+
buffer: file.buffer,
|
|
159
|
+
originalName,
|
|
160
|
+
mimeType: mime,
|
|
161
|
+
size: file.size,
|
|
162
|
+
req,
|
|
163
|
+
});
|
|
164
|
+
res.json({
|
|
165
|
+
url: result.publicUrl,
|
|
166
|
+
publicUrl: result.publicUrl,
|
|
167
|
+
key: result.key,
|
|
168
|
+
});
|
|
169
|
+
} catch (e) {
|
|
170
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
171
|
+
res.status(500).json({ error: message, message });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
ctx.addRoute('post', normalizedPath, ...mw, handler);
|
|
177
|
+
|
|
178
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
179
|
+
console.log(` Upload plugin: POST ${normalizedPath}`);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
uploadPlugin,
|
|
187
|
+
createLocalFileProvider,
|
|
188
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default upload storage: write files to local disk and expose a public URL path.
|
|
3
|
+
* @module plugins/upload/local-file-provider
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs/promises');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { randomBytes } = require('crypto');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Common MIME → canonical extension (lowercase, with leading dot).
|
|
12
|
+
* Used so stored filenames match the MIME the server accepted (see uploadPlugin mimeAllowlist).
|
|
13
|
+
* Does not verify file content; spoofed MIME is a separate concern (allowlist + optional magic-byte later).
|
|
14
|
+
*/
|
|
15
|
+
const MIME_TO_EXT = {
|
|
16
|
+
'image/jpeg': '.jpg',
|
|
17
|
+
'image/png': '.png',
|
|
18
|
+
'image/gif': '.gif',
|
|
19
|
+
'image/webp': '.webp',
|
|
20
|
+
'image/svg+xml': '.svg',
|
|
21
|
+
'image/avif': '.avif',
|
|
22
|
+
'application/pdf': '.pdf',
|
|
23
|
+
'text/plain': '.txt',
|
|
24
|
+
'text/csv': '.csv',
|
|
25
|
+
'text/html': '.html',
|
|
26
|
+
'application/json': '.json',
|
|
27
|
+
'application/zip': '.zip',
|
|
28
|
+
'application/gzip': '.gz',
|
|
29
|
+
'video/mp4': '.mp4',
|
|
30
|
+
'video/webm': '.webm',
|
|
31
|
+
'audio/mpeg': '.mp3',
|
|
32
|
+
'audio/webm': '.weba',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize public URL prefix (no trailing slash).
|
|
37
|
+
* @param {string} [publicBasePath]
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function normalizePublicBase(publicBasePath) {
|
|
41
|
+
let p = (publicBasePath == null || publicBasePath === '' ? '/uploads' : String(publicBasePath)).trim();
|
|
42
|
+
p = p.replace(/\/+$/, '');
|
|
43
|
+
if (!p.startsWith('/')) {
|
|
44
|
+
p = `/${p}`;
|
|
45
|
+
}
|
|
46
|
+
return p || '/uploads';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} [mimeType]
|
|
51
|
+
* @returns {string|null} extension with leading dot, or null if unknown
|
|
52
|
+
*/
|
|
53
|
+
function canonicalExtFromMime(mimeType) {
|
|
54
|
+
if (!mimeType || typeof mimeType !== 'string') return null;
|
|
55
|
+
const base = mimeType.split(';')[0].trim().toLowerCase();
|
|
56
|
+
return MIME_TO_EXT[base] || null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Prefer a safe extension from the filename; align with MIME when we have a mapping.
|
|
61
|
+
* @param {string} [mimeType]
|
|
62
|
+
* @param {string} extFromName - already normalized (lowercase, with dot or '')
|
|
63
|
+
* @returns {string} extension with leading dot or empty string
|
|
64
|
+
*/
|
|
65
|
+
function resolveStoredExtension(mimeType, extFromName) {
|
|
66
|
+
const fromMime = canonicalExtFromMime(mimeType);
|
|
67
|
+
if (fromMime) {
|
|
68
|
+
if (!extFromName || extFromName !== fromMime) {
|
|
69
|
+
return fromMime;
|
|
70
|
+
}
|
|
71
|
+
return extFromName;
|
|
72
|
+
}
|
|
73
|
+
return extFromName || '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {Object} [options]
|
|
78
|
+
* @param {string} [options.destDir] — Absolute or cwd-relative directory for stored files
|
|
79
|
+
* @param {string} [options.publicBasePath='/uploads'] — URL prefix (first segment of public URL)
|
|
80
|
+
* @returns {import('./index').UploadStorageProvider}
|
|
81
|
+
*/
|
|
82
|
+
function createLocalFileProvider(options = {}) {
|
|
83
|
+
const destDir = path.resolve(
|
|
84
|
+
options.destDir || path.join(process.cwd(), 'public', 'uploads')
|
|
85
|
+
);
|
|
86
|
+
const publicBase = normalizePublicBase(options.publicBasePath);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
async put({ buffer, originalName, mimeType, size, req }) {
|
|
90
|
+
void size;
|
|
91
|
+
void req;
|
|
92
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
const base = path.basename(originalName || 'upload');
|
|
95
|
+
let ext = path.extname(base).toLowerCase();
|
|
96
|
+
if (!/^\.[a-z0-9]{1,12}$/.test(ext)) {
|
|
97
|
+
ext = '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
ext = resolveStoredExtension(mimeType, ext);
|
|
101
|
+
|
|
102
|
+
const storedName = `${Date.now()}-${randomBytes(8).toString('hex')}${ext}`;
|
|
103
|
+
const target = path.join(destDir, storedName);
|
|
104
|
+
const resolvedDest = path.resolve(destDir);
|
|
105
|
+
if (!target.startsWith(resolvedDest + path.sep) && target !== resolvedDest) {
|
|
106
|
+
throw new Error('Invalid storage path');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await fs.writeFile(target, buffer);
|
|
110
|
+
|
|
111
|
+
const publicUrl = `${publicBase}/${storedName}`.replace(/\/{2,}/g, '/');
|
|
112
|
+
return { publicUrl, key: storedName };
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
createLocalFileProvider,
|
|
119
|
+
normalizePublicBase,
|
|
120
|
+
canonicalExtFromMime,
|
|
121
|
+
resolveStoredExtension,
|
|
122
|
+
};
|