mdv-live 0.3.1 → 0.3.3

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/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
- const message = JSON.stringify({ type: 'tree_update' });
21
- wss.clients.forEach(client => {
22
- if (client.readyState === 1) { // WebSocket.OPEN
23
- client.send(message);
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
- try {
37
- const { path: relativePath } = req.query;
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
- // Security check (must use relativePath, not fullPath)
43
- if (!validatePath(relativePath, app.locals.rootDir)) {
44
- return res.status(403).json({ error: 'Access denied' });
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
- const fullPath = path.join(app.locals.rootDir, relativePath);
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
- return res.json({
59
- name,
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
- try {
94
- const { path: relativePath, content } = req.body;
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
- // Security check (must use relativePath, not fullPath)
100
- if (!validatePath(relativePath, app.locals.rootDir)) {
101
- return res.status(403).json({ error: 'Access denied' });
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
- const fullPath = path.join(app.locals.rootDir, relativePath);
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
- try {
116
- const { path: relativePath } = req.query;
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
- // Security check (must use relativePath, not fullPath)
122
- if (!validatePath(relativePath, app.locals.rootDir)) {
123
- return res.status(403).json({ error: 'Access denied' });
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
- const fullPath = path.join(app.locals.rootDir, relativePath);
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
- try {
147
- const { path: relativePath } = req.body;
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
- // Security check (must use relativePath, not fullPath)
153
- if (!validatePath(relativePath, app.locals.rootDir)) {
154
- return res.status(403).json({ error: 'Access denied' });
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
- const fullPath = path.join(app.locals.rootDir, relativePath);
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
- try {
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
- // Security check (must use relative paths, not full paths)
175
- if (!validatePath(source, app.locals.rootDir) ||
176
- !validatePath(destination, app.locals.rootDir)) {
177
- return res.status(403).json({ error: 'Access denied' });
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
- const sourcePath = path.join(app.locals.rootDir, source);
181
- const destPath = path.join(app.locals.rootDir, destination);
182
- await fs.rename(sourcePath, destPath);
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 fileSize = stat.size;
211
- const range = req.headers.range;
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 __filename = fileURLToPath(import.meta.url);
14
- const __dirname = path.dirname(__filename);
15
-
16
- // Get path to local marp-cli binary
17
- const marpBin = path.join(__dirname, '..', '..', 'node_modules', '.bin', 'marp');
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
- // Export Marp presentation to PDF
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
- const rootDir = app.locals.rootDir;
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
- // Check if file exists
42
- if (!fs.existsSync(resolvedPath)) {
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
- // Generate output path (same directory, .pdf extension)
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 (error) {
65
- console.error('PDF export error:', 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: error.message
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
- // Skip hidden files and common ignore patterns
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 = path.relative(rootDir, fullPath).split(path.sep).join('/');
53
+ const relativePath = getRelativePath(fullPath, rootDir);
39
54
 
40
55
  if (entry.isDirectory()) {
41
- const item = {
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: false
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: fileType.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
- try {
54
- if (!req.files || req.files.length === 0) {
55
- return res.status(400).json({ error: 'No files uploaded' });
56
- }
58
+ if (!req.files || req.files.length === 0) {
59
+ return res.status(400).json({ error: 'No files uploaded' });
60
+ }
57
61
 
58
- const uploaded = req.files.map(f => ({
59
- name: f.originalname,
60
- size: f.size
61
- }));
62
+ const uploaded = req.files.map(file => ({
63
+ name: file.originalname,
64
+ size: file.size
65
+ }));
62
66
 
63
- res.json({ success: true, files: uploaded });
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