mdv-live 0.5.3 → 0.5.4
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/package.json +1 -1
- package/src/api/file.js +16 -13
- package/src/api/pdf.js +8 -7
- package/src/api/tree.js +4 -5
- package/src/server.js +3 -1
- package/src/static/app.js +130 -25
- package/src/utils/path.js +39 -0
- package/src/websocket.js +2 -1
package/package.json
CHANGED
package/src/api/file.js
CHANGED
|
@@ -9,16 +9,16 @@ import mime from 'mime-types';
|
|
|
9
9
|
import WebSocket from 'ws';
|
|
10
10
|
import { getFileType } from '../utils/fileTypes.js';
|
|
11
11
|
import { renderFile } from '../rendering/index.js';
|
|
12
|
-
import {
|
|
12
|
+
import { validatePathReal } from '../utils/path.js';
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Validate path and resolve to full path
|
|
15
|
+
* Validate path and resolve to full path (with symlink protection)
|
|
16
16
|
* @param {string} relativePath - Relative path to validate
|
|
17
17
|
* @param {string} rootDir - Root directory
|
|
18
|
-
* @returns {{ valid: boolean, fullPath: string }} Validation result with full path
|
|
18
|
+
* @returns {Promise<{ valid: boolean, fullPath: string }>} Validation result with full path
|
|
19
19
|
*/
|
|
20
|
-
function resolveAndValidate(relativePath, rootDir) {
|
|
21
|
-
if (!relativePath || !
|
|
20
|
+
async function resolveAndValidate(relativePath, rootDir) {
|
|
21
|
+
if (!relativePath || !await validatePathReal(relativePath, rootDir)) {
|
|
22
22
|
return { valid: false, fullPath: '' };
|
|
23
23
|
}
|
|
24
24
|
return { valid: true, fullPath: path.join(rootDir, relativePath) };
|
|
@@ -92,7 +92,7 @@ export function setupFileRoutes(app) {
|
|
|
92
92
|
// Serve raw files (for HTML preview with relative paths)
|
|
93
93
|
app.get('/raw/*', async (req, res) => {
|
|
94
94
|
const relativePath = req.params[0];
|
|
95
|
-
const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
|
|
95
|
+
const { valid, fullPath } = await resolveAndValidate(relativePath, rootDir);
|
|
96
96
|
|
|
97
97
|
if (!relativePath || !valid) {
|
|
98
98
|
return res.status(403).json({ error: 'Access denied' });
|
|
@@ -118,7 +118,7 @@ export function setupFileRoutes(app) {
|
|
|
118
118
|
// Get file content
|
|
119
119
|
app.get('/api/file', async (req, res) => {
|
|
120
120
|
const { path: relativePath } = req.query;
|
|
121
|
-
const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
|
|
121
|
+
const { valid, fullPath } = await resolveAndValidate(relativePath, rootDir);
|
|
122
122
|
|
|
123
123
|
if (!relativePath) {
|
|
124
124
|
return res.status(400).json({ error: 'Path is required' });
|
|
@@ -174,7 +174,7 @@ export function setupFileRoutes(app) {
|
|
|
174
174
|
// Save file content
|
|
175
175
|
app.post('/api/file', async (req, res) => {
|
|
176
176
|
const { path: relativePath, content } = req.body;
|
|
177
|
-
const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
|
|
177
|
+
const { valid, fullPath } = await resolveAndValidate(relativePath, rootDir);
|
|
178
178
|
|
|
179
179
|
if (!relativePath) {
|
|
180
180
|
return res.status(400).json({ error: 'Path is required' });
|
|
@@ -195,7 +195,7 @@ export function setupFileRoutes(app) {
|
|
|
195
195
|
// Delete file or directory
|
|
196
196
|
app.delete('/api/file', async (req, res) => {
|
|
197
197
|
const { path: relativePath } = req.query;
|
|
198
|
-
const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
|
|
198
|
+
const { valid, fullPath } = await resolveAndValidate(relativePath, rootDir);
|
|
199
199
|
|
|
200
200
|
if (!relativePath) {
|
|
201
201
|
return res.status(400).json({ error: 'Path is required' });
|
|
@@ -225,7 +225,7 @@ export function setupFileRoutes(app) {
|
|
|
225
225
|
// Create directory
|
|
226
226
|
app.post('/api/mkdir', async (req, res) => {
|
|
227
227
|
const { path: relativePath } = req.body;
|
|
228
|
-
const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
|
|
228
|
+
const { valid, fullPath } = await resolveAndValidate(relativePath, rootDir);
|
|
229
229
|
|
|
230
230
|
if (!relativePath) {
|
|
231
231
|
return res.status(400).json({ error: 'Path is required' });
|
|
@@ -251,8 +251,8 @@ export function setupFileRoutes(app) {
|
|
|
251
251
|
return res.status(400).json({ error: 'Source and destination are required' });
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
const sourceResult = resolveAndValidate(source, rootDir);
|
|
255
|
-
const destResult = resolveAndValidate(destination, rootDir);
|
|
254
|
+
const sourceResult = await resolveAndValidate(source, rootDir);
|
|
255
|
+
const destResult = await resolveAndValidate(destination, rootDir);
|
|
256
256
|
|
|
257
257
|
if (!sourceResult.valid || !destResult.valid) {
|
|
258
258
|
return res.status(403).json({ error: 'Access denied' });
|
|
@@ -270,7 +270,7 @@ export function setupFileRoutes(app) {
|
|
|
270
270
|
// Download file (with Range Request support for video/audio streaming)
|
|
271
271
|
app.get('/api/download', async (req, res) => {
|
|
272
272
|
const { path: relativePath } = req.query;
|
|
273
|
-
const { valid, fullPath } = resolveAndValidate(relativePath, rootDir);
|
|
273
|
+
const { valid, fullPath } = await resolveAndValidate(relativePath, rootDir);
|
|
274
274
|
|
|
275
275
|
if (!relativePath) {
|
|
276
276
|
return res.status(400).json({ error: 'Path is required' });
|
|
@@ -301,6 +301,9 @@ export function setupFileRoutes(app) {
|
|
|
301
301
|
return res.status(416).set('Content-Range', `bytes */${fileSize}`).end();
|
|
302
302
|
}
|
|
303
303
|
const end = Math.min(match[2] ? Number(match[2]) : fileSize - 1, fileSize - 1);
|
|
304
|
+
if (end < start) {
|
|
305
|
+
return res.status(416).set('Content-Range', `bytes */${fileSize}`).end();
|
|
306
|
+
}
|
|
304
307
|
const chunkSize = end - start + 1;
|
|
305
308
|
const mimeType = mime.lookup(fullPath) || 'application/octet-stream';
|
|
306
309
|
|
package/src/api/pdf.js
CHANGED
|
@@ -8,6 +8,7 @@ import { promisify } from 'util';
|
|
|
8
8
|
import fs from 'fs/promises';
|
|
9
9
|
import path from 'path';
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
|
+
import os from 'os';
|
|
11
12
|
import { validatePath } from '../utils/path.js';
|
|
12
13
|
|
|
13
14
|
const execFileAsync = promisify(execFile);
|
|
@@ -47,22 +48,22 @@ export function setupPdfRoutes(app) {
|
|
|
47
48
|
return res.status(404).json({ error: 'File not found' });
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
const
|
|
51
|
-
const
|
|
51
|
+
const baseName = path.basename(fullPath, '.md');
|
|
52
|
+
const outputPath = path.join(os.tmpdir(), `mdv-${Date.now()}-${baseName}.pdf`);
|
|
53
|
+
const outputFileName = `${baseName}.pdf`;
|
|
52
54
|
|
|
53
55
|
try {
|
|
54
56
|
await execFileAsync(marpBin, [fullPath, '-o', outputPath, '--html', '--allow-local-files', '--no-stdin'], { timeout: 60000 });
|
|
55
|
-
res.download(outputPath, outputFileName, (err) => {
|
|
57
|
+
res.download(outputPath, outputFileName, async (err) => {
|
|
56
58
|
if (err) {
|
|
57
59
|
console.error('Download error:', err);
|
|
58
60
|
}
|
|
61
|
+
try { await fs.unlink(outputPath); } catch { /* ignore cleanup errors */ }
|
|
59
62
|
});
|
|
60
63
|
} catch (err) {
|
|
61
64
|
console.error('PDF export error:', err);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
details: err.message
|
|
65
|
-
});
|
|
65
|
+
try { await fs.unlink(outputPath); } catch { /* ignore */ }
|
|
66
|
+
res.status(500).json({ error: 'PDF export failed' });
|
|
66
67
|
}
|
|
67
68
|
});
|
|
68
69
|
}
|
package/src/api/tree.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import fs from 'fs/promises';
|
|
6
6
|
import path from 'path';
|
|
7
7
|
import { getFileType } from '../utils/fileTypes.js';
|
|
8
|
-
import { getRelativePath,
|
|
8
|
+
import { getRelativePath, validatePathReal } from '../utils/path.js';
|
|
9
9
|
|
|
10
10
|
const IGNORED_PATTERNS = new Set(['node_modules', '__pycache__', '.git']);
|
|
11
11
|
const MAX_INITIAL_DEPTH = 1;
|
|
@@ -100,13 +100,12 @@ export function setupTreeRoutes(app) {
|
|
|
100
100
|
return res.status(400).json({ error: 'Path is required' });
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// Security: ensure path is within root
|
|
106
|
-
if (!validatePath(relativePath, app.locals.rootDir)) {
|
|
103
|
+
// Security: validate before resolving path (with symlink check)
|
|
104
|
+
if (!await validatePathReal(relativePath, app.locals.rootDir)) {
|
|
107
105
|
return res.status(403).json({ error: 'Access denied' });
|
|
108
106
|
}
|
|
109
107
|
|
|
108
|
+
const fullPath = path.join(app.locals.rootDir, relativePath);
|
|
110
109
|
const children = await buildFileTree(fullPath, app.locals.rootDir, 0);
|
|
111
110
|
res.json(children);
|
|
112
111
|
} catch (err) {
|
package/src/server.js
CHANGED
|
@@ -67,7 +67,9 @@ export function createMdvServer(options) {
|
|
|
67
67
|
|
|
68
68
|
setupApiRoutes(app);
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
// Catch-all: serve index.html for SPA (path-based routing)
|
|
71
|
+
// Express matches routes in order, so API/static routes above take priority
|
|
72
|
+
app.get('*', (req, res) => {
|
|
71
73
|
res.sendFile(path.join(STATIC_DIR, 'index.html'));
|
|
72
74
|
});
|
|
73
75
|
|
package/src/static/app.js
CHANGED
|
@@ -93,14 +93,13 @@
|
|
|
93
93
|
// ============================================================
|
|
94
94
|
|
|
95
95
|
function updateUrlPath(path) {
|
|
96
|
-
const url = new URL(window.location);
|
|
97
96
|
if (path) {
|
|
98
|
-
//
|
|
99
|
-
|
|
97
|
+
// パスベースURL: /README.md, /04_提案/10億円戦略.md
|
|
98
|
+
const encoded = path.split('/').map(s => encodeURIComponent(s)).join('/');
|
|
99
|
+
history.replaceState(null, '', '/' + encoded);
|
|
100
100
|
} else {
|
|
101
|
-
|
|
101
|
+
history.replaceState(null, '', '/');
|
|
102
102
|
}
|
|
103
|
-
history.replaceState(null, '', url);
|
|
104
103
|
}
|
|
105
104
|
|
|
106
105
|
// ============================================================
|
|
@@ -430,7 +429,7 @@
|
|
|
430
429
|
|
|
431
430
|
// 展開済みディレクトリを復元(子要素も再取得)
|
|
432
431
|
for (const path of expandedPaths) {
|
|
433
|
-
const item = document.querySelector(`.tree-item[data-path="${path}"]`);
|
|
432
|
+
const item = document.querySelector(`.tree-item[data-path="${CSS.escape(path)}"]`);
|
|
434
433
|
if (item) {
|
|
435
434
|
const children = item.querySelector('.tree-children');
|
|
436
435
|
const chevron = item.querySelector('.chevron');
|
|
@@ -461,8 +460,10 @@
|
|
|
461
460
|
|
|
462
461
|
renderDirectory(item) {
|
|
463
462
|
const loaded = item.loaded !== false;
|
|
463
|
+
const safePath = escapeHtml(item.path);
|
|
464
|
+
const safeName = escapeHtml(item.name);
|
|
464
465
|
return `
|
|
465
|
-
<div class="tree-item" data-path="${
|
|
466
|
+
<div class="tree-item" data-path="${safePath}" data-loaded="${loaded}" draggable="true">
|
|
466
467
|
<div class="tree-item-content" onclick="MDV.toggleDirectory(this)">
|
|
467
468
|
<svg class="chevron" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
468
469
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
@@ -470,7 +471,7 @@
|
|
|
470
471
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
471
472
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
472
473
|
</svg>
|
|
473
|
-
<span class="name">${
|
|
474
|
+
<span class="name">${safeName}</span>
|
|
474
475
|
</div>
|
|
475
476
|
<div class="tree-children collapsed">${this.renderItems(item.children)}</div>
|
|
476
477
|
</div>
|
|
@@ -501,7 +502,7 @@
|
|
|
501
502
|
for (const part of parts) {
|
|
502
503
|
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
503
504
|
|
|
504
|
-
const item = document.querySelector(`.tree-item[data-path="${currentPath}"]`);
|
|
505
|
+
const item = document.querySelector(`.tree-item[data-path="${CSS.escape(currentPath)}"]`);
|
|
505
506
|
if (!item) continue;
|
|
506
507
|
|
|
507
508
|
const children = item.querySelector('.tree-children');
|
|
@@ -525,13 +526,15 @@
|
|
|
525
526
|
renderFile(item) {
|
|
526
527
|
const iconClass = item.icon ? `icon-${item.icon}` : '';
|
|
527
528
|
const iconSvg = getFileIcon(item.icon);
|
|
529
|
+
const safePath = escapeHtml(item.path);
|
|
530
|
+
const safeName = escapeHtml(item.name);
|
|
528
531
|
return `
|
|
529
|
-
<div class="tree-item" data-path="${
|
|
530
|
-
<div class="tree-item-content"
|
|
532
|
+
<div class="tree-item" data-path="${safePath}" draggable="true">
|
|
533
|
+
<div class="tree-item-content" data-action="open">
|
|
531
534
|
<span class="${iconClass}" style="margin-left: 22px; display: flex; align-items: center;">
|
|
532
535
|
${iconSvg}
|
|
533
536
|
</span>
|
|
534
|
-
<span class="name">${
|
|
537
|
+
<span class="name">${safeName}</span>
|
|
535
538
|
</div>
|
|
536
539
|
</div>
|
|
537
540
|
`;
|
|
@@ -543,7 +546,7 @@
|
|
|
543
546
|
});
|
|
544
547
|
if (state.activeTabIndex >= 0) {
|
|
545
548
|
const path = state.tabs[state.activeTabIndex].path;
|
|
546
|
-
const el = document.querySelector(`.tree-item[data-path="${path}"] > .tree-item-content`);
|
|
549
|
+
const el = document.querySelector(`.tree-item[data-path="${CSS.escape(path)}"] > .tree-item-content`);
|
|
547
550
|
if (el) el.classList.add('active');
|
|
548
551
|
}
|
|
549
552
|
}
|
|
@@ -857,29 +860,32 @@
|
|
|
857
860
|
|
|
858
861
|
renderImage(imageUrl, name) {
|
|
859
862
|
const url = imageUrl + '&t=' + Date.now();
|
|
863
|
+
const safeName = escapeHtml(name);
|
|
860
864
|
elements.content.innerHTML = `
|
|
861
865
|
<div class="image-preview">
|
|
862
|
-
<img src="${url}" alt="${
|
|
863
|
-
<div class="image-info">${
|
|
866
|
+
<img src="${url}" alt="${safeName}" />
|
|
867
|
+
<div class="image-info">${safeName}</div>
|
|
864
868
|
</div>
|
|
865
869
|
`;
|
|
866
870
|
},
|
|
867
871
|
|
|
868
872
|
renderPDF(pdfUrl, name) {
|
|
869
873
|
const url = pdfUrl + '&t=' + Date.now();
|
|
874
|
+
const safeName = escapeHtml(name);
|
|
870
875
|
elements.content.style.padding = '0';
|
|
871
876
|
elements.content.innerHTML = `
|
|
872
877
|
<div class="pdf-viewer">
|
|
873
|
-
<iframe src="${url}" title="${
|
|
878
|
+
<iframe src="${url}" title="${safeName}"></iframe>
|
|
874
879
|
</div>
|
|
875
880
|
`;
|
|
876
881
|
},
|
|
877
882
|
|
|
878
883
|
renderHTML(htmlUrl, name) {
|
|
884
|
+
const safeName = escapeHtml(name);
|
|
879
885
|
elements.content.style.padding = '0';
|
|
880
886
|
elements.content.innerHTML = `
|
|
881
887
|
<div class="html-preview">
|
|
882
|
-
<iframe src="${htmlUrl}" title="${
|
|
888
|
+
<iframe src="${htmlUrl}" title="${safeName}"
|
|
883
889
|
sandbox="allow-scripts allow-same-origin allow-forms allow-modals">
|
|
884
890
|
</iframe>
|
|
885
891
|
</div>
|
|
@@ -887,35 +893,38 @@
|
|
|
887
893
|
},
|
|
888
894
|
|
|
889
895
|
renderVideo(mediaUrl, name) {
|
|
896
|
+
const safeName = escapeHtml(name);
|
|
890
897
|
elements.content.innerHTML = `
|
|
891
898
|
<div class="video-preview">
|
|
892
899
|
<video controls>
|
|
893
900
|
<source src="${mediaUrl}" type="video/mp4">
|
|
894
901
|
お使いのブラウザは動画再生に対応していません。
|
|
895
902
|
</video>
|
|
896
|
-
<div class="media-info">${
|
|
903
|
+
<div class="media-info">${safeName}</div>
|
|
897
904
|
</div>
|
|
898
905
|
`;
|
|
899
906
|
},
|
|
900
907
|
|
|
901
908
|
renderAudio(mediaUrl, name) {
|
|
909
|
+
const safeName = escapeHtml(name);
|
|
902
910
|
elements.content.innerHTML = `
|
|
903
911
|
<div class="audio-preview">
|
|
904
912
|
<audio controls>
|
|
905
913
|
<source src="${mediaUrl}">
|
|
906
914
|
お使いのブラウザは音声再生に対応していません。
|
|
907
915
|
</audio>
|
|
908
|
-
<div class="media-info">${
|
|
916
|
+
<div class="media-info">${safeName}</div>
|
|
909
917
|
</div>
|
|
910
918
|
`;
|
|
911
919
|
},
|
|
912
920
|
|
|
913
921
|
renderBinary(name, icon) {
|
|
922
|
+
const safeName = escapeHtml(name);
|
|
914
923
|
const iconSvg = getFileIcon(icon);
|
|
915
924
|
elements.content.innerHTML = `
|
|
916
925
|
<div class="binary-preview">
|
|
917
926
|
<div class="binary-icon">${iconSvg}</div>
|
|
918
|
-
<div class="binary-info">${
|
|
927
|
+
<div class="binary-info">${safeName}</div>
|
|
919
928
|
</div>
|
|
920
929
|
`;
|
|
921
930
|
},
|
|
@@ -1015,6 +1024,22 @@
|
|
|
1015
1024
|
},
|
|
1016
1025
|
|
|
1017
1026
|
close(index) {
|
|
1027
|
+
// Warn about unsaved changes
|
|
1028
|
+
if (state.isEditMode && state.hasUnsavedChanges && index === state.activeTabIndex) {
|
|
1029
|
+
DialogManager.show('未保存の変更', {
|
|
1030
|
+
message: '変更を保存せずにタブを閉じますか?',
|
|
1031
|
+
isConfirm: true,
|
|
1032
|
+
danger: true,
|
|
1033
|
+
confirmText: '閉じる',
|
|
1034
|
+
onConfirm: () => {
|
|
1035
|
+
state.hasUnsavedChanges = false;
|
|
1036
|
+
state.isEditMode = false;
|
|
1037
|
+
EditorManager.updateButton();
|
|
1038
|
+
TabManager.close(index);
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1018
1043
|
state.tabs.splice(index, 1);
|
|
1019
1044
|
|
|
1020
1045
|
if (state.tabs.length === 0) {
|
|
@@ -1040,7 +1065,7 @@
|
|
|
1040
1065
|
render() {
|
|
1041
1066
|
elements.tabBar.innerHTML = state.tabs.map((tab, i) => `
|
|
1042
1067
|
<button class="tab ${i === state.activeTabIndex ? 'active' : ''}" onclick="MDV.switchTab(${i})">
|
|
1043
|
-
${tab.name}
|
|
1068
|
+
${escapeHtml(tab.name)}
|
|
1044
1069
|
<span class="tab-close" onclick="event.stopPropagation(); MDV.closeTab(${i})">
|
|
1045
1070
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
1046
1071
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
@@ -1885,7 +1910,7 @@
|
|
|
1885
1910
|
const isTextInput = document.activeElement.tagName === 'INPUT' ||
|
|
1886
1911
|
document.activeElement.tagName === 'TEXTAREA';
|
|
1887
1912
|
|
|
1888
|
-
const activeItem = document.querySelector(`.tree-item[data-path="${this.selectedTreePath}"]`);
|
|
1913
|
+
const activeItem = document.querySelector(`.tree-item[data-path="${CSS.escape(this.selectedTreePath)}"]`);
|
|
1889
1914
|
const isDir = activeItem && !!activeItem.querySelector('.tree-children');
|
|
1890
1915
|
|
|
1891
1916
|
if ((key === 'Delete' || key === 'Backspace') && !isTextInput) {
|
|
@@ -1918,6 +1943,14 @@
|
|
|
1918
1943
|
if (treeItem) {
|
|
1919
1944
|
this.selectedTreePath = treeItem.dataset.path;
|
|
1920
1945
|
}
|
|
1946
|
+
// Event delegation for file open (replaces inline onclick)
|
|
1947
|
+
const openTarget = e.target.closest('[data-action="open"]');
|
|
1948
|
+
if (openTarget) {
|
|
1949
|
+
const item = openTarget.closest('.tree-item');
|
|
1950
|
+
if (item && item.dataset.path) {
|
|
1951
|
+
TabManager.open(item.dataset.path);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1921
1954
|
});
|
|
1922
1955
|
}
|
|
1923
1956
|
};
|
|
@@ -1997,6 +2030,14 @@
|
|
|
1997
2030
|
ContextMenuManager.init();
|
|
1998
2031
|
DragDropManager.init();
|
|
1999
2032
|
KeyboardManager.init();
|
|
2033
|
+
|
|
2034
|
+
// Warn before leaving with unsaved changes
|
|
2035
|
+
window.addEventListener('beforeunload', (e) => {
|
|
2036
|
+
if (state.isEditMode && state.hasUnsavedChanges) {
|
|
2037
|
+
e.preventDefault();
|
|
2038
|
+
e.returnValue = '';
|
|
2039
|
+
}
|
|
2040
|
+
});
|
|
2000
2041
|
TabManager.render();
|
|
2001
2042
|
|
|
2002
2043
|
try {
|
|
@@ -2017,19 +2058,83 @@
|
|
|
2017
2058
|
});
|
|
2018
2059
|
window.addEventListener('focus', handleFocusChange);
|
|
2019
2060
|
|
|
2020
|
-
|
|
2061
|
+
// パスベースURL: /README.md → path = "README.md"
|
|
2062
|
+
// ?path= も後方互換で対応
|
|
2063
|
+
let initialPath = decodeURIComponent(window.location.pathname).replace(/^\//, '');
|
|
2064
|
+
if (!initialPath) {
|
|
2065
|
+
initialPath = new URLSearchParams(window.location.search).get('path') || '';
|
|
2066
|
+
}
|
|
2021
2067
|
if (initialPath) {
|
|
2022
|
-
// 末尾の/でディレクトリ判定
|
|
2023
2068
|
const isDirectoryPath = initialPath.endsWith('/');
|
|
2024
2069
|
const cleanPath = isDirectoryPath ? initialPath.slice(0, -1) : initialPath;
|
|
2025
2070
|
|
|
2026
2071
|
await FileTreeManager.expandToPath(cleanPath);
|
|
2027
2072
|
|
|
2028
|
-
// ファイルの場合のみ開く
|
|
2029
2073
|
if (!isDirectoryPath) {
|
|
2030
2074
|
await TabManager.open(cleanPath);
|
|
2031
2075
|
}
|
|
2032
2076
|
}
|
|
2077
|
+
|
|
2078
|
+
// Markdown内リンクのクリックをインターセプト
|
|
2079
|
+
elements.content.addEventListener('click', (e) => {
|
|
2080
|
+
const link = e.target.closest('a[href]');
|
|
2081
|
+
if (!link) return;
|
|
2082
|
+
|
|
2083
|
+
const href = link.getAttribute('href');
|
|
2084
|
+
if (!href) return;
|
|
2085
|
+
|
|
2086
|
+
// 外部リンク・非HTTPスキーム・アンカーはブラウザに任せる
|
|
2087
|
+
if (href.startsWith('#') || href.startsWith('http') || href.startsWith('//') ||
|
|
2088
|
+
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(href)) return;
|
|
2089
|
+
|
|
2090
|
+
e.preventDefault();
|
|
2091
|
+
|
|
2092
|
+
// フラグメントを保持しつつパスを取り出す
|
|
2093
|
+
const hashIndex = href.indexOf('#');
|
|
2094
|
+
const fragment = hashIndex >= 0 ? href.slice(hashIndex + 1) : '';
|
|
2095
|
+
const urlPath = (hashIndex >= 0 ? href.slice(0, hashIndex) : href).split('?')[0];
|
|
2096
|
+
const decoded = decodeURIComponent(urlPath);
|
|
2097
|
+
|
|
2098
|
+
// 相対パスを現在のファイルパスから解決
|
|
2099
|
+
let targetPath = decoded;
|
|
2100
|
+
if (!decoded.startsWith('/')) {
|
|
2101
|
+
const currentTab = state.tabs[state.activeTabIndex];
|
|
2102
|
+
const currentDir = currentTab ? currentTab.path.replace(/[^/]*$/, '') : '';
|
|
2103
|
+
targetPath = currentDir + decoded;
|
|
2104
|
+
} else {
|
|
2105
|
+
targetPath = decoded.replace(/^\//, '');
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
// 末尾スラッシュ(ディレクトリ)を保持
|
|
2109
|
+
const isDirectory = targetPath.endsWith('/');
|
|
2110
|
+
|
|
2111
|
+
// パス正規化(foo/../bar → bar)
|
|
2112
|
+
const parts = targetPath.split('/');
|
|
2113
|
+
const resolved = [];
|
|
2114
|
+
for (const part of parts) {
|
|
2115
|
+
if (part === '..') resolved.pop();
|
|
2116
|
+
else if (part !== '.' && part !== '') resolved.push(part);
|
|
2117
|
+
}
|
|
2118
|
+
targetPath = resolved.join('/');
|
|
2119
|
+
|
|
2120
|
+
if (isDirectory) {
|
|
2121
|
+
// ディレクトリはツリーを展開
|
|
2122
|
+
FileTreeManager.expandToPath(targetPath);
|
|
2123
|
+
updateUrlPath(targetPath + '/');
|
|
2124
|
+
} else {
|
|
2125
|
+
TabManager.open(targetPath).then(() => {
|
|
2126
|
+
// フラグメントがあればアンカーにスクロール
|
|
2127
|
+
if (fragment) {
|
|
2128
|
+
const decodedFragment = decodeURIComponent(fragment);
|
|
2129
|
+
// id一致 → heading textContent一致 の順で検索
|
|
2130
|
+
const target = elements.content.querySelector(`#${CSS.escape(decodedFragment)}`) ||
|
|
2131
|
+
Array.from(elements.content.querySelectorAll('h1, h2, h3, h4, h5, h6'))
|
|
2132
|
+
.find(h => h.textContent.trim().toLowerCase() === decodedFragment.toLowerCase());
|
|
2133
|
+
if (target) target.scrollIntoView({ behavior: 'smooth' });
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
});
|
|
2033
2138
|
}
|
|
2034
2139
|
|
|
2035
2140
|
// DOMContentLoadedを待ってから初期化
|
package/src/utils/path.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import path from 'path';
|
|
6
|
+
import fs from 'fs/promises';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Validate that a path is within the allowed root directory.
|
|
@@ -34,6 +35,44 @@ export function validatePath(targetPath, rootDir) {
|
|
|
34
35
|
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep);
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Validate path with symlink resolution.
|
|
40
|
+
* Calls validatePath first, then verifies the real filesystem path stays within rootDir.
|
|
41
|
+
* @param {string} targetPath - Relative path to validate
|
|
42
|
+
* @param {string} rootDir - Allowed root directory
|
|
43
|
+
* @returns {Promise<boolean>} True if path is safe after symlink resolution
|
|
44
|
+
*/
|
|
45
|
+
export async function validatePathReal(targetPath, rootDir) {
|
|
46
|
+
if (!validatePath(targetPath, rootDir)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fullPath = path.resolve(rootDir, targetPath);
|
|
51
|
+
try {
|
|
52
|
+
const realPath = await fs.realpath(fullPath);
|
|
53
|
+
const realRoot = await fs.realpath(rootDir);
|
|
54
|
+
return realPath === realRoot || realPath.startsWith(realRoot + path.sep);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (err.code === 'ENOENT') {
|
|
57
|
+
// File/dir doesn't exist yet — walk up to find nearest existing ancestor
|
|
58
|
+
const realRoot = await fs.realpath(rootDir);
|
|
59
|
+
let current = fullPath;
|
|
60
|
+
while (current !== path.dirname(current)) {
|
|
61
|
+
current = path.dirname(current);
|
|
62
|
+
try {
|
|
63
|
+
const realAncestor = await fs.realpath(current);
|
|
64
|
+
return realAncestor === realRoot || realAncestor.startsWith(realRoot + path.sep);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (e.code !== 'ENOENT') return false;
|
|
67
|
+
// Keep walking up
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
37
76
|
/**
|
|
38
77
|
* Convert absolute path to relative path with forward slashes
|
|
39
78
|
* @param {string} fullPath - Absolute file path
|
package/src/websocket.js
CHANGED
|
@@ -19,7 +19,7 @@ function isClientReady(client) {
|
|
|
19
19
|
* @returns {WebSocketServer} WebSocket server instance
|
|
20
20
|
*/
|
|
21
21
|
export function setupWebSocket(server) {
|
|
22
|
-
const wss = new WebSocketServer({ server });
|
|
22
|
+
const wss = new WebSocketServer({ server, maxPayload: 64 * 1024 });
|
|
23
23
|
const clientWatches = new Map();
|
|
24
24
|
|
|
25
25
|
wss.on('connection', (ws) => {
|
|
@@ -30,6 +30,7 @@ export function setupWebSocket(server) {
|
|
|
30
30
|
const message = JSON.parse(data.toString());
|
|
31
31
|
|
|
32
32
|
if (message.type === 'watch') {
|
|
33
|
+
if (typeof message.path !== 'string' || message.path.length > 1024) return;
|
|
33
34
|
const watches = clientWatches.get(ws);
|
|
34
35
|
watches.clear();
|
|
35
36
|
watches.add(message.path);
|