@goodandready/dsh-remote-workspace 0.1.1 → 0.3.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/lib/routes.js CHANGED
@@ -1,41 +1,45 @@
1
- export function writeJson(res, code, data) {
2
- try {
3
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
4
- res.end(JSON.stringify(data));
5
- } catch {}
6
- }
1
+ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store, dockerService, healthService, watcherService) {
2
+ function writeJson(res, statusCode, body) {
3
+ if (typeof res.setHeader === 'function') {
4
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
5
+ res.statusCode = statusCode;
6
+ } else if (typeof res.writeHead === 'function') {
7
+ res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
8
+ } else {
9
+ res.statusCode = statusCode;
10
+ }
11
+ res.end(JSON.stringify(body));
12
+ }
7
13
 
8
- export function readBody(req, maxBytes = 256 * 1024) {
9
- return new Promise((resolve, reject) => {
10
- const chunks = [];
11
- let size = 0;
12
- req.on('data', (c) => {
13
- size += c.length;
14
- if (size > maxBytes) {
15
- reject(new Error('Payload too large'));
16
- req.destroy();
17
- return;
18
- }
19
- chunks.push(c);
20
- });
21
- req.on('end', () => {
22
- try {
23
- const raw = Buffer.concat(chunks).toString('utf8');
24
- resolve(raw ? JSON.parse(raw) : {});
25
- } catch (err) {
26
- reject(new Error('Invalid JSON'));
27
- }
14
+ function readBody(req) {
15
+ return new Promise((resolve, reject) => {
16
+ let body = '';
17
+ req.on('data', (chunk) => { body += chunk.toString(); });
18
+ req.on('end', () => {
19
+ try {
20
+ resolve(body ? JSON.parse(body) : {});
21
+ } catch (err) {
22
+ reject(err);
23
+ }
24
+ });
25
+ req.on('error', reject);
28
26
  });
29
- req.on('error', reject);
30
- });
31
- }
27
+ }
32
28
 
33
- /** Reject cross-site writes. LAN / reverse-proxy UIs are allowed. */
34
- export function isTrustedSettingsRequest(request) {
35
- return request.headers['sec-fetch-site'] !== 'cross-site';
36
- }
29
+ function isTrustedSettingsRequest(req) {
30
+ const rawAuth = req.headers['authorization'];
31
+ if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) return true;
32
+ const cookieHeader = req.headers['cookie'];
33
+ if (typeof cookieHeader === 'string' && (cookieHeader.includes('token=') || cookieHeader.includes('dsh_token='))) {
34
+ return true;
35
+ }
36
+ const secFetch = req.headers['sec-fetch-site'];
37
+ if (secFetch === 'same-origin') return true;
38
+ const ip = req.socket?.remoteAddress || '';
39
+ if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return true;
40
+ return false;
41
+ }
37
42
 
38
- export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store) {
39
43
  ctx.inject(['webServer'], (sctx) => {
40
44
  const webServer = sctx.get ? (sctx.get('webServer') ?? sctx.webServer) : sctx.webServer;
41
45
  if (!webServer || typeof webServer.register !== 'function') return;
@@ -46,10 +50,22 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
46
50
  path: '/dsh-remote-workspace/state',
47
51
  handler: async (req, res) => {
48
52
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
49
- const profiles = store.getProfiles();
50
- const activeId = store.getActiveId();
51
- const tunnels = tunnelService.listActiveTunnels();
52
- writeJson(res, 200, { ok: true, profiles, activeId, tunnels });
53
+ try {
54
+ const profiles = store.getProfiles();
55
+ const activeId = store.getActiveId() || (profiles[0]?.id || '');
56
+ const tunnels = tunnelService.listActiveTunnels();
57
+ const autoSync = store.isAutoSyncEnabled ? store.isAutoSyncEnabled() : false;
58
+ writeJson(res, 200, {
59
+ ok: true,
60
+ profiles,
61
+ activeId,
62
+ tunnels,
63
+ autoSync,
64
+ vaultProtected: true
65
+ });
66
+ } catch (err) {
67
+ writeJson(res, 500, { ok: false, error: err.message });
68
+ }
53
69
  }
54
70
  }), 'dsh-remote-workspace: /state');
55
71
 
@@ -67,7 +83,8 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
67
83
  }
68
84
  sshService.invalidate(profile.id);
69
85
  await store.saveProfile(profile);
70
- writeJson(res, 200, { ok: true, profile });
86
+ const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : profile;
87
+ writeJson(res, 200, { ok: true, profile: sanitized });
71
88
  } catch (err) {
72
89
  writeJson(res, 500, { ok: false, error: err.message });
73
90
  }
@@ -119,7 +136,10 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
119
136
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
120
137
  try {
121
138
  const body = await readBody(req);
122
- const profile = body.profile || (body.id ? store.getProfile(body.id) : body);
139
+ let profile = body.profile || (body.id ? store.getProfile(body.id) : body);
140
+ if (store.vault && profile) {
141
+ profile = store.vault.hydrateProfile(profile);
142
+ }
123
143
  if (!profile || !profile.host) {
124
144
  return writeJson(res, 400, { ok: false, error: 'Invalid profile configuration' });
125
145
  }
@@ -152,7 +172,7 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
152
172
  return writeJson(res, 400, { ok: false, error: 'No profile specified' });
153
173
  }
154
174
  const remotePath = body.remotePath || '/';
155
- const entries = await remoteFs.listDir(profile, remotePath);
175
+ const entries = await remoteFs.listDir(profile, remotePath, Boolean(body.refresh));
156
176
  writeJson(res, 200, { ok: true, currentPath: remotePath, entries });
157
177
  } catch (err) {
158
178
  writeJson(res, 500, { ok: false, error: err.message });
@@ -160,7 +180,143 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
160
180
  }
161
181
  }), 'dsh-remote-workspace: /browse');
162
182
 
163
- // 7. POST /dsh-remote-workspace/sync
183
+ // 7. POST /dsh-remote-workspace/file/view
184
+ sctx.effect(() => webServer.register({
185
+ kind: 'exact',
186
+ path: '/dsh-remote-workspace/file/view',
187
+ handler: async (req, res) => {
188
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
189
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
190
+ try {
191
+ const { profileId, filePath } = await readBody(req);
192
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
193
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
194
+ if (!filePath) return writeJson(res, 400, { ok: false, error: 'filePath required' });
195
+
196
+ const stat = await remoteFs.stat(profile, filePath);
197
+ if (stat.size > 1024 * 512) {
198
+ return writeJson(res, 400, { ok: false, error: `File too large for preview (${Math.round(stat.size / 1024)} KB). Max 512 KB.` });
199
+ }
200
+
201
+ const content = await remoteFs.readFile(profile, filePath, 'utf8');
202
+ writeJson(res, 200, { ok: true, filePath, content, size: stat.size, mtime: stat.mtime });
203
+ } catch (err) {
204
+ writeJson(res, 500, { ok: false, error: err.message });
205
+ }
206
+ }
207
+ }), 'dsh-remote-workspace: /file/view');
208
+
209
+ // 8. POST /dsh-remote-workspace/file/save
210
+ sctx.effect(() => webServer.register({
211
+ kind: 'exact',
212
+ path: '/dsh-remote-workspace/file/save',
213
+ handler: async (req, res) => {
214
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
215
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
216
+ try {
217
+ const { profileId, filePath, content } = await readBody(req);
218
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
219
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
220
+ if (!filePath || content === undefined) return writeJson(res, 400, { ok: false, error: 'filePath and content required' });
221
+
222
+ const result = await remoteFs.writeFile(profile, filePath, content, 'utf8');
223
+ writeJson(res, 200, { ok: true, ...result });
224
+ } catch (err) {
225
+ writeJson(res, 500, { ok: false, error: err.message });
226
+ }
227
+ }
228
+ }), 'dsh-remote-workspace: /file/save');
229
+
230
+ // 9. POST /dsh-remote-workspace/terminal/create
231
+ sctx.effect(() => webServer.register({
232
+ kind: 'exact',
233
+ path: '/dsh-remote-workspace/terminal/create',
234
+ handler: async (req, res) => {
235
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
236
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
237
+ try {
238
+ const { profileId, cols, rows } = await readBody(req);
239
+ const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
240
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
241
+
242
+ const session = await sshService.createTerminalSession(profile, { cols, rows });
243
+ writeJson(res, 200, { ok: true, sessionId: session.id });
244
+ } catch (err) {
245
+ writeJson(res, 500, { ok: false, error: err.message });
246
+ }
247
+ }
248
+ }), 'dsh-remote-workspace: /terminal/create');
249
+
250
+ // 10. POST /dsh-remote-workspace/terminal/input
251
+ sctx.effect(() => webServer.register({
252
+ kind: 'exact',
253
+ path: '/dsh-remote-workspace/terminal/input',
254
+ handler: async (req, res) => {
255
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
256
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
257
+ try {
258
+ const { sessionId, data } = await readBody(req);
259
+ const session = sshService.getTerminalSession(sessionId);
260
+ if (!session) return writeJson(res, 404, { ok: false, error: 'Terminal session not found' });
261
+ session.write(data || '');
262
+ writeJson(res, 200, { ok: true });
263
+ } catch (err) {
264
+ writeJson(res, 500, { ok: false, error: err.message });
265
+ }
266
+ }
267
+ }), 'dsh-remote-workspace: /terminal/input');
268
+
269
+ // 11. GET /dsh-remote-workspace/terminal/stream (SSE)
270
+ sctx.effect(() => webServer.register({
271
+ kind: 'exact',
272
+ path: '/dsh-remote-workspace/terminal/stream',
273
+ handler: async (req, res) => {
274
+ const url = new URL(req.url, 'http://localhost');
275
+ const sessionId = url.searchParams.get('sessionId');
276
+ const session = sshService.getTerminalSession(sessionId);
277
+ if (!session) return writeJson(res, 404, { ok: false, error: 'Terminal session not found' });
278
+
279
+ if (typeof res.writeHead === 'function') {
280
+ res.writeHead(200, {
281
+ 'Content-Type': 'text/event-stream',
282
+ 'Cache-Control': 'no-cache, no-transform',
283
+ 'Connection': 'keep-alive'
284
+ });
285
+ }
286
+
287
+ for (const chunk of session.buffer) {
288
+ res.write(`data: ${JSON.stringify({ chunk })}\n\n`);
289
+ }
290
+
291
+ const listener = (chunk) => {
292
+ res.write(`data: ${JSON.stringify({ chunk })}\n\n`);
293
+ };
294
+ session.subscribers.add(listener);
295
+
296
+ req.on('close', () => {
297
+ session.subscribers.delete(listener);
298
+ });
299
+ }
300
+ }), 'dsh-remote-workspace: /terminal/stream');
301
+
302
+ // 12. POST /dsh-remote-workspace/terminal/close
303
+ sctx.effect(() => webServer.register({
304
+ kind: 'exact',
305
+ path: '/dsh-remote-workspace/terminal/close',
306
+ handler: async (req, res) => {
307
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
308
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
309
+ try {
310
+ const { sessionId } = await readBody(req);
311
+ const ok = sshService.closeTerminalSession(sessionId);
312
+ writeJson(res, 200, { ok });
313
+ } catch (err) {
314
+ writeJson(res, 500, { ok: false, error: err.message });
315
+ }
316
+ }
317
+ }), 'dsh-remote-workspace: /terminal/close');
318
+
319
+ // 13. POST /dsh-remote-workspace/sync
164
320
  sctx.effect(() => webServer.register({
165
321
  kind: 'exact',
166
322
  path: '/dsh-remote-workspace/sync',
@@ -168,14 +324,14 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
168
324
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
169
325
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
170
326
  try {
171
- const { profileId, direction } = await readBody(req);
327
+ const { profileId, direction, force } = await readBody(req);
172
328
  const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
173
329
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
174
330
  if (!profile.remoteWorkspace || !profile.localMirrorPath) {
175
331
  return writeJson(res, 400, { ok: false, error: 'Both remoteWorkspace and localMirrorPath must be configured' });
176
332
  }
177
333
  const resSync = direction === 'push'
178
- ? await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace)
334
+ ? await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace, Boolean(force))
179
335
  : await mirrorSync.pull(profile, profile.remoteWorkspace, profile.localMirrorPath);
180
336
  writeJson(res, 200, { ok: resSync.success, ...resSync });
181
337
  } catch (err) {
@@ -184,7 +340,7 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
184
340
  }
185
341
  }), 'dsh-remote-workspace: /sync');
186
342
 
187
- // 8. POST /dsh-remote-workspace/tunnels/start
343
+ // 14. POST /dsh-remote-workspace/tunnels/start
188
344
  sctx.effect(() => webServer.register({
189
345
  kind: 'exact',
190
346
  path: '/dsh-remote-workspace/tunnels/start',
@@ -208,7 +364,7 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
208
364
  }
209
365
  }), 'dsh-remote-workspace: /tunnels/start');
210
366
 
211
- // 9. POST /dsh-remote-workspace/tunnels/stop
367
+ // 15. POST /dsh-remote-workspace/tunnels/stop
212
368
  sctx.effect(() => webServer.register({
213
369
  kind: 'exact',
214
370
  path: '/dsh-remote-workspace/tunnels/stop',
@@ -225,5 +381,142 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
225
381
  }
226
382
  }
227
383
  }), 'dsh-remote-workspace: /tunnels/stop');
384
+
385
+ // 16. GET /dsh-remote-workspace/tunnels/telemetry
386
+ sctx.effect(() => webServer.register({
387
+ kind: 'exact',
388
+ path: '/dsh-remote-workspace/tunnels/telemetry',
389
+ handler: async (req, res) => {
390
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
391
+ try {
392
+ writeJson(res, 200, { ok: true, tunnels: tunnelService.listActiveTunnels() });
393
+ } catch (err) {
394
+ writeJson(res, 500, { ok: false, error: err.message });
395
+ }
396
+ }
397
+ }), 'dsh-remote-workspace: /tunnels/telemetry');
398
+
399
+ // 17. GET /dsh-remote-workspace/health
400
+ sctx.effect(() => webServer.register({
401
+ kind: 'exact',
402
+ path: '/dsh-remote-workspace/health',
403
+ handler: async (req, res) => {
404
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
405
+ try {
406
+ const url = new URL(req.url, 'http://localhost');
407
+ const profileId = url.searchParams.get('profileId');
408
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
409
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
410
+
411
+ const health = await healthService.getHealth(profile);
412
+ writeJson(res, 200, { ok: true, health });
413
+ } catch (err) {
414
+ writeJson(res, 500, { ok: false, error: err.message });
415
+ }
416
+ }
417
+ }), 'dsh-remote-workspace: /health');
418
+
419
+ // 18. GET /dsh-remote-workspace/docker/list
420
+ sctx.effect(() => webServer.register({
421
+ kind: 'exact',
422
+ path: '/dsh-remote-workspace/docker/list',
423
+ handler: async (req, res) => {
424
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
425
+ try {
426
+ const url = new URL(req.url, 'http://localhost');
427
+ const profileId = url.searchParams.get('profileId');
428
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
429
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
430
+
431
+ const result = await dockerService.listContainers(profile, true);
432
+ writeJson(res, 200, { ok: true, ...result });
433
+ } catch (err) {
434
+ writeJson(res, 500, { ok: false, error: err.message });
435
+ }
436
+ }
437
+ }), 'dsh-remote-workspace: /docker/list');
438
+
439
+ // 19. POST /dsh-remote-workspace/docker/action
440
+ sctx.effect(() => webServer.register({
441
+ kind: 'exact',
442
+ path: '/dsh-remote-workspace/docker/action',
443
+ handler: async (req, res) => {
444
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
445
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
446
+ try {
447
+ const { profileId, containerId, action } = await readBody(req);
448
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
449
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
450
+ if (!containerId || !action) return writeJson(res, 400, { ok: false, error: 'containerId and action required' });
451
+
452
+ const result = await dockerService.containerAction(profile, containerId, action);
453
+ writeJson(res, 200, { ok: result.success, ...result });
454
+ } catch (err) {
455
+ writeJson(res, 500, { ok: false, error: err.message });
456
+ }
457
+ }
458
+ }), 'dsh-remote-workspace: /docker/action');
459
+
460
+ // 20. POST /dsh-remote-workspace/docker/logs
461
+ sctx.effect(() => webServer.register({
462
+ kind: 'exact',
463
+ path: '/dsh-remote-workspace/docker/logs',
464
+ handler: async (req, res) => {
465
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
466
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
467
+ try {
468
+ const { profileId, containerId, tail } = await readBody(req);
469
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
470
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
471
+ if (!containerId) return writeJson(res, 400, { ok: false, error: 'containerId required' });
472
+
473
+ const result = await dockerService.containerLogs(profile, containerId, tail || 100);
474
+ writeJson(res, 200, { ok: result.success, ...result });
475
+ } catch (err) {
476
+ writeJson(res, 500, { ok: false, error: err.message });
477
+ }
478
+ }
479
+ }), 'dsh-remote-workspace: /docker/logs');
480
+
481
+ // 21. POST /dsh-remote-workspace/watcher/toggle
482
+ sctx.effect(() => webServer.register({
483
+ kind: 'exact',
484
+ path: '/dsh-remote-workspace/watcher/toggle',
485
+ handler: async (req, res) => {
486
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
487
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
488
+ try {
489
+ const { enabled } = await readBody(req);
490
+ await store.setAutoSyncEnabled(Boolean(enabled));
491
+ writeJson(res, 200, { ok: true, autoSync: store.isAutoSyncEnabled() });
492
+ } catch (err) {
493
+ writeJson(res, 500, { ok: false, error: err.message });
494
+ }
495
+ }
496
+ }), 'dsh-remote-workspace: /watcher/toggle');
497
+
498
+ // 22. POST /dsh-remote-workspace/transfer
499
+ sctx.effect(() => webServer.register({
500
+ kind: 'exact',
501
+ path: '/dsh-remote-workspace/transfer',
502
+ handler: async (req, res) => {
503
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
504
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
505
+ try {
506
+ const { sourceProfileId, sourcePath, destProfileId, destPath } = await readBody(req);
507
+ const srcProfile = sourceProfileId ? store.getProfile(sourceProfileId) : store.getActiveProfile();
508
+ const destProfile = store.getProfile(destProfileId);
509
+
510
+ if (!srcProfile) return writeJson(res, 404, { ok: false, error: 'Source profile not found' });
511
+ if (!destProfile) return writeJson(res, 404, { ok: false, error: 'Destination profile not found' });
512
+ if (!sourcePath || !destPath) return writeJson(res, 400, { ok: false, error: 'sourcePath and destPath required' });
513
+
514
+ const result = await sshService.transferFileBetweenHosts(srcProfile, sourcePath, destProfile, destPath);
515
+ writeJson(res, 200, { ok: result.success, ...result });
516
+ } catch (err) {
517
+ writeJson(res, 500, { ok: false, error: err.message });
518
+ }
519
+ }
520
+ }), 'dsh-remote-workspace: /transfer');
228
521
  });
229
522
  }