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.
@@ -1,124 +1,193 @@
1
1
  /**
2
2
  * File Upload Field Renderer
3
- * Droppable file upload component
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, value = '', onchange, meta = {} } = vnode.attrs;
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
- // Prevent default drag behaviors
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
- // Highlight drop zone when item is dragged over it
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
- handleFile(files[0], vnode, meta);
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
- handleFile(e.target.files[0], vnode, meta);
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 || 5 * 1024 * 1024; // 5MB default
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('label.block.text-sm.font-medium.mb-2',
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('div#drop-zone-' + name + '.border-2.border-dashed.border-gray-300 dark:border-slate-600.rounded.p-8.text-center', {
67
- style: 'cursor: pointer;'
68
- }, [
69
- m('input[type=file]', {
70
- class: 'hidden',
71
- id: 'file-input-' + name,
72
- accept,
73
- onchange: (e) => {
74
- if (e.target.files.length > 0) {
75
- handleFile(e.target.files[0], vnode, meta);
76
- }
77
- }
78
- }),
79
- m('div', [
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
- }, 'Remove'),
94
- ]) : null,
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
- * Handle file upload
107
- * @param {File} file - File object
108
- * @param {Object} vnode - Mithril vnode
109
- * @param {Object} meta - Field metadata
155
+ * @param {File} file
156
+ * @param {import('mithril').Vnode} vnode
157
+ * @param {Object} meta
110
158
  */
111
- function handleFile(file, vnode, meta) {
112
- const maxSize = meta.maxSize || 5 * 1024 * 1024;
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 size exceeds maximum allowed size of ${Math.round(maxSize / 1024 / 1024)}MB`);
162
+ alert(`File too large (max ${Math.round(maxSize / 1024 / 1024)} MB).`);
116
163
  return;
117
164
  }
118
-
119
- // For now, just store the file name
120
- // In a real implementation, you'd upload to server and get URL
121
- if (vnode.attrs.onchange) {
122
- vnode.attrs.onchange(file.name);
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
+ };