sh3-server 0.6.0 → 0.8.1
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/app/assets/index-C3rCTpjL.js +17 -0
- package/app/assets/index-C3rCTpjL.js.map +1 -0
- package/app/assets/index-GfhVhkjD.css +1 -0
- package/app/assets/tauri-backend-B3LR3-lo.js +1 -0
- package/app/assets/tauri-backend-B3LR3-lo.js.map +1 -0
- package/app/index.html +2 -2
- package/dist/cli.js +9 -7
- package/dist/index.js +59 -12
- package/dist/packages.d.ts +20 -2
- package/dist/packages.js +64 -3
- package/dist/routes/docs.d.ts +2 -0
- package/dist/routes/docs.js +30 -0
- package/dist/settings.d.ts +13 -0
- package/dist/settings.js +33 -0
- package/dist/shard-router.d.ts +26 -10
- package/dist/shell-shard/index.d.ts +2 -0
- package/dist/shell-shard/index.js +10 -5
- package/dist/shell-shard/runner.d.ts +22 -0
- package/dist/shell-shard/runner.js +38 -6
- package/dist/shell-shard/session-manager.d.ts +12 -4
- package/dist/shell-shard/session-manager.js +48 -19
- package/dist/shell-shard/ws.js +14 -14
- package/dist/tenant-fs/http.d.ts +15 -0
- package/dist/tenant-fs/http.js +109 -0
- package/dist/tenant-fs/index.d.ts +4 -0
- package/dist/tenant-fs/index.js +4 -0
- package/dist/tenant-fs/paths.d.ts +23 -0
- package/dist/tenant-fs/paths.js +51 -0
- package/dist/tenant-fs/resolve.d.ts +16 -0
- package/dist/tenant-fs/resolve.js +48 -0
- package/dist/tenant-fs/session-required.d.ts +11 -0
- package/dist/tenant-fs/session-required.js +19 -0
- package/package.json +3 -3
- package/app/assets/index-B1mKDfA-.css +0 -1
- package/app/assets/index-C7fUtJvb.js +0 -9
package/dist/shard-router.d.ts
CHANGED
|
@@ -11,21 +11,37 @@ export interface MountContext {
|
|
|
11
11
|
* route prefix. The returned value is a Hono middleware handler that
|
|
12
12
|
* can be passed directly as the handler argument to `router.get(path, ...)`.
|
|
13
13
|
*
|
|
14
|
-
* The `ws` argument passed to `onConnect` is the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
14
|
+
* The `ws` argument passed to `onConnect` is the `WSContext` from
|
|
15
|
+
* `@hono/node-ws` enriched with two per-connection setter methods:
|
|
16
|
+
*
|
|
17
|
+
* - `ws.send(data)` — inherited from WSContext, send a frame.
|
|
18
|
+
* - `ws.close()` — inherited from WSContext, close the connection.
|
|
19
|
+
* - `ws.onMessage(handler)` — register a callback that receives each
|
|
20
|
+
* incoming frame as a string. Only one handler; last call wins.
|
|
21
|
+
* - `ws.onClose(handler)` — register a callback fired when the
|
|
22
|
+
* connection closes. Only one handler; last call wins.
|
|
23
|
+
*
|
|
24
|
+
* These setters exist because `@hono/node-ws` exposes the lifecycle
|
|
25
|
+
* via the `WSEvents` interface returned from `upgradeWebSocket`, not
|
|
26
|
+
* via `addEventListener` on WSContext. `wsRegister` in sh3-server
|
|
27
|
+
* bridges the two so shards can keep all per-connection state in a
|
|
28
|
+
* single closure. Do NOT try `ws.addEventListener('message', ...)` —
|
|
29
|
+
* WSContext has no such method and the call silently no-ops.
|
|
30
|
+
*
|
|
31
|
+
* `@hono/node-ws` does not re-export a type name for WSContext and
|
|
32
|
+
* `@types/ws` is not a dependency, so we use `any` per the plan's
|
|
33
|
+
* explicit fallback rule. The returned handler is likewise typed as
|
|
34
|
+
* `any` — its concrete type is Hono's internal
|
|
35
|
+
* `MiddlewareHandler<..., { outputFormat: "ws" }>`, which would leak
|
|
36
|
+
* Hono ws internals into every shard that touches it.
|
|
21
37
|
*
|
|
22
38
|
* The `c` argument is the Hono `Context` for the upgrade request.
|
|
23
39
|
* Shards can read `c.get('session')` to route the connection to the
|
|
24
40
|
* correct per-user state (e.g. `manager.getOrCreate(session.userId)`).
|
|
25
41
|
*
|
|
26
|
-
* @param onConnect Called with the
|
|
27
|
-
* when a client connects. Attach listeners
|
|
28
|
-
* return nothing.
|
|
42
|
+
* @param onConnect Called with the enriched WebSocket and request
|
|
43
|
+
* Context when a client connects. Attach listeners
|
|
44
|
+
* via `ws.onMessage` / `ws.onClose`; return nothing.
|
|
29
45
|
*/
|
|
30
46
|
wsRegister(onConnect: (ws: any, c: any) => void): any;
|
|
31
47
|
}
|
|
@@ -4,6 +4,8 @@ import type { WsLike } from './session-manager.js';
|
|
|
4
4
|
export interface ShellServerContext {
|
|
5
5
|
shardId: string;
|
|
6
6
|
dataDir: string;
|
|
7
|
+
/** Base for per-user document roots; empty string → <dataDir>/users. */
|
|
8
|
+
tenantRootBase?: string;
|
|
7
9
|
adminOnly: any;
|
|
8
10
|
wsRegister: (onConnect: (ws: WsLike, c: Context) => void) => any;
|
|
9
11
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { LocalRunner } from './runner.js';
|
|
11
11
|
import { SessionManager } from './session-manager.js';
|
|
12
12
|
import { handleClientMessage } from './ws.js';
|
|
13
|
+
import { shardDocumentsPath } from '../tenant-fs/paths.js';
|
|
13
14
|
function sessionUser(c) {
|
|
14
15
|
const session = c.get('session') ?? c.env?.session;
|
|
15
16
|
return session?.userId ?? 'admin';
|
|
@@ -20,7 +21,8 @@ export default {
|
|
|
20
21
|
// Default config — overridable via shard env state in a future pass.
|
|
21
22
|
const cfg = { ringSize: 500, historyMaxLines: 10_000, defaultCwd: '' };
|
|
22
23
|
const runner = new LocalRunner();
|
|
23
|
-
const
|
|
24
|
+
const userCwd = (userId) => shardDocumentsPath(ctx.dataDir, userId, 'shell', ctx.tenantRootBase ?? '');
|
|
25
|
+
const manager = new SessionManager(ctx.dataDir, runner, cfg, userCwd);
|
|
24
26
|
// NOTE: the JSON /history endpoint is convenience — the authoritative
|
|
25
27
|
// delivery of history is via the `history` server message sent on WS
|
|
26
28
|
// attach. Clients can skip the REST endpoint entirely. Kept for
|
|
@@ -39,11 +41,14 @@ export default {
|
|
|
39
41
|
const userId = sessionUser(c);
|
|
40
42
|
const session = manager.getOrCreate(userId);
|
|
41
43
|
session.attach(ws);
|
|
42
|
-
// Wire incoming frames through the dispatch layer.
|
|
43
|
-
ws
|
|
44
|
-
|
|
44
|
+
// Wire incoming frames through the dispatch layer. `wsRegister`
|
|
45
|
+
// enriches the ws object with per-connection `onMessage` /
|
|
46
|
+
// `onClose` setters that forward the hono/node-ws WSEvents
|
|
47
|
+
// callbacks for this upgrade.
|
|
48
|
+
ws.onMessage((data) => {
|
|
49
|
+
handleClientMessage(session, ws, data);
|
|
45
50
|
});
|
|
46
|
-
ws.
|
|
51
|
+
ws.onClose(() => {
|
|
47
52
|
session.detach(ws);
|
|
48
53
|
});
|
|
49
54
|
}));
|
|
@@ -3,6 +3,28 @@ export interface RunnerStartOpts {
|
|
|
3
3
|
cmd: string;
|
|
4
4
|
cwd: string;
|
|
5
5
|
env: Record<string, string>;
|
|
6
|
+
/**
|
|
7
|
+
* Optional event callbacks wired up synchronously at the top of start()
|
|
8
|
+
* BEFORE the emitter can fire. Pass these when the caller cannot attach
|
|
9
|
+
* listeners on `handle.events` atomically with receiving the handle —
|
|
10
|
+
* e.g., ShellSession.submit, which uses `await runner.start(...)` and
|
|
11
|
+
* therefore only gets a chance to attach listeners in the next microtask.
|
|
12
|
+
*
|
|
13
|
+
* Why this matters: sync-failure paths (tokenize throw, empty command,
|
|
14
|
+
* Node 24's CVE-2024-27980 EINVAL for .cmd/.bat on Windows) emit their
|
|
15
|
+
* 'stderr'/'exit' events immediately via setImmediate. Without pre-
|
|
16
|
+
* registration, there's a subtle race between the emitter firing and
|
|
17
|
+
* the caller's post-await listener attachment — when `this.running = handle`
|
|
18
|
+
* is assigned only after await, an exit callback that fires on the wrong
|
|
19
|
+
* side of the boundary leaves `running` pointing at a dead handle and
|
|
20
|
+
* every subsequent submit gets silently dropped by the running-guard.
|
|
21
|
+
*/
|
|
22
|
+
onStdout?: (data: string) => void;
|
|
23
|
+
onStderr?: (data: string) => void;
|
|
24
|
+
onExit?: (info: {
|
|
25
|
+
code: number | null;
|
|
26
|
+
signal: string | null;
|
|
27
|
+
}) => void;
|
|
6
28
|
}
|
|
7
29
|
export interface RunnerHandle {
|
|
8
30
|
/** Send bytes to the process's stdin. No-op for LocalRunner in v1. */
|
|
@@ -14,12 +14,35 @@ import { tokenize } from './tokenize.js';
|
|
|
14
14
|
export class LocalRunner {
|
|
15
15
|
async start(opts) {
|
|
16
16
|
const events = new EventEmitter();
|
|
17
|
+
// Pre-attach caller-supplied listeners BEFORE any code path that
|
|
18
|
+
// could emit. See note on RunnerStartOpts — this closes the race
|
|
19
|
+
// for every sync-failure path (tokenize throw, empty cmd, EINVAL).
|
|
20
|
+
if (opts.onStdout)
|
|
21
|
+
events.on('stdout', opts.onStdout);
|
|
22
|
+
if (opts.onStderr)
|
|
23
|
+
events.on('stderr', opts.onStderr);
|
|
24
|
+
if (opts.onExit)
|
|
25
|
+
events.on('exit', opts.onExit);
|
|
17
26
|
let child;
|
|
18
27
|
try {
|
|
19
|
-
|
|
28
|
+
// tokenize is used for VALIDATION only — detect unterminated quotes
|
|
29
|
+
// and empty commands before handing off to the shell. We don't pass
|
|
30
|
+
// the tokenized args to spawn: with `shell: true` (below) the shell
|
|
31
|
+
// re-parses the raw command line, and tokenizing would strip quotes
|
|
32
|
+
// the shell needs for its own escaping.
|
|
33
|
+
const [bin] = tokenize(opts.cmd);
|
|
20
34
|
if (!bin) {
|
|
21
|
-
// Empty command — emit an error exit without spawning
|
|
22
|
-
queueMicrotask
|
|
35
|
+
// Empty command — emit an error exit without spawning.
|
|
36
|
+
// Must use setImmediate, not queueMicrotask: the caller
|
|
37
|
+
// (ShellSession.submit) attaches its 'stdout'/'stderr'/'exit'
|
|
38
|
+
// listeners in the continuation after `await runner.start()`,
|
|
39
|
+
// which is itself a microtask. queueMicrotask would drain FIFO
|
|
40
|
+
// before that continuation, emitting to an emitter with zero
|
|
41
|
+
// listeners — the exit event silently vanishes and the session
|
|
42
|
+
// gets stuck with `running` pointing at a dead handle. setImmediate
|
|
43
|
+
// defers to the event loop's check phase, strictly after microtask
|
|
44
|
+
// drain, so listeners are always in place when we fire.
|
|
45
|
+
setImmediate(() => {
|
|
23
46
|
events.emit('stderr', 'shell: empty command\n');
|
|
24
47
|
events.emit('exit', { code: null, signal: 'spawn-error' });
|
|
25
48
|
});
|
|
@@ -30,15 +53,24 @@ export class LocalRunner {
|
|
|
30
53
|
events,
|
|
31
54
|
};
|
|
32
55
|
}
|
|
33
|
-
|
|
56
|
+
// Use shell: true so the platform shell resolves PATH, handles
|
|
57
|
+
// Windows .cmd/.bat files (npm, yarn, tsc, …), and gives the user
|
|
58
|
+
// the metacharacter semantics they expect from a terminal. Node
|
|
59
|
+
// 24's CVE-2024-27980 patch throws EINVAL synchronously for
|
|
60
|
+
// .cmd/.bat with shell: false — which is what the terminal IS
|
|
61
|
+
// supposed to run, so shell: true is the only correct mode here.
|
|
62
|
+
// We pass opts.cmd as the sole command string and [] as args so
|
|
63
|
+
// spawn hands the whole line to the shell untouched.
|
|
64
|
+
child = spawn(opts.cmd, [], {
|
|
34
65
|
cwd: opts.cwd,
|
|
35
66
|
env: opts.env,
|
|
36
|
-
shell:
|
|
67
|
+
shell: true,
|
|
37
68
|
});
|
|
38
69
|
}
|
|
39
70
|
catch (err) {
|
|
40
71
|
const message = err.message ?? String(err);
|
|
41
|
-
queueMicrotask
|
|
72
|
+
// See note above on setImmediate vs queueMicrotask — same race.
|
|
73
|
+
setImmediate(() => {
|
|
42
74
|
events.emit('stderr', `shell: ${message}\n`);
|
|
43
75
|
events.emit('exit', { code: null, signal: 'spawn-error' });
|
|
44
76
|
});
|
|
@@ -7,13 +7,20 @@ export interface SessionConfig {
|
|
|
7
7
|
defaultCwd: string;
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
|
-
* Minimal WebSocket interface —
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Minimal WebSocket interface — what ShellSession needs to talk to a
|
|
11
|
+
* connected client. Real connections come from sh3-server's wsRegister
|
|
12
|
+
* (see MountContext.wsRegister in shard-router.ts), which enriches the
|
|
13
|
+
* `@hono/node-ws` WSContext with per-connection `onMessage` / `onClose`
|
|
14
|
+
* setter methods. ShellSession itself only ever calls `send` / `close`;
|
|
15
|
+
* the setter methods are only used by the connection-wiring code in
|
|
16
|
+
* shell-shard/index.ts, but they stay on this interface so the same
|
|
17
|
+
* type flows through `wsRegister(onConnect: (ws: WsLike, ...))`.
|
|
13
18
|
*/
|
|
14
19
|
export interface WsLike {
|
|
15
20
|
send(data: string): void;
|
|
16
21
|
close(): void;
|
|
22
|
+
onMessage(handler: (data: string) => void): void;
|
|
23
|
+
onClose(handler: () => void): void;
|
|
17
24
|
}
|
|
18
25
|
export declare class ShellSession {
|
|
19
26
|
readonly userId: string;
|
|
@@ -51,6 +58,7 @@ export declare class SessionManager {
|
|
|
51
58
|
private readonly dataDir;
|
|
52
59
|
private readonly runner;
|
|
53
60
|
private readonly cfg;
|
|
54
|
-
|
|
61
|
+
private readonly userCwd;
|
|
62
|
+
constructor(dataDir: string, runner: Runner, cfg: SessionConfig, userCwd: (userId: string) => string);
|
|
55
63
|
getOrCreate(userId: string): ShellSession;
|
|
56
64
|
}
|
|
@@ -80,6 +80,13 @@ export class ShellSession {
|
|
|
80
80
|
// Claim the start slot synchronously so a concurrent submit during
|
|
81
81
|
// the `await runner.start()` below cannot slip past the lock check.
|
|
82
82
|
this.starting = true;
|
|
83
|
+
// Track whether the runner already exited before we latched `running`.
|
|
84
|
+
// Sync-failure paths (empty cmd, tokenize throw, Node 24's EINVAL for
|
|
85
|
+
// .cmd/.bat on Windows) can fire onExit via setImmediate BEFORE the
|
|
86
|
+
// await continuation runs — so we must not assign `this.running = handle`
|
|
87
|
+
// for a process that's already dead, or subsequent submits get wedged
|
|
88
|
+
// by the running-guard above. See runner.ts for the full race writeup.
|
|
89
|
+
let exited = false;
|
|
83
90
|
try {
|
|
84
91
|
this.history.append(line);
|
|
85
92
|
// Emit a prompt event so every attached client echoes the line
|
|
@@ -94,24 +101,33 @@ export class ShellSession {
|
|
|
94
101
|
cmd: line,
|
|
95
102
|
cwd: this.cwd,
|
|
96
103
|
env: this.env,
|
|
104
|
+
// Callback listeners — attached synchronously inside start() BEFORE
|
|
105
|
+
// any emit can fire. Do NOT switch back to `handle.events.on(...)`
|
|
106
|
+
// after await: that re-opens the race the callbacks exist to close.
|
|
107
|
+
onStdout: (data) => {
|
|
108
|
+
this.broadcast({ kind: 'stdout', data, ts: Date.now(), seq: 0 });
|
|
109
|
+
},
|
|
110
|
+
onStderr: (data) => {
|
|
111
|
+
this.broadcast({ kind: 'stderr', data, ts: Date.now(), seq: 0 });
|
|
112
|
+
},
|
|
113
|
+
onExit: (info) => {
|
|
114
|
+
this.broadcast({
|
|
115
|
+
kind: 'exit',
|
|
116
|
+
code: info.code,
|
|
117
|
+
signal: info.signal,
|
|
118
|
+
ts: Date.now(),
|
|
119
|
+
seq: 0,
|
|
120
|
+
});
|
|
121
|
+
exited = true;
|
|
122
|
+
this.running = null;
|
|
123
|
+
},
|
|
97
124
|
});
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
});
|
|
105
|
-
handle.events.on('exit', (info) => {
|
|
106
|
-
this.broadcast({
|
|
107
|
-
kind: 'exit',
|
|
108
|
-
code: info.code,
|
|
109
|
-
signal: info.signal,
|
|
110
|
-
ts: Date.now(),
|
|
111
|
-
seq: 0,
|
|
112
|
-
});
|
|
113
|
-
this.running = null;
|
|
114
|
-
});
|
|
125
|
+
// Only latch `running` if the process hasn't already died during the
|
|
126
|
+
// await boundary. Otherwise we'd resurrect a dead handle and wedge
|
|
127
|
+
// every subsequent submit.
|
|
128
|
+
if (!exited) {
|
|
129
|
+
this.running = handle;
|
|
130
|
+
}
|
|
115
131
|
}
|
|
116
132
|
finally {
|
|
117
133
|
this.starting = false;
|
|
@@ -144,16 +160,29 @@ export class SessionManager {
|
|
|
144
160
|
dataDir;
|
|
145
161
|
runner;
|
|
146
162
|
cfg;
|
|
147
|
-
|
|
163
|
+
userCwd;
|
|
164
|
+
constructor(dataDir, runner, cfg, userCwd) {
|
|
148
165
|
this.dataDir = dataDir;
|
|
149
166
|
this.runner = runner;
|
|
150
167
|
this.cfg = cfg;
|
|
168
|
+
this.userCwd = userCwd;
|
|
151
169
|
}
|
|
152
170
|
getOrCreate(userId) {
|
|
153
171
|
let session = this.sessions.get(userId);
|
|
154
172
|
if (!session) {
|
|
155
173
|
const history = new HistoryStore(this.dataDir, userId, this.cfg.historyMaxLines);
|
|
156
|
-
|
|
174
|
+
// Resolve user cwd; on disk failure fall back to cfg.defaultCwd so
|
|
175
|
+
// a sick data dir doesn't block login. The warning surfaces in the
|
|
176
|
+
// server log so ops can notice.
|
|
177
|
+
let cwd = this.cfg.defaultCwd;
|
|
178
|
+
try {
|
|
179
|
+
cwd = this.userCwd(userId);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
console.warn(`[shell-shard] userCwd failed for ${userId}: ${err.message} — falling back to ${cwd || 'process.cwd()'}`);
|
|
183
|
+
}
|
|
184
|
+
const cfg = { ...this.cfg, defaultCwd: cwd };
|
|
185
|
+
session = new ShellSession(userId, this.runner, history, cfg);
|
|
157
186
|
this.sessions.set(userId, session);
|
|
158
187
|
}
|
|
159
188
|
return session;
|
package/dist/shell-shard/ws.js
CHANGED
|
@@ -15,21 +15,16 @@ export function handleClientMessage(session, ws, raw) {
|
|
|
15
15
|
msg = JSON.parse(raw);
|
|
16
16
|
}
|
|
17
17
|
catch {
|
|
18
|
-
// Malformed frame — ignore, do not crash the session.
|
|
19
18
|
return;
|
|
20
19
|
}
|
|
21
20
|
switch (msg.t) {
|
|
22
21
|
case 'hello':
|
|
23
|
-
// attach() already sent welcome+replay+history at connection time.
|
|
24
|
-
// The only meaningful thing here is a late hello with replayFrom,
|
|
25
|
-
// which v1 ignores — the client can resync by reconnecting.
|
|
26
22
|
return;
|
|
27
23
|
case 'submit': {
|
|
28
24
|
const trimmed = msg.line.trim();
|
|
29
25
|
if (trimmed.startsWith('cd ') || trimmed === 'cd') {
|
|
30
|
-
// Server-managed cd — don't spawn, update session cwd directly.
|
|
31
26
|
const target = trimmed === 'cd' ? '' : trimmed.slice(3).trim();
|
|
32
|
-
|
|
27
|
+
applyCwdChange(session, target, 'cd');
|
|
33
28
|
return;
|
|
34
29
|
}
|
|
35
30
|
void session.submit(msg.line, ws);
|
|
@@ -42,12 +37,12 @@ export function handleClientMessage(session, ws, raw) {
|
|
|
42
37
|
session.historyLog(msg.line);
|
|
43
38
|
return;
|
|
44
39
|
case 'cwd-query':
|
|
45
|
-
// Re-emit a cwd update message (reuses setCwd() broadcast path).
|
|
46
40
|
session.setCwd(session.cwd);
|
|
47
41
|
return;
|
|
42
|
+
case 'setCwd':
|
|
43
|
+
applyCwdChange(session, msg.path, 'setCwd');
|
|
44
|
+
return;
|
|
48
45
|
default: {
|
|
49
|
-
// Exhaustiveness check. If a new ClientMessage variant is added to
|
|
50
|
-
// the protocol without a handler here, TypeScript flags this line.
|
|
51
46
|
const _exhaustive = msg;
|
|
52
47
|
void _exhaustive;
|
|
53
48
|
return;
|
|
@@ -55,11 +50,16 @@ export function handleClientMessage(session, ws, raw) {
|
|
|
55
50
|
}
|
|
56
51
|
}
|
|
57
52
|
/**
|
|
58
|
-
*
|
|
59
|
-
* exists and is a directory, then update session.cwd via setCwd()
|
|
60
|
-
*
|
|
53
|
+
* Resolve a cwd-change request against the session's current cwd, validate
|
|
54
|
+
* it exists and is a directory, then update session.cwd via setCwd(). Used
|
|
55
|
+
* for both interactive `cd` (from submit) and programmatic `setCwd` (from
|
|
56
|
+
* the docs tree / file explorer).
|
|
57
|
+
*
|
|
58
|
+
* Stderr wording keeps the familiar `cd: no such directory` for shell users
|
|
59
|
+
* and `setCwd: no such directory` for programmatic callers, so the source
|
|
60
|
+
* of a bad path is obvious in the terminal log.
|
|
61
61
|
*/
|
|
62
|
-
function
|
|
62
|
+
function applyCwdChange(session, target, source) {
|
|
63
63
|
const dest = target === '' || target === '~'
|
|
64
64
|
? homedir()
|
|
65
65
|
: isAbsolute(target)
|
|
@@ -69,7 +69,7 @@ function handleCd(session, target) {
|
|
|
69
69
|
session.broadcast({
|
|
70
70
|
seq: 0,
|
|
71
71
|
kind: 'stderr',
|
|
72
|
-
data:
|
|
72
|
+
data: `${source}: no such directory: ${target}\n`,
|
|
73
73
|
ts: Date.now(),
|
|
74
74
|
});
|
|
75
75
|
return;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant FS HTTP API — mounts /api/fs/list, /api/fs/stat, /api/fs/read.
|
|
3
|
+
*
|
|
4
|
+
* Gated by sessionRequired; scope is the caller's own documentsRoot.
|
|
5
|
+
* Read-only. Writes are out of scope for this iteration.
|
|
6
|
+
*/
|
|
7
|
+
import type { Hono } from 'hono';
|
|
8
|
+
import type { SettingsStore } from '../settings.js';
|
|
9
|
+
export interface TenantFsRouteContext {
|
|
10
|
+
dataDir: string;
|
|
11
|
+
rootBase: string;
|
|
12
|
+
settings: SettingsStore;
|
|
13
|
+
maxReadBytes: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function registerTenantFsRoutes(app: Hono, ctx: TenantFsRouteContext): void;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant FS HTTP API — mounts /api/fs/list, /api/fs/stat, /api/fs/read.
|
|
3
|
+
*
|
|
4
|
+
* Gated by sessionRequired; scope is the caller's own documentsRoot.
|
|
5
|
+
* Read-only. Writes are out of scope for this iteration.
|
|
6
|
+
*/
|
|
7
|
+
import { readdir, stat, readFile } from 'node:fs/promises';
|
|
8
|
+
import { basename } from 'node:path';
|
|
9
|
+
import { documentsRoot } from './paths.js';
|
|
10
|
+
import { resolveTenantPath, TenantPathEscapeError } from './resolve.js';
|
|
11
|
+
import { makeSessionRequired } from './session-required.js';
|
|
12
|
+
function userIdFromContext(c) {
|
|
13
|
+
const session = c.get('session') ?? c.env?.session;
|
|
14
|
+
if (!session?.userId)
|
|
15
|
+
throw new Error('sessionRequired missing before handler');
|
|
16
|
+
return session.userId;
|
|
17
|
+
}
|
|
18
|
+
export function registerTenantFsRoutes(app, ctx) {
|
|
19
|
+
const sessionRequired = makeSessionRequired(ctx.settings);
|
|
20
|
+
app.get('/api/fs/list', sessionRequired, async (c) => {
|
|
21
|
+
const rel = c.req.query('path') ?? '';
|
|
22
|
+
const root = documentsRoot(ctx.dataDir, userIdFromContext(c), ctx.rootBase);
|
|
23
|
+
let abs;
|
|
24
|
+
try {
|
|
25
|
+
abs = await resolveTenantPath(root, rel);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err instanceof TenantPathEscapeError)
|
|
29
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
let names;
|
|
33
|
+
try {
|
|
34
|
+
names = await readdir(abs);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
if (err?.code === 'ENOENT')
|
|
38
|
+
return c.json({ error: 'not found' }, 404);
|
|
39
|
+
if (err?.code === 'ENOTDIR')
|
|
40
|
+
return c.json({ error: 'not a directory' }, 400);
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
const entries = await Promise.all(names.map(async (name) => {
|
|
44
|
+
const s = await stat(`${abs}/${name}`);
|
|
45
|
+
return {
|
|
46
|
+
name,
|
|
47
|
+
kind: s.isDirectory() ? 'dir' : 'file',
|
|
48
|
+
size: s.size,
|
|
49
|
+
mtime: s.mtimeMs,
|
|
50
|
+
};
|
|
51
|
+
}));
|
|
52
|
+
return c.json({ entries });
|
|
53
|
+
});
|
|
54
|
+
app.get('/api/fs/stat', sessionRequired, async (c) => {
|
|
55
|
+
const rel = c.req.query('path') ?? '';
|
|
56
|
+
const root = documentsRoot(ctx.dataDir, userIdFromContext(c), ctx.rootBase);
|
|
57
|
+
let abs;
|
|
58
|
+
try {
|
|
59
|
+
abs = await resolveTenantPath(root, rel);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err instanceof TenantPathEscapeError)
|
|
63
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const s = await stat(abs);
|
|
68
|
+
return c.json({
|
|
69
|
+
name: basename(abs),
|
|
70
|
+
kind: s.isDirectory() ? 'dir' : 'file',
|
|
71
|
+
size: s.size,
|
|
72
|
+
mtime: s.mtimeMs,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (err?.code === 'ENOENT')
|
|
77
|
+
return c.json({ error: 'not found' }, 404);
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
app.get('/api/fs/read', sessionRequired, async (c) => {
|
|
82
|
+
const rel = c.req.query('path') ?? '';
|
|
83
|
+
const root = documentsRoot(ctx.dataDir, userIdFromContext(c), ctx.rootBase);
|
|
84
|
+
let abs;
|
|
85
|
+
try {
|
|
86
|
+
abs = await resolveTenantPath(root, rel);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (err instanceof TenantPathEscapeError)
|
|
90
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
let s;
|
|
94
|
+
try {
|
|
95
|
+
s = await stat(abs);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (err?.code === 'ENOENT')
|
|
99
|
+
return c.json({ error: 'not found' }, 404);
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
if (s.isDirectory())
|
|
103
|
+
return c.json({ error: 'is a directory' }, 400);
|
|
104
|
+
if (s.size > ctx.maxReadBytes)
|
|
105
|
+
return c.json({ error: 'file too large' }, 413);
|
|
106
|
+
const buf = await readFile(abs);
|
|
107
|
+
return c.body(buf, 200, { 'Content-Type': 'application/octet-stream' });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant path helpers — the framework owns the `data/users/<id>/...` layout.
|
|
3
|
+
*
|
|
4
|
+
* Two tiers:
|
|
5
|
+
* users/<id>/<shardId>/ — shard's private per-user state (internal)
|
|
6
|
+
* users/<id>/documents/<shardId>/ — user-facing files (exposed via /api/fs)
|
|
7
|
+
*
|
|
8
|
+
* Shards call these helpers server-side. Clients use /api/fs/* to reach
|
|
9
|
+
* documents/ only — shard state is never served over HTTP.
|
|
10
|
+
*/
|
|
11
|
+
export type TenantRootResolver = (userId: string) => string;
|
|
12
|
+
/**
|
|
13
|
+
* Back-compat resolver: <base>/<userId>/documents. Equivalent to
|
|
14
|
+
* documentsRoot() but parameterized by rootBase. Retained for sites that
|
|
15
|
+
* historically accepted a resolver function.
|
|
16
|
+
*/
|
|
17
|
+
export declare function makeTenantRootResolver(dataDir: string, rootBase: string): TenantRootResolver;
|
|
18
|
+
/** <dataDir>/users/<userId>/documents — the document library root. */
|
|
19
|
+
export declare function documentsRoot(dataDir: string, userId: string, rootBase?: string): string;
|
|
20
|
+
/** <dataDir>/users/<userId>/documents/<shardId> — user docs produced by shard. */
|
|
21
|
+
export declare function shardDocumentsPath(dataDir: string, userId: string, shardId: string, rootBase?: string): string;
|
|
22
|
+
/** <dataDir>/users/<userId>/<shardId> — shard-internal per-user state. */
|
|
23
|
+
export declare function shardStatePath(dataDir: string, userId: string, shardId: string, rootBase?: string): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant path helpers — the framework owns the `data/users/<id>/...` layout.
|
|
3
|
+
*
|
|
4
|
+
* Two tiers:
|
|
5
|
+
* users/<id>/<shardId>/ — shard's private per-user state (internal)
|
|
6
|
+
* users/<id>/documents/<shardId>/ — user-facing files (exposed via /api/fs)
|
|
7
|
+
*
|
|
8
|
+
* Shards call these helpers server-side. Clients use /api/fs/* to reach
|
|
9
|
+
* documents/ only — shard state is never served over HTTP.
|
|
10
|
+
*/
|
|
11
|
+
import { mkdirSync } from 'node:fs';
|
|
12
|
+
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
13
|
+
function resolveBase(dataDir, rootBase) {
|
|
14
|
+
if (rootBase === '')
|
|
15
|
+
return join(dataDir, 'users');
|
|
16
|
+
return isAbsolute(rootBase) ? normalize(rootBase) : resolve(dataDir, rootBase);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Back-compat resolver: <base>/<userId>/documents. Equivalent to
|
|
20
|
+
* documentsRoot() but parameterized by rootBase. Retained for sites that
|
|
21
|
+
* historically accepted a resolver function.
|
|
22
|
+
*/
|
|
23
|
+
export function makeTenantRootResolver(dataDir, rootBase) {
|
|
24
|
+
const base = resolveBase(dataDir, rootBase);
|
|
25
|
+
return (userId) => {
|
|
26
|
+
const root = join(base, userId, 'documents');
|
|
27
|
+
mkdirSync(root, { recursive: true });
|
|
28
|
+
return root;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** <dataDir>/users/<userId>/documents — the document library root. */
|
|
32
|
+
export function documentsRoot(dataDir, userId, rootBase = '') {
|
|
33
|
+
const base = resolveBase(dataDir, rootBase);
|
|
34
|
+
const p = join(base, userId, 'documents');
|
|
35
|
+
mkdirSync(p, { recursive: true });
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
/** <dataDir>/users/<userId>/documents/<shardId> — user docs produced by shard. */
|
|
39
|
+
export function shardDocumentsPath(dataDir, userId, shardId, rootBase = '') {
|
|
40
|
+
const base = resolveBase(dataDir, rootBase);
|
|
41
|
+
const p = join(base, userId, 'documents', shardId);
|
|
42
|
+
mkdirSync(p, { recursive: true });
|
|
43
|
+
return p;
|
|
44
|
+
}
|
|
45
|
+
/** <dataDir>/users/<userId>/<shardId> — shard-internal per-user state. */
|
|
46
|
+
export function shardStatePath(dataDir, userId, shardId, rootBase = '') {
|
|
47
|
+
const base = resolveBase(dataDir, rootBase);
|
|
48
|
+
const p = join(base, userId, shardId);
|
|
49
|
+
mkdirSync(p, { recursive: true });
|
|
50
|
+
return p;
|
|
51
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Containment helper — resolves a relative path under a tenant root and
|
|
3
|
+
* asserts the real resolved path stays inside the root. Use this for ANY
|
|
4
|
+
* file I/O that accepts a path from the client.
|
|
5
|
+
*
|
|
6
|
+
* - Resolves `rel` against `root`.
|
|
7
|
+
* - Calls fs.realpath to follow symlinks.
|
|
8
|
+
* - If the target does not exist yet (e.g. future write ops), realpath
|
|
9
|
+
* walks up to the deepest existing ancestor and appends the remainder.
|
|
10
|
+
* - Throws TenantPathEscapeError if the real path is not root or a descendant.
|
|
11
|
+
*/
|
|
12
|
+
export declare class TenantPathEscapeError extends Error {
|
|
13
|
+
readonly rel: string;
|
|
14
|
+
constructor(rel: string);
|
|
15
|
+
}
|
|
16
|
+
export declare function resolveTenantPath(root: string, rel: string): Promise<string>;
|