mdv-live 0.3.1 → 0.3.2
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/CHANGELOG.md +20 -0
- package/bin/mdv.js +134 -89
- package/package.json +1 -1
- package/src/api/file.js +140 -111
- package/src/api/pdf.js +24 -27
- package/src/api/tree.js +35 -28
- package/src/api/upload.js +26 -25
- package/src/rendering/index.js +26 -25
- package/src/rendering/markdown.js +27 -40
- package/src/server.js +53 -66
- package/src/static/app.js +107 -140
- package/src/static/index.html +48 -25
- package/src/static/styles.css +95 -169
- package/src/utils/fileTypes.js +99 -90
- package/src/utils/path.js +11 -14
- package/src/watcher.js +38 -48
- package/src/websocket.js +17 -13
- package/README.pdf +0 -0
package/src/api/file.js
CHANGED
|
@@ -6,45 +6,102 @@ import fs from 'fs/promises';
|
|
|
6
6
|
import { createReadStream } from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import mime from 'mime-types';
|
|
9
|
+
import WebSocket from 'ws';
|
|
9
10
|
import { getFileType } from '../utils/fileTypes.js';
|
|
10
11
|
import { renderFile } from '../rendering/index.js';
|
|
11
12
|
import { validatePath } from '../utils/path.js';
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Validate path and resolve to full path
|
|
16
|
+
* @param {string} relativePath - Relative path to validate
|
|
17
|
+
* @param {string} rootDir - Root directory
|
|
18
|
+
* @returns {{ valid: boolean, fullPath: string }} Validation result with full path
|
|
19
|
+
*/
|
|
20
|
+
function resolveAndValidate(relativePath, rootDir) {
|
|
21
|
+
if (!relativePath || !validatePath(relativePath, rootDir)) {
|
|
22
|
+
return { valid: false, fullPath: '' };
|
|
23
|
+
}
|
|
24
|
+
return { valid: true, fullPath: path.join(rootDir, relativePath) };
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
/**
|
|
14
28
|
* Broadcast tree_update to all WebSocket clients
|
|
15
29
|
* @param {Express} app - Express app instance
|
|
30
|
+
* @returns {void}
|
|
16
31
|
*/
|
|
17
32
|
function broadcastTreeUpdate(app) {
|
|
18
33
|
const wss = app.locals.wss;
|
|
19
|
-
if (wss)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
34
|
+
if (!wss) return;
|
|
35
|
+
|
|
36
|
+
const message = JSON.stringify({ type: 'tree_update' });
|
|
37
|
+
for (const client of wss.clients) {
|
|
38
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
39
|
+
client.send(message);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build download URL for a file
|
|
46
|
+
* @param {string} relativePath - Relative path to file
|
|
47
|
+
* @returns {string} Download URL
|
|
48
|
+
*/
|
|
49
|
+
function buildDownloadUrl(relativePath) {
|
|
50
|
+
return `/api/download?path=${encodeURIComponent(relativePath)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build response for binary files with appropriate media URLs
|
|
55
|
+
* @param {string} name - File name
|
|
56
|
+
* @param {object} fileType - File type info
|
|
57
|
+
* @param {string} downloadUrl - Download URL
|
|
58
|
+
* @returns {object} Response object
|
|
59
|
+
*/
|
|
60
|
+
function buildBinaryFileResponse(name, fileType, downloadUrl) {
|
|
61
|
+
const response = {
|
|
62
|
+
name,
|
|
63
|
+
fileType: fileType.type,
|
|
64
|
+
icon: fileType.icon,
|
|
65
|
+
downloadUrl
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
switch (fileType.type) {
|
|
69
|
+
case 'image':
|
|
70
|
+
response.imageUrl = downloadUrl;
|
|
71
|
+
break;
|
|
72
|
+
case 'pdf':
|
|
73
|
+
response.pdfUrl = downloadUrl;
|
|
74
|
+
break;
|
|
75
|
+
case 'video':
|
|
76
|
+
case 'audio':
|
|
77
|
+
response.mediaUrl = downloadUrl;
|
|
78
|
+
break;
|
|
26
79
|
}
|
|
80
|
+
|
|
81
|
+
return response;
|
|
27
82
|
}
|
|
28
83
|
|
|
29
84
|
/**
|
|
30
85
|
* Setup file routes
|
|
31
86
|
* @param {Express} app - Express app instance
|
|
87
|
+
* @returns {void}
|
|
32
88
|
*/
|
|
33
89
|
export function setupFileRoutes(app) {
|
|
90
|
+
const { rootDir } = app.locals;
|
|
91
|
+
|
|
34
92
|
// Get file content
|
|
35
93
|
app.get('/api/file', async (req, res) => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (!relativePath) {
|
|
39
|
-
return res.status(400).json({ error: 'Path is required' });
|
|
40
|
-
}
|
|
94
|
+
const { path: relativePath } = req.query;
|
|
95
|
+
const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
|
|
41
96
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
97
|
+
if (!relativePath) {
|
|
98
|
+
return res.status(400).json({ error: 'Path is required' });
|
|
99
|
+
}
|
|
100
|
+
if (!valid) {
|
|
101
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
102
|
+
}
|
|
46
103
|
|
|
47
|
-
|
|
104
|
+
try {
|
|
48
105
|
const stats = await fs.stat(fullPath);
|
|
49
106
|
if (stats.isDirectory()) {
|
|
50
107
|
return res.status(400).json({ error: 'Cannot read directory' });
|
|
@@ -53,33 +110,13 @@ export function setupFileRoutes(app) {
|
|
|
53
110
|
const fileType = getFileType(relativePath);
|
|
54
111
|
const name = path.basename(relativePath);
|
|
55
112
|
|
|
56
|
-
// Handle binary files
|
|
57
113
|
if (fileType.binary) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
fileType: fileType.type,
|
|
61
|
-
icon: fileType.icon,
|
|
62
|
-
downloadUrl: `/api/download?path=${encodeURIComponent(relativePath)}`,
|
|
63
|
-
// Special URLs for media types
|
|
64
|
-
...(fileType.type === 'image' && {
|
|
65
|
-
imageUrl: `/api/download?path=${encodeURIComponent(relativePath)}`
|
|
66
|
-
}),
|
|
67
|
-
...(fileType.type === 'pdf' && {
|
|
68
|
-
pdfUrl: `/api/download?path=${encodeURIComponent(relativePath)}`
|
|
69
|
-
}),
|
|
70
|
-
...(['video', 'audio'].includes(fileType.type) && {
|
|
71
|
-
mediaUrl: `/api/download?path=${encodeURIComponent(relativePath)}`
|
|
72
|
-
})
|
|
73
|
-
});
|
|
114
|
+
const downloadUrl = buildDownloadUrl(relativePath);
|
|
115
|
+
return res.json(buildBinaryFileResponse(name, fileType, downloadUrl));
|
|
74
116
|
}
|
|
75
117
|
|
|
76
|
-
// Read and render text files
|
|
77
118
|
const rendered = await renderFile(fullPath);
|
|
78
|
-
|
|
79
|
-
res.json({
|
|
80
|
-
name,
|
|
81
|
-
...rendered
|
|
82
|
-
});
|
|
119
|
+
res.json({ name, ...rendered });
|
|
83
120
|
} catch (err) {
|
|
84
121
|
if (err.code === 'ENOENT') {
|
|
85
122
|
return res.status(404).json({ error: 'File not found' });
|
|
@@ -90,18 +127,17 @@ export function setupFileRoutes(app) {
|
|
|
90
127
|
|
|
91
128
|
// Save file content
|
|
92
129
|
app.post('/api/file', async (req, res) => {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (!relativePath) {
|
|
96
|
-
return res.status(400).json({ error: 'Path is required' });
|
|
97
|
-
}
|
|
130
|
+
const { path: relativePath, content } = req.body;
|
|
131
|
+
const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
|
|
98
132
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
133
|
+
if (!relativePath) {
|
|
134
|
+
return res.status(400).json({ error: 'Path is required' });
|
|
135
|
+
}
|
|
136
|
+
if (!valid) {
|
|
137
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
138
|
+
}
|
|
103
139
|
|
|
104
|
-
|
|
140
|
+
try {
|
|
105
141
|
await fs.writeFile(fullPath, content, 'utf-8');
|
|
106
142
|
broadcastTreeUpdate(app);
|
|
107
143
|
res.json({ success: true });
|
|
@@ -112,18 +148,17 @@ export function setupFileRoutes(app) {
|
|
|
112
148
|
|
|
113
149
|
// Delete file or directory
|
|
114
150
|
app.delete('/api/file', async (req, res) => {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (!relativePath) {
|
|
118
|
-
return res.status(400).json({ error: 'Path is required' });
|
|
119
|
-
}
|
|
151
|
+
const { path: relativePath } = req.query;
|
|
152
|
+
const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
|
|
120
153
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
154
|
+
if (!relativePath) {
|
|
155
|
+
return res.status(400).json({ error: 'Path is required' });
|
|
156
|
+
}
|
|
157
|
+
if (!valid) {
|
|
158
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
159
|
+
}
|
|
125
160
|
|
|
126
|
-
|
|
161
|
+
try {
|
|
127
162
|
const stats = await fs.stat(fullPath);
|
|
128
163
|
if (stats.isDirectory()) {
|
|
129
164
|
await fs.rm(fullPath, { recursive: true });
|
|
@@ -143,18 +178,17 @@ export function setupFileRoutes(app) {
|
|
|
143
178
|
|
|
144
179
|
// Create directory
|
|
145
180
|
app.post('/api/mkdir', async (req, res) => {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!relativePath) {
|
|
149
|
-
return res.status(400).json({ error: 'Path is required' });
|
|
150
|
-
}
|
|
181
|
+
const { path: relativePath } = req.body;
|
|
182
|
+
const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
|
|
151
183
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
184
|
+
if (!relativePath) {
|
|
185
|
+
return res.status(400).json({ error: 'Path is required' });
|
|
186
|
+
}
|
|
187
|
+
if (!valid) {
|
|
188
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
189
|
+
}
|
|
156
190
|
|
|
157
|
-
|
|
191
|
+
try {
|
|
158
192
|
await fs.mkdir(fullPath, { recursive: true });
|
|
159
193
|
broadcastTreeUpdate(app);
|
|
160
194
|
res.json({ success: true });
|
|
@@ -165,21 +199,21 @@ export function setupFileRoutes(app) {
|
|
|
165
199
|
|
|
166
200
|
// Move/rename file or directory
|
|
167
201
|
app.post('/api/move', async (req, res) => {
|
|
168
|
-
|
|
169
|
-
const { source, destination } = req.body;
|
|
170
|
-
if (!source || !destination) {
|
|
171
|
-
return res.status(400).json({ error: 'Source and destination are required' });
|
|
172
|
-
}
|
|
202
|
+
const { source, destination } = req.body;
|
|
173
203
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
204
|
+
if (!source || !destination) {
|
|
205
|
+
return res.status(400).json({ error: 'Source and destination are required' });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const sourceResult = resolveAndValidate(source, rootDir);
|
|
209
|
+
const destResult = resolveAndValidate(destination, rootDir);
|
|
179
210
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
211
|
+
if (!sourceResult.valid || !destResult.valid) {
|
|
212
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await fs.rename(sourceResult.fullPath, destResult.fullPath);
|
|
183
217
|
broadcastTreeUpdate(app);
|
|
184
218
|
res.json({ success: true });
|
|
185
219
|
} catch (err) {
|
|
@@ -190,47 +224,42 @@ export function setupFileRoutes(app) {
|
|
|
190
224
|
// Download file (with Range Request support for video/audio streaming)
|
|
191
225
|
app.get('/api/download', async (req, res) => {
|
|
192
226
|
const { path: relativePath } = req.query;
|
|
227
|
+
const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
|
|
228
|
+
|
|
193
229
|
if (!relativePath) {
|
|
194
230
|
return res.status(400).json({ error: 'Path is required' });
|
|
195
231
|
}
|
|
196
|
-
|
|
197
|
-
// Security check (must use relativePath, not fullPath)
|
|
198
|
-
if (!validatePath(relativePath, app.locals.rootDir)) {
|
|
232
|
+
if (!valid) {
|
|
199
233
|
return res.status(403).json({ error: 'Access denied' });
|
|
200
234
|
}
|
|
201
235
|
|
|
202
|
-
const fullPath = path.join(app.locals.rootDir, relativePath);
|
|
203
|
-
|
|
204
236
|
try {
|
|
205
237
|
const stat = await fs.stat(fullPath);
|
|
206
238
|
if (!stat.isFile()) {
|
|
207
239
|
return res.status(400).json({ error: 'Not a file' });
|
|
208
240
|
}
|
|
209
241
|
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (range) {
|
|
214
|
-
// Range Request対応(動画/音声ストリーミング用)
|
|
215
|
-
const parts = range.replace(/bytes=/, '').split('-');
|
|
216
|
-
const start = parseInt(parts[0], 10);
|
|
217
|
-
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
|
218
|
-
const chunkSize = end - start + 1;
|
|
219
|
-
|
|
220
|
-
const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
|
|
221
|
-
res.writeHead(206, {
|
|
222
|
-
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
223
|
-
'Accept-Ranges': 'bytes',
|
|
224
|
-
'Content-Length': chunkSize,
|
|
225
|
-
'Content-Type': mimeType,
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
const stream = createReadStream(fullPath, { start, end });
|
|
229
|
-
stream.pipe(res);
|
|
230
|
-
} else {
|
|
231
|
-
// 通常のファイル送信
|
|
232
|
-
res.sendFile(fullPath);
|
|
242
|
+
const rangeHeader = req.headers.range;
|
|
243
|
+
if (!rangeHeader) {
|
|
244
|
+
return res.sendFile(fullPath);
|
|
233
245
|
}
|
|
246
|
+
|
|
247
|
+
// Range Request for video/audio streaming
|
|
248
|
+
const fileSize = stat.size;
|
|
249
|
+
const parts = rangeHeader.replace(/bytes=/, '').split('-');
|
|
250
|
+
const start = parseInt(parts[0], 10);
|
|
251
|
+
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
|
252
|
+
const chunkSize = end - start + 1;
|
|
253
|
+
const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
|
|
254
|
+
|
|
255
|
+
res.writeHead(206, {
|
|
256
|
+
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
257
|
+
'Accept-Ranges': 'bytes',
|
|
258
|
+
'Content-Length': chunkSize,
|
|
259
|
+
'Content-Type': mimeType,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
createReadStream(fullPath, { start, end }).pipe(res);
|
|
234
263
|
} catch (err) {
|
|
235
264
|
if (err.code === 'ENOENT') {
|
|
236
265
|
return res.status(404).json({ error: 'File not found' });
|
package/src/api/pdf.js
CHANGED
|
@@ -5,23 +5,29 @@
|
|
|
5
5
|
|
|
6
6
|
import { exec } from 'child_process';
|
|
7
7
|
import { promisify } from 'util';
|
|
8
|
+
import fs from 'fs/promises';
|
|
8
9
|
import path from 'path';
|
|
9
|
-
import fs from 'fs';
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
|
+
import { validatePath } from '../utils/path.js';
|
|
11
12
|
|
|
12
13
|
const execAsync = promisify(exec);
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const marpBin = path.join(
|
|
15
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
16
|
+
'..',
|
|
17
|
+
'..',
|
|
18
|
+
'node_modules',
|
|
19
|
+
'.bin',
|
|
20
|
+
'marp'
|
|
21
|
+
);
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* Setup PDF export routes
|
|
21
25
|
* @param {Express} app - Express application
|
|
26
|
+
* @returns {void}
|
|
22
27
|
*/
|
|
23
28
|
export function setupPdfRoutes(app) {
|
|
24
|
-
|
|
29
|
+
const { rootDir } = app.locals;
|
|
30
|
+
|
|
25
31
|
app.post('/api/pdf/export', async (req, res) => {
|
|
26
32
|
const { filePath } = req.body;
|
|
27
33
|
|
|
@@ -29,43 +35,34 @@ export function setupPdfRoutes(app) {
|
|
|
29
35
|
return res.status(400).json({ error: 'filePath is required' });
|
|
30
36
|
}
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
const fullPath = path.join(rootDir, filePath);
|
|
34
|
-
|
|
35
|
-
// Security check: ensure path is within rootDir
|
|
36
|
-
const resolvedPath = path.resolve(fullPath);
|
|
37
|
-
if (!resolvedPath.startsWith(rootDir)) {
|
|
38
|
+
if (!validatePath(filePath, rootDir)) {
|
|
38
39
|
return res.status(403).json({ error: 'Access denied' });
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
const fullPath = path.join(rootDir, filePath);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await fs.access(fullPath);
|
|
46
|
+
} catch {
|
|
43
47
|
return res.status(404).json({ error: 'File not found' });
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
const outputPath = resolvedPath.replace(/\.md$/, '.pdf');
|
|
50
|
+
const outputPath = fullPath.replace(/\.md$/, '.pdf');
|
|
48
51
|
const outputFileName = path.basename(outputPath);
|
|
52
|
+
const command = `"${marpBin}" "${fullPath}" -o "${outputPath}" --allow-local-files --no-stdin`;
|
|
49
53
|
|
|
50
54
|
try {
|
|
51
|
-
// Run marp-cli using local binary (faster than npx)
|
|
52
|
-
// --no-stdin prevents waiting for stdin input
|
|
53
|
-
const command = `"${marpBin}" "${resolvedPath}" -o "${outputPath}" --allow-local-files --no-stdin`;
|
|
54
55
|
await execAsync(command, { timeout: 60000 });
|
|
55
|
-
|
|
56
|
-
// Send PDF as download
|
|
57
56
|
res.download(outputPath, outputFileName, (err) => {
|
|
58
57
|
if (err) {
|
|
59
58
|
console.error('Download error:', err);
|
|
60
59
|
}
|
|
61
|
-
// Optionally delete the PDF after download
|
|
62
|
-
// fs.unlinkSync(outputPath);
|
|
63
60
|
});
|
|
64
|
-
} catch (
|
|
65
|
-
console.error('PDF export error:',
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('PDF export error:', err);
|
|
66
63
|
res.status(500).json({
|
|
67
64
|
error: 'PDF export failed',
|
|
68
|
-
details:
|
|
65
|
+
details: err.message
|
|
69
66
|
});
|
|
70
67
|
}
|
|
71
68
|
});
|
package/src/api/tree.js
CHANGED
|
@@ -5,7 +5,32 @@
|
|
|
5
5
|
import fs from 'fs/promises';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { getFileType } from '../utils/fileTypes.js';
|
|
8
|
-
import { validatePath } from '../utils/path.js';
|
|
8
|
+
import { getRelativePath, validatePath } from '../utils/path.js';
|
|
9
|
+
|
|
10
|
+
const IGNORED_PATTERNS = new Set(['node_modules', '__pycache__']);
|
|
11
|
+
const MAX_INITIAL_DEPTH = 1;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if an entry should be ignored
|
|
15
|
+
* @param {string} name - Entry name
|
|
16
|
+
* @returns {boolean} True if should be ignored
|
|
17
|
+
*/
|
|
18
|
+
function shouldIgnore(name) {
|
|
19
|
+
return name.startsWith('.') || IGNORED_PATTERNS.has(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sort entries: directories first, then files, alphabetically
|
|
24
|
+
* @param {fs.Dirent} a - First entry
|
|
25
|
+
* @param {fs.Dirent} b - Second entry
|
|
26
|
+
* @returns {number} Sort order
|
|
27
|
+
*/
|
|
28
|
+
function sortEntries(a, b) {
|
|
29
|
+
const aIsDir = a.isDirectory();
|
|
30
|
+
const bIsDir = b.isDirectory();
|
|
31
|
+
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
|
|
32
|
+
return a.name.localeCompare(b.name);
|
|
33
|
+
}
|
|
9
34
|
|
|
10
35
|
/**
|
|
11
36
|
* Build file tree for a directory
|
|
@@ -16,50 +41,32 @@ import { validatePath } from '../utils/path.js';
|
|
|
16
41
|
*/
|
|
17
42
|
export async function buildFileTree(dirPath, rootDir, depth = 0) {
|
|
18
43
|
const items = [];
|
|
19
|
-
const maxInitialDepth = 1; // Only expand first level initially
|
|
20
44
|
|
|
21
45
|
try {
|
|
22
46
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
23
|
-
|
|
24
|
-
// Sort: directories first, then files, alphabetically
|
|
25
|
-
entries.sort((a, b) => {
|
|
26
|
-
if (a.isDirectory() && !b.isDirectory()) return -1;
|
|
27
|
-
if (!a.isDirectory() && b.isDirectory()) return 1;
|
|
28
|
-
return a.name.localeCompare(b.name);
|
|
29
|
-
});
|
|
47
|
+
entries.sort(sortEntries);
|
|
30
48
|
|
|
31
49
|
for (const entry of entries) {
|
|
32
|
-
|
|
33
|
-
if (entry.name.startsWith('.')) continue;
|
|
34
|
-
if (entry.name === 'node_modules') continue;
|
|
35
|
-
if (entry.name === '__pycache__') continue;
|
|
50
|
+
if (shouldIgnore(entry.name)) continue;
|
|
36
51
|
|
|
37
52
|
const fullPath = path.join(dirPath, entry.name);
|
|
38
|
-
const relativePath =
|
|
53
|
+
const relativePath = getRelativePath(fullPath, rootDir);
|
|
39
54
|
|
|
40
55
|
if (entry.isDirectory()) {
|
|
41
|
-
const
|
|
56
|
+
const shouldLoadChildren = depth < MAX_INITIAL_DEPTH;
|
|
57
|
+
items.push({
|
|
42
58
|
type: 'directory',
|
|
43
59
|
name: entry.name,
|
|
44
60
|
path: relativePath,
|
|
45
|
-
children: [],
|
|
46
|
-
loaded:
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
// Load children for first level
|
|
50
|
-
if (depth < maxInitialDepth) {
|
|
51
|
-
item.children = await buildFileTree(fullPath, rootDir, depth + 1);
|
|
52
|
-
item.loaded = true;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
items.push(item);
|
|
61
|
+
children: shouldLoadChildren ? await buildFileTree(fullPath, rootDir, depth + 1) : [],
|
|
62
|
+
loaded: shouldLoadChildren
|
|
63
|
+
});
|
|
56
64
|
} else {
|
|
57
|
-
const fileType = getFileType(entry.name);
|
|
58
65
|
items.push({
|
|
59
66
|
type: 'file',
|
|
60
67
|
name: entry.name,
|
|
61
68
|
path: relativePath,
|
|
62
|
-
icon:
|
|
69
|
+
icon: getFileType(entry.name).icon
|
|
63
70
|
});
|
|
64
71
|
}
|
|
65
72
|
}
|
package/src/api/upload.js
CHANGED
|
@@ -2,29 +2,41 @@
|
|
|
2
2
|
* File upload API routes
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import multer from 'multer';
|
|
6
5
|
import fs from 'fs/promises';
|
|
6
|
+
import multer from 'multer';
|
|
7
7
|
import path from 'path';
|
|
8
|
+
|
|
8
9
|
import { validatePath } from '../utils/path.js';
|
|
9
10
|
|
|
11
|
+
const FILE_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sanitize filename to prevent path traversal and remove control characters
|
|
15
|
+
* @param {string} originalName - Original filename from upload
|
|
16
|
+
* @returns {string} Sanitized filename
|
|
17
|
+
*/
|
|
18
|
+
function sanitizeFilename(originalName) {
|
|
19
|
+
const baseName = path.basename(originalName);
|
|
20
|
+
const sanitized = baseName.replace(/[\x00-\x1f]/g, '');
|
|
21
|
+
return sanitized || 'unnamed';
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
/**
|
|
11
25
|
* Setup upload routes
|
|
12
26
|
* @param {Express} app - Express app instance
|
|
27
|
+
* @returns {void}
|
|
13
28
|
*/
|
|
14
29
|
export function setupUploadRoutes(app) {
|
|
15
|
-
// Configure multer for file uploads
|
|
16
30
|
const storage = multer.diskStorage({
|
|
17
31
|
destination: async (req, file, cb) => {
|
|
18
32
|
const targetPath = req.body.path || '';
|
|
19
33
|
|
|
20
|
-
// Security check: validate relative path before joining
|
|
21
34
|
if (!validatePath(targetPath, app.locals.rootDir)) {
|
|
22
35
|
return cb(new Error('Access denied'));
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
const fullPath = path.join(app.locals.rootDir, targetPath);
|
|
26
39
|
|
|
27
|
-
// Ensure directory exists
|
|
28
40
|
try {
|
|
29
41
|
await fs.mkdir(fullPath, { recursive: true });
|
|
30
42
|
cb(null, fullPath);
|
|
@@ -33,37 +45,26 @@ export function setupUploadRoutes(app) {
|
|
|
33
45
|
}
|
|
34
46
|
},
|
|
35
47
|
filename: (req, file, cb) => {
|
|
36
|
-
|
|
37
|
-
const safeName = path.basename(file.originalname);
|
|
38
|
-
// null byteや制御文字を除去
|
|
39
|
-
const sanitized = safeName.replace(/[\x00-\x1f]/g, '');
|
|
40
|
-
cb(null, sanitized || 'unnamed');
|
|
48
|
+
cb(null, sanitizeFilename(file.originalname));
|
|
41
49
|
}
|
|
42
50
|
});
|
|
43
51
|
|
|
44
52
|
const upload = multer({
|
|
45
53
|
storage,
|
|
46
|
-
limits: {
|
|
47
|
-
fileSize: 100 * 1024 * 1024 // 100MB limit
|
|
48
|
-
}
|
|
54
|
+
limits: { fileSize: FILE_SIZE_LIMIT }
|
|
49
55
|
});
|
|
50
56
|
|
|
51
|
-
// Upload files
|
|
52
57
|
app.post('/api/upload', upload.array('files'), (req, res) => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
58
|
+
if (!req.files || req.files.length === 0) {
|
|
59
|
+
return res.status(400).json({ error: 'No files uploaded' });
|
|
60
|
+
}
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
const uploaded = req.files.map(file => ({
|
|
63
|
+
name: file.originalname,
|
|
64
|
+
size: file.size
|
|
65
|
+
}));
|
|
62
66
|
|
|
63
|
-
|
|
64
|
-
} catch (err) {
|
|
65
|
-
res.status(500).json({ error: err.message });
|
|
66
|
-
}
|
|
67
|
+
res.json({ success: true, files: uploaded });
|
|
67
68
|
});
|
|
68
69
|
}
|
|
69
70
|
|