@feasibleone/blong-gogo 1.14.2 → 1.15.0
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 +14 -0
- package/package.json +1 -1
- package/src/Gateway.ts +7 -0
- package/src/RestFs.test.ts +341 -0
- package/src/RestFs.ts +386 -0
- package/src/Watch.ts +2 -2
- package/src/load.ts +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.15.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.14.3...blong-gogo-v1.15.0) (2026-03-29)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* built-in REST-FS server component for remote filesystem over Gateway ([#108](https://github.com/feasibleone/blong/issues/108)) ([352ef2a](https://github.com/feasibleone/blong/commit/352ef2a86a4da1f29eaf931c6057d018918fedaf))
|
|
9
|
+
|
|
10
|
+
## [1.14.3](https://github.com/feasibleone/blong/compare/blong-gogo-v1.14.2...blong-gogo-v1.14.3) (2026-03-24)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* type generation ([425c3ca](https://github.com/feasibleone/blong/commit/425c3cac5863253c39138a3245be876f0e0a74dd))
|
|
16
|
+
|
|
3
17
|
## [1.14.2](https://github.com/feasibleone/blong/compare/blong-gogo-v1.14.1...blong-gogo-v1.14.2) (2026-03-23)
|
|
4
18
|
|
|
5
19
|
|
package/package.json
CHANGED
package/src/Gateway.ts
CHANGED
|
@@ -153,6 +153,7 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
153
153
|
#routes: RouteOptions[];
|
|
154
154
|
#local: ILocal;
|
|
155
155
|
#errorFields: [string, unknown][] = [];
|
|
156
|
+
#plugins: {plugin: unknown; options: unknown}[] = [];
|
|
156
157
|
|
|
157
158
|
public constructor(
|
|
158
159
|
config: IConfig,
|
|
@@ -195,6 +196,10 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
195
196
|
return Object.freeze({...this.#config});
|
|
196
197
|
}
|
|
197
198
|
|
|
199
|
+
public registerPlugin(plugin: unknown, options?: unknown): void {
|
|
200
|
+
this.#plugins.push({plugin, options});
|
|
201
|
+
}
|
|
202
|
+
|
|
198
203
|
// https://github.com/openzipkin/b3-propagation
|
|
199
204
|
private _forward(headers: object): object {
|
|
200
205
|
return [
|
|
@@ -498,6 +503,8 @@ export default class Gateway extends Internal implements IGateway {
|
|
|
498
503
|
await this.#server.register(swagger, {
|
|
499
504
|
version: '',
|
|
500
505
|
});
|
|
506
|
+
for (const {plugin, options} of this.#plugins)
|
|
507
|
+
await this.#server.register(plugin, options);
|
|
501
508
|
this.#routes.forEach(route => this.#server.route(route));
|
|
502
509
|
} finally {
|
|
503
510
|
await old?.close();
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for RestFs component — path traversal, CRUD, and edge cases via Fastify injection.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import assert from 'node:assert';
|
|
6
|
+
import {mkdir, readFile, rm, symlink, writeFile} from 'node:fs/promises';
|
|
7
|
+
import {tmpdir} from 'node:os';
|
|
8
|
+
import {join} from 'node:path';
|
|
9
|
+
import {after, before, describe, it} from 'node:test';
|
|
10
|
+
|
|
11
|
+
import fastify, {type FastifyInstance} from 'fastify';
|
|
12
|
+
import fp from 'fastify-plugin';
|
|
13
|
+
import RestFs from './RestFs.ts';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Helper: create an isolated RestFs + Fastify instance for testing.
|
|
17
|
+
* Returns { server, baseDir, cleanup }.
|
|
18
|
+
*/
|
|
19
|
+
async function setup(options?: {shell?: boolean}): Promise<{
|
|
20
|
+
server: FastifyInstance;
|
|
21
|
+
baseDir: string;
|
|
22
|
+
cleanup: () => Promise<void>;
|
|
23
|
+
}> {
|
|
24
|
+
const baseDir = join(tmpdir(), `restfs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
25
|
+
await mkdir(baseDir, {recursive: true});
|
|
26
|
+
|
|
27
|
+
// RestFs expects a gateway with registerPlugin — we accumulate the plugin and register it ourselves
|
|
28
|
+
let capturedPlugin: unknown;
|
|
29
|
+
let capturedOptions: unknown;
|
|
30
|
+
const fakeGateway = {
|
|
31
|
+
registerPlugin(plugin: unknown, opts?: unknown) {
|
|
32
|
+
capturedPlugin = plugin;
|
|
33
|
+
capturedOptions = opts;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const restFs = new (RestFs as any)(
|
|
38
|
+
{enabled: true, baseDir, routePrefix: '/api/fs', maxFileSize: 52428800, auth: false, shell: options?.shell ?? false},
|
|
39
|
+
{gateway: fakeGateway},
|
|
40
|
+
);
|
|
41
|
+
await restFs.init();
|
|
42
|
+
|
|
43
|
+
const server = fastify();
|
|
44
|
+
// Stub auth config so routes don't require it
|
|
45
|
+
server.addHook('preValidation', (_req, _reply, done) => done());
|
|
46
|
+
if (capturedPlugin) await server.register(capturedPlugin as any, capturedOptions as any);
|
|
47
|
+
await server.ready();
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
server,
|
|
51
|
+
baseDir,
|
|
52
|
+
cleanup: async () => {
|
|
53
|
+
await server.close();
|
|
54
|
+
await rm(baseDir, {recursive: true, force: true});
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('RestFs', () => {
|
|
60
|
+
let server: FastifyInstance;
|
|
61
|
+
let baseDir: string;
|
|
62
|
+
let cleanup: () => Promise<void>;
|
|
63
|
+
|
|
64
|
+
before(async () => {
|
|
65
|
+
({server, baseDir, cleanup} = await setup());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
after(async () => {
|
|
69
|
+
await cleanup();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---- CRUD ----
|
|
73
|
+
|
|
74
|
+
describe('CRUD operations', () => {
|
|
75
|
+
it('GET /stat/* — returns 404 for non-existent path', async () => {
|
|
76
|
+
const res = await server.inject({method: 'GET', url: '/api/fs/stat/does-not-exist'});
|
|
77
|
+
assert.strictEqual(res.statusCode, 404);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('POST /mkdir/* — creates a directory', async () => {
|
|
81
|
+
const res = await server.inject({method: 'POST', url: '/api/fs/mkdir/test-dir'});
|
|
82
|
+
assert.strictEqual(res.statusCode, 200);
|
|
83
|
+
assert.deepStrictEqual(res.json(), {success: true});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('GET /stat/* — returns metadata for existing directory', async () => {
|
|
87
|
+
const res = await server.inject({method: 'GET', url: '/api/fs/stat/test-dir'});
|
|
88
|
+
assert.strictEqual(res.statusCode, 200);
|
|
89
|
+
const body = res.json();
|
|
90
|
+
assert.strictEqual(body.type, 'directory');
|
|
91
|
+
assert.ok(typeof body.mtime === 'number');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('POST /write/* — writes a file', async () => {
|
|
95
|
+
const content = Buffer.from('hello world');
|
|
96
|
+
const res = await server.inject({
|
|
97
|
+
method: 'POST',
|
|
98
|
+
url: '/api/fs/write/test-dir/file.txt',
|
|
99
|
+
headers: {'content-type': 'application/octet-stream'},
|
|
100
|
+
payload: content,
|
|
101
|
+
});
|
|
102
|
+
assert.strictEqual(res.statusCode, 200);
|
|
103
|
+
assert.deepStrictEqual(res.json(), {success: true});
|
|
104
|
+
|
|
105
|
+
// Verify the file on disk
|
|
106
|
+
const written = await readFile(join(baseDir, 'test-dir', 'file.txt'));
|
|
107
|
+
assert.deepStrictEqual(written, content);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('POST /write/* — returns 415 for non-binary body', async () => {
|
|
111
|
+
const res = await server.inject({
|
|
112
|
+
method: 'POST',
|
|
113
|
+
url: '/api/fs/write/test-dir/bad.txt',
|
|
114
|
+
headers: {'content-type': 'application/json'},
|
|
115
|
+
payload: JSON.stringify({text: 'oops'}),
|
|
116
|
+
});
|
|
117
|
+
assert.strictEqual(res.statusCode, 415);
|
|
118
|
+
assert.ok(res.json().error.includes('octet-stream'));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('GET /read/* — reads file contents back', async () => {
|
|
122
|
+
const res = await server.inject({method: 'GET', url: '/api/fs/read/test-dir/file.txt'});
|
|
123
|
+
assert.strictEqual(res.statusCode, 200);
|
|
124
|
+
assert.strictEqual(res.body, 'hello world');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('GET /read/* — returns 404 for non-existent file', async () => {
|
|
128
|
+
const res = await server.inject({method: 'GET', url: '/api/fs/read/nope.txt'});
|
|
129
|
+
assert.strictEqual(res.statusCode, 404);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('GET /readdir/* — lists directory entries', async () => {
|
|
133
|
+
const res = await server.inject({method: 'GET', url: '/api/fs/readdir/test-dir'});
|
|
134
|
+
assert.strictEqual(res.statusCode, 200);
|
|
135
|
+
const entries = res.json();
|
|
136
|
+
assert.ok(Array.isArray(entries));
|
|
137
|
+
assert.ok(entries.some((e: {name: string}) => e.name === 'file.txt'));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('POST /rename — renames a file', async () => {
|
|
141
|
+
const res = await server.inject({
|
|
142
|
+
method: 'POST',
|
|
143
|
+
url: '/api/fs/rename',
|
|
144
|
+
payload: {oldPath: 'test-dir/file.txt', newPath: 'test-dir/renamed.txt'},
|
|
145
|
+
});
|
|
146
|
+
assert.strictEqual(res.statusCode, 200);
|
|
147
|
+
assert.deepStrictEqual(res.json(), {success: true});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('POST /copy — copies a file', async () => {
|
|
151
|
+
const res = await server.inject({
|
|
152
|
+
method: 'POST',
|
|
153
|
+
url: '/api/fs/copy',
|
|
154
|
+
payload: {source: 'test-dir/renamed.txt', destination: 'test-dir/copied.txt'},
|
|
155
|
+
});
|
|
156
|
+
assert.strictEqual(res.statusCode, 200);
|
|
157
|
+
assert.deepStrictEqual(res.json(), {success: true});
|
|
158
|
+
|
|
159
|
+
const content = await readFile(join(baseDir, 'test-dir', 'copied.txt'), 'utf-8');
|
|
160
|
+
assert.strictEqual(content, 'hello world');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('DELETE /delete/* — deletes a file', async () => {
|
|
164
|
+
const res = await server.inject({method: 'DELETE', url: '/api/fs/delete/test-dir/copied.txt'});
|
|
165
|
+
assert.strictEqual(res.statusCode, 200);
|
|
166
|
+
assert.deepStrictEqual(res.json(), {success: true});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('DELETE /delete/* — returns 404 for non-existent path', async () => {
|
|
170
|
+
const res = await server.inject({method: 'DELETE', url: '/api/fs/delete/nope.txt'});
|
|
171
|
+
assert.strictEqual(res.statusCode, 404);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('DELETE /delete/* — deletes directory recursively', async () => {
|
|
175
|
+
const res = await server.inject({
|
|
176
|
+
method: 'DELETE',
|
|
177
|
+
url: '/api/fs/delete/test-dir?recursive=true',
|
|
178
|
+
});
|
|
179
|
+
assert.strictEqual(res.statusCode, 200);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ---- Path traversal ----
|
|
184
|
+
|
|
185
|
+
describe('Path traversal protection', () => {
|
|
186
|
+
// Note: URL-level .. traversal is normalized by Fastify before routing,
|
|
187
|
+
// resulting in 404 (route not matched). This is valid HTTP-level protection.
|
|
188
|
+
// The handler-level resolveSafePath protection is tested via body-based
|
|
189
|
+
// endpoints (rename, copy) below, which bypass URL normalization.
|
|
190
|
+
|
|
191
|
+
it('rejects .. traversal in URL (Fastify normalizes → 404)', async () => {
|
|
192
|
+
const res = await server.inject({method: 'GET', url: '/api/fs/stat/../../../etc/passwd'});
|
|
193
|
+
assert.strictEqual(res.statusCode, 404);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('rejects .. traversal in readdir URL (Fastify normalizes → 404)', async () => {
|
|
197
|
+
const res = await server.inject({method: 'GET', url: '/api/fs/readdir/../../'});
|
|
198
|
+
assert.strictEqual(res.statusCode, 404);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('rejects .. traversal in write URL (Fastify normalizes → 404)', async () => {
|
|
202
|
+
const res = await server.inject({
|
|
203
|
+
method: 'POST',
|
|
204
|
+
url: '/api/fs/write/../escape.txt',
|
|
205
|
+
headers: {'content-type': 'application/octet-stream'},
|
|
206
|
+
payload: Buffer.from('pwned'),
|
|
207
|
+
});
|
|
208
|
+
assert.strictEqual(res.statusCode, 404);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('rejects .. traversal in rename (oldPath) — handler-level', async () => {
|
|
212
|
+
const res = await server.inject({
|
|
213
|
+
method: 'POST',
|
|
214
|
+
url: '/api/fs/rename',
|
|
215
|
+
payload: {oldPath: '../../etc/passwd', newPath: 'safe.txt'},
|
|
216
|
+
});
|
|
217
|
+
assert.strictEqual(res.statusCode, 403);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('rejects .. traversal in rename (newPath) — handler-level', async () => {
|
|
221
|
+
await writeFile(join(baseDir, 'a.txt'), 'test');
|
|
222
|
+
const res = await server.inject({
|
|
223
|
+
method: 'POST',
|
|
224
|
+
url: '/api/fs/rename',
|
|
225
|
+
payload: {oldPath: 'a.txt', newPath: '../../escape.txt'},
|
|
226
|
+
});
|
|
227
|
+
assert.strictEqual(res.statusCode, 403);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('rejects .. traversal in copy (destination) — handler-level', async () => {
|
|
231
|
+
await writeFile(join(baseDir, 'b.txt'), 'test');
|
|
232
|
+
const res = await server.inject({
|
|
233
|
+
method: 'POST',
|
|
234
|
+
url: '/api/fs/copy',
|
|
235
|
+
payload: {source: 'b.txt', destination: '../../escape.txt'},
|
|
236
|
+
});
|
|
237
|
+
assert.strictEqual(res.statusCode, 403);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ---- Symlink escape ----
|
|
242
|
+
|
|
243
|
+
describe('Symlink escape protection', () => {
|
|
244
|
+
it('rejects symlink pointing outside baseDir', async () => {
|
|
245
|
+
const linkPath = join(baseDir, 'evil-link');
|
|
246
|
+
try {
|
|
247
|
+
await symlink('/tmp', linkPath);
|
|
248
|
+
} catch {
|
|
249
|
+
// symlink creation may fail on some CI — skip
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const res = await server.inject({method: 'GET', url: '/api/fs/stat/evil-link'});
|
|
253
|
+
assert.strictEqual(res.statusCode, 403);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('rejects write through symlinked parent directory', async () => {
|
|
257
|
+
const linkPath = join(baseDir, 'escape-dir');
|
|
258
|
+
try {
|
|
259
|
+
await symlink('/tmp', linkPath);
|
|
260
|
+
} catch {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const res = await server.inject({
|
|
264
|
+
method: 'POST',
|
|
265
|
+
url: '/api/fs/write/escape-dir/file.txt',
|
|
266
|
+
headers: {'content-type': 'application/octet-stream'},
|
|
267
|
+
payload: Buffer.from('pwned'),
|
|
268
|
+
});
|
|
269
|
+
assert.strictEqual(res.statusCode, 403);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('RestFs disabled', () => {
|
|
275
|
+
it('does not register plugin when enabled is false', async () => {
|
|
276
|
+
let registered = false;
|
|
277
|
+
const fakeGateway = {
|
|
278
|
+
registerPlugin() {
|
|
279
|
+
registered = true;
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
const restFs = new (RestFs as any)(
|
|
283
|
+
{enabled: false, baseDir: '/tmp', routePrefix: '/api/fs', maxFileSize: 1024, auth: false, shell: false},
|
|
284
|
+
{gateway: fakeGateway},
|
|
285
|
+
);
|
|
286
|
+
await restFs.init();
|
|
287
|
+
assert.strictEqual(registered, false, 'Plugin should not be registered when disabled');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('does not register plugin when gateway is missing', async () => {
|
|
291
|
+
const restFs = new (RestFs as any)(
|
|
292
|
+
{enabled: true, baseDir: '/tmp', routePrefix: '/api/fs', maxFileSize: 1024, auth: false, shell: false},
|
|
293
|
+
{},
|
|
294
|
+
);
|
|
295
|
+
// Should not throw
|
|
296
|
+
await restFs.init();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('RestFs shell endpoint', () => {
|
|
301
|
+
let server: FastifyInstance;
|
|
302
|
+
let baseDir: string;
|
|
303
|
+
let cleanup: () => Promise<void>;
|
|
304
|
+
|
|
305
|
+
before(async () => {
|
|
306
|
+
({server, baseDir, cleanup} = await setup({shell: true}));
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
after(async () => {
|
|
310
|
+
await cleanup();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('POST /shell — executes a command and streams output', async () => {
|
|
314
|
+
const res = await server.inject({
|
|
315
|
+
method: 'POST',
|
|
316
|
+
url: '/api/fs/shell',
|
|
317
|
+
payload: {command: 'echo hello'},
|
|
318
|
+
});
|
|
319
|
+
// Shell uses raw streaming; inject returns the raw response
|
|
320
|
+
assert.strictEqual(res.statusCode, 200);
|
|
321
|
+
assert.ok(res.body.includes('hello'));
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('POST /shell — returns 400 when command is missing', async () => {
|
|
325
|
+
const res = await server.inject({
|
|
326
|
+
method: 'POST',
|
|
327
|
+
url: '/api/fs/shell',
|
|
328
|
+
payload: {command: ''},
|
|
329
|
+
});
|
|
330
|
+
assert.strictEqual(res.statusCode, 400);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('POST /shell — validates cwd within baseDir', async () => {
|
|
334
|
+
const res = await server.inject({
|
|
335
|
+
method: 'POST',
|
|
336
|
+
url: '/api/fs/shell',
|
|
337
|
+
payload: {command: 'pwd', cwd: '../../'},
|
|
338
|
+
});
|
|
339
|
+
assert.strictEqual(res.statusCode, 400);
|
|
340
|
+
});
|
|
341
|
+
});
|
package/src/RestFs.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import {Internal, type ILog} from '@feasibleone/blong/types';
|
|
2
|
+
import type {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify';
|
|
3
|
+
import fp from 'fastify-plugin';
|
|
4
|
+
import {spawn} from 'node:child_process';
|
|
5
|
+
import {
|
|
6
|
+
copyFile,
|
|
7
|
+
cp,
|
|
8
|
+
mkdir,
|
|
9
|
+
readdir,
|
|
10
|
+
readFile,
|
|
11
|
+
realpath,
|
|
12
|
+
rename,
|
|
13
|
+
rm,
|
|
14
|
+
stat,
|
|
15
|
+
unlink,
|
|
16
|
+
writeFile,
|
|
17
|
+
} from 'node:fs/promises';
|
|
18
|
+
import {dirname, isAbsolute, join, relative, resolve, sep} from 'node:path';
|
|
19
|
+
import {pipeline} from 'node:stream/promises';
|
|
20
|
+
|
|
21
|
+
interface IConfig {
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
baseDir: string;
|
|
24
|
+
routePrefix: string;
|
|
25
|
+
maxFileSize: number;
|
|
26
|
+
auth: false | 'jwt';
|
|
27
|
+
shell: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface IGatewayWithPlugins {
|
|
31
|
+
registerPlugin(plugin: unknown, options?: unknown): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default class RestFs extends Internal {
|
|
35
|
+
#config: IConfig = {
|
|
36
|
+
enabled: false,
|
|
37
|
+
baseDir: process.cwd(),
|
|
38
|
+
routePrefix: '/api/fs',
|
|
39
|
+
maxFileSize: 52428800, // 50MB
|
|
40
|
+
auth: false,
|
|
41
|
+
shell: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
#gateway: IGatewayWithPlugins;
|
|
45
|
+
|
|
46
|
+
public constructor(
|
|
47
|
+
config: IConfig,
|
|
48
|
+
{log, gateway}: {log?: ILog; gateway?: IGatewayWithPlugins},
|
|
49
|
+
) {
|
|
50
|
+
super({log});
|
|
51
|
+
this.merge(this.#config, config);
|
|
52
|
+
this.#gateway = gateway;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public async init(): Promise<void> {
|
|
56
|
+
if (!this.#config.enabled || !this.#gateway) return;
|
|
57
|
+
|
|
58
|
+
const config = this.#config;
|
|
59
|
+
const baseDir = resolve(config.baseDir);
|
|
60
|
+
|
|
61
|
+
await mkdir(baseDir, {recursive: true});
|
|
62
|
+
|
|
63
|
+
const isWithinBase = (candidate: string): boolean => {
|
|
64
|
+
if (candidate === baseDir) return true;
|
|
65
|
+
const rel = relative(baseDir, candidate);
|
|
66
|
+
return !rel.startsWith('..') && !isAbsolute(rel) && !rel.startsWith('..' + sep);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const resolveSafePath = async (requestPath: string): Promise<string> => {
|
|
70
|
+
const joined = join(baseDir, requestPath || '/');
|
|
71
|
+
const resolved = resolve(joined);
|
|
72
|
+
if (!isWithinBase(resolved)) {
|
|
73
|
+
const error = new Error('Access denied: path outside base directory');
|
|
74
|
+
(error as NodeJS.ErrnoException).code = 'EACCES';
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
// Walk up to the nearest existing ancestor and realpath-check it
|
|
78
|
+
// to prevent symlink-escape attacks on non-existent paths
|
|
79
|
+
let check = resolved;
|
|
80
|
+
let real: string | undefined;
|
|
81
|
+
while (check !== baseDir) {
|
|
82
|
+
try {
|
|
83
|
+
real = await realpath(check);
|
|
84
|
+
break;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
87
|
+
check = dirname(check);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (real && !isWithinBase(real)) {
|
|
94
|
+
const error = new Error('Access denied: path outside base directory');
|
|
95
|
+
(error as NodeJS.ErrnoException).code = 'EACCES';
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
// If the full path exists, return the realpath; otherwise the resolved path
|
|
99
|
+
if (real && check === resolved) return real;
|
|
100
|
+
return resolved;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const fsError = (
|
|
104
|
+
reply: FastifyReply,
|
|
105
|
+
err: unknown,
|
|
106
|
+
): FastifyReply => {
|
|
107
|
+
const error = err as NodeJS.ErrnoException;
|
|
108
|
+
switch (error.code) {
|
|
109
|
+
case 'ENOENT':
|
|
110
|
+
return reply.code(404).send({error: 'Not found'});
|
|
111
|
+
case 'EACCES':
|
|
112
|
+
return reply.code(403).send({error: error.message || 'Access denied'});
|
|
113
|
+
case 'ENOTEMPTY':
|
|
114
|
+
return reply
|
|
115
|
+
.code(400)
|
|
116
|
+
.send({error: 'Directory not empty (use recursive=true)'});
|
|
117
|
+
default:
|
|
118
|
+
return reply.code(500).send({error: error.message || 'Internal server error'});
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const authConfig = config.auth;
|
|
123
|
+
|
|
124
|
+
const plugin = fp(
|
|
125
|
+
async (server: FastifyInstance) => {
|
|
126
|
+
const prefix = config.routePrefix;
|
|
127
|
+
|
|
128
|
+
// Add raw body parser for octet-stream content
|
|
129
|
+
server.addContentTypeParser(
|
|
130
|
+
'application/octet-stream',
|
|
131
|
+
{parseAs: 'buffer', bodyLimit: config.maxFileSize},
|
|
132
|
+
(_req, body, done) => {
|
|
133
|
+
done(null, body);
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// GET /stat/* — file/directory metadata
|
|
138
|
+
server.route({
|
|
139
|
+
method: 'GET',
|
|
140
|
+
url: `${prefix}/stat/*`,
|
|
141
|
+
config: {auth: authConfig},
|
|
142
|
+
handler: async (
|
|
143
|
+
request: FastifyRequest<{Params: {'*': string}}>,
|
|
144
|
+
reply,
|
|
145
|
+
) => {
|
|
146
|
+
try {
|
|
147
|
+
const fullPath = await resolveSafePath(request.params['*']);
|
|
148
|
+
const stats = await stat(fullPath);
|
|
149
|
+
return {
|
|
150
|
+
type: stats.isDirectory() ? 'directory' : 'file',
|
|
151
|
+
ctime: stats.ctimeMs,
|
|
152
|
+
mtime: stats.mtimeMs,
|
|
153
|
+
size: stats.size,
|
|
154
|
+
};
|
|
155
|
+
} catch (err) {
|
|
156
|
+
return fsError(reply, err);
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// GET /readdir/* — directory listing
|
|
162
|
+
server.route({
|
|
163
|
+
method: 'GET',
|
|
164
|
+
url: `${prefix}/readdir/*`,
|
|
165
|
+
config: {auth: authConfig},
|
|
166
|
+
handler: async (
|
|
167
|
+
request: FastifyRequest<{Params: {'*': string}}>,
|
|
168
|
+
reply,
|
|
169
|
+
) => {
|
|
170
|
+
try {
|
|
171
|
+
const fullPath = await resolveSafePath(request.params['*']);
|
|
172
|
+
const entries = await readdir(fullPath, {withFileTypes: true});
|
|
173
|
+
return entries.map(entry => ({
|
|
174
|
+
name: entry.name,
|
|
175
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
176
|
+
}));
|
|
177
|
+
} catch (err) {
|
|
178
|
+
return fsError(reply, err);
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// POST /mkdir/* — create directory
|
|
184
|
+
server.route({
|
|
185
|
+
method: 'POST',
|
|
186
|
+
url: `${prefix}/mkdir/*`,
|
|
187
|
+
config: {auth: authConfig},
|
|
188
|
+
handler: async (
|
|
189
|
+
request: FastifyRequest<{Params: {'*': string}}>,
|
|
190
|
+
reply,
|
|
191
|
+
) => {
|
|
192
|
+
try {
|
|
193
|
+
const fullPath = await resolveSafePath(request.params['*']);
|
|
194
|
+
await mkdir(fullPath, {recursive: true});
|
|
195
|
+
return {success: true};
|
|
196
|
+
} catch (err) {
|
|
197
|
+
return fsError(reply, err);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// GET /read/* — read file contents
|
|
203
|
+
server.route({
|
|
204
|
+
method: 'GET',
|
|
205
|
+
url: `${prefix}/read/*`,
|
|
206
|
+
config: {auth: authConfig},
|
|
207
|
+
handler: async (
|
|
208
|
+
request: FastifyRequest<{Params: {'*': string}}>,
|
|
209
|
+
reply,
|
|
210
|
+
) => {
|
|
211
|
+
try {
|
|
212
|
+
const fullPath = await resolveSafePath(request.params['*']);
|
|
213
|
+
const content = await readFile(fullPath);
|
|
214
|
+
return reply.type('application/octet-stream').send(content);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return fsError(reply, err);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// POST /write/* — write file contents
|
|
222
|
+
server.route({
|
|
223
|
+
method: 'POST',
|
|
224
|
+
url: `${prefix}/write/*`,
|
|
225
|
+
config: {auth: authConfig},
|
|
226
|
+
handler: async (
|
|
227
|
+
request: FastifyRequest<{Params: {'*': string}}>,
|
|
228
|
+
reply,
|
|
229
|
+
) => {
|
|
230
|
+
try {
|
|
231
|
+
const body = request.body;
|
|
232
|
+
if (!Buffer.isBuffer(body)) {
|
|
233
|
+
return reply.code(415).send({
|
|
234
|
+
error: 'Unsupported media type: expected application/octet-stream',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const fullPath = await resolveSafePath(request.params['*']);
|
|
238
|
+
await mkdir(dirname(fullPath), {recursive: true});
|
|
239
|
+
await writeFile(fullPath, body);
|
|
240
|
+
return {success: true};
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return fsError(reply, err);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// DELETE /delete/* — delete file or directory
|
|
248
|
+
server.route({
|
|
249
|
+
method: 'DELETE',
|
|
250
|
+
url: `${prefix}/delete/*`,
|
|
251
|
+
config: {auth: authConfig},
|
|
252
|
+
handler: async (
|
|
253
|
+
request: FastifyRequest<{
|
|
254
|
+
Params: {'*': string};
|
|
255
|
+
Querystring: {recursive?: string};
|
|
256
|
+
}>,
|
|
257
|
+
reply,
|
|
258
|
+
) => {
|
|
259
|
+
try {
|
|
260
|
+
const fullPath = await resolveSafePath(request.params['*']);
|
|
261
|
+
const recursive = request.query.recursive === 'true';
|
|
262
|
+
const stats = await stat(fullPath);
|
|
263
|
+
if (stats.isDirectory()) {
|
|
264
|
+
await rm(fullPath, {recursive});
|
|
265
|
+
} else {
|
|
266
|
+
await unlink(fullPath);
|
|
267
|
+
}
|
|
268
|
+
return {success: true};
|
|
269
|
+
} catch (err) {
|
|
270
|
+
return fsError(reply, err);
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// POST /rename — rename/move
|
|
276
|
+
server.route({
|
|
277
|
+
method: 'POST',
|
|
278
|
+
url: `${prefix}/rename`,
|
|
279
|
+
config: {auth: authConfig},
|
|
280
|
+
handler: async (
|
|
281
|
+
request: FastifyRequest<{Body: {oldPath: string; newPath: string}}>,
|
|
282
|
+
reply,
|
|
283
|
+
) => {
|
|
284
|
+
try {
|
|
285
|
+
const {oldPath, newPath} = request.body;
|
|
286
|
+
const oldFullPath = await resolveSafePath(oldPath);
|
|
287
|
+
const newFullPath = await resolveSafePath(newPath);
|
|
288
|
+
await rename(oldFullPath, newFullPath);
|
|
289
|
+
return {success: true};
|
|
290
|
+
} catch (err) {
|
|
291
|
+
return fsError(reply, err);
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// POST /copy — copy file or directory
|
|
297
|
+
server.route({
|
|
298
|
+
method: 'POST',
|
|
299
|
+
url: `${prefix}/copy`,
|
|
300
|
+
config: {auth: authConfig},
|
|
301
|
+
handler: async (
|
|
302
|
+
request: FastifyRequest<{
|
|
303
|
+
Body: {source: string; destination: string};
|
|
304
|
+
}>,
|
|
305
|
+
reply,
|
|
306
|
+
) => {
|
|
307
|
+
try {
|
|
308
|
+
const {source, destination} = request.body;
|
|
309
|
+
const sourceFullPath = await resolveSafePath(source);
|
|
310
|
+
const destFullPath = await resolveSafePath(destination);
|
|
311
|
+
const stats = await stat(sourceFullPath);
|
|
312
|
+
if (stats.isDirectory()) {
|
|
313
|
+
await cp(sourceFullPath, destFullPath, {recursive: true});
|
|
314
|
+
} else {
|
|
315
|
+
await copyFile(sourceFullPath, destFullPath);
|
|
316
|
+
}
|
|
317
|
+
return {success: true};
|
|
318
|
+
} catch (err) {
|
|
319
|
+
return fsError(reply, err);
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// POST /shell — execute shell command with streaming output
|
|
325
|
+
if (config.shell) {
|
|
326
|
+
server.route({
|
|
327
|
+
method: 'POST',
|
|
328
|
+
url: `${prefix}/shell`,
|
|
329
|
+
// Shell endpoint always requires auth when enabled
|
|
330
|
+
config: {auth: authConfig || 'jwt'},
|
|
331
|
+
handler: async (
|
|
332
|
+
request: FastifyRequest<{Body: {command: string; cwd?: string}}>,
|
|
333
|
+
reply,
|
|
334
|
+
) => {
|
|
335
|
+
const {command, cwd} = request.body;
|
|
336
|
+
if (!command) {
|
|
337
|
+
return reply.code(400).send({error: 'Command is required'});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let workingDir: string;
|
|
341
|
+
try {
|
|
342
|
+
workingDir = cwd
|
|
343
|
+
? await resolveSafePath(cwd)
|
|
344
|
+
: baseDir;
|
|
345
|
+
} catch {
|
|
346
|
+
return reply
|
|
347
|
+
.code(400)
|
|
348
|
+
.send({error: 'Invalid working directory'});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
reply.raw.writeHead(200, {
|
|
352
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
353
|
+
'transfer-encoding': 'chunked',
|
|
354
|
+
'cache-control': 'no-cache',
|
|
355
|
+
'x-content-type-options': 'nosniff',
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const child = spawn(command, [], {
|
|
359
|
+
cwd: workingDir,
|
|
360
|
+
shell: true,
|
|
361
|
+
env: process.env,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
request.raw.on('close', () => child.kill());
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
await Promise.all([
|
|
368
|
+
pipeline(child.stdout, reply.raw, {end: false}),
|
|
369
|
+
pipeline(child.stderr, reply.raw, {end: false}),
|
|
370
|
+
new Promise(resolve => child.on('close', resolve)),
|
|
371
|
+
]);
|
|
372
|
+
} catch {
|
|
373
|
+
// Streaming error — client may have disconnected
|
|
374
|
+
} finally {
|
|
375
|
+
reply.raw.end();
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
{name: 'rest-fs'},
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
this.#gateway.registerPlugin(plugin);
|
|
385
|
+
}
|
|
386
|
+
}
|
package/src/Watch.ts
CHANGED
|
@@ -151,11 +151,11 @@ export default class Watch extends Internal implements IWatch {
|
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
declare module '@feasibleone/blong' {
|
|
154
|
-
interface
|
|
154
|
+
interface ISchema {
|
|
155
155
|
${names
|
|
156
156
|
.map(
|
|
157
157
|
name =>
|
|
158
|
-
`${name}
|
|
158
|
+
`${name}(params: Parameters<${name}>[0], $meta: IMeta): ReturnType<${name}>;`,
|
|
159
159
|
)
|
|
160
160
|
.join('\n')}
|
|
161
161
|
}
|
package/src/load.ts
CHANGED
|
@@ -265,6 +265,7 @@ export default async function loadRealm<T extends TSchema>(
|
|
|
265
265
|
local: {},
|
|
266
266
|
rpcServer: {},
|
|
267
267
|
gateway: {},
|
|
268
|
+
restFs: {},
|
|
268
269
|
});
|
|
269
270
|
items = [
|
|
270
271
|
function log() {
|
|
@@ -299,6 +300,9 @@ export default async function loadRealm<T extends TSchema>(
|
|
|
299
300
|
function gateway() {
|
|
300
301
|
return import('./Gateway.ts');
|
|
301
302
|
},
|
|
303
|
+
function restFs() {
|
|
304
|
+
return import('./RestFs.ts');
|
|
305
|
+
},
|
|
302
306
|
function registry() {
|
|
303
307
|
return import('./Registry.ts');
|
|
304
308
|
},
|