@tuongaz/seeflow 0.1.109 → 0.1.110
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/dist/web/assets/{architectureDiagram-3BPJPVTR-CvIAMnMC.js → architectureDiagram-3BPJPVTR-CyC7OltI.js} +1 -1
- package/dist/web/assets/{blockDiagram-GPEHLZMM-C55itdvA.js → blockDiagram-GPEHLZMM-Bzp66cSV.js} +1 -1
- package/dist/web/assets/{c4Diagram-AAUBKEIU-BUJz6OcY.js → c4Diagram-AAUBKEIU-DG8Iponw.js} +1 -1
- package/dist/web/assets/channel-ByV7mN6x.js +1 -0
- package/dist/web/assets/{chart-Ck4M6xxI.js → chart-DYrOhreJ.js} +1 -1
- package/dist/web/assets/{chunk-2J33WTMH-CYHRe8M7.js → chunk-2J33WTMH-DAqVVWtt.js} +1 -1
- package/dist/web/assets/{chunk-4BX2VUAB-0G6VpCw_.js → chunk-4BX2VUAB-BLSkydjn.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-YeyA7Efg.js → chunk-55IACEB6-DQPfPbiJ.js} +1 -1
- package/dist/web/assets/{chunk-727SXJPM-irL9oAdE.js → chunk-727SXJPM-BTf0cYfp.js} +1 -1
- package/dist/web/assets/{chunk-AQP2D5EJ-DB0ZGTqs.js → chunk-AQP2D5EJ-3Vhv9mu3.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-Z48rYWhG.js → chunk-FMBD7UC4-DFLLVi48.js} +1 -1
- package/dist/web/assets/{chunk-ND2GUHAM-BgYSDKdi.js → chunk-ND2GUHAM-CxNVSIAI.js} +1 -1
- package/dist/web/assets/{chunk-QZHKN3VN-CzcjFmi-.js → chunk-QZHKN3VN-CgQH2fWj.js} +1 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-CftRaxtv.js +1 -0
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-CftRaxtv.js +1 -0
- package/dist/web/assets/{code-block-BwE8Ip6V.js → code-block-DbJK70Ez.js} +1 -1
- package/dist/web/assets/{cose-bilkent-S5V4N54A-DtufDmle.js → cose-bilkent-S5V4N54A-Byq3k4cK.js} +1 -1
- package/dist/web/assets/{dagre-BM42HDAG-DwYVzLme.js → dagre-BM42HDAG-BsD1H1iU.js} +1 -1
- package/dist/web/assets/{diagram-2AECGRRQ-DDw_qvjI.js → diagram-2AECGRRQ-CtzAGbyS.js} +1 -1
- package/dist/web/assets/{diagram-5GNKFQAL-BYG2VdwF.js → diagram-5GNKFQAL-D0UfPbiW.js} +1 -1
- package/dist/web/assets/{diagram-KO2AKTUF-Drp2zu_G.js → diagram-KO2AKTUF-9h1XRmj3.js} +1 -1
- package/dist/web/assets/{diagram-LMA3HP47-CXmAihfn.js → diagram-LMA3HP47-BrI6e364.js} +1 -1
- package/dist/web/assets/{diagram-OG6HWLK6-i6-iKnAU.js → diagram-OG6HWLK6-BFGFDRp3.js} +1 -1
- package/dist/web/assets/{erDiagram-TEJ5UH35-Bo96y9hO.js → erDiagram-TEJ5UH35-ybC8Itku.js} +1 -1
- package/dist/web/assets/{flowDiagram-I6XJVG4X-D9fneMTu.js → flowDiagram-I6XJVG4X-Dhol-kR8.js} +1 -1
- package/dist/web/assets/{ganttDiagram-6RSMTGT7-Cbefa8NE.js → ganttDiagram-6RSMTGT7-Y0-j3xBh.js} +1 -1
- package/dist/web/assets/{gitGraphDiagram-PVQCEYII-BHYd5nUs.js → gitGraphDiagram-PVQCEYII-CkmdZb3E.js} +1 -1
- package/dist/web/assets/{iconify-BuhA_8An.js → iconify-C6Z3PVFi.js} +1 -1
- package/dist/web/assets/index-D3ZH7sGq.js +8629 -0
- package/dist/web/assets/{index.es-pImjWTGX.js → index.es-D2LpuCoX.js} +1 -1
- package/dist/web/assets/{infoDiagram-5YYISTIA-Bw7MIIwj.js → infoDiagram-5YYISTIA-C349fRyb.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-YF4QCWOH-CySt1i7t.js → ishikawaDiagram-YF4QCWOH-CdAxgdA0.js} +1 -1
- package/dist/web/assets/{journeyDiagram-JHISSGLW-q4YV61wv.js → journeyDiagram-JHISSGLW-Gdj93qhh.js} +1 -1
- package/dist/web/assets/{jspdf.es.min-B95ptK5L.js → jspdf.es.min-BaNs7eNe.js} +3 -3
- package/dist/web/assets/{kanban-definition-UN3LZRKU-C1oN-zoM.js → kanban-definition-UN3LZRKU-utWOpj47.js} +1 -1
- package/dist/web/assets/{linear-DPSv7VcC.js → linear-ysH3nWHa.js} +1 -1
- package/dist/web/assets/{markdown-btlM2PIA.js → markdown-DKlQMpx8.js} +1 -1
- package/dist/web/assets/{mermaid.core-Cmch2GTm.js → mermaid.core-eyZWidoa.js} +4 -4
- package/dist/web/assets/{mindmap-definition-RKZ34NQL--XOTWr6e.js → mindmap-definition-RKZ34NQL-Dhprs5tv.js} +1 -1
- package/dist/web/assets/{pieDiagram-4H26LBE5-DxZgtzul.js → pieDiagram-4H26LBE5-z_3vPWNO.js} +1 -1
- package/dist/web/assets/{quadrantDiagram-W4KKPZXB-bbV2bAO3.js → quadrantDiagram-W4KKPZXB-BOBDCRWb.js} +1 -1
- package/dist/web/assets/{requirementDiagram-4Y6WPE33-CiJM8hio.js → requirementDiagram-4Y6WPE33-qn0up2ND.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-5OEKKPKP-DABBnuaB.js → sankeyDiagram-5OEKKPKP-CrcTPUVx.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-3UESZ5HK-BTQJk7bM.js → sequenceDiagram-3UESZ5HK-r24r7OqC.js} +1 -1
- package/dist/web/assets/{stateDiagram-AJRCARHV-yL0tAd3x.js → stateDiagram-AJRCARHV-2EJNhDf-.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-YVpQlkEY.js +1 -0
- package/dist/web/assets/{time-hCZUKGxT.js → time-CheTsYvu.js} +1 -1
- package/dist/web/assets/{timeline-definition-PNZ67QCA-CG6k16Wj.js → timeline-definition-PNZ67QCA-BNj38YIy.js} +1 -1
- package/dist/web/assets/{vennDiagram-CIIHVFJN-BVH530xf.js → vennDiagram-CIIHVFJN-C6ktujmS.js} +1 -1
- package/dist/web/assets/{wardley-L42UT6IY-BYVkFMrL.js → wardley-L42UT6IY-Bl_M5GRw.js} +1 -1
- package/dist/web/assets/{wardleyDiagram-YWT4CUSO-BGMuHejP.js → wardleyDiagram-YWT4CUSO-B0CVz9vg.js} +1 -1
- package/dist/web/assets/{xychartDiagram-2RQKCTM6-D6MSlnJD.js → xychartDiagram-2RQKCTM6-ljSwqYiu.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/api.ts +0 -291
- package/src/server.ts +0 -20
- package/dist/web/assets/channel-DtcQ9fhj.js +0 -1
- package/dist/web/assets/classDiagram-4FO5ZUOK-BjYXB41E.js +0 -1
- package/dist/web/assets/classDiagram-v2-Q7XG4LA2-BjYXB41E.js +0 -1
- package/dist/web/assets/index-08hmlCqO.js +0 -8644
- package/dist/web/assets/stateDiagram-v2-BHNVJYJU-BzWj6i1l.js +0 -1
- package/src/share/sse-frame.ts +0 -85
- package/src/share/sse-outbound-queue.ts +0 -173
- package/src/share/sse-rate-limit.ts +0 -205
- package/src/share/sse-tap.ts +0 -183
- package/src/share-audit.ts +0 -267
- package/src/share-envelope.ts +0 -155
- package/src/share-file-request.ts +0 -399
- package/src/share-file-resolver.ts +0 -68
- package/src/share-file-upload.ts +0 -595
- package/src/share-files-manifest.ts +0 -232
- package/src/share-ratelimit.ts +0 -69
- package/src/share-rpc-schema.ts +0 -249
- package/src/share-transport.ts +0 -205
- package/src/share.ts +0 -1663
|
@@ -1,399 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host-side `file-request` handler. The relay routes inbound `file-request`
|
|
3
|
-
* envelopes (peer -> host) here; the handler either responds inline with a
|
|
4
|
-
* single `file-bytes` frame (<=256 KB) or stages the file in the relay's
|
|
5
|
-
* ephemeral S3 bucket via `file-upload-intent` and replies with a
|
|
6
|
-
* `file-redirect` URL.
|
|
7
|
-
*
|
|
8
|
-
* Design choice: S3 staging is preferred over multi-chunk WS streaming for
|
|
9
|
-
* oversize files because the relay rate-limits `file-bytes` at 5 MB / 60 s per
|
|
10
|
-
* peer (US-058); large assets would otherwise stall. Staged objects live for
|
|
11
|
-
* <=60 s (the staging bucket has a 1-day lifecycle but presigned URLs expire
|
|
12
|
-
* in 60 s) so bytes never persist past the read.
|
|
13
|
-
*
|
|
14
|
-
* Errors reply with both a sentinel `file-bytes { eof:true, sha256:'' }` so
|
|
15
|
-
* the peer's `/files/<path>` proxy can return 4xx, AND an `rpc-result
|
|
16
|
-
* { id:reqId, ok:false, reason }` so peer rpc callers see a structured error.
|
|
17
|
-
*
|
|
18
|
-
* Rate-limit: at most 30 concurrent in-flight file-requests per peer; excess
|
|
19
|
-
* are rejected immediately with `reason: 'too-many-in-flight'`.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { createHash } from 'node:crypto';
|
|
23
|
-
import * as fsPromises from 'node:fs/promises';
|
|
24
|
-
import { basename, extname } from 'node:path';
|
|
25
|
-
import type { FlowEntry, Registry } from './registry.ts';
|
|
26
|
-
import {
|
|
27
|
-
type Envelope,
|
|
28
|
-
type FileBytesPayload,
|
|
29
|
-
type FileRedirectPayload,
|
|
30
|
-
FileRequestPayloadSchema,
|
|
31
|
-
type FileUploadIntentPayload,
|
|
32
|
-
makeEnvelope,
|
|
33
|
-
} from './share-envelope.ts';
|
|
34
|
-
import { resolveNodeFile } from './share-file-resolver.ts';
|
|
35
|
-
|
|
36
|
-
const INLINE_LIMIT_BYTES = 256 * 1024;
|
|
37
|
-
// Chunk size for the WS-streaming fallback (used when S3 staging is absent).
|
|
38
|
-
// Match the inline single-frame budget so the same MTU shape lands on the wire
|
|
39
|
-
// either way; the peer's reassembler is size-agnostic.
|
|
40
|
-
const FILE_CHUNK_BYTES = 256 * 1024;
|
|
41
|
-
// Hard cap on chunked-WS file serving — keeps a single oversize asset from
|
|
42
|
-
// dominating the per-peer queue when S3 isn't available. Above this size,
|
|
43
|
-
// host returns 'too-large' so the user uploads via a normal HTTP path.
|
|
44
|
-
const MAX_WS_BYTES = 10 * 1024 * 1024;
|
|
45
|
-
const MAX_INFLIGHT_PER_PEER = 30;
|
|
46
|
-
|
|
47
|
-
// Extension -> content type allowlist. Anything not in the map falls back to
|
|
48
|
-
// `application/octet-stream`. Lowercased keys; we lowercase the extension at
|
|
49
|
-
// lookup so `.PNG` resolves the same as `.png`.
|
|
50
|
-
const CONTENT_TYPE_MAP: Record<string, string> = {
|
|
51
|
-
'.png': 'image/png',
|
|
52
|
-
'.jpg': 'image/jpeg',
|
|
53
|
-
'.jpeg': 'image/jpeg',
|
|
54
|
-
'.webp': 'image/webp',
|
|
55
|
-
'.svg': 'image/svg+xml',
|
|
56
|
-
'.gif': 'image/gif',
|
|
57
|
-
'.html': 'text/html',
|
|
58
|
-
'.htm': 'text/html',
|
|
59
|
-
'.md': 'text/markdown',
|
|
60
|
-
'.txt': 'text/plain',
|
|
61
|
-
'.json': 'application/json',
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
export function contentTypeFor(filename: string): string {
|
|
65
|
-
const ext = extname(filename).toLowerCase();
|
|
66
|
-
return CONTENT_TYPE_MAP[ext] ?? 'application/octet-stream';
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export interface UploadIntentReply {
|
|
70
|
-
// `getUrl`+`expiresAt` are returned alongside `putUrl` so the host can
|
|
71
|
-
// immediately reply with `file-redirect` after the PUT succeeds. The cloud
|
|
72
|
-
// relay currently embeds only `putUrl` (US-058); the wiring layer is
|
|
73
|
-
// responsible for materialising matching getUrl/expiresAt either by
|
|
74
|
-
// re-using the staging key with a second presigner call or by extending the
|
|
75
|
-
// relay reply. Tests inject this dep wholesale.
|
|
76
|
-
putUrl: string;
|
|
77
|
-
getUrl: string;
|
|
78
|
-
expiresAt: number;
|
|
79
|
-
key: string;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export type RequestUploadIntent = (payload: {
|
|
83
|
-
reqId: string;
|
|
84
|
-
filename: string;
|
|
85
|
-
size: number;
|
|
86
|
-
contentType: string;
|
|
87
|
-
sha256: string;
|
|
88
|
-
nodeId: string;
|
|
89
|
-
role: 'host-serve';
|
|
90
|
-
}) => Promise<UploadIntentReply>;
|
|
91
|
-
|
|
92
|
-
export type PutToS3 = (
|
|
93
|
-
url: string,
|
|
94
|
-
bytes: Buffer,
|
|
95
|
-
contentType: string,
|
|
96
|
-
) => Promise<{ ok: boolean; status: number }>;
|
|
97
|
-
|
|
98
|
-
export interface FileRequestDeps {
|
|
99
|
-
registry: Registry;
|
|
100
|
-
// Sends outbound envelopes through the active transport. Defaults wire to
|
|
101
|
-
// share.ts's existing `broadcast` closure when integrated.
|
|
102
|
-
broadcast: (envelope: Envelope) => void;
|
|
103
|
-
// S3 staging path. Both deps must be provided to enable the oversize path;
|
|
104
|
-
// if either is absent, oversize requests reply with `reason: 'too-large'`
|
|
105
|
-
// and the peer falls back to a 413.
|
|
106
|
-
requestUploadIntent?: RequestUploadIntent;
|
|
107
|
-
putToS3?: PutToS3;
|
|
108
|
-
// Test overrides — production callers leave these on the node:fs defaults.
|
|
109
|
-
readFile?: (p: string) => Promise<Buffer>;
|
|
110
|
-
fileExists?: (p: string) => Promise<boolean>;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export interface FileRequestHandler {
|
|
114
|
-
handle(envelope: Envelope): Promise<void>;
|
|
115
|
-
// Exposed for tests so they can assert the in-flight counter is released
|
|
116
|
-
// after each request settles.
|
|
117
|
-
inflightCount(peerConnId: string): number;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const defaultFileExists = async (p: string): Promise<boolean> => {
|
|
121
|
-
try {
|
|
122
|
-
const s = await fsPromises.stat(p);
|
|
123
|
-
return s.isFile();
|
|
124
|
-
} catch {
|
|
125
|
-
return false;
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
const defaultReadFile = (p: string): Promise<Buffer> => fsPromises.readFile(p) as Promise<Buffer>;
|
|
130
|
-
|
|
131
|
-
// Iterate all registered flow entries and return the first whose resolved
|
|
132
|
-
// per-node path exists on disk. nodeIds are globally unique (10-char base62
|
|
133
|
-
// shortIds) so collisions across flows are astronomically unlikely; the
|
|
134
|
-
// existence probe disambiguates if it ever happens.
|
|
135
|
-
type LocateResult =
|
|
136
|
-
| { kind: 'ok'; entry: FlowEntry; absPath: string }
|
|
137
|
-
| { kind: 'bad-node-id' }
|
|
138
|
-
| { kind: 'traversal' }
|
|
139
|
-
| { kind: 'not-found' };
|
|
140
|
-
|
|
141
|
-
async function locateNodeFile(
|
|
142
|
-
registry: Registry,
|
|
143
|
-
nodeId: string,
|
|
144
|
-
relPath: string,
|
|
145
|
-
fileExists: (p: string) => Promise<boolean>,
|
|
146
|
-
): Promise<LocateResult> {
|
|
147
|
-
const entries = registry.list();
|
|
148
|
-
if (entries.length === 0) return { kind: 'not-found' };
|
|
149
|
-
|
|
150
|
-
// Run the resolver against the first entry to surface the static-shape
|
|
151
|
-
// errors (bad nodeId / traversal). These depend only on nodeId+relPath, so
|
|
152
|
-
// any registry entry yields the same verdict.
|
|
153
|
-
const first = entries[0];
|
|
154
|
-
if (first) {
|
|
155
|
-
const r = resolveNodeFile({
|
|
156
|
-
repoPath: first.repoPath,
|
|
157
|
-
flowPath: first.flowPath,
|
|
158
|
-
nodeId,
|
|
159
|
-
relPath,
|
|
160
|
-
});
|
|
161
|
-
if ('error' in r) {
|
|
162
|
-
if (r.error === 'bad-node-id') return { kind: 'bad-node-id' };
|
|
163
|
-
return { kind: 'traversal' };
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
for (const entry of entries) {
|
|
168
|
-
const r = resolveNodeFile({
|
|
169
|
-
repoPath: entry.repoPath,
|
|
170
|
-
flowPath: entry.flowPath,
|
|
171
|
-
nodeId,
|
|
172
|
-
relPath,
|
|
173
|
-
});
|
|
174
|
-
if ('error' in r) continue;
|
|
175
|
-
if (await fileExists(r.absPath)) {
|
|
176
|
-
return { kind: 'ok', entry, absPath: r.absPath };
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return { kind: 'not-found' };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function createFileRequestHandler(deps: FileRequestDeps): FileRequestHandler {
|
|
183
|
-
const readFile = deps.readFile ?? defaultReadFile;
|
|
184
|
-
const fileExists = deps.fileExists ?? defaultFileExists;
|
|
185
|
-
const inflightPerPeer = new Map<string, number>();
|
|
186
|
-
|
|
187
|
-
const sendErrorReply = (replyTo: string, reqId: string, reason: string) => {
|
|
188
|
-
// Send the sentinel file-bytes so the peer's /files proxy returns 4xx,
|
|
189
|
-
// then the structured rpc-result with the actual reason. Order matters
|
|
190
|
-
// for the peer-side state machine in US-063 (proxy resolves on the
|
|
191
|
-
// file-bytes sentinel, rpc callers resolve on the rpc-result).
|
|
192
|
-
const sentinel: FileBytesPayload = {
|
|
193
|
-
reqId,
|
|
194
|
-
seq: 0,
|
|
195
|
-
total: 0,
|
|
196
|
-
base64: '',
|
|
197
|
-
sha256: '',
|
|
198
|
-
eof: true,
|
|
199
|
-
};
|
|
200
|
-
deps.broadcast(makeEnvelope('file-bytes', sentinel, { to: replyTo }));
|
|
201
|
-
deps.broadcast(makeEnvelope('rpc-result', { ok: false, reason }, { to: replyTo, id: reqId }));
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
const respondInline = (
|
|
205
|
-
replyTo: string,
|
|
206
|
-
reqId: string,
|
|
207
|
-
bytes: Buffer,
|
|
208
|
-
contentType: string,
|
|
209
|
-
sha256: string,
|
|
210
|
-
) => {
|
|
211
|
-
const payload: FileBytesPayload = {
|
|
212
|
-
reqId,
|
|
213
|
-
seq: 0,
|
|
214
|
-
total: 1,
|
|
215
|
-
base64: bytes.toString('base64'),
|
|
216
|
-
contentType,
|
|
217
|
-
sha256,
|
|
218
|
-
eof: true,
|
|
219
|
-
};
|
|
220
|
-
deps.broadcast(makeEnvelope('file-bytes', payload, { to: replyTo }));
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
// Chunked WS fallback for files larger than INLINE_LIMIT_BYTES when S3 staging
|
|
224
|
-
// is not configured. The peer's `handleFileBytesIn` already reassembles by
|
|
225
|
-
// (seq, total, eof) and verifies the final sha256 against the assembled bytes,
|
|
226
|
-
// so each chunk repeats the same sha256 (it describes the whole file). Used
|
|
227
|
-
// by self-hosted deployments + local studios where the relay can't mint a
|
|
228
|
-
// presigned PUT.
|
|
229
|
-
const respondChunked = (
|
|
230
|
-
replyTo: string,
|
|
231
|
-
reqId: string,
|
|
232
|
-
bytes: Buffer,
|
|
233
|
-
contentType: string,
|
|
234
|
-
sha256: string,
|
|
235
|
-
) => {
|
|
236
|
-
const total = Math.max(1, Math.ceil(bytes.length / FILE_CHUNK_BYTES));
|
|
237
|
-
for (let seq = 0; seq < total; seq++) {
|
|
238
|
-
const start = seq * FILE_CHUNK_BYTES;
|
|
239
|
-
const end = Math.min(start + FILE_CHUNK_BYTES, bytes.length);
|
|
240
|
-
const chunk = bytes.subarray(start, end);
|
|
241
|
-
const payload: FileBytesPayload = {
|
|
242
|
-
reqId,
|
|
243
|
-
seq,
|
|
244
|
-
total,
|
|
245
|
-
base64: chunk.toString('base64'),
|
|
246
|
-
contentType,
|
|
247
|
-
sha256,
|
|
248
|
-
eof: seq === total - 1,
|
|
249
|
-
};
|
|
250
|
-
deps.broadcast(makeEnvelope('file-bytes', payload, { to: replyTo }));
|
|
251
|
-
}
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
const respondRedirect = (
|
|
255
|
-
replyTo: string,
|
|
256
|
-
reqId: string,
|
|
257
|
-
intent: UploadIntentReply,
|
|
258
|
-
sha256: string,
|
|
259
|
-
) => {
|
|
260
|
-
const payload: FileRedirectPayload = {
|
|
261
|
-
reqId,
|
|
262
|
-
getUrl: intent.getUrl,
|
|
263
|
-
sha256,
|
|
264
|
-
expiresAt: intent.expiresAt,
|
|
265
|
-
};
|
|
266
|
-
deps.broadcast(makeEnvelope('file-redirect', payload, { to: replyTo }));
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
const handle = async (envelope: Envelope): Promise<void> => {
|
|
270
|
-
const replyTo = envelope.from;
|
|
271
|
-
const parsed = FileRequestPayloadSchema.safeParse(envelope.payload);
|
|
272
|
-
if (!parsed.success) {
|
|
273
|
-
// We can't address the rpc-result without a reqId. Pull a best-effort id
|
|
274
|
-
// from the raw payload (relay never inspects it, so anything goes) and
|
|
275
|
-
// fall back to a literal `'invalid'` so the wire schema's min(1) holds.
|
|
276
|
-
const rawReqId =
|
|
277
|
-
envelope.payload &&
|
|
278
|
-
typeof envelope.payload === 'object' &&
|
|
279
|
-
'reqId' in envelope.payload &&
|
|
280
|
-
typeof (envelope.payload as { reqId?: unknown }).reqId === 'string' &&
|
|
281
|
-
(envelope.payload as { reqId: string }).reqId.length > 0
|
|
282
|
-
? (envelope.payload as { reqId: string }).reqId
|
|
283
|
-
: 'invalid';
|
|
284
|
-
sendErrorReply(replyTo, rawReqId, 'bad-payload');
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
const { reqId, nodeId, relPath } = parsed.data;
|
|
288
|
-
|
|
289
|
-
const current = inflightPerPeer.get(replyTo) ?? 0;
|
|
290
|
-
if (current >= MAX_INFLIGHT_PER_PEER) {
|
|
291
|
-
sendErrorReply(replyTo, reqId, 'too-many-in-flight');
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
inflightPerPeer.set(replyTo, current + 1);
|
|
295
|
-
|
|
296
|
-
try {
|
|
297
|
-
const located = await locateNodeFile(deps.registry, nodeId, relPath, fileExists);
|
|
298
|
-
if (located.kind === 'bad-node-id') {
|
|
299
|
-
sendErrorReply(replyTo, reqId, 'bad-node-id');
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
if (located.kind === 'traversal') {
|
|
303
|
-
sendErrorReply(replyTo, reqId, 'traversal');
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
if (located.kind === 'not-found') {
|
|
307
|
-
sendErrorReply(replyTo, reqId, 'not-found');
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
let bytes: Buffer;
|
|
312
|
-
try {
|
|
313
|
-
bytes = await readFile(located.absPath);
|
|
314
|
-
} catch (err) {
|
|
315
|
-
console.warn('[share] file-request read failed:', {
|
|
316
|
-
nodeId,
|
|
317
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
318
|
-
});
|
|
319
|
-
sendErrorReply(replyTo, reqId, 'read-failed');
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
324
|
-
const filename = basename(located.absPath);
|
|
325
|
-
const contentType = contentTypeFor(filename);
|
|
326
|
-
|
|
327
|
-
if (bytes.length <= INLINE_LIMIT_BYTES) {
|
|
328
|
-
respondInline(replyTo, reqId, bytes, contentType, sha256);
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Oversize path: prefer S3 redirect when staging is wired (avoids
|
|
333
|
-
// base64-over-WS overhead). Fall back to chunked WS frames for
|
|
334
|
-
// self-hosted / local studios that lack S3 — capped at MAX_WS_BYTES so
|
|
335
|
-
// a giant asset doesn't pin the relay's per-peer queue.
|
|
336
|
-
if (!deps.requestUploadIntent || !deps.putToS3) {
|
|
337
|
-
if (bytes.length > MAX_WS_BYTES) {
|
|
338
|
-
sendErrorReply(replyTo, reqId, 'too-large');
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
respondChunked(replyTo, reqId, bytes, contentType, sha256);
|
|
342
|
-
return;
|
|
343
|
-
}
|
|
344
|
-
const intentPayload: FileUploadIntentPayload = {
|
|
345
|
-
reqId,
|
|
346
|
-
filename,
|
|
347
|
-
size: bytes.length,
|
|
348
|
-
contentType,
|
|
349
|
-
sha256,
|
|
350
|
-
nodeId,
|
|
351
|
-
role: 'host-serve',
|
|
352
|
-
};
|
|
353
|
-
let intent: UploadIntentReply;
|
|
354
|
-
try {
|
|
355
|
-
intent = await deps.requestUploadIntent({
|
|
356
|
-
reqId: intentPayload.reqId,
|
|
357
|
-
filename: intentPayload.filename,
|
|
358
|
-
size: intentPayload.size,
|
|
359
|
-
contentType: intentPayload.contentType,
|
|
360
|
-
sha256: intentPayload.sha256,
|
|
361
|
-
nodeId: intentPayload.nodeId,
|
|
362
|
-
role: 'host-serve',
|
|
363
|
-
});
|
|
364
|
-
} catch (err) {
|
|
365
|
-
console.warn('[share] file-request intent failed:', {
|
|
366
|
-
nodeId,
|
|
367
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
368
|
-
});
|
|
369
|
-
sendErrorReply(replyTo, reqId, 'intent-failed');
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
let putResult: { ok: boolean; status: number };
|
|
373
|
-
try {
|
|
374
|
-
putResult = await deps.putToS3(intent.putUrl, bytes, contentType);
|
|
375
|
-
} catch (err) {
|
|
376
|
-
console.warn('[share] file-request s3 put threw:', {
|
|
377
|
-
nodeId,
|
|
378
|
-
reason: err instanceof Error ? err.message : String(err),
|
|
379
|
-
});
|
|
380
|
-
sendErrorReply(replyTo, reqId, 'put-failed');
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
if (!putResult.ok) {
|
|
384
|
-
sendErrorReply(replyTo, reqId, `put-status-${putResult.status}`);
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
respondRedirect(replyTo, reqId, intent, sha256);
|
|
388
|
-
} finally {
|
|
389
|
-
const next = (inflightPerPeer.get(replyTo) ?? 1) - 1;
|
|
390
|
-
if (next <= 0) inflightPerPeer.delete(replyTo);
|
|
391
|
-
else inflightPerPeer.set(replyTo, next);
|
|
392
|
-
}
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
return {
|
|
396
|
-
handle,
|
|
397
|
-
inflightCount: (peerConnId) => inflightPerPeer.get(peerConnId) ?? 0,
|
|
398
|
-
};
|
|
399
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import * as nodePath from 'node:path';
|
|
2
|
-
|
|
3
|
-
export type ResolveError = 'bad-node-id' | 'traversal' | 'outside-flow-dir';
|
|
4
|
-
|
|
5
|
-
export type ResolveNodeFileResult = { absPath: string } | { error: ResolveError };
|
|
6
|
-
|
|
7
|
-
export interface ResolveNodeFileOptions {
|
|
8
|
-
repoPath: string;
|
|
9
|
-
flowPath: string;
|
|
10
|
-
nodeId: string;
|
|
11
|
-
relPath: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const NODE_ID_RE = /^node-[A-Za-z0-9]{10}$/;
|
|
15
|
-
|
|
16
|
-
// Map a peer-supplied `{ nodeId, relPath }` request into a guaranteed-safe
|
|
17
|
-
// absolute path under `<repoPath>/<dirname(flowPath)>/nodes/<nodeId>/`. Mirrors
|
|
18
|
-
// the per-node upload sink at api.ts /projects/.../nodes/:nodeId/files/upload:
|
|
19
|
-
// for manifest-driven projects the base dir is `<flowDir>/nodes/<id>/`; for
|
|
20
|
-
// legacy single-flow registrations (flow.json at the project root, flowDir==='.')
|
|
21
|
-
// it collapses to `<repoPath>/nodes/<id>/`. Refuses any input that escapes that
|
|
22
|
-
// per-node scope.
|
|
23
|
-
export const resolveNodeFile = (
|
|
24
|
-
opts: ResolveNodeFileOptions,
|
|
25
|
-
pathMod: typeof nodePath = nodePath,
|
|
26
|
-
): ResolveNodeFileResult => {
|
|
27
|
-
const { repoPath, flowPath, nodeId, relPath } = opts;
|
|
28
|
-
|
|
29
|
-
if (!NODE_ID_RE.test(nodeId)) {
|
|
30
|
-
return { error: 'bad-node-id' };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
if (typeof relPath !== 'string' || relPath.length === 0) {
|
|
34
|
-
return { error: 'traversal' };
|
|
35
|
-
}
|
|
36
|
-
// Refuse absolute paths in either separator style so a host running on POSIX
|
|
37
|
-
// still rejects a `C:\…` or `\\server\share` payload from a win32 peer (and
|
|
38
|
-
// vice-versa). Win32 absoluteness covers drive letters and UNC paths.
|
|
39
|
-
if (relPath.startsWith('/') || relPath.startsWith('\\')) {
|
|
40
|
-
return { error: 'traversal' };
|
|
41
|
-
}
|
|
42
|
-
if (pathMod.isAbsolute(relPath) || nodePath.win32.isAbsolute(relPath)) {
|
|
43
|
-
return { error: 'traversal' };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// After normalize, any remaining `..` segment means the relPath tries to
|
|
47
|
-
// escape its node folder. Split on BOTH separators so a posix host catches
|
|
48
|
-
// `..\\evil` and a win32 host catches `../evil`.
|
|
49
|
-
const normalized = pathMod.normalize(relPath);
|
|
50
|
-
const segments = normalized.split(/[/\\]/);
|
|
51
|
-
if (segments.some((segment) => segment === '..')) {
|
|
52
|
-
return { error: 'traversal' };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const flowDir = pathMod.dirname(flowPath);
|
|
56
|
-
const baseDir =
|
|
57
|
-
flowDir === '.'
|
|
58
|
-
? pathMod.join(repoPath, 'nodes', nodeId)
|
|
59
|
-
: pathMod.join(repoPath, flowDir, 'nodes', nodeId);
|
|
60
|
-
|
|
61
|
-
const absPath = pathMod.resolve(baseDir, relPath);
|
|
62
|
-
|
|
63
|
-
if (absPath !== baseDir && !absPath.startsWith(baseDir + pathMod.sep)) {
|
|
64
|
-
return { error: 'outside-flow-dir' };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return { absPath };
|
|
68
|
-
};
|