tabminal 3.0.11 → 3.0.13
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/README.md +4 -0
- package/package.json +1 -1
- package/public/app.js +1527 -40
- package/public/index.html +23 -0
- package/public/styles.css +120 -5
- package/src/fs-routes.mjs +294 -24
package/public/index.html
CHANGED
|
@@ -500,6 +500,29 @@
|
|
|
500
500
|
</form>
|
|
501
501
|
</div>
|
|
502
502
|
</div>
|
|
503
|
+
<div id="confirm-modal" class="modal" style="display: none;">
|
|
504
|
+
<div class="modal-content confirm-modal-content">
|
|
505
|
+
<h2 id="confirm-modal-title">Confirm</h2>
|
|
506
|
+
<p id="confirm-modal-message"></p>
|
|
507
|
+
<p id="confirm-modal-note" class="confirm-modal-note"></p>
|
|
508
|
+
<div class="confirm-modal-actions">
|
|
509
|
+
<button
|
|
510
|
+
id="confirm-modal-cancel"
|
|
511
|
+
type="button"
|
|
512
|
+
class="secondary-button"
|
|
513
|
+
>
|
|
514
|
+
Cancel
|
|
515
|
+
</button>
|
|
516
|
+
<button
|
|
517
|
+
id="confirm-modal-confirm"
|
|
518
|
+
type="button"
|
|
519
|
+
class="danger-button"
|
|
520
|
+
>
|
|
521
|
+
Delete
|
|
522
|
+
</button>
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
503
526
|
<div id="shortcuts-modal" class="modal" style="display: none;">
|
|
504
527
|
<div class="modal-content shortcuts-content">
|
|
505
528
|
<h2>Keyboard Shortcuts</h2>
|
package/public/styles.css
CHANGED
|
@@ -1156,6 +1156,37 @@ body {
|
|
|
1156
1156
|
background-color: rgba(255, 255, 255, 0.05);
|
|
1157
1157
|
}
|
|
1158
1158
|
|
|
1159
|
+
.modal-content button.danger-button {
|
|
1160
|
+
background-color: #dc322f;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
.modal-content button.danger-button:hover {
|
|
1164
|
+
background-color: #c1272d;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
.confirm-modal-content {
|
|
1168
|
+
max-width: 420px;
|
|
1169
|
+
text-align: left;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
.confirm-modal-actions {
|
|
1173
|
+
display: flex;
|
|
1174
|
+
justify-content: flex-end;
|
|
1175
|
+
gap: 0.75rem;
|
|
1176
|
+
margin-top: 1.25rem;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
.confirm-modal-note {
|
|
1180
|
+
margin: 0.5rem 0 0;
|
|
1181
|
+
color: var(--text-dim);
|
|
1182
|
+
font-size: 0.85rem;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
.confirm-modal-actions button {
|
|
1186
|
+
width: auto;
|
|
1187
|
+
min-width: 120px;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1159
1190
|
.agent-setup-content {
|
|
1160
1191
|
max-width: 520px;
|
|
1161
1192
|
text-align: left;
|
|
@@ -1471,11 +1502,20 @@ kbd {
|
|
|
1471
1502
|
color: var(--text-highlight);
|
|
1472
1503
|
}
|
|
1473
1504
|
|
|
1505
|
+
.file-tree-item.selected {
|
|
1506
|
+
background-color: rgba(88, 110, 117, 0.26);
|
|
1507
|
+
color: var(--text-highlight);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1474
1510
|
.file-tree-item.active {
|
|
1475
1511
|
background-color: rgba(38, 139, 210, 0.15);
|
|
1476
1512
|
color: var(--accent-color);
|
|
1477
1513
|
}
|
|
1478
1514
|
|
|
1515
|
+
.file-tree-item.selected.active {
|
|
1516
|
+
background-color: rgba(38, 139, 210, 0.22);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1479
1519
|
.file-tree-item .icon {
|
|
1480
1520
|
width: 16px;
|
|
1481
1521
|
text-align: center;
|
|
@@ -1495,6 +1535,81 @@ kbd {
|
|
|
1495
1535
|
font-weight: bold;
|
|
1496
1536
|
}
|
|
1497
1537
|
|
|
1538
|
+
.file-tree-name {
|
|
1539
|
+
min-width: 0;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
.file-tree-rename-btn,
|
|
1543
|
+
.file-tree-delete-btn,
|
|
1544
|
+
.file-tree-new-folder-btn,
|
|
1545
|
+
.file-tree-new-file-btn {
|
|
1546
|
+
display: none;
|
|
1547
|
+
border: 1px solid rgba(38, 139, 210, 0.28);
|
|
1548
|
+
border-radius: 999px;
|
|
1549
|
+
background: rgba(38, 139, 210, 0.12);
|
|
1550
|
+
color: var(--text-highlight);
|
|
1551
|
+
width: 20px;
|
|
1552
|
+
height: 20px;
|
|
1553
|
+
padding: 0;
|
|
1554
|
+
margin: 0;
|
|
1555
|
+
cursor: pointer;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
.file-tree-item:hover .file-tree-rename-btn,
|
|
1559
|
+
.file-tree-item.selected .file-tree-rename-btn,
|
|
1560
|
+
.file-tree-item:focus-within .file-tree-rename-btn,
|
|
1561
|
+
.file-tree-item:hover .file-tree-delete-btn,
|
|
1562
|
+
.file-tree-item.selected .file-tree-delete-btn,
|
|
1563
|
+
.file-tree-item:focus-within .file-tree-delete-btn {
|
|
1564
|
+
display: inline-flex;
|
|
1565
|
+
align-items: center;
|
|
1566
|
+
justify-content: center;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
.file-tree-delete-btn {
|
|
1570
|
+
border-color: rgba(220, 50, 47, 0.28);
|
|
1571
|
+
background: rgba(220, 50, 47, 0.12);
|
|
1572
|
+
color: #dc322f;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
.file-tree-rename-btn:disabled,
|
|
1576
|
+
.file-tree-delete-btn:disabled,
|
|
1577
|
+
.file-tree-new-folder-btn:disabled,
|
|
1578
|
+
.file-tree-new-file-btn:disabled {
|
|
1579
|
+
display: none;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
.file-tree-create-entry {
|
|
1583
|
+
list-style: none;
|
|
1584
|
+
padding: 0;
|
|
1585
|
+
margin: 0;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
.file-tree-create-actions {
|
|
1589
|
+
display: flex;
|
|
1590
|
+
align-items: center;
|
|
1591
|
+
gap: 6px;
|
|
1592
|
+
padding: 3px 6px 3px 28px;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
.file-tree-create-actions .file-tree-new-folder-btn,
|
|
1596
|
+
.file-tree-create-actions .file-tree-new-file-btn {
|
|
1597
|
+
display: inline-flex;
|
|
1598
|
+
align-items: center;
|
|
1599
|
+
justify-content: center;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
.file-tree-rename-input {
|
|
1603
|
+
min-width: 0;
|
|
1604
|
+
flex: 1 1 auto;
|
|
1605
|
+
padding: 1px 4px;
|
|
1606
|
+
border: 1px solid rgba(38, 139, 210, 0.5);
|
|
1607
|
+
border-radius: 4px;
|
|
1608
|
+
background: rgba(0, 43, 54, 0.9);
|
|
1609
|
+
color: var(--text-highlight);
|
|
1610
|
+
font: inherit;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1498
1613
|
.editor-workspace {
|
|
1499
1614
|
flex-grow: 1;
|
|
1500
1615
|
display: flex;
|
|
@@ -3235,12 +3350,12 @@ kbd {
|
|
|
3235
3350
|
align-items: center;
|
|
3236
3351
|
justify-content: center;
|
|
3237
3352
|
overflow: auto;
|
|
3238
|
-
background-color: #
|
|
3353
|
+
background-color: #073642;
|
|
3239
3354
|
background-image:
|
|
3240
|
-
linear-gradient(45deg, #
|
|
3241
|
-
linear-gradient(-45deg, #
|
|
3242
|
-
linear-gradient(45deg, transparent 75%, #
|
|
3243
|
-
linear-gradient(-45deg, transparent 75%, #
|
|
3355
|
+
linear-gradient(45deg, #0b3f4b 25%, transparent 25%),
|
|
3356
|
+
linear-gradient(-45deg, #0b3f4b 25%, transparent 25%),
|
|
3357
|
+
linear-gradient(45deg, transparent 75%, #0b3f4b 75%),
|
|
3358
|
+
linear-gradient(-45deg, transparent 75%, #0b3f4b 75%);
|
|
3244
3359
|
background-size: 20px 20px;
|
|
3245
3360
|
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
|
|
3246
3361
|
}
|
package/src/fs-routes.mjs
CHANGED
|
@@ -1,7 +1,150 @@
|
|
|
1
|
+
import { constants as fsConstants } from 'node:fs';
|
|
1
2
|
import fs from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import process from 'node:process';
|
|
4
5
|
|
|
6
|
+
const IMAGE_MIME_TYPES = {
|
|
7
|
+
'.png': 'image/png',
|
|
8
|
+
'.jpg': 'image/jpeg',
|
|
9
|
+
'.jpeg': 'image/jpeg',
|
|
10
|
+
'.gif': 'image/gif',
|
|
11
|
+
'.svg': 'image/svg+xml',
|
|
12
|
+
'.webp': 'image/webp'
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function isSupportedTextBuffer(buffer) {
|
|
16
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let suspiciousControlBytes = 0;
|
|
21
|
+
for (const byte of buffer) {
|
|
22
|
+
if (byte === 0x00) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (
|
|
26
|
+
byte < 0x20
|
|
27
|
+
&& byte !== 0x09
|
|
28
|
+
&& byte !== 0x0a
|
|
29
|
+
&& byte !== 0x0d
|
|
30
|
+
) {
|
|
31
|
+
suspiciousControlBytes += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (suspiciousControlBytes > Math.max(1, buffer.length * 0.01)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
41
|
+
decoder.decode(buffer);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function joinRelativePath(basePath, name) {
|
|
49
|
+
if (!basePath || basePath === '.' || basePath === path.sep) {
|
|
50
|
+
return name;
|
|
51
|
+
}
|
|
52
|
+
return path.join(basePath, name);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function canWritePath(targetPath) {
|
|
56
|
+
try {
|
|
57
|
+
await fs.access(targetPath, fsConstants.W_OK);
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function createUniqueChild(baseDir, parentPath, kind) {
|
|
65
|
+
const normalizedParentPath = parentPath || '.';
|
|
66
|
+
const fullParentPath = resolvePath(baseDir, normalizedParentPath);
|
|
67
|
+
const parentStats = await fs.stat(fullParentPath);
|
|
68
|
+
if (!parentStats.isDirectory()) {
|
|
69
|
+
const error = new Error('Parent path is not a directory');
|
|
70
|
+
error.status = 400;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
const writable = await canWritePath(fullParentPath);
|
|
74
|
+
if (!writable) {
|
|
75
|
+
const error = new Error('Parent directory is read-only');
|
|
76
|
+
error.status = 403;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const baseName = kind === 'directory'
|
|
81
|
+
? 'untitled_folder'
|
|
82
|
+
: 'untitled_file';
|
|
83
|
+
|
|
84
|
+
for (let attempt = 0; attempt < 10000; attempt += 1) {
|
|
85
|
+
const name = attempt === 0
|
|
86
|
+
? baseName
|
|
87
|
+
: `${baseName}_${attempt}`;
|
|
88
|
+
const relativePath = joinRelativePath(normalizedParentPath, name);
|
|
89
|
+
const fullPath = resolvePath(baseDir, relativePath);
|
|
90
|
+
try {
|
|
91
|
+
if (kind === 'directory') {
|
|
92
|
+
await fs.mkdir(fullPath);
|
|
93
|
+
} else {
|
|
94
|
+
const handle = await fs.open(fullPath, 'wx');
|
|
95
|
+
await handle.close();
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
path: relativePath,
|
|
99
|
+
parentPath: normalizedParentPath,
|
|
100
|
+
name,
|
|
101
|
+
isDirectory: kind === 'directory'
|
|
102
|
+
};
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error?.code === 'EEXIST') {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const error = new Error('Unable to find an available name');
|
|
112
|
+
error.status = 409;
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function ensureRenameTargetAvailable(baseDir, sourcePath, newName) {
|
|
117
|
+
const nextPath = path.join(path.dirname(sourcePath), newName);
|
|
118
|
+
const fullSourcePath = resolvePath(baseDir, sourcePath);
|
|
119
|
+
const fullNextPath = resolvePath(baseDir, nextPath);
|
|
120
|
+
|
|
121
|
+
if (fullSourcePath === fullNextPath) {
|
|
122
|
+
return {
|
|
123
|
+
nextPath,
|
|
124
|
+
fullSourcePath,
|
|
125
|
+
fullNextPath
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await fs.stat(fullNextPath);
|
|
131
|
+
const error = new Error(
|
|
132
|
+
'A file or folder with that name already exists.'
|
|
133
|
+
);
|
|
134
|
+
error.status = 409;
|
|
135
|
+
throw error;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error?.code === 'ENOENT') {
|
|
138
|
+
return {
|
|
139
|
+
nextPath,
|
|
140
|
+
fullSourcePath,
|
|
141
|
+
fullNextPath
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
5
148
|
// Helper to safely resolve path
|
|
6
149
|
const resolvePath = (baseDir, targetPath) => {
|
|
7
150
|
return path.resolve(baseDir, targetPath);
|
|
@@ -23,28 +166,43 @@ export const setupFsRoutes = (router) => {
|
|
|
23
166
|
return;
|
|
24
167
|
}
|
|
25
168
|
|
|
169
|
+
let renameable = false;
|
|
170
|
+
try {
|
|
171
|
+
await fs.access(fullPath, fsConstants.W_OK);
|
|
172
|
+
renameable = true;
|
|
173
|
+
} catch {
|
|
174
|
+
renameable = false;
|
|
175
|
+
}
|
|
176
|
+
|
|
26
177
|
const dirents = await fs.readdir(fullPath, { withFileTypes: true });
|
|
27
178
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
179
|
+
const items = await Promise.all(
|
|
180
|
+
dirents
|
|
181
|
+
.filter(dirent => dirent.name !== '.DS_Store')
|
|
182
|
+
.map(async (dirent) => {
|
|
183
|
+
const entryPath = path.join(dirPath, dirent.name);
|
|
184
|
+
return {
|
|
185
|
+
name: dirent.name,
|
|
186
|
+
isDirectory: dirent.isDirectory(),
|
|
187
|
+
path: entryPath,
|
|
188
|
+
renameable,
|
|
189
|
+
deleteable: renameable
|
|
190
|
+
};
|
|
191
|
+
})
|
|
192
|
+
);
|
|
38
193
|
|
|
39
194
|
// Sort: Directories first, then files
|
|
40
|
-
|
|
195
|
+
items.sort((a, b) => {
|
|
41
196
|
if (a.isDirectory === b.isDirectory) {
|
|
42
197
|
return a.name.localeCompare(b.name);
|
|
43
198
|
}
|
|
44
199
|
return a.isDirectory ? -1 : 1;
|
|
45
200
|
});
|
|
46
201
|
|
|
47
|
-
ctx.body =
|
|
202
|
+
ctx.body = {
|
|
203
|
+
items,
|
|
204
|
+
creatable: renameable
|
|
205
|
+
};
|
|
48
206
|
} catch (err) {
|
|
49
207
|
console.error('FS List Error:', err);
|
|
50
208
|
ctx.status = 500;
|
|
@@ -52,6 +210,110 @@ export const setupFsRoutes = (router) => {
|
|
|
52
210
|
}
|
|
53
211
|
});
|
|
54
212
|
|
|
213
|
+
router.post('/api/fs/rename', async (ctx) => {
|
|
214
|
+
const sourcePath = ctx.request.body?.path;
|
|
215
|
+
const newName = ctx.request.body?.newName;
|
|
216
|
+
if (typeof sourcePath !== 'string' || sourcePath.length === 0) {
|
|
217
|
+
ctx.status = 400;
|
|
218
|
+
ctx.body = { error: 'Path required' };
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (typeof newName !== 'string' || newName.length === 0) {
|
|
222
|
+
ctx.status = 400;
|
|
223
|
+
ctx.body = { error: 'New name required' };
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (newName === '.' || newName === '..') {
|
|
227
|
+
ctx.status = 400;
|
|
228
|
+
ctx.body = { error: 'Invalid name' };
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (/[\\/]/.test(newName)) {
|
|
232
|
+
ctx.status = 400;
|
|
233
|
+
ctx.body = { error: 'Name must not contain path separators' };
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const {
|
|
239
|
+
nextPath,
|
|
240
|
+
fullSourcePath,
|
|
241
|
+
fullNextPath
|
|
242
|
+
} = await ensureRenameTargetAvailable(baseDir, sourcePath, newName);
|
|
243
|
+
const stats = await fs.stat(fullSourcePath);
|
|
244
|
+
|
|
245
|
+
if (fullSourcePath !== fullNextPath) {
|
|
246
|
+
await fs.rename(fullSourcePath, fullNextPath);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
ctx.body = {
|
|
250
|
+
path: sourcePath,
|
|
251
|
+
newPath: nextPath,
|
|
252
|
+
isDirectory: stats.isDirectory()
|
|
253
|
+
};
|
|
254
|
+
} catch (err) {
|
|
255
|
+
console.error('FS Rename Error:', err);
|
|
256
|
+
ctx.status = err?.status || (err?.code === 'EEXIST' ? 409 : 500);
|
|
257
|
+
ctx.body = { error: err.message };
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
router.post('/api/fs/delete', async (ctx) => {
|
|
262
|
+
const targetPath = ctx.request.body?.path;
|
|
263
|
+
if (typeof targetPath !== 'string' || targetPath.length === 0) {
|
|
264
|
+
ctx.status = 400;
|
|
265
|
+
ctx.body = { error: 'Path required' };
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const fullTargetPath = resolvePath(baseDir, targetPath);
|
|
271
|
+
const stats = await fs.stat(fullTargetPath);
|
|
272
|
+
await fs.rm(fullTargetPath, {
|
|
273
|
+
recursive: stats.isDirectory(),
|
|
274
|
+
force: false
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
ctx.body = {
|
|
278
|
+
path: targetPath,
|
|
279
|
+
isDirectory: stats.isDirectory()
|
|
280
|
+
};
|
|
281
|
+
} catch (err) {
|
|
282
|
+
console.error('FS Delete Error:', err);
|
|
283
|
+
ctx.status = 500;
|
|
284
|
+
ctx.body = { error: err.message };
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
router.post('/api/fs/create', async (ctx) => {
|
|
289
|
+
const parentPath = ctx.request.body?.parentPath;
|
|
290
|
+
const kind = ctx.request.body?.kind;
|
|
291
|
+
|
|
292
|
+
if (typeof parentPath !== 'string' || parentPath.length === 0) {
|
|
293
|
+
ctx.status = 400;
|
|
294
|
+
ctx.body = { error: 'Parent path required' };
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (kind !== 'file' && kind !== 'directory') {
|
|
298
|
+
ctx.status = 400;
|
|
299
|
+
ctx.body = { error: 'Invalid create kind' };
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const created = await createUniqueChild(
|
|
305
|
+
baseDir,
|
|
306
|
+
parentPath,
|
|
307
|
+
kind
|
|
308
|
+
);
|
|
309
|
+
ctx.body = created;
|
|
310
|
+
} catch (err) {
|
|
311
|
+
console.error('FS Create Error:', err);
|
|
312
|
+
ctx.status = err?.status || 500;
|
|
313
|
+
ctx.body = { error: err.message };
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
55
317
|
// Read file
|
|
56
318
|
router.get('/api/fs/read', async (ctx) => {
|
|
57
319
|
const filePath = ctx.query.path;
|
|
@@ -65,13 +327,30 @@ export const setupFsRoutes = (router) => {
|
|
|
65
327
|
const fullPath = resolvePath(baseDir, filePath);
|
|
66
328
|
const stats = await fs.stat(fullPath);
|
|
67
329
|
|
|
330
|
+
if (!stats.isFile()) {
|
|
331
|
+
ctx.status = 400;
|
|
332
|
+
ctx.body = { error: 'Not a file' };
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
68
336
|
if (stats.size > 1024 * 1024 * 5) { // 5MB limit for now
|
|
69
337
|
ctx.status = 400;
|
|
70
338
|
ctx.body = { error: 'File too large' };
|
|
71
339
|
return;
|
|
72
340
|
}
|
|
73
341
|
|
|
74
|
-
const
|
|
342
|
+
const contentBuffer = await fs.readFile(fullPath);
|
|
343
|
+
if (!isSupportedTextBuffer(contentBuffer)) {
|
|
344
|
+
ctx.status = 415;
|
|
345
|
+
ctx.body = {
|
|
346
|
+
error: 'Unsupported file type',
|
|
347
|
+
code: 'unsupported-file-type'
|
|
348
|
+
};
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
353
|
+
const content = decoder.decode(contentBuffer);
|
|
75
354
|
|
|
76
355
|
let readonly = false;
|
|
77
356
|
try {
|
|
@@ -99,19 +378,10 @@ export const setupFsRoutes = (router) => {
|
|
|
99
378
|
|
|
100
379
|
try {
|
|
101
380
|
const fullPath = resolvePath(baseDir, filePath);
|
|
102
|
-
// Basic mime type handling could be added here
|
|
103
381
|
const ext = path.extname(fullPath).toLowerCase();
|
|
104
|
-
const mimeTypes = {
|
|
105
|
-
'.png': 'image/png',
|
|
106
|
-
'.jpg': 'image/jpeg',
|
|
107
|
-
'.jpeg': 'image/jpeg',
|
|
108
|
-
'.gif': 'image/gif',
|
|
109
|
-
'.svg': 'image/svg+xml',
|
|
110
|
-
'.webp': 'image/webp'
|
|
111
|
-
};
|
|
112
382
|
|
|
113
|
-
if (
|
|
114
|
-
ctx.type =
|
|
383
|
+
if (IMAGE_MIME_TYPES[ext]) {
|
|
384
|
+
ctx.type = IMAGE_MIME_TYPES[ext];
|
|
115
385
|
ctx.body = await fs.readFile(fullPath);
|
|
116
386
|
} else {
|
|
117
387
|
ctx.status = 400;
|