@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
package/src/share-file-upload.ts
DELETED
|
@@ -1,595 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host-side `file-upload-intent` / `file-bytes` / `file-upload-done` handler.
|
|
3
|
-
*
|
|
4
|
-
* Peer drops a file onto a node; the relay routes `file-upload-intent` to the
|
|
5
|
-
* host (US-058 amends the intent with `payload.upload = { via, key?, putUrl? }`
|
|
6
|
-
* so the host learns whether the peer will stream chunks over WS or PUT to
|
|
7
|
-
* ephemeral S3). On accept, the host replies with an `rpc-result` carrying the
|
|
8
|
-
* via decision back to the peer so the peer knows which transfer path to take.
|
|
9
|
-
*
|
|
10
|
-
* For the `via: 'ws'` path the peer follows up with one or more `file-bytes`
|
|
11
|
-
* frames; the host appends to an in-flight buffer keyed by `reqId`, hashes
|
|
12
|
-
* incrementally, and on `eof: true` verifies the assembled bytes against the
|
|
13
|
-
* intent's `sha256`, writes atomically under the resolved per-node folder, and
|
|
14
|
-
* broadcasts a `node-patched` diff.
|
|
15
|
-
*
|
|
16
|
-
* For the `via: 's3'` path the peer PUTs the bytes directly to the relay's
|
|
17
|
-
* staging URL and then sends `file-upload-done`. The host fetches the
|
|
18
|
-
* relay-presigned GET URL embedded on the done frame, verifies the sha256,
|
|
19
|
-
* writes atomically, and acks the relay with `rpc-result { ok:true }` so the
|
|
20
|
-
* relay can DeleteObject (US-058).
|
|
21
|
-
*
|
|
22
|
-
* Defense-in-depth: file-bytes are rate-limited per peer to 5 MB / 60 s (the
|
|
23
|
-
* relay tracks the same window but the host re-enforces). Path safety is
|
|
24
|
-
* delegated to `resolveNodeFile` — any traversal-equivalent filename is
|
|
25
|
-
* rejected with `reason: 'path-invalid'` before bytes flow.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import { type Hash, createHash } from 'node:crypto';
|
|
29
|
-
import { mkdirSync } from 'node:fs';
|
|
30
|
-
import { dirname, extname } from 'node:path';
|
|
31
|
-
import { writeFileAtomic } from './atomic-write.ts';
|
|
32
|
-
import type { Registry } from './registry.ts';
|
|
33
|
-
import type { FileUploadAuditEntry } from './share-audit.ts';
|
|
34
|
-
import {
|
|
35
|
-
type Envelope,
|
|
36
|
-
FileBytesPayloadSchema,
|
|
37
|
-
FileUploadDonePayloadSchema,
|
|
38
|
-
FileUploadIntentPayloadSchema,
|
|
39
|
-
makeEnvelope,
|
|
40
|
-
} from './share-envelope.ts';
|
|
41
|
-
import { resolveNodeFile } from './share-file-resolver.ts';
|
|
42
|
-
|
|
43
|
-
export type { FileUploadAuditEntry } from './share-audit.ts';
|
|
44
|
-
|
|
45
|
-
const INLINE_LIMIT_BYTES = 256 * 1024;
|
|
46
|
-
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
47
|
-
const RATE_LIMIT_BYTES = 5 * 1024 * 1024;
|
|
48
|
-
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
49
|
-
|
|
50
|
-
// Mirrors `UPLOAD_ALLOWED_EXTS` in api.ts ~line 181. Drift here means a peer
|
|
51
|
-
// upload would accept an extension the canonical /nodes/.../files/upload
|
|
52
|
-
// endpoint refuses (or vice versa) — keep the sets identical.
|
|
53
|
-
const UPLOAD_ALLOWED_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']);
|
|
54
|
-
|
|
55
|
-
const CONTENT_TYPE_MAP: Record<string, string> = {
|
|
56
|
-
'.png': 'image/png',
|
|
57
|
-
'.jpg': 'image/jpeg',
|
|
58
|
-
'.jpeg': 'image/jpeg',
|
|
59
|
-
'.gif': 'image/gif',
|
|
60
|
-
'.webp': 'image/webp',
|
|
61
|
-
'.svg': 'image/svg+xml',
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const contentTypeForExt = (ext: string): string =>
|
|
65
|
-
CONTENT_TYPE_MAP[ext.toLowerCase()] ?? 'application/octet-stream';
|
|
66
|
-
|
|
67
|
-
export type AppendFileUploadAudit = (sessionId: string, entry: FileUploadAuditEntry) => void;
|
|
68
|
-
|
|
69
|
-
export interface FileUploadDeps {
|
|
70
|
-
registry: Registry;
|
|
71
|
-
// Sends outbound envelopes; wired from share.ts's `broadcast` closure so
|
|
72
|
-
// `to: 'all'` fan-out and direct `to: <connId>` replies share the transport.
|
|
73
|
-
broadcast: (envelope: Envelope) => void;
|
|
74
|
-
// Returns the active session id when the controller is in the `active`
|
|
75
|
-
// state, otherwise null. The handler refuses to write when there is no
|
|
76
|
-
// session (the relay shouldn't be routing frames in that case anyway).
|
|
77
|
-
getSessionId: () => string | null;
|
|
78
|
-
// Test seam: append a one-line JSONL entry to <auditDir>/<sessionId>.jsonl.
|
|
79
|
-
// Defaults to a closure that calls `appendShareAudit`-style writes — but the
|
|
80
|
-
// shape differs from `RpcAuditEntry` (no flowId/ok; uses op:'file-upload'),
|
|
81
|
-
// so callers can also inject a recording stub.
|
|
82
|
-
appendAudit?: AppendFileUploadAudit;
|
|
83
|
-
// Test override: write bytes atomically. Defaults to `writeFileAtomic`.
|
|
84
|
-
writeFile?: (absPath: string, bytes: Uint8Array) => void;
|
|
85
|
-
// Test override: create the parent directory. Defaults to `mkdirSync(p, { recursive: true })`.
|
|
86
|
-
ensureDir?: (dir: string) => void;
|
|
87
|
-
// Test override: fetch the relay-presigned GET URL on the S3-done path.
|
|
88
|
-
fetchFn?: typeof fetch;
|
|
89
|
-
// Test override: monotonic clock for the rate-limit window.
|
|
90
|
-
now?: () => number;
|
|
91
|
-
// Resolve which `flowId` owns a given nodeId. Defaults to: iterate
|
|
92
|
-
// registry.list() and take the first entry whose flowPath can produce a
|
|
93
|
-
// valid resolveNodeFile() result for this nodeId. Tests can inject to
|
|
94
|
-
// disambiguate multi-flow projects.
|
|
95
|
-
flowIdForNode?: (nodeId: string) => string | null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface PeerContext {
|
|
99
|
-
peerId: string;
|
|
100
|
-
displayName: string;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export interface FileUploadHandler {
|
|
104
|
-
handleIntent(envelope: Envelope, peer: PeerContext): Promise<void>;
|
|
105
|
-
handleBytes(envelope: Envelope, peer: PeerContext): Promise<void>;
|
|
106
|
-
handleDone(envelope: Envelope, peer: PeerContext): Promise<void>;
|
|
107
|
-
// Test helpers — assert in-flight state was released after each request settles.
|
|
108
|
-
inflightCount(connId: string): number;
|
|
109
|
-
windowBytes(connId: string): number;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
interface InflightUpload {
|
|
113
|
-
reqId: string;
|
|
114
|
-
peerId: string;
|
|
115
|
-
connId: string;
|
|
116
|
-
absPath: string;
|
|
117
|
-
filename: string;
|
|
118
|
-
contentType: string;
|
|
119
|
-
intentSha256: string;
|
|
120
|
-
totalBytes: number;
|
|
121
|
-
receivedBytes: number;
|
|
122
|
-
chunks: Buffer[];
|
|
123
|
-
hash: Hash;
|
|
124
|
-
nodeId: string;
|
|
125
|
-
flowId: string;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
interface RateWindow {
|
|
129
|
-
startMs: number;
|
|
130
|
-
bytes: number;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const defaultEnsureDir = (dir: string): void => {
|
|
134
|
-
mkdirSync(dir, { recursive: true });
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const defaultWriteFile = (absPath: string, bytes: Uint8Array): void => {
|
|
138
|
-
writeFileAtomic(absPath, bytes);
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
const sendReply = (
|
|
142
|
-
broadcast: (env: Envelope) => void,
|
|
143
|
-
replyTo: string,
|
|
144
|
-
reqId: string,
|
|
145
|
-
payload: unknown,
|
|
146
|
-
): void => {
|
|
147
|
-
broadcast(makeEnvelope('rpc-result', payload, { to: replyTo, id: reqId }));
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
const sendOk = (
|
|
151
|
-
broadcast: (env: Envelope) => void,
|
|
152
|
-
replyTo: string,
|
|
153
|
-
reqId: string,
|
|
154
|
-
result?: unknown,
|
|
155
|
-
): void => {
|
|
156
|
-
const payload = result === undefined ? { ok: true as const } : { ok: true as const, result };
|
|
157
|
-
sendReply(broadcast, replyTo, reqId, payload);
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
const sendError = (
|
|
161
|
-
broadcast: (env: Envelope) => void,
|
|
162
|
-
replyTo: string,
|
|
163
|
-
reqId: string,
|
|
164
|
-
reason: string,
|
|
165
|
-
): void => {
|
|
166
|
-
sendReply(broadcast, replyTo, reqId, { ok: false as const, reason });
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
export function createFileUploadHandler(deps: FileUploadDeps): FileUploadHandler {
|
|
170
|
-
const writeFile = deps.writeFile ?? defaultWriteFile;
|
|
171
|
-
const ensureDir = deps.ensureDir ?? defaultEnsureDir;
|
|
172
|
-
const fetchFn = deps.fetchFn ?? fetch;
|
|
173
|
-
const now = deps.now ?? Date.now;
|
|
174
|
-
const flowIdForNode =
|
|
175
|
-
deps.flowIdForNode ??
|
|
176
|
-
((nodeId: string): string | null => {
|
|
177
|
-
for (const entry of deps.registry.list()) {
|
|
178
|
-
const r = resolveNodeFile({
|
|
179
|
-
repoPath: entry.repoPath,
|
|
180
|
-
flowPath: entry.flowPath,
|
|
181
|
-
nodeId,
|
|
182
|
-
relPath: 'probe',
|
|
183
|
-
});
|
|
184
|
-
if (!('error' in r)) return entry.id;
|
|
185
|
-
}
|
|
186
|
-
return null;
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
// reqId -> in-flight upload state. Lives for the lifetime of a single
|
|
190
|
-
// upload (intent -> chunks -> verified write, or intent -> done -> verified
|
|
191
|
-
// write). Cleared on terminal outcome (success / mismatch / abandon).
|
|
192
|
-
const inflight = new Map<string, InflightUpload>();
|
|
193
|
-
// connId -> sliding 60 s file-bytes window. Reset (start a new window)
|
|
194
|
-
// whenever the current window has aged past RATE_LIMIT_WINDOW_MS.
|
|
195
|
-
const peerWindows = new Map<string, RateWindow>();
|
|
196
|
-
|
|
197
|
-
const releaseInflight = (reqId: string) => {
|
|
198
|
-
inflight.delete(reqId);
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
const audit = (sessionId: string, entry: FileUploadAuditEntry) => {
|
|
202
|
-
if (!deps.appendAudit) return;
|
|
203
|
-
try {
|
|
204
|
-
deps.appendAudit(sessionId, entry);
|
|
205
|
-
} catch (err) {
|
|
206
|
-
console.warn('[share] file-upload audit append failed:', err);
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const broadcastNodePatched = (flowId: string, nodeId: string, relPathFromRepo: string): void => {
|
|
211
|
-
deps.broadcast(
|
|
212
|
-
makeEnvelope(
|
|
213
|
-
'node-patched',
|
|
214
|
-
{
|
|
215
|
-
flowId,
|
|
216
|
-
op: 'file-upload',
|
|
217
|
-
diff: { nodeId, data: { path: relPathFromRepo } },
|
|
218
|
-
},
|
|
219
|
-
{ to: 'all' },
|
|
220
|
-
),
|
|
221
|
-
);
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
// Accumulate `chunkBytes` against the peer's window; return false if the
|
|
225
|
-
// peer has exhausted the 5 MB / 60 s budget. Window resets when the elapsed
|
|
226
|
-
// span exceeds `RATE_LIMIT_WINDOW_MS`.
|
|
227
|
-
const consumeWindow = (connId: string, chunkBytes: number): boolean => {
|
|
228
|
-
const t = now();
|
|
229
|
-
const w = peerWindows.get(connId);
|
|
230
|
-
if (!w || t - w.startMs > RATE_LIMIT_WINDOW_MS) {
|
|
231
|
-
if (chunkBytes > RATE_LIMIT_BYTES) return false;
|
|
232
|
-
peerWindows.set(connId, { startMs: t, bytes: chunkBytes });
|
|
233
|
-
return true;
|
|
234
|
-
}
|
|
235
|
-
if (w.bytes + chunkBytes > RATE_LIMIT_BYTES) return false;
|
|
236
|
-
w.bytes += chunkBytes;
|
|
237
|
-
return true;
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
const handleIntent: FileUploadHandler['handleIntent'] = async (envelope, peer) => {
|
|
241
|
-
const replyTo = envelope.from;
|
|
242
|
-
const parsed = FileUploadIntentPayloadSchema.safeParse(envelope.payload);
|
|
243
|
-
if (!parsed.success) {
|
|
244
|
-
const rawReqId =
|
|
245
|
-
envelope.payload &&
|
|
246
|
-
typeof envelope.payload === 'object' &&
|
|
247
|
-
'reqId' in envelope.payload &&
|
|
248
|
-
typeof (envelope.payload as { reqId?: unknown }).reqId === 'string' &&
|
|
249
|
-
(envelope.payload as { reqId: string }).reqId.length > 0
|
|
250
|
-
? (envelope.payload as { reqId: string }).reqId
|
|
251
|
-
: 'invalid';
|
|
252
|
-
sendError(deps.broadcast, replyTo, rawReqId, 'bad-payload');
|
|
253
|
-
return;
|
|
254
|
-
}
|
|
255
|
-
const payload = parsed.data;
|
|
256
|
-
const { reqId, filename, size, nodeId, sha256 } = payload;
|
|
257
|
-
|
|
258
|
-
// Host enforces the intent shape before any state mutation.
|
|
259
|
-
if (size > MAX_UPLOAD_BYTES) {
|
|
260
|
-
sendError(deps.broadcast, replyTo, reqId, 'too-large');
|
|
261
|
-
auditReject(reqId, peer, nodeId, filename, size, sha256, 'too-large');
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
const ext = extname(filename).toLowerCase();
|
|
265
|
-
if (!UPLOAD_ALLOWED_EXTS.has(ext)) {
|
|
266
|
-
sendError(deps.broadcast, replyTo, reqId, 'extension-not-allowed');
|
|
267
|
-
auditReject(reqId, peer, nodeId, filename, size, sha256, 'extension-not-allowed');
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const flowId = flowIdForNode(nodeId);
|
|
272
|
-
if (!flowId) {
|
|
273
|
-
sendError(deps.broadcast, replyTo, reqId, 'node-not-found');
|
|
274
|
-
auditReject(reqId, peer, nodeId, filename, size, sha256, 'node-not-found');
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
const entry = deps.registry.list().find((e) => e.id === flowId);
|
|
278
|
-
if (!entry) {
|
|
279
|
-
sendError(deps.broadcast, replyTo, reqId, 'node-not-found');
|
|
280
|
-
auditReject(reqId, peer, nodeId, filename, size, sha256, 'node-not-found');
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Re-run the resolver against the chosen entry using the supplied filename.
|
|
285
|
-
// resolveNodeFile rejects traversal-equivalent payloads (`../../foo.png`,
|
|
286
|
-
// absolute paths, win32 drive letters) so a malicious peer can't escape
|
|
287
|
-
// the per-node folder.
|
|
288
|
-
const resolved = resolveNodeFile({
|
|
289
|
-
repoPath: entry.repoPath,
|
|
290
|
-
flowPath: entry.flowPath,
|
|
291
|
-
nodeId,
|
|
292
|
-
relPath: filename,
|
|
293
|
-
});
|
|
294
|
-
if ('error' in resolved) {
|
|
295
|
-
sendError(deps.broadcast, replyTo, reqId, 'path-invalid');
|
|
296
|
-
auditReject(reqId, peer, nodeId, filename, size, sha256, 'path-invalid');
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Materialise the relay's via decision so the peer knows which transfer
|
|
301
|
-
// path to take. The relay (US-058) amends `payload.upload` before
|
|
302
|
-
// forwarding the intent; absent that, default to ws so small uploads
|
|
303
|
-
// still work in tests that don't model the relay's amendment. Read from
|
|
304
|
-
// the raw `envelope.payload` because `FileUploadIntentPayloadSchema`
|
|
305
|
-
// strips unknown keys, so the schema-parsed `payload` won't carry the
|
|
306
|
-
// `upload` amendment.
|
|
307
|
-
const rawPayload =
|
|
308
|
-
envelope.payload && typeof envelope.payload === 'object'
|
|
309
|
-
? (envelope.payload as { upload?: { via?: 'ws' | 's3'; key?: string; putUrl?: string } })
|
|
310
|
-
: {};
|
|
311
|
-
const via = rawPayload.upload?.via ?? (size > INLINE_LIMIT_BYTES ? 's3' : 'ws');
|
|
312
|
-
const result: { via: 'ws' | 's3'; key?: string; putUrl?: string } = { via };
|
|
313
|
-
if (rawPayload.upload?.key !== undefined) result.key = rawPayload.upload.key;
|
|
314
|
-
if (rawPayload.upload?.putUrl !== undefined) result.putUrl = rawPayload.upload.putUrl;
|
|
315
|
-
|
|
316
|
-
const contentType = payload.contentType || contentTypeForExt(ext);
|
|
317
|
-
inflight.set(reqId, {
|
|
318
|
-
reqId,
|
|
319
|
-
peerId: peer.peerId,
|
|
320
|
-
connId: replyTo,
|
|
321
|
-
absPath: resolved.absPath,
|
|
322
|
-
filename,
|
|
323
|
-
contentType,
|
|
324
|
-
intentSha256: sha256,
|
|
325
|
-
totalBytes: size,
|
|
326
|
-
receivedBytes: 0,
|
|
327
|
-
chunks: [],
|
|
328
|
-
hash: createHash('sha256'),
|
|
329
|
-
nodeId,
|
|
330
|
-
flowId,
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
sendOk(deps.broadcast, replyTo, reqId, result);
|
|
334
|
-
};
|
|
335
|
-
|
|
336
|
-
const auditReject = (
|
|
337
|
-
_reqId: string,
|
|
338
|
-
peer: PeerContext,
|
|
339
|
-
nodeId: string,
|
|
340
|
-
filename: string,
|
|
341
|
-
size: number,
|
|
342
|
-
sha256: string,
|
|
343
|
-
reason: string,
|
|
344
|
-
): void => {
|
|
345
|
-
const sessionId = deps.getSessionId();
|
|
346
|
-
if (!sessionId) return;
|
|
347
|
-
audit(sessionId, {
|
|
348
|
-
ts: now(),
|
|
349
|
-
peerId: peer.peerId,
|
|
350
|
-
op: 'file-upload',
|
|
351
|
-
nodeId,
|
|
352
|
-
filename,
|
|
353
|
-
size,
|
|
354
|
-
sha256,
|
|
355
|
-
accept: false,
|
|
356
|
-
reason,
|
|
357
|
-
});
|
|
358
|
-
};
|
|
359
|
-
|
|
360
|
-
const writeAndBroadcast = (
|
|
361
|
-
state: InflightUpload,
|
|
362
|
-
bytes: Buffer,
|
|
363
|
-
finalSha: string,
|
|
364
|
-
peer: PeerContext,
|
|
365
|
-
): { ok: true } | { ok: false; reason: string } => {
|
|
366
|
-
// Verify the chosen entry is still resolvable + the abs path is still
|
|
367
|
-
// within bounds at write time. resolveNodeFile is cheap; doing it again
|
|
368
|
-
// catches the (vanishingly rare) case where the registry mutated between
|
|
369
|
-
// intent + verify.
|
|
370
|
-
const entry = deps.registry.list().find((e) => e.id === state.flowId);
|
|
371
|
-
if (!entry) return { ok: false, reason: 'node-not-found' };
|
|
372
|
-
const reResolved = resolveNodeFile({
|
|
373
|
-
repoPath: entry.repoPath,
|
|
374
|
-
flowPath: entry.flowPath,
|
|
375
|
-
nodeId: state.nodeId,
|
|
376
|
-
relPath: state.filename,
|
|
377
|
-
});
|
|
378
|
-
if ('error' in reResolved || reResolved.absPath !== state.absPath) {
|
|
379
|
-
return { ok: false, reason: 'path-invalid' };
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
try {
|
|
383
|
-
ensureDir(dirname(state.absPath));
|
|
384
|
-
} catch (err) {
|
|
385
|
-
console.warn('[share] file-upload ensureDir failed:', err);
|
|
386
|
-
return { ok: false, reason: 'write-failed' };
|
|
387
|
-
}
|
|
388
|
-
try {
|
|
389
|
-
writeFile(state.absPath, bytes);
|
|
390
|
-
} catch (err) {
|
|
391
|
-
console.warn('[share] file-upload write failed:', err);
|
|
392
|
-
return { ok: false, reason: 'write-failed' };
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// PROJECT-ROOT-relative path mirrors the api.ts upload endpoint return
|
|
396
|
-
// shape: legacy single-flow `flow.json` projects collapse `flowDir` to
|
|
397
|
-
// `.`; manifest-driven projects include the flow folder prefix so the
|
|
398
|
-
// peer's canvas can reference the asset by the same path it would have
|
|
399
|
-
// used had the user uploaded via the HTTP endpoint.
|
|
400
|
-
const flowDir = dirname(entry.flowPath);
|
|
401
|
-
const relFromRepo =
|
|
402
|
-
flowDir === '.'
|
|
403
|
-
? `nodes/${state.nodeId}/${state.filename}`
|
|
404
|
-
: `${flowDir}/nodes/${state.nodeId}/${state.filename}`;
|
|
405
|
-
broadcastNodePatched(state.flowId, state.nodeId, relFromRepo);
|
|
406
|
-
|
|
407
|
-
const sessionId = deps.getSessionId();
|
|
408
|
-
if (sessionId) {
|
|
409
|
-
audit(sessionId, {
|
|
410
|
-
ts: now(),
|
|
411
|
-
peerId: peer.peerId,
|
|
412
|
-
op: 'file-upload',
|
|
413
|
-
nodeId: state.nodeId,
|
|
414
|
-
filename: state.filename,
|
|
415
|
-
size: bytes.length,
|
|
416
|
-
sha256: finalSha,
|
|
417
|
-
accept: true,
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
return { ok: true };
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
const handleBytes: FileUploadHandler['handleBytes'] = async (envelope, peer) => {
|
|
424
|
-
const replyTo = envelope.from;
|
|
425
|
-
const parsed = FileBytesPayloadSchema.safeParse(envelope.payload);
|
|
426
|
-
if (!parsed.success) {
|
|
427
|
-
console.warn('[share] file-bytes rejected: bad payload');
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
const payload = parsed.data;
|
|
431
|
-
const state = inflight.get(payload.reqId);
|
|
432
|
-
if (!state) {
|
|
433
|
-
// No matching intent — drop. The peer can retry with a fresh intent.
|
|
434
|
-
console.warn('[share] file-bytes dropped: no in-flight intent', { reqId: payload.reqId });
|
|
435
|
-
return;
|
|
436
|
-
}
|
|
437
|
-
if (state.connId !== replyTo) {
|
|
438
|
-
// Different connection trying to fulfil another peer's upload — drop.
|
|
439
|
-
console.warn('[share] file-bytes dropped: peer mismatch');
|
|
440
|
-
return;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// Base64 decode + size accumulate happen before the rate-limit check so
|
|
444
|
-
// we know the exact byte budget the peer is asking to spend.
|
|
445
|
-
let chunk: Buffer;
|
|
446
|
-
try {
|
|
447
|
-
chunk = Buffer.from(payload.base64, 'base64');
|
|
448
|
-
} catch {
|
|
449
|
-
sendError(deps.broadcast, replyTo, state.reqId, 'bad-payload');
|
|
450
|
-
releaseInflight(state.reqId);
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
if (!consumeWindow(replyTo, chunk.length)) {
|
|
454
|
-
sendError(deps.broadcast, replyTo, state.reqId, 'rate-limited');
|
|
455
|
-
releaseInflight(state.reqId);
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
if (state.receivedBytes + chunk.length > state.totalBytes) {
|
|
459
|
-
sendError(deps.broadcast, replyTo, state.reqId, 'oversize-chunk');
|
|
460
|
-
releaseInflight(state.reqId);
|
|
461
|
-
return;
|
|
462
|
-
}
|
|
463
|
-
state.chunks.push(chunk);
|
|
464
|
-
state.hash.update(chunk);
|
|
465
|
-
state.receivedBytes += chunk.length;
|
|
466
|
-
if (!payload.eof) return;
|
|
467
|
-
|
|
468
|
-
const finalBytes = Buffer.concat(state.chunks);
|
|
469
|
-
const finalSha = state.hash.digest('hex');
|
|
470
|
-
if (finalSha !== state.intentSha256) {
|
|
471
|
-
sendError(deps.broadcast, replyTo, state.reqId, 'integrity');
|
|
472
|
-
auditReject(
|
|
473
|
-
state.reqId,
|
|
474
|
-
peer,
|
|
475
|
-
state.nodeId,
|
|
476
|
-
state.filename,
|
|
477
|
-
state.totalBytes,
|
|
478
|
-
state.intentSha256,
|
|
479
|
-
'integrity',
|
|
480
|
-
);
|
|
481
|
-
releaseInflight(state.reqId);
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
if (finalBytes.length !== state.totalBytes) {
|
|
485
|
-
sendError(deps.broadcast, replyTo, state.reqId, 'integrity');
|
|
486
|
-
releaseInflight(state.reqId);
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const outcome = writeAndBroadcast(state, finalBytes, finalSha, peer);
|
|
491
|
-
if (!outcome.ok) {
|
|
492
|
-
sendError(deps.broadcast, replyTo, state.reqId, outcome.reason);
|
|
493
|
-
releaseInflight(state.reqId);
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
496
|
-
sendOk(deps.broadcast, replyTo, state.reqId);
|
|
497
|
-
releaseInflight(state.reqId);
|
|
498
|
-
};
|
|
499
|
-
|
|
500
|
-
const handleDone: FileUploadHandler['handleDone'] = async (envelope, peer) => {
|
|
501
|
-
const replyTo = envelope.from;
|
|
502
|
-
const parsed = FileUploadDonePayloadSchema.safeParse(envelope.payload);
|
|
503
|
-
if (!parsed.success) {
|
|
504
|
-
console.warn('[share] file-upload-done rejected: bad payload');
|
|
505
|
-
return;
|
|
506
|
-
}
|
|
507
|
-
const payload = parsed.data;
|
|
508
|
-
const state = inflight.get(payload.reqId);
|
|
509
|
-
if (!state) {
|
|
510
|
-
console.warn('[share] file-upload-done dropped: no in-flight intent', {
|
|
511
|
-
reqId: payload.reqId,
|
|
512
|
-
});
|
|
513
|
-
return;
|
|
514
|
-
}
|
|
515
|
-
if (state.connId !== replyTo) {
|
|
516
|
-
console.warn('[share] file-upload-done dropped: peer mismatch');
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
// The relay (US-058) embeds the presigned GET URL alongside the existing
|
|
521
|
-
// `payload.key + payload.sha256` so the host can fetch the staged bytes
|
|
522
|
-
// without the peer ever streaming them through the WS. Read from raw
|
|
523
|
-
// `envelope.payload` because `FileUploadDonePayloadSchema` strips unknown
|
|
524
|
-
// keys.
|
|
525
|
-
const rawPayload =
|
|
526
|
-
envelope.payload && typeof envelope.payload === 'object'
|
|
527
|
-
? (envelope.payload as { getUrl?: string })
|
|
528
|
-
: {};
|
|
529
|
-
const getUrl = rawPayload.getUrl;
|
|
530
|
-
if (!getUrl) {
|
|
531
|
-
sendError(deps.broadcast, replyTo, state.reqId, 'missing-get-url');
|
|
532
|
-
releaseInflight(state.reqId);
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
let bytes: Buffer;
|
|
537
|
-
try {
|
|
538
|
-
const res = await fetchFn(getUrl);
|
|
539
|
-
if (!res.ok) {
|
|
540
|
-
sendError(deps.broadcast, replyTo, state.reqId, `fetch-status-${res.status}`);
|
|
541
|
-
releaseInflight(state.reqId);
|
|
542
|
-
return;
|
|
543
|
-
}
|
|
544
|
-
const ab = await res.arrayBuffer();
|
|
545
|
-
bytes = Buffer.from(ab);
|
|
546
|
-
} catch (err) {
|
|
547
|
-
console.warn('[share] file-upload-done fetch failed:', err);
|
|
548
|
-
sendError(deps.broadcast, replyTo, state.reqId, 'fetch-failed');
|
|
549
|
-
releaseInflight(state.reqId);
|
|
550
|
-
return;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
if (bytes.length !== state.totalBytes) {
|
|
554
|
-
sendError(deps.broadcast, replyTo, state.reqId, 'integrity');
|
|
555
|
-
releaseInflight(state.reqId);
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
const finalSha = createHash('sha256').update(bytes).digest('hex');
|
|
559
|
-
if (finalSha !== state.intentSha256) {
|
|
560
|
-
sendError(deps.broadcast, replyTo, state.reqId, 'integrity');
|
|
561
|
-
auditReject(
|
|
562
|
-
state.reqId,
|
|
563
|
-
peer,
|
|
564
|
-
state.nodeId,
|
|
565
|
-
state.filename,
|
|
566
|
-
state.totalBytes,
|
|
567
|
-
state.intentSha256,
|
|
568
|
-
'integrity',
|
|
569
|
-
);
|
|
570
|
-
releaseInflight(state.reqId);
|
|
571
|
-
return;
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
const outcome = writeAndBroadcast(state, bytes, finalSha, peer);
|
|
575
|
-
if (!outcome.ok) {
|
|
576
|
-
sendError(deps.broadcast, replyTo, state.reqId, outcome.reason);
|
|
577
|
-
releaseInflight(state.reqId);
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
sendOk(deps.broadcast, replyTo, state.reqId);
|
|
581
|
-
releaseInflight(state.reqId);
|
|
582
|
-
};
|
|
583
|
-
|
|
584
|
-
return {
|
|
585
|
-
handleIntent,
|
|
586
|
-
handleBytes,
|
|
587
|
-
handleDone,
|
|
588
|
-
inflightCount: (connId) => {
|
|
589
|
-
let n = 0;
|
|
590
|
-
for (const v of inflight.values()) if (v.connId === connId) n++;
|
|
591
|
-
return n;
|
|
592
|
-
},
|
|
593
|
-
windowBytes: (connId) => peerWindows.get(connId)?.bytes ?? 0,
|
|
594
|
-
};
|
|
595
|
-
}
|