webspresso 0.0.95 → 0.0.96
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 +225 -0
- package/.agents/AGENTS.md +72 -0
- package/.agents/ARCHITECTURE.md +119 -0
- package/.agents/AUTH.md +103 -0
- package/.agents/CLI.md +115 -0
- package/.agents/COMPRESSION.md +59 -0
- package/.agents/CONVENTIONS.md +46 -0
- package/.agents/EMAIL.md +141 -0
- package/.agents/ERRORS.md +201 -0
- package/.agents/MCP.md +103 -0
- package/.agents/ORM.md +230 -0
- package/.agents/PLUGINS.md +419 -0
- package/.agents/QUEUE.md +113 -0
- package/.agents/REALTIME.md +213 -0
- package/.agents/ROUTING.md +292 -0
- package/.agents/SERVICES.md +341 -0
- package/.agents/SHUTDOWN.md +67 -0
- package/.agents/STREAMING.md +73 -0
- package/.agents/WORKFLOW.md +27 -0
- package/.agents/internal/DOCUMENTATION_REVIEW.md +485 -0
- package/.agents/internal/TECHNICAL_REVIEW.md +428 -0
- package/.cursorrules +14 -0
- package/AGENTS.md +175 -0
- package/CLAUDE.md +54 -0
- package/README.md +18 -14
- package/bin/commands/agents-init.js +169 -0
- package/bin/commands/api.js +114 -48
- package/bin/commands/doctor.js +35 -0
- package/bin/commands/new.js +13 -2
- package/bin/commands/page.js +75 -89
- package/bin/commands/service.js +77 -0
- package/bin/webspresso.js +2 -2
- package/core/compileSchema.js +1 -1
- package/core/errors/middleware.js +22 -2
- package/core/errors/normalize.js +29 -2
- package/{utils/schemaCache.js → core/schema-cache.js} +2 -3
- package/index.d.ts +306 -0
- package/index.js +35 -1
- package/package.json +5 -3
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +22 -1
- package/plugins/csrf/index.js +3 -2
- package/plugins/csv/engine.js +447 -0
- package/plugins/csv/index.js +94 -0
- package/plugins/csv/middleware.js +80 -0
- package/plugins/csv/services.js +138 -0
- package/plugins/file-manager/admin-component.js +613 -0
- package/plugins/file-manager/api-handlers.js +261 -0
- package/plugins/file-manager/core.js +601 -0
- package/plugins/file-manager/index.js +139 -0
- package/plugins/file-manager/security.js +207 -0
- package/plugins/index.js +8 -0
- package/plugins/xlsx/engine.js +397 -0
- package/plugins/xlsx/index.js +91 -0
- package/plugins/xlsx/middleware.js +87 -0
- package/plugins/xlsx/services.js +118 -0
- package/src/api/api-loader.js +233 -0
- package/src/api/define-api.js +35 -0
- package/src/discovery/file-route-parser.js +201 -0
- package/src/discovery/scan-routes.js +265 -0
- package/src/file-router.js +6 -0
- package/src/modules/define-module.js +99 -0
- package/src/modules/module-discovery.js +117 -0
- package/src/pages/define-page.js +36 -0
- package/src/pages/page-loader.js +214 -0
- package/src/routing/mount-discovered-routes.js +78 -0
- package/src/routing/route-table.js +191 -0
- package/src/server.js +112 -15
- package/src/services/builtins/file-manager.js +178 -0
- package/src/services/index.js +2 -0
- package/bin/commands/skill.js +0 -187
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +0 -289
- package/templates/skills/webspresso-usage/REFERENCE-kernel.md +0 -93
- 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, '/').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
|
+
};
|