webspresso 0.0.95 → 0.0.97

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 (94) hide show
  1. package/.agents/ADMIN.md +225 -0
  2. package/.agents/AGENTS.md +72 -0
  3. package/.agents/ARCHITECTURE.md +119 -0
  4. package/.agents/AUTH.md +103 -0
  5. package/.agents/CLI.md +115 -0
  6. package/.agents/COMPRESSION.md +59 -0
  7. package/.agents/CONVENTIONS.md +46 -0
  8. package/.agents/EMAIL.md +141 -0
  9. package/.agents/ERRORS.md +201 -0
  10. package/.agents/MCP.md +103 -0
  11. package/.agents/ORM.md +230 -0
  12. package/.agents/PLUGINS.md +419 -0
  13. package/.agents/QUEUE.md +113 -0
  14. package/.agents/REALTIME.md +213 -0
  15. package/.agents/ROUTING.md +292 -0
  16. package/.agents/SERVICES.md +341 -0
  17. package/.agents/SHUTDOWN.md +67 -0
  18. package/.agents/STREAMING.md +73 -0
  19. package/.agents/WORKFLOW.md +27 -0
  20. package/.agents/internal/DOCUMENTATION_REVIEW.md +485 -0
  21. package/.agents/internal/TECHNICAL_REVIEW.md +428 -0
  22. package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
  23. package/.agents/skills/webspresso-framework/SKILL.md +56 -0
  24. package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
  25. package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
  26. package/.agents/skills/webspresso-services/SKILL.md +101 -0
  27. package/.cursorrules +14 -0
  28. package/AGENTS.md +175 -0
  29. package/CLAUDE.md +54 -0
  30. package/README.md +18 -14
  31. package/bin/commands/admin-password.js +2 -1
  32. package/bin/commands/admin-setup.js +1 -1
  33. package/bin/commands/agents-init.js +209 -0
  34. package/bin/commands/api.js +114 -48
  35. package/bin/commands/audit-prune.js +1 -1
  36. package/bin/commands/doctor.js +35 -0
  37. package/bin/commands/favicon-generate.js +1 -1
  38. package/bin/commands/new.js +13 -2
  39. package/bin/commands/orm-map.js +5 -4
  40. package/bin/commands/page.js +75 -89
  41. package/bin/commands/polar-migrate.js +1 -1
  42. package/bin/commands/seed.js +1 -1
  43. package/bin/commands/service.js +77 -0
  44. package/bin/webspresso.js +2 -2
  45. package/core/auth/hash.js +10 -5
  46. package/core/compileSchema.js +1 -1
  47. package/core/errors/middleware.js +22 -2
  48. package/core/errors/normalize.js +29 -2
  49. package/{utils/schemaCache.js → core/schema-cache.js} +2 -3
  50. package/index.d.ts +306 -0
  51. package/index.js +35 -1
  52. package/package.json +36 -14
  53. package/plugins/admin-panel/app.js +10 -9
  54. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +22 -1
  55. package/plugins/admin-panel/core/api-extensions.js +4 -0
  56. package/plugins/basic-auth/index.js +3 -1
  57. package/plugins/content/client/inline-edit.js +12 -12
  58. package/plugins/cors.js +6 -7
  59. package/plugins/csrf/index.js +3 -2
  60. package/plugins/csv/engine.js +447 -0
  61. package/plugins/csv/index.js +94 -0
  62. package/plugins/csv/middleware.js +80 -0
  63. package/plugins/csv/services.js +138 -0
  64. package/plugins/file-manager/admin-component.js +613 -0
  65. package/plugins/file-manager/api-handlers.js +261 -0
  66. package/plugins/file-manager/core.js +601 -0
  67. package/plugins/file-manager/index.js +139 -0
  68. package/plugins/file-manager/security.js +217 -0
  69. package/plugins/index.js +45 -79
  70. package/plugins/mcp/transports/sse.js +4 -2
  71. package/plugins/site-analytics/admin-component.js +7 -8
  72. package/plugins/xlsx/engine.js +397 -0
  73. package/plugins/xlsx/index.js +91 -0
  74. package/plugins/xlsx/middleware.js +87 -0
  75. package/plugins/xlsx/services.js +118 -0
  76. package/src/api/api-loader.js +233 -0
  77. package/src/api/define-api.js +35 -0
  78. package/src/discovery/file-route-parser.js +201 -0
  79. package/src/discovery/scan-routes.js +265 -0
  80. package/src/file-router.js +9 -1
  81. package/src/modules/define-module.js +99 -0
  82. package/src/modules/module-discovery.js +117 -0
  83. package/src/pages/define-page.js +36 -0
  84. package/src/pages/page-loader.js +214 -0
  85. package/src/routing/mount-discovered-routes.js +78 -0
  86. package/src/routing/route-table.js +191 -0
  87. package/src/server.js +112 -15
  88. package/src/services/builtins/file-manager.js +178 -0
  89. package/src/services/discovery.js +6 -1
  90. package/src/services/index.js +6 -11
  91. package/bin/commands/skill.js +0 -187
  92. package/templates/skills/webspresso-usage/REFERENCE-framework.md +0 -289
  93. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +0 -93
  94. package/templates/skills/webspresso-usage/SKILL.md +0 -48
@@ -0,0 +1,601 @@
1
+ /**
2
+ * File Manager Core Engine — Safe filesystem operations, traversal protection & metadata resolution.
3
+ * @module plugins/file-manager/core
4
+ */
5
+
6
+ const fs = require('fs/promises');
7
+ const path = require('path');
8
+ const { randomBytes } = require('crypto');
9
+ const { ValidationError, SecurityError, NotFoundError } = require('../../core/errors');
10
+ const { normalizePublicBase, canonicalExtFromMime } = require('../upload/local-file-provider');
11
+ const {
12
+ validateSafeExtension,
13
+ validateMagicBytes,
14
+ sanitizeSvgContent,
15
+ } = require('./security');
16
+
17
+ /**
18
+ * File category map by extension
19
+ */
20
+ const EXTENSION_CATEGORIES = {
21
+ // Images
22
+ jpg: 'image',
23
+ jpeg: 'image',
24
+ png: 'image',
25
+ gif: 'image',
26
+ webp: 'image',
27
+ svg: 'image',
28
+ avif: 'image',
29
+ bmp: 'image',
30
+ ico: 'image',
31
+ tiff: 'image',
32
+
33
+ // Documents
34
+ pdf: 'document',
35
+ doc: 'document',
36
+ docx: 'document',
37
+ xls: 'document',
38
+ xlsx: 'document',
39
+ ppt: 'document',
40
+ pptx: 'document',
41
+ odt: 'document',
42
+ ods: 'document',
43
+ odp: 'document',
44
+ rtf: 'document',
45
+
46
+ // Text & Code
47
+ txt: 'text',
48
+ csv: 'text',
49
+ md: 'text',
50
+ json: 'code',
51
+ xml: 'code',
52
+ html: 'code',
53
+ css: 'code',
54
+ js: 'code',
55
+ ts: 'code',
56
+ jsx: 'code',
57
+ tsx: 'code',
58
+ sql: 'code',
59
+ yaml: 'code',
60
+ yml: 'code',
61
+
62
+ // Audio
63
+ mp3: 'audio',
64
+ wav: 'audio',
65
+ ogg: 'audio',
66
+ m4a: 'audio',
67
+ flac: 'audio',
68
+ aac: 'audio',
69
+ weba: 'audio',
70
+
71
+ // Video
72
+ mp4: 'video',
73
+ webm: 'video',
74
+ mov: 'video',
75
+ avi: 'video',
76
+ mkv: 'video',
77
+ wmv: 'video',
78
+
79
+ // Archives
80
+ zip: 'archive',
81
+ rar: 'archive',
82
+ tar: 'archive',
83
+ gz: 'archive',
84
+ '7z': 'archive',
85
+ bz2: 'archive',
86
+ };
87
+
88
+ const MIME_MAP = {
89
+ jpg: 'image/jpeg',
90
+ jpeg: 'image/jpeg',
91
+ png: 'image/png',
92
+ gif: 'image/gif',
93
+ webp: 'image/webp',
94
+ svg: 'image/svg+xml',
95
+ avif: 'image/avif',
96
+ ico: 'image/x-icon',
97
+ pdf: 'application/pdf',
98
+ txt: 'text/plain',
99
+ csv: 'text/csv',
100
+ json: 'application/json',
101
+ zip: 'application/zip',
102
+ gz: 'application/gzip',
103
+ mp3: 'audio/mpeg',
104
+ wav: 'audio/wav',
105
+ ogg: 'audio/ogg',
106
+ mp4: 'video/mp4',
107
+ webm: 'video/webm',
108
+ html: 'text/html',
109
+ css: 'text/css',
110
+ js: 'application/javascript',
111
+ };
112
+
113
+ /**
114
+ * Validate and safely resolve relative path within baseDir to prevent path traversal.
115
+ * @param {string} baseDir - Base root directory
116
+ * @param {string} [relPath=''] - Relative path inside baseDir
117
+ * @returns {string} Absolute normalized filesystem path
118
+ */
119
+ function resolveSafePath(baseDir, relPath = '') {
120
+ if (typeof baseDir !== 'string' || !baseDir.trim()) {
121
+ throw new ValidationError('Invalid base directory');
122
+ }
123
+
124
+ const normalizedBase = path.resolve(baseDir);
125
+
126
+ if (relPath == null || relPath === '') {
127
+ return normalizedBase;
128
+ }
129
+
130
+ if (typeof relPath !== 'string') {
131
+ throw new ValidationError('Path must be a string');
132
+ }
133
+
134
+ // Detect null byte injection
135
+ if (relPath.includes('\0')) {
136
+ throw new SecurityError('Null byte detected in path', 400);
137
+ }
138
+
139
+ // Prevent URL decoded traversal attempts
140
+ const decodedPath = decodeURIComponent(relPath);
141
+ if (decodedPath.includes('\0')) {
142
+ throw new SecurityError('Null byte detected in path', 400);
143
+ }
144
+
145
+ // Normalize slashes
146
+ const cleanRel = decodedPath.replace(/\\/g, '/').replace(/^\/+/, '');
147
+ const segments = cleanRel.split('/');
148
+ for (const segment of segments) {
149
+ if (segment === '..' || segment === '.') {
150
+ throw new SecurityError('Directory traversal attempt detected', 403);
151
+ }
152
+ }
153
+
154
+ const targetPath = path.resolve(normalizedBase, cleanRel);
155
+
156
+ // Guarantee target is within baseDir
157
+ if (!targetPath.startsWith(normalizedBase + path.sep) && targetPath !== normalizedBase) {
158
+ throw new SecurityError('Path traversal attempt detected', 403);
159
+ }
160
+
161
+ return targetPath;
162
+ }
163
+
164
+ /**
165
+ * Sanitize a single filename or folder name.
166
+ * @param {string} name
167
+ * @returns {string} Clean name
168
+ */
169
+ function sanitizeName(name) {
170
+ if (!name || typeof name !== 'string') {
171
+ throw new ValidationError('Name is required');
172
+ }
173
+
174
+ const trimmed = name.trim();
175
+ if (!trimmed || trimmed === '.' || trimmed === '..') {
176
+ throw new ValidationError('Invalid file or directory name');
177
+ }
178
+
179
+ if (trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('\0')) {
180
+ throw new ValidationError('Name cannot contain path separators or null bytes');
181
+ }
182
+
183
+ // Disallow hidden or risky filenames
184
+ if (trimmed.startsWith('.')) {
185
+ throw new ValidationError('Dotfiles are not allowed');
186
+ }
187
+
188
+ return trimmed;
189
+ }
190
+
191
+ /**
192
+ * Get category type of a file
193
+ * @param {string} ext
194
+ * @returns {string} 'image' | 'document' | 'video' | 'audio' | 'archive' | 'code' | 'text' | 'other'
195
+ */
196
+ function getFileType(ext) {
197
+ const cleanExt = (ext || '').replace(/^\./, '').toLowerCase();
198
+ return EXTENSION_CATEGORIES[cleanExt] || 'other';
199
+ }
200
+
201
+ /**
202
+ * Guess MIME type from extension
203
+ * @param {string} ext
204
+ * @returns {string}
205
+ */
206
+ function getMimeType(ext) {
207
+ const cleanExt = (ext || '').replace(/^\./, '').toLowerCase();
208
+ return MIME_MAP[cleanExt] || 'application/octet-stream';
209
+ }
210
+
211
+ /**
212
+ * Compute breadcrumbs array for navigation
213
+ * @param {string} relPath
214
+ * @returns {Array<{ name: string, path: string }>}
215
+ */
216
+ function buildBreadcrumbs(relPath) {
217
+ const breadcrumbs = [{ name: 'Root', path: '' }];
218
+ if (!relPath) return breadcrumbs;
219
+
220
+ const segments = relPath.replace(/\\/g, '/').split('/').filter(Boolean);
221
+ let current = '';
222
+
223
+ for (const seg of segments) {
224
+ current = current ? `${current}/${seg}` : seg;
225
+ breadcrumbs.push({ name: seg, path: current });
226
+ }
227
+
228
+ return breadcrumbs;
229
+ }
230
+
231
+ /**
232
+ * List files and directories in relativePath.
233
+ * @param {string} baseDir
234
+ * @param {string} [relPath='']
235
+ * @param {Object} [options]
236
+ * @returns {Promise<Object>}
237
+ */
238
+ async function listFiles(baseDir, relPath = '', options = {}) {
239
+ const targetDir = resolveSafePath(baseDir, relPath);
240
+ const normalizedRel = path.relative(path.resolve(baseDir), targetDir).replace(/\\/g, '');
241
+ const publicBase = normalizePublicBase(options.publicBasePath);
242
+
243
+ let dirents;
244
+ try {
245
+ dirents = await fs.readdir(targetDir, { withFileTypes: true });
246
+ } catch (err) {
247
+ if (err.code === 'ENOENT') {
248
+ throw new NotFoundError(`Directory not found: ${relPath}`);
249
+ }
250
+ throw err;
251
+ }
252
+
253
+ const items = [];
254
+ let dirsCount = 0;
255
+ let filesCount = 0;
256
+
257
+ for (const dirent of dirents) {
258
+ // Ignore hidden files / dotfiles (.git, .DS_Store, etc.)
259
+ if (dirent.name.startsWith('.')) continue;
260
+
261
+ const itemRelPath = normalizedRel ? `${normalizedRel}/${dirent.name}` : dirent.name;
262
+ const fullPath = path.join(targetDir, dirent.name);
263
+
264
+ if (dirent.isDirectory()) {
265
+ dirsCount++;
266
+ let mtime = null;
267
+ try {
268
+ const stats = await fs.stat(fullPath);
269
+ mtime = stats.mtime;
270
+ } catch (e) {}
271
+
272
+ items.push({
273
+ name: dirent.name,
274
+ isDir: true,
275
+ path: itemRelPath,
276
+ size: 0,
277
+ mtime,
278
+ ext: '',
279
+ type: 'folder',
280
+ mimeType: '',
281
+ publicUrl: null,
282
+ });
283
+ } else if (dirent.isFile()) {
284
+ filesCount++;
285
+ try {
286
+ const stats = await fs.stat(fullPath);
287
+ const ext = path.extname(dirent.name).replace(/^\./, '').toLowerCase();
288
+ const type = getFileType(ext);
289
+ const mimeType = getMimeType(ext);
290
+ const publicUrl = `${publicBase}/${itemRelPath}`.replace(/\/{2,}/g, '/');
291
+
292
+ items.push({
293
+ name: dirent.name,
294
+ isDir: false,
295
+ path: itemRelPath,
296
+ size: stats.size,
297
+ mtime: stats.mtime,
298
+ ext,
299
+ type,
300
+ mimeType,
301
+ publicUrl,
302
+ });
303
+ } catch (e) {
304
+ // Skip unreadable files
305
+ }
306
+ }
307
+ }
308
+
309
+ // Filter: Search
310
+ let filtered = items;
311
+ if (options.search && typeof options.search === 'string') {
312
+ const q = options.search.toLowerCase().trim();
313
+ filtered = filtered.filter((i) => i.name.toLowerCase().includes(q));
314
+ }
315
+
316
+ // Filter: Type
317
+ if (options.type && options.type !== 'all') {
318
+ const targetType = String(options.type).toLowerCase();
319
+ filtered = filtered.filter((i) => i.isDir || i.type === targetType);
320
+ }
321
+
322
+ // Sorting
323
+ const sortBy = options.sort || 'name';
324
+ const order = (options.order || 'asc').toLowerCase() === 'desc' ? -1 : 1;
325
+
326
+ filtered.sort((a, b) => {
327
+ // Folders always come first
328
+ if (a.isDir && !b.isDir) return -1;
329
+ if (!a.isDir && b.isDir) return 1;
330
+
331
+ if (sortBy === 'size') {
332
+ return (a.size - b.size) * order;
333
+ }
334
+ if (sortBy === 'mtime') {
335
+ const timeA = a.mtime ? new Date(a.mtime).getTime() : 0;
336
+ const timeB = b.mtime ? new Date(b.mtime).getTime() : 0;
337
+ return (timeA - timeB) * order;
338
+ }
339
+ if (sortBy === 'ext') {
340
+ return (a.ext || '').localeCompare(b.ext || '') * order;
341
+ }
342
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }) * order;
343
+ });
344
+
345
+ const total = filtered.length;
346
+ let pagedItems = filtered;
347
+
348
+ if (options.page && options.perPage) {
349
+ const page = Math.max(1, parseInt(options.page, 10) || 1);
350
+ const perPage = Math.max(1, parseInt(options.perPage, 10) || 20);
351
+ const start = (page - 1) * perPage;
352
+ pagedItems = filtered.slice(start, start + perPage);
353
+ }
354
+
355
+ return {
356
+ path: normalizedRel,
357
+ breadcrumbs: buildBreadcrumbs(normalizedRel),
358
+ items: pagedItems,
359
+ total,
360
+ dirsCount,
361
+ filesCount,
362
+ page: options.page ? parseInt(options.page, 10) : 1,
363
+ perPage: options.perPage ? parseInt(options.perPage, 10) : total,
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Create a new folder
369
+ * @param {string} baseDir
370
+ * @param {string} parentRelPath
371
+ * @param {string} name
372
+ * @returns {Promise<{ success: boolean, path: string, name: string }>}
373
+ */
374
+ async function createDirectory(baseDir, parentRelPath, name) {
375
+ const safeName = sanitizeName(name);
376
+ const parentDir = resolveSafePath(baseDir, parentRelPath);
377
+ const targetDir = path.join(parentDir, safeName);
378
+
379
+ // Security check on target
380
+ resolveSafePath(baseDir, path.relative(baseDir, targetDir));
381
+
382
+ try {
383
+ await fs.mkdir(targetDir, { recursive: false });
384
+ } catch (err) {
385
+ if (err.code === 'EEXIST') {
386
+ throw new ValidationError(`Directory already exists: ${safeName}`);
387
+ }
388
+ throw err;
389
+ }
390
+
391
+ const relNewPath = path.relative(path.resolve(baseDir), targetDir).replace(/\\/g, '/');
392
+ return { success: true, path: relNewPath, name: safeName };
393
+ }
394
+
395
+ /**
396
+ * Rename a file or folder
397
+ * @param {string} baseDir
398
+ * @param {string} itemRelPath
399
+ * @param {string} newName
400
+ * @returns {Promise<{ success: boolean, oldPath: string, newPath: string, newName: string }>}
401
+ */
402
+ async function renameItem(baseDir, itemRelPath, newName) {
403
+ const safeNewName = sanitizeName(newName);
404
+ const oldFullPath = resolveSafePath(baseDir, itemRelPath);
405
+ const parentDir = path.dirname(oldFullPath);
406
+ const newFullPath = path.join(parentDir, safeNewName);
407
+
408
+ // Security check
409
+ resolveSafePath(baseDir, path.relative(baseDir, newFullPath));
410
+
411
+ try {
412
+ await fs.access(oldFullPath);
413
+ } catch (e) {
414
+ throw new NotFoundError(`File or folder not found: ${itemRelPath}`);
415
+ }
416
+
417
+ try {
418
+ await fs.rename(oldFullPath, newFullPath);
419
+ } catch (err) {
420
+ if (err.code === 'EEXIST') {
421
+ throw new ValidationError(`An item named "${safeNewName}" already exists`);
422
+ }
423
+ throw err;
424
+ }
425
+
426
+ const relNewPath = path.relative(path.resolve(baseDir), newFullPath).replace(/\\/g, '/');
427
+ return { success: true, oldPath: itemRelPath, newPath: relNewPath, newName: safeNewName };
428
+ }
429
+
430
+ /**
431
+ * Delete a file or folder safely
432
+ * @param {string} baseDir
433
+ * @param {string} itemRelPath
434
+ * @returns {Promise<{ success: boolean, path: string }>}
435
+ */
436
+ async function deleteItem(baseDir, itemRelPath) {
437
+ if (!itemRelPath || itemRelPath.trim() === '' || itemRelPath === '/') {
438
+ throw new SecurityError('Cannot delete root directory', 403);
439
+ }
440
+
441
+ const targetFullPath = resolveSafePath(baseDir, itemRelPath);
442
+
443
+ let stats;
444
+ try {
445
+ stats = await fs.stat(targetFullPath);
446
+ } catch (err) {
447
+ if (err.code === 'ENOENT') {
448
+ throw new NotFoundError(`Item not found: ${itemRelPath}`);
449
+ }
450
+ throw err;
451
+ }
452
+
453
+ if (stats.isDirectory()) {
454
+ await fs.rm(targetFullPath, { recursive: true, force: true });
455
+ } else {
456
+ await fs.unlink(targetFullPath);
457
+ }
458
+
459
+ return { success: true, path: itemRelPath };
460
+ }
461
+
462
+ /**
463
+ * Move a file or folder into a destination directory
464
+ * @param {string} baseDir
465
+ * @param {string} sourceRelPath
466
+ * @param {string} destDirRelPath
467
+ * @returns {Promise<{ success: boolean, oldPath: string, newPath: string }>}
468
+ */
469
+ async function moveItem(baseDir, sourceRelPath, destDirRelPath) {
470
+ if (!sourceRelPath) throw new ValidationError('Source path is required');
471
+ const sourceFullPath = resolveSafePath(baseDir, sourceRelPath);
472
+ const destDirFullPath = resolveSafePath(baseDir, destDirRelPath || '');
473
+
474
+ const itemName = path.basename(sourceFullPath);
475
+ const targetFullPath = path.join(destDirFullPath, itemName);
476
+
477
+ // Ensure target doesn't move a folder into itself
478
+ if (targetFullPath.startsWith(sourceFullPath + path.sep)) {
479
+ throw new ValidationError('Cannot move a folder into its own subdirectory');
480
+ }
481
+
482
+ resolveSafePath(baseDir, path.relative(baseDir, targetFullPath));
483
+
484
+ await fs.rename(sourceFullPath, targetFullPath);
485
+
486
+ const newRelPath = path.relative(path.resolve(baseDir), targetFullPath).replace(/\\/g, '/');
487
+ return { success: true, oldPath: sourceRelPath, newPath: newRelPath };
488
+ }
489
+
490
+ /**
491
+ * Calculate total disk usage in bytes under baseDir
492
+ * @param {string} baseDir
493
+ * @returns {Promise<number>}
494
+ */
495
+ async function calculateStorageUsage(baseDir) {
496
+ let totalBytes = 0;
497
+ async function scan(dir) {
498
+ try {
499
+ const entries = await fs.readdir(dir, { withFileTypes: true });
500
+ for (const entry of entries) {
501
+ if (entry.name.startsWith('.')) continue;
502
+ const full = path.join(dir, entry.name);
503
+ if (entry.isDirectory()) {
504
+ await scan(full);
505
+ } else if (entry.isFile()) {
506
+ const st = await fs.stat(full);
507
+ totalBytes += st.size;
508
+ }
509
+ }
510
+ } catch (e) {}
511
+ }
512
+ await scan(path.resolve(baseDir));
513
+ return totalBytes;
514
+ }
515
+
516
+ /**
517
+ * Save an uploaded buffer to a specific target relative folder
518
+ * @param {string} baseDir
519
+ * @param {string} targetDirRelPath
520
+ * @param {Buffer} buffer
521
+ * @param {string} originalName
522
+ * @param {string} [mimeType]
523
+ * @param {string} [publicBasePath]
524
+ * @param {Object} [securityOpts]
525
+ * @returns {Promise<Object>}
526
+ */
527
+ async function saveUploadedFile(baseDir, targetDirRelPath, buffer, originalName, mimeType, publicBasePath, securityOpts = {}) {
528
+ const destDir = resolveSafePath(baseDir, targetDirRelPath || '');
529
+ await fs.mkdir(destDir, { recursive: true });
530
+
531
+ const safeOriginal = path.basename(originalName || 'upload');
532
+
533
+ // Security 1: Validate dangerous & double extensions
534
+ const cleanExt = validateSafeExtension(safeOriginal);
535
+ let ext = `.${cleanExt}`;
536
+
537
+ const fromMime = canonicalExtFromMime(mimeType);
538
+ if (fromMime && ext !== fromMime) {
539
+ ext = fromMime;
540
+ }
541
+
542
+ let finalBuffer = buffer;
543
+
544
+ // Security 2: SVG Stored XSS Sanitization
545
+ if (cleanExt === 'svg' || mimeType === 'image/svg+xml') {
546
+ if (securityOpts.sanitizeSvg !== false) {
547
+ finalBuffer = sanitizeSvgContent(finalBuffer);
548
+ }
549
+ }
550
+
551
+ // Security 3: Magic Bytes Verification
552
+ if (securityOpts.validateMagicBytes !== false) {
553
+ validateMagicBytes(finalBuffer, cleanExt);
554
+ }
555
+
556
+ let baseNameOnly = path.basename(safeOriginal, path.extname(safeOriginal)).replace(/[^a-zA-Z0-9_\-\s]/g, '').trim() || 'file';
557
+ let storedName = `${baseNameOnly}${ext}`;
558
+ let targetPath = path.join(destDir, storedName);
559
+
560
+ try {
561
+ await fs.access(targetPath);
562
+ storedName = `${baseNameOnly}-${Date.now()}${ext}`;
563
+ targetPath = path.join(destDir, storedName);
564
+ } catch (e) {
565
+ // Safe to use original clean name
566
+ }
567
+
568
+ resolveSafePath(baseDir, path.relative(baseDir, targetPath));
569
+
570
+ await fs.writeFile(targetPath, finalBuffer);
571
+
572
+ const relFilePath = path.relative(path.resolve(baseDir), targetPath).replace(/\\/g, '/');
573
+ const publicBase = normalizePublicBase(publicBasePath);
574
+ const publicUrl = `${publicBase}/${relFilePath}`.replace(/\/{2,}/g, '/');
575
+
576
+ return {
577
+ name: storedName,
578
+ originalName: safeOriginal,
579
+ path: relFilePath,
580
+ publicUrl,
581
+ size: finalBuffer.length,
582
+ mimeType: mimeType || getMimeType(ext),
583
+ ext: ext.replace(/^\./, ''),
584
+ type: getFileType(ext),
585
+ };
586
+ }
587
+
588
+ module.exports = {
589
+ resolveSafePath,
590
+ sanitizeName,
591
+ getFileType,
592
+ getMimeType,
593
+ buildBreadcrumbs,
594
+ listFiles,
595
+ createDirectory,
596
+ renameItem,
597
+ deleteItem,
598
+ moveItem,
599
+ saveUploadedFile,
600
+ calculateStorageUsage,
601
+ };