@melaya/runner 1.1.44 → 1.1.46
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/browserBridge.js +139 -8
- package/dist/connection.js +36 -2
- package/dist/uploadFetch.d.ts +39 -0
- package/dist/uploadFetch.js +393 -0
- package/package.json +1 -1
package/dist/browserBridge.js
CHANGED
|
@@ -39,6 +39,8 @@ import { SessionManager, SessionError, spaceUserDataDir, } from "./sessionManage
|
|
|
39
39
|
import { ensureEngine } from "./browserProvisioner.js";
|
|
40
40
|
import { runSandboxedScript } from "./codeWorker.js";
|
|
41
41
|
import { classifyControlEffect, writeGatedInMode } from "./publishGate.js";
|
|
42
|
+
import { applyUploadResize, parseUploadResize, resolveDataUrlSource, resolveHandleSource, resolvePathSource, resolveUrlSource, UploadSourceError, } from "./uploadFetch.js";
|
|
43
|
+
import { unlink } from "node:fs/promises";
|
|
42
44
|
// A wall-clock around a CDP command. Playwright's CDPSession.send has NO default
|
|
43
45
|
// timeout, so a wedged/busy renderer means the promise NEVER settles (not a
|
|
44
46
|
// rejection - no catch fires) and the lease op-chain queues every later op
|
|
@@ -1772,14 +1774,136 @@ export async function startBrowserBridge(opts) {
|
|
|
1772
1774
|
};
|
|
1773
1775
|
}
|
|
1774
1776
|
case "upload_file": {
|
|
1775
|
-
//
|
|
1776
|
-
//
|
|
1777
|
-
//
|
|
1778
|
-
//
|
|
1779
|
-
//
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1777
|
+
// Attach file(s) to a page <input type=file> via CDP
|
|
1778
|
+
// DOM.setFileInputFiles. Sources, in precedence order: a staged
|
|
1779
|
+
// handle ("fh_<hex>_<ext>", from the server's browserUploadMedia
|
|
1780
|
+
// staging flow), a public image URL (guarded direct download — the
|
|
1781
|
+
// runner IS the executing side), or a LOCAL-RUNNER-ONLY file path.
|
|
1782
|
+
// v1 is images only (png/jpg/webp/gif), 10MB, magic-byte verified.
|
|
1783
|
+
//
|
|
1784
|
+
// GATE FIRST, download after: selecting a file onto a site is a
|
|
1785
|
+
// commit-shaped write (same family as submit), so in safe autonomy
|
|
1786
|
+
// it stages the approval card BEFORE any bytes move. browser.py's
|
|
1787
|
+
// poll loop re-issues the same act; the approved re-issue is the one
|
|
1788
|
+
// that downloads and attaches.
|
|
1789
|
+
await enforcePublishGate(reg, page, kind, "", String(args.ref ?? "file input"), args.hitl_mode, "publish");
|
|
1790
|
+
const handle = String(args.handle ?? args.value ?? (Array.isArray(args.fileHandles) ? args.fileHandles[0] : "") ?? "").trim();
|
|
1791
|
+
const srcUrl = String(args.url ?? "").trim();
|
|
1792
|
+
const srcPath = String(args.path ?? "").trim();
|
|
1793
|
+
const srcData = String(args.data ?? "").trim();
|
|
1794
|
+
const attachmentId = String(args.attachment_id ?? "").trim();
|
|
1795
|
+
let file;
|
|
1796
|
+
try {
|
|
1797
|
+
if (attachmentId) {
|
|
1798
|
+
// Per-turn chat attachments resolve to fh_ handles on the SERVER
|
|
1799
|
+
// (assistant stream / ext-bridge). A runner-LAUNCHED browser is a
|
|
1800
|
+
// pipeline transport with no chat turn to hold them, so the id is
|
|
1801
|
+
// unresolvable here — name the sources that do work.
|
|
1802
|
+
throw new BridgeError("act_args_invalid", "attachment_id is not available on the runner-launched browser transport — pass the image as " +
|
|
1803
|
+
"a public `url`, a base64 `data` URL, or a runner-local `path` instead.");
|
|
1804
|
+
}
|
|
1805
|
+
if (handle)
|
|
1806
|
+
file = await resolveHandleSource(String(reg.spec.serverBase || ""), handle);
|
|
1807
|
+
else if (srcUrl)
|
|
1808
|
+
file = await resolveUrlSource(srcUrl);
|
|
1809
|
+
else if (srcData)
|
|
1810
|
+
file = await resolveDataUrlSource(srcData);
|
|
1811
|
+
else if (srcPath)
|
|
1812
|
+
file = await resolvePathSource(srcPath);
|
|
1813
|
+
else {
|
|
1814
|
+
throw new BridgeError("act_args_invalid", "upload_file needs a file source: `url` (public http(s) image URL, max 10MB), `data` (a base64 " +
|
|
1815
|
+
"data URL), a staged `handle` (fh_...), or — on this local runner only — `path` (an image file " +
|
|
1816
|
+
"on this machine).");
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
catch (e) {
|
|
1820
|
+
if (e instanceof BridgeError)
|
|
1821
|
+
throw e;
|
|
1822
|
+
if (e instanceof UploadSourceError)
|
|
1823
|
+
throw new BridgeError("act_args_invalid", `upload_file: ${e.message}`);
|
|
1824
|
+
throw new BridgeError("unknown_outcome", `upload_file could not resolve the file source: ${e.message}`);
|
|
1825
|
+
}
|
|
1826
|
+
if (file.temp)
|
|
1827
|
+
reg.uploadTmpFiles.push(file.path);
|
|
1828
|
+
// Convert-before-attach (width/height/fit/format): a canvas job run
|
|
1829
|
+
// through the page's JS context — the runner has no image library,
|
|
1830
|
+
// but it HAS a browser. Pure computation; no DOM mutation.
|
|
1831
|
+
const resizeSpec = parseUploadResize(args);
|
|
1832
|
+
if (resizeSpec) {
|
|
1833
|
+
const cdpForResize = await getCdp(rec, page);
|
|
1834
|
+
try {
|
|
1835
|
+
file = await applyUploadResize(file, resizeSpec, async (expression) => {
|
|
1836
|
+
const r = await cdpRace(cdpForResize.send("Runtime.evaluate", {
|
|
1837
|
+
expression, awaitPromise: true, returnByValue: true,
|
|
1838
|
+
}), "Runtime.evaluate", 60_000);
|
|
1839
|
+
if (r.exceptionDetails)
|
|
1840
|
+
throw new Error(r.exceptionDetails.text || "page threw during the canvas conversion");
|
|
1841
|
+
return r.result?.value;
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
catch (e) {
|
|
1845
|
+
if (e instanceof UploadSourceError)
|
|
1846
|
+
throw new BridgeError("act_args_invalid", `upload_file: ${e.message}`);
|
|
1847
|
+
throw new BridgeError("unknown_outcome", `upload_file could not convert the image: ${e.message}`);
|
|
1848
|
+
}
|
|
1849
|
+
if (file.temp)
|
|
1850
|
+
reg.uploadTmpFiles.push(file.path);
|
|
1851
|
+
}
|
|
1852
|
+
// Locate the REAL <input type=file>. The ref may point at the input
|
|
1853
|
+
// itself, at a styled upload button that HIDES the input, or be
|
|
1854
|
+
// absent entirely — the common case, since hidden inputs rarely get
|
|
1855
|
+
// a ref in the tree. Search order: self, descendant, label control,
|
|
1856
|
+
// the enclosing form's input, the page's first file input.
|
|
1857
|
+
const FIND_FROM_REF = "function(){ var el = this;" +
|
|
1858
|
+
" var isFile = function(n){ return n && n.tagName && n.tagName.toLowerCase()==='input' && (n.getAttribute('type')||'').toLowerCase()==='file'; };" +
|
|
1859
|
+
" if (isFile(el)) return el;" +
|
|
1860
|
+
" var q = el && el.querySelector ? el.querySelector('input[type=file]') : null; if (q) return q;" +
|
|
1861
|
+
" if (el && el.tagName && el.tagName.toLowerCase()==='label') {" +
|
|
1862
|
+
" var c = el.control || (el.htmlFor ? document.getElementById(el.htmlFor) : null); if (isFile(c)) return c; }" +
|
|
1863
|
+
" var f = el && el.closest ? el.closest('form') : null;" +
|
|
1864
|
+
" var q2 = f ? f.querySelector('input[type=file]') : null; if (q2) return q2;" +
|
|
1865
|
+
" var all = document.querySelectorAll('input[type=file]'); return all.length ? all[0] : null; }";
|
|
1866
|
+
const FIND_ON_PAGE = "(function(){ var all = Array.prototype.slice.call(document.querySelectorAll('input[type=file]'));" +
|
|
1867
|
+
" if (!all.length) return null;" +
|
|
1868
|
+
" var vis = all.filter(function(i){ var r = i.getBoundingClientRect(); return r.width>0 && r.height>0; });" +
|
|
1869
|
+
" return vis[0] || all[0]; })()";
|
|
1870
|
+
const cdp = await getCdp(rec, page);
|
|
1871
|
+
let inputObjectId = null;
|
|
1872
|
+
if (args.ref) {
|
|
1873
|
+
const binding = sessions.resolveRef(lease, args.ref);
|
|
1874
|
+
const resolved = await cdpRace(cdp.send("DOM.resolveNode", { backendNodeId: binding.backendNodeId }), "DOM.resolveNode");
|
|
1875
|
+
const found = await cdpRace(cdp.send("Runtime.callFunctionOn", {
|
|
1876
|
+
objectId: resolved.object.objectId,
|
|
1877
|
+
functionDeclaration: FIND_FROM_REF,
|
|
1878
|
+
}), "Runtime.callFunctionOn");
|
|
1879
|
+
inputObjectId = found.result?.objectId && found.result.subtype !== "null" ? found.result.objectId : null;
|
|
1880
|
+
}
|
|
1881
|
+
else {
|
|
1882
|
+
const found = await cdpRace(cdp.send("Runtime.evaluate", {
|
|
1883
|
+
expression: FIND_ON_PAGE,
|
|
1884
|
+
}), "Runtime.evaluate");
|
|
1885
|
+
inputObjectId = found.result?.objectId && found.result.subtype !== "null" ? found.result.objectId : null;
|
|
1886
|
+
}
|
|
1887
|
+
if (!inputObjectId) {
|
|
1888
|
+
throw new BridgeError("act_args_invalid", "no <input type=file> found" + (args.ref ? ` at or near ref '${args.ref}'` : " on this page") + ". " +
|
|
1889
|
+
"Some uploaders only create the input after their Upload button is clicked — click it first, then " +
|
|
1890
|
+
"retry upload_file. If the page opens a NATIVE file-picker dialog instead, hand off with browser_ask_user.");
|
|
1891
|
+
}
|
|
1892
|
+
// Set the files, then fire the events a real selection fires — CDP's
|
|
1893
|
+
// DOM.setFileInputFiles changes .files WITHOUT dispatching them, and
|
|
1894
|
+
// every framework listens on input/change.
|
|
1895
|
+
await cdpRace(cdp.send("DOM.setFileInputFiles", { files: [file.path], objectId: inputObjectId }), "DOM.setFileInputFiles");
|
|
1896
|
+
await cdpRace(cdp.send("Runtime.callFunctionOn", {
|
|
1897
|
+
objectId: inputObjectId,
|
|
1898
|
+
functionDeclaration: "function(){ this.dispatchEvent(new Event('input',{bubbles:true})); this.dispatchEvent(new Event('change',{bubbles:true})); return this.files ? this.files.length : 0; }",
|
|
1899
|
+
returnByValue: true,
|
|
1900
|
+
}), "Runtime.callFunctionOn");
|
|
1901
|
+
return {
|
|
1902
|
+
uploaded: true,
|
|
1903
|
+
files: [file.name],
|
|
1904
|
+
bytes: file.bytes,
|
|
1905
|
+
note: "file attached to the input — the page's own submit/save control still commits it",
|
|
1906
|
+
};
|
|
1783
1907
|
}
|
|
1784
1908
|
default:
|
|
1785
1909
|
throw new BridgeError("act_kind_unknown", `unhandled kind '${kind}'`);
|
|
@@ -2423,6 +2547,7 @@ export async function startBrowserBridge(opts) {
|
|
|
2423
2547
|
sessionInit: null,
|
|
2424
2548
|
traces: [],
|
|
2425
2549
|
externalAttach: false,
|
|
2550
|
+
uploadTmpFiles: [],
|
|
2426
2551
|
};
|
|
2427
2552
|
byToken.set(token, reg);
|
|
2428
2553
|
byRunId.set(spec.runId, reg);
|
|
@@ -2451,6 +2576,12 @@ export async function startBrowserBridge(opts) {
|
|
|
2451
2576
|
for (const e of reg.enforcement.splice(0)) {
|
|
2452
2577
|
await e.dispose().catch(() => { });
|
|
2453
2578
|
}
|
|
2579
|
+
// Delete any upload temp files this run staged. Deferred to teardown on
|
|
2580
|
+
// purpose: the browser reads the file LAZILY when the page's form submits,
|
|
2581
|
+
// which can be several agent turns after the upload_file act.
|
|
2582
|
+
for (const p of reg.uploadTmpFiles.splice(0)) {
|
|
2583
|
+
await unlink(p).catch(() => { });
|
|
2584
|
+
}
|
|
2454
2585
|
byRunId.delete(runId);
|
|
2455
2586
|
byToken.delete(reg.token);
|
|
2456
2587
|
activePageByRunId.delete(runId);
|
package/dist/connection.js
CHANGED
|
@@ -78,6 +78,27 @@ export async function connect(opts) {
|
|
|
78
78
|
reconnectionAttempts: Infinity,
|
|
79
79
|
});
|
|
80
80
|
let relay = null;
|
|
81
|
+
// ── Disconnect diagnostics (transport-close root-cause) ──────────────
|
|
82
|
+
// A "transport close" disconnect can be a NETWORK blip OR the Node event loop
|
|
83
|
+
// being BLOCKED (e.g. relaying big browser screenshots/trees) long enough that
|
|
84
|
+
// Socket.IO can't service the connection. These need OPPOSITE fixes, so we
|
|
85
|
+
// sample event-loop lag continuously and print the recent PEAK on disconnect:
|
|
86
|
+
// a multi-second spike ⇒ event-loop block (in-process work); ~0 ⇒ network/proxy.
|
|
87
|
+
let connectedAt = 0;
|
|
88
|
+
let lastHeartbeatAt = 0;
|
|
89
|
+
let peakLagMs = 0;
|
|
90
|
+
{
|
|
91
|
+
let last = Date.now();
|
|
92
|
+
const TICK = 500;
|
|
93
|
+
const t = setInterval(() => {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
const lag = now - last - TICK; // how much longer than TICK the loop actually took
|
|
96
|
+
last = now;
|
|
97
|
+
if (lag > peakLagMs)
|
|
98
|
+
peakLagMs = lag;
|
|
99
|
+
}, TICK);
|
|
100
|
+
t.unref?.();
|
|
101
|
+
}
|
|
81
102
|
// ── Melaya Browser bridge state (plan Sections 3/4) ──────────────────
|
|
82
103
|
// The governed browser control bridge is started lazily on the first
|
|
83
104
|
// browser run. Grant public keys arrive over the socket (server push)
|
|
@@ -209,6 +230,8 @@ export async function connect(opts) {
|
|
|
209
230
|
};
|
|
210
231
|
socket.on("connect", async () => {
|
|
211
232
|
spinner.succeed(chalk.green("Connected to Melaya"));
|
|
233
|
+
connectedAt = Date.now();
|
|
234
|
+
peakLagMs = 0; // fresh window each connection so the disconnect line reflects THIS session
|
|
212
235
|
// New socket lifetime → new generation. Grants minted for an earlier
|
|
213
236
|
// generation are refused (plan Section 4). Any browser runs from the
|
|
214
237
|
// previous connection are already torn down on the disconnect path.
|
|
@@ -269,7 +292,16 @@ export async function connect(opts) {
|
|
|
269
292
|
}
|
|
270
293
|
});
|
|
271
294
|
socket.on("disconnect", (reason) => {
|
|
272
|
-
|
|
295
|
+
const upS = connectedAt ? Math.round((Date.now() - connectedAt) / 1000) : 0;
|
|
296
|
+
const hbS = lastHeartbeatAt ? Math.round((Date.now() - lastHeartbeatAt) / 1000) : -1;
|
|
297
|
+
console.log(chalk.yellow(`\n Disconnected: ${reason} (up ${upS}s, last heartbeat ${hbS}s ago, peak event-loop lag ${peakLagMs}ms)`));
|
|
298
|
+
// The discriminator: a big peak lag (or a stale heartbeat) means the Node
|
|
299
|
+
// event loop was BLOCKED while connected — heavy in-process work (big browser
|
|
300
|
+
// screenshot/tree relay), NOT a network drop → fix is to offload/chunk that
|
|
301
|
+
// work. A small lag with "transport close" points at the network/proxy path.
|
|
302
|
+
if (peakLagMs > 2000 || hbS > 45) {
|
|
303
|
+
console.log(chalk.yellow(` ↳ event loop was blocked ~${peakLagMs}ms (heartbeat ${hbS}s ago) — this is in-process backpressure (likely big browser payload relay), not a network drop.`));
|
|
304
|
+
}
|
|
273
305
|
// Melaya Browser: a dropped socket invalidates every browser grant
|
|
274
306
|
// bound to this socket generation. Tear down ALL browser runs
|
|
275
307
|
// (cancels in-flight ops, releases leases, closes owned browsers);
|
|
@@ -430,8 +462,10 @@ export async function connect(opts) {
|
|
|
430
462
|
});
|
|
431
463
|
// ── Heartbeat ──────────────────────────────────────────────────────
|
|
432
464
|
setInterval(() => {
|
|
433
|
-
if (socket.connected)
|
|
465
|
+
if (socket.connected) {
|
|
434
466
|
socket.emit("runner:heartbeat");
|
|
467
|
+
lastHeartbeatAt = Date.now();
|
|
468
|
+
}
|
|
435
469
|
}, HEARTBEAT_INTERVAL);
|
|
436
470
|
// ── Run command ────────────────────────────────────────────────────
|
|
437
471
|
socket.on("runner:run", async (payload) => {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export declare const UPLOAD_MAX_BYTES: number;
|
|
2
|
+
export interface ResolvedUploadFile {
|
|
3
|
+
/** Absolute path of the temp (or caller-supplied local) file. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** Basename to report back ("logo.png" style). */
|
|
6
|
+
name: string;
|
|
7
|
+
bytes: number;
|
|
8
|
+
/** True when `path` is a temp file this module created (caller cleans up). */
|
|
9
|
+
temp: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare class UploadSourceError extends Error {
|
|
12
|
+
}
|
|
13
|
+
export interface UploadResize {
|
|
14
|
+
width?: number;
|
|
15
|
+
height?: number;
|
|
16
|
+
fit?: "contain" | "cover";
|
|
17
|
+
format?: "png" | "jpeg" | "webp";
|
|
18
|
+
}
|
|
19
|
+
/** Loose act args (flat width/height/fit/format from browser.py, or a strict
|
|
20
|
+
* `resize` object) → a normalized UploadResize, or null when none asked. */
|
|
21
|
+
export declare function parseUploadResize(args: Record<string, unknown>): UploadResize | null;
|
|
22
|
+
/** Apply a resize/convert to a resolved file by running a canvas job through
|
|
23
|
+
* the supplied JS evaluator (an async-IIFE expression whose awaited value is
|
|
24
|
+
* the transformed image as base64). Returns a NEW temp file; throws
|
|
25
|
+
* UploadSourceError with an actionable message when the page cannot do it. */
|
|
26
|
+
export declare function applyUploadResize(file: ResolvedUploadFile, resize: UploadResize, evaluate: (expression: string) => Promise<unknown>): Promise<ResolvedUploadFile>;
|
|
27
|
+
/** Staged-handle source: fetch <serverBase>/browser-media/<hex>.<ext>. */
|
|
28
|
+
export declare function resolveHandleSource(serverBase: string, handle: string): Promise<ResolvedUploadFile>;
|
|
29
|
+
/** URL source: guarded direct download from the runner machine. */
|
|
30
|
+
export declare function resolveUrlSource(url: string): Promise<ResolvedUploadFile>;
|
|
31
|
+
/** Base64 data-URL source: decoded locally (no network). Same image-only
|
|
32
|
+
* allowlist, sniff and cap. NOTE the local bridge's request body cap bounds
|
|
33
|
+
* how much base64 can arrive this way; large files should come as a staged
|
|
34
|
+
* handle or a url. */
|
|
35
|
+
export declare function resolveDataUrlSource(dataUrl: string): Promise<ResolvedUploadFile>;
|
|
36
|
+
/** Local-path source (LOCAL RUNNER ONLY): an image file already on this
|
|
37
|
+
* machine. Image-gated by magic bytes, so this cannot be used to lift an
|
|
38
|
+
* arbitrary local file (keys, configs, code) onto a website. */
|
|
39
|
+
export declare function resolvePathSource(path: string): Promise<ResolvedUploadFile>;
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// File-source resolution for the runner bridge's `upload_file` act.
|
|
2
|
+
//
|
|
3
|
+
// The runner machine is the EXECUTING side, so it is the one that turns a file
|
|
4
|
+
// source into real bytes on disk before CDP's DOM.setFileInputFiles can attach
|
|
5
|
+
// them to a page's <input type=file>. Three sources:
|
|
6
|
+
//
|
|
7
|
+
// handle "fh_<hex>_<ext>" — minted by the server's staging flow
|
|
8
|
+
// (services/browser/browserUploadMedia.ts);
|
|
9
|
+
// fetched from GET <serverBase>/browser-media/
|
|
10
|
+
// <hex>.<ext> (public capability URL, short TTL).
|
|
11
|
+
// url (http/https) — downloaded directly by the runner. The URL was
|
|
12
|
+
// chosen by a model, and this code runs on the
|
|
13
|
+
// USER'S machine, so private/loopback/link-local
|
|
14
|
+
// addresses are blocked (a mirror of the server's
|
|
15
|
+
// safeFetch guard, self-contained here): the
|
|
16
|
+
// user's LAN must not be reachable through it.
|
|
17
|
+
// path (local file) — LOCAL-RUNNER-ONLY convenience for pipeline
|
|
18
|
+
// artifacts already on this machine. Never
|
|
19
|
+
// reachable from the cloud action schema (it has
|
|
20
|
+
// no path field); the loose local-bridge contract
|
|
21
|
+
// is the only door, and it is image-gated below.
|
|
22
|
+
//
|
|
23
|
+
// v1 is IMAGES ONLY (png/jpg/webp/gif), 10MB cap, magic-byte verified — the
|
|
24
|
+
// same allowlist the server's stager enforces, re-checked here because this
|
|
25
|
+
// path can bypass the server entirely (direct url / local path).
|
|
26
|
+
import { lookup as dnsLookup } from "node:dns";
|
|
27
|
+
import * as http from "node:http";
|
|
28
|
+
import * as https from "node:https";
|
|
29
|
+
import { randomBytes } from "node:crypto";
|
|
30
|
+
import { writeFile, readFile, stat } from "node:fs/promises";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
import { tmpdir } from "node:os";
|
|
33
|
+
export const UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
|
34
|
+
const CT_BY_EXT = {
|
|
35
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp",
|
|
36
|
+
gif: "image/gif",
|
|
37
|
+
};
|
|
38
|
+
export class UploadSourceError extends Error {
|
|
39
|
+
}
|
|
40
|
+
function extForContentType(ct) {
|
|
41
|
+
const base = (ct || "").split(";")[0].trim().toLowerCase();
|
|
42
|
+
switch (base) {
|
|
43
|
+
case "image/png": return "png";
|
|
44
|
+
case "image/jpeg":
|
|
45
|
+
case "image/jpg": return "jpg";
|
|
46
|
+
case "image/webp": return "webp";
|
|
47
|
+
case "image/gif": return "gif";
|
|
48
|
+
default: return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function extOfName(name) {
|
|
52
|
+
const m = /\.([a-z0-9]{2,4})(?:[?#]|$)/i.exec(name || "");
|
|
53
|
+
const ext = m ? m[1].toLowerCase() : "";
|
|
54
|
+
return ext in CT_BY_EXT ? ext : null;
|
|
55
|
+
}
|
|
56
|
+
function imageSniffOk(buf, ext) {
|
|
57
|
+
if (buf.length < 12)
|
|
58
|
+
return false;
|
|
59
|
+
if (ext === "png")
|
|
60
|
+
return buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47;
|
|
61
|
+
if (ext === "jpg" || ext === "jpeg")
|
|
62
|
+
return buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff;
|
|
63
|
+
if (ext === "webp")
|
|
64
|
+
return buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP";
|
|
65
|
+
if (ext === "gif")
|
|
66
|
+
return buf.toString("ascii", 0, 3) === "GIF";
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
// ── Private-address guard (mirror of server safeFetch's block ranges) ────────
|
|
70
|
+
function ipv4ToInt(ip) {
|
|
71
|
+
const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
72
|
+
if (!m)
|
|
73
|
+
return null;
|
|
74
|
+
const o = m.slice(1).map(Number);
|
|
75
|
+
if (o.some((x) => x > 255))
|
|
76
|
+
return null;
|
|
77
|
+
return (((o[0] << 24) >>> 0) + (o[1] << 16) + (o[2] << 8) + o[3]) >>> 0;
|
|
78
|
+
}
|
|
79
|
+
function isBlockedV4(ip) {
|
|
80
|
+
const n = ipv4ToInt(ip);
|
|
81
|
+
if (n === null)
|
|
82
|
+
return true; // unparseable -> block (fail closed)
|
|
83
|
+
const inRange = (base, bits) => {
|
|
84
|
+
const b = ipv4ToInt(base);
|
|
85
|
+
if (b === null)
|
|
86
|
+
return false;
|
|
87
|
+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
|
|
88
|
+
return (n & mask) === (b & mask);
|
|
89
|
+
};
|
|
90
|
+
return inRange("0.0.0.0", 8) || inRange("10.0.0.0", 8) || inRange("100.64.0.0", 10) ||
|
|
91
|
+
inRange("127.0.0.0", 8) || inRange("169.254.0.0", 16) || inRange("172.16.0.0", 12) ||
|
|
92
|
+
inRange("192.0.0.0", 24) || inRange("192.168.0.0", 16) || inRange("198.18.0.0", 15) ||
|
|
93
|
+
inRange("224.0.0.0", 4) || inRange("240.0.0.0", 4);
|
|
94
|
+
}
|
|
95
|
+
function parseV6(addr) {
|
|
96
|
+
let a = addr.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/%.*$/, "");
|
|
97
|
+
if (!a.includes(":"))
|
|
98
|
+
return null;
|
|
99
|
+
const dotted = a.match(/(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
100
|
+
if (dotted) {
|
|
101
|
+
const v4 = ipv4ToInt(dotted[2]);
|
|
102
|
+
if (v4 === null)
|
|
103
|
+
return null;
|
|
104
|
+
a = dotted[1] + (((v4 >>> 16) & 0xffff).toString(16)) + ":" + ((v4 & 0xffff).toString(16));
|
|
105
|
+
}
|
|
106
|
+
const halves = a.split("::");
|
|
107
|
+
if (halves.length > 2)
|
|
108
|
+
return null;
|
|
109
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
110
|
+
const tail = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : null;
|
|
111
|
+
const toWords = (parts) => {
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const p of parts) {
|
|
114
|
+
if (!/^[0-9a-f]{1,4}$/.test(p))
|
|
115
|
+
return null;
|
|
116
|
+
out.push(parseInt(p, 16));
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
};
|
|
120
|
+
const h = toWords(head);
|
|
121
|
+
if (h === null)
|
|
122
|
+
return null;
|
|
123
|
+
if (tail === null)
|
|
124
|
+
return h.length === 8 ? h : null;
|
|
125
|
+
const t = toWords(tail);
|
|
126
|
+
if (t === null)
|
|
127
|
+
return null;
|
|
128
|
+
const fill = 8 - h.length - t.length;
|
|
129
|
+
if (fill < 0)
|
|
130
|
+
return null;
|
|
131
|
+
return [...h, ...Array(fill).fill(0), ...t];
|
|
132
|
+
}
|
|
133
|
+
function isBlockedIp(addr) {
|
|
134
|
+
const a = addr.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/%.*$/, "");
|
|
135
|
+
if (!a.includes(":"))
|
|
136
|
+
return isBlockedV4(a);
|
|
137
|
+
const w = parseV6(a);
|
|
138
|
+
if (w === null)
|
|
139
|
+
return true;
|
|
140
|
+
if (w[0] === 0 && w[1] === 0 && w[2] === 0 && w[3] === 0 && w[4] === 0 && w[5] === 0xffff) {
|
|
141
|
+
const v4 = `${(w[6] >> 8) & 0xff}.${w[6] & 0xff}.${(w[7] >> 8) & 0xff}.${w[7] & 0xff}`;
|
|
142
|
+
return isBlockedV4(v4);
|
|
143
|
+
}
|
|
144
|
+
return (w[0] & 0xe000) !== 0x2000; // only global unicast 2000::/3
|
|
145
|
+
}
|
|
146
|
+
/** Validating + pinning lookup: the socket connects to exactly the vetted
|
|
147
|
+
* address, so there is no DNS-rebinding TOCTOU window. */
|
|
148
|
+
function validatingLookup(hostname, options, callback) {
|
|
149
|
+
const all = !!(options && typeof options === "object" && options.all);
|
|
150
|
+
dnsLookup(hostname, { all: true, verbatim: true }, (err, addresses) => {
|
|
151
|
+
if (err || !addresses || (Array.isArray(addresses) && addresses.length === 0)) {
|
|
152
|
+
callback(Object.assign(new Error("upload_url_unresolvable"), { code: "ENOTFOUND" }), "");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const recs = addresses;
|
|
156
|
+
for (const r of recs) {
|
|
157
|
+
if (isBlockedIp(r.address)) {
|
|
158
|
+
callback(Object.assign(new Error("upload_url_blocked_ip"), { code: "EACCES" }), "");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (all)
|
|
163
|
+
callback(null, recs);
|
|
164
|
+
else
|
|
165
|
+
callback(null, recs[0].address, recs[0].family);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/** One guarded GET, following up to 5 redirects (each hop re-validated). */
|
|
169
|
+
async function guardedGet(urlStr, opts) {
|
|
170
|
+
let current = urlStr;
|
|
171
|
+
for (let hop = 0; hop <= 5; hop++) {
|
|
172
|
+
let u;
|
|
173
|
+
try {
|
|
174
|
+
u = new URL(current);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
throw new UploadSourceError(`invalid URL: ${current}`);
|
|
178
|
+
}
|
|
179
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
180
|
+
throw new UploadSourceError(`unsupported scheme '${u.protocol}' — only http(s) URLs can be uploaded`);
|
|
181
|
+
if (opts.guardPrivate && u.port && u.port !== "80" && u.port !== "443")
|
|
182
|
+
throw new UploadSourceError("non-standard ports are blocked for upload URLs");
|
|
183
|
+
if (opts.guardPrivate) {
|
|
184
|
+
const bare = u.hostname.replace(/^\[|\]$/g, "");
|
|
185
|
+
if ((ipv4ToInt(bare) !== null || bare.includes(":")) && isBlockedIp(bare)) {
|
|
186
|
+
throw new UploadSourceError("that URL points at a private/loopback address, which is blocked");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const r = await new Promise((resolve, reject) => {
|
|
190
|
+
const mod = u.protocol === "https:" ? https : http;
|
|
191
|
+
const req = mod.request(u, {
|
|
192
|
+
method: "GET",
|
|
193
|
+
...(opts.guardPrivate ? { lookup: validatingLookup } : {}),
|
|
194
|
+
headers: { Accept: "image/*,*/*;q=0.5", "User-Agent": "MelayaRunner/1.0 (browser_upload)" },
|
|
195
|
+
}, (res) => {
|
|
196
|
+
const status = res.statusCode || 0;
|
|
197
|
+
const contentType = String(res.headers["content-type"] || "");
|
|
198
|
+
if (status >= 300 && status < 400 && res.headers.location) {
|
|
199
|
+
res.resume();
|
|
200
|
+
resolve({ status, contentType, location: String(res.headers.location), body: Buffer.alloc(0) });
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const chunks = [];
|
|
204
|
+
let received = 0;
|
|
205
|
+
res.on("data", (c) => {
|
|
206
|
+
received += c.length;
|
|
207
|
+
if (received > opts.maxBytes) {
|
|
208
|
+
req.destroy();
|
|
209
|
+
reject(new UploadSourceError(`the file exceeds the ${Math.round(opts.maxBytes / 1024 / 1024)}MB upload cap`));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
chunks.push(c);
|
|
213
|
+
});
|
|
214
|
+
res.on("end", () => resolve({ status, contentType, body: Buffer.concat(chunks) }));
|
|
215
|
+
res.on("error", (e) => reject(e));
|
|
216
|
+
});
|
|
217
|
+
req.on("error", (e) => reject(e));
|
|
218
|
+
req.setTimeout(opts.timeoutMs, () => { req.destroy(); reject(new UploadSourceError("timed out downloading the file")); });
|
|
219
|
+
req.end();
|
|
220
|
+
});
|
|
221
|
+
if (r.status >= 300 && r.status < 400 && r.location) {
|
|
222
|
+
current = new URL(r.location, current).toString();
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (r.status < 200 || r.status >= 300)
|
|
226
|
+
throw new UploadSourceError(`download failed with HTTP ${r.status}`);
|
|
227
|
+
return { body: r.body, contentType: r.contentType, finalUrl: current };
|
|
228
|
+
}
|
|
229
|
+
throw new UploadSourceError("too many redirects");
|
|
230
|
+
}
|
|
231
|
+
function validateImage(buf, extHint, label) {
|
|
232
|
+
if (!buf.length)
|
|
233
|
+
throw new UploadSourceError(`${label} is empty`);
|
|
234
|
+
if (buf.length > UPLOAD_MAX_BYTES)
|
|
235
|
+
throw new UploadSourceError(`${label} exceeds the ${Math.round(UPLOAD_MAX_BYTES / 1024 / 1024)}MB upload cap`);
|
|
236
|
+
// Sniff first, trust headers/extensions second: the bytes are the authority.
|
|
237
|
+
for (const ext of extHint ? [extHint] : []) {
|
|
238
|
+
if (imageSniffOk(buf, ext))
|
|
239
|
+
return ext;
|
|
240
|
+
}
|
|
241
|
+
for (const ext of Object.keys(CT_BY_EXT)) {
|
|
242
|
+
if (imageSniffOk(buf, ext))
|
|
243
|
+
return ext;
|
|
244
|
+
}
|
|
245
|
+
throw new UploadSourceError(`${label} is not a supported image — v1 uploads accept png, jpg, webp or gif only`);
|
|
246
|
+
}
|
|
247
|
+
async function toTemp(buf, ext) {
|
|
248
|
+
const name = `melaya-upload-${randomBytes(6).toString("hex")}.${ext}`;
|
|
249
|
+
const p = join(tmpdir(), name);
|
|
250
|
+
await writeFile(p, buf, { mode: 0o600 });
|
|
251
|
+
return { path: p, name: `upload.${ext}`, bytes: buf.length, temp: true };
|
|
252
|
+
}
|
|
253
|
+
/** Loose act args (flat width/height/fit/format from browser.py, or a strict
|
|
254
|
+
* `resize` object) → a normalized UploadResize, or null when none asked. */
|
|
255
|
+
export function parseUploadResize(args) {
|
|
256
|
+
const src = (args.resize && typeof args.resize === "object" ? args.resize : args);
|
|
257
|
+
const w = Math.trunc(Number(src.width));
|
|
258
|
+
const h = Math.trunc(Number(src.height));
|
|
259
|
+
const fit = String(src.fit ?? "").trim().toLowerCase();
|
|
260
|
+
const format = String(src.format ?? "").trim().toLowerCase().replace(/^jpg$/, "jpeg");
|
|
261
|
+
const out = {
|
|
262
|
+
...(Number.isFinite(w) && w >= 1 && w <= 10_000 ? { width: w } : {}),
|
|
263
|
+
...(Number.isFinite(h) && h >= 1 && h <= 10_000 ? { height: h } : {}),
|
|
264
|
+
...(fit === "contain" || fit === "cover" ? { fit: fit } : {}),
|
|
265
|
+
...(format === "png" || format === "jpeg" || format === "webp" ? { format: format } : {}),
|
|
266
|
+
};
|
|
267
|
+
return out.width || out.height || out.format ? out : null;
|
|
268
|
+
}
|
|
269
|
+
const MIME_BY_EXT = {
|
|
270
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif",
|
|
271
|
+
};
|
|
272
|
+
/** Apply a resize/convert to a resolved file by running a canvas job through
|
|
273
|
+
* the supplied JS evaluator (an async-IIFE expression whose awaited value is
|
|
274
|
+
* the transformed image as base64). Returns a NEW temp file; throws
|
|
275
|
+
* UploadSourceError with an actionable message when the page cannot do it. */
|
|
276
|
+
export async function applyUploadResize(file, resize, evaluate) {
|
|
277
|
+
const srcExt = (file.path.split(".").pop() || "png").toLowerCase();
|
|
278
|
+
const srcMime = MIME_BY_EXT[srcExt] || "image/png";
|
|
279
|
+
const outExt = resize.format === "jpeg" ? "jpg" : (resize.format || (srcExt === "gif" ? "png" : srcExt));
|
|
280
|
+
const outMime = MIME_BY_EXT[outExt] || srcMime;
|
|
281
|
+
const raw = await readFile(file.path);
|
|
282
|
+
const w = resize.width || 0;
|
|
283
|
+
const h = resize.height || 0;
|
|
284
|
+
const fit = resize.fit === "cover" ? "cover" : "contain";
|
|
285
|
+
// Everything interpolated below is numeric or JSON-quoted — nothing from a
|
|
286
|
+
// page, nothing free-form; the base64 alphabet cannot break the string out.
|
|
287
|
+
const expression = `(async function(){
|
|
288
|
+
var bin = atob(${JSON.stringify(raw.toString("base64"))});
|
|
289
|
+
var bytes = new Uint8Array(bin.length);
|
|
290
|
+
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
291
|
+
var bmp = await createImageBitmap(new Blob([bytes], { type: ${JSON.stringify(srcMime)} }));
|
|
292
|
+
var sw = bmp.width, sh = bmp.height;
|
|
293
|
+
var w = ${w}, h = ${h};
|
|
294
|
+
var W, H, dx, dy, dw, dh;
|
|
295
|
+
if (w && h) {
|
|
296
|
+
W = w; H = h;
|
|
297
|
+
var s = ${JSON.stringify(fit)} === "cover" ? Math.max(w / sw, h / sh) : Math.min(w / sw, h / sh);
|
|
298
|
+
dw = sw * s; dh = sh * s; dx = (W - dw) / 2; dy = (H - dh) / 2;
|
|
299
|
+
} else if (w) { W = w; H = Math.max(1, Math.round(sh * w / sw)); dx = 0; dy = 0; dw = W; dh = H; }
|
|
300
|
+
else if (h) { H = h; W = Math.max(1, Math.round(sw * h / sh)); dx = 0; dy = 0; dw = W; dh = H; }
|
|
301
|
+
else { W = sw; H = sh; dx = 0; dy = 0; dw = sw; dh = sh; }
|
|
302
|
+
var canvas = new OffscreenCanvas(W, H);
|
|
303
|
+
var g = canvas.getContext("2d");
|
|
304
|
+
if (${JSON.stringify(outMime)} === "image/jpeg") { g.fillStyle = "#fff"; g.fillRect(0, 0, W, H); }
|
|
305
|
+
g.imageSmoothingQuality = "high";
|
|
306
|
+
g.drawImage(bmp, dx, dy, dw, dh);
|
|
307
|
+
var blob = await canvas.convertToBlob({ type: ${JSON.stringify(outMime)}, quality: 0.92 });
|
|
308
|
+
var out = new Uint8Array(await blob.arrayBuffer());
|
|
309
|
+
var s2 = ""; var CH = 32768;
|
|
310
|
+
for (var j = 0; j < out.length; j += CH) s2 += String.fromCharCode.apply(null, Array.prototype.slice.call(out.subarray(j, j + CH)));
|
|
311
|
+
return btoa(s2);
|
|
312
|
+
})()`;
|
|
313
|
+
let outB64;
|
|
314
|
+
try {
|
|
315
|
+
outB64 = await evaluate(expression);
|
|
316
|
+
}
|
|
317
|
+
catch (e) {
|
|
318
|
+
throw new UploadSourceError(`could not convert the image in the browser (${e.message}). ` +
|
|
319
|
+
"Retry without width/height/format, or convert the file before uploading.");
|
|
320
|
+
}
|
|
321
|
+
if (typeof outB64 !== "string" || !outB64) {
|
|
322
|
+
throw new UploadSourceError("the in-browser image conversion returned nothing — retry without width/height/format, or convert the file before uploading.");
|
|
323
|
+
}
|
|
324
|
+
const buf = Buffer.from(outB64, "base64");
|
|
325
|
+
if (!buf.length)
|
|
326
|
+
throw new UploadSourceError("the in-browser image conversion produced an empty file.");
|
|
327
|
+
if (buf.length > UPLOAD_MAX_BYTES)
|
|
328
|
+
throw new UploadSourceError(`the converted image exceeds the ${Math.round(UPLOAD_MAX_BYTES / 1024 / 1024)}MB upload cap.`);
|
|
329
|
+
return toTemp(buf, outExt);
|
|
330
|
+
}
|
|
331
|
+
/** Staged-handle source: fetch <serverBase>/browser-media/<hex>.<ext>. */
|
|
332
|
+
export async function resolveHandleSource(serverBase, handle) {
|
|
333
|
+
const m = /^fh_([a-f0-9]{32})_([a-z0-9]{2,4})$/.exec((handle || "").trim());
|
|
334
|
+
if (!m || !(m[2].toLowerCase() in CT_BY_EXT)) {
|
|
335
|
+
throw new UploadSourceError(`'${String(handle).slice(0, 60)}' is not a staged file handle (expected "fh_<hex>_<ext>"). ` +
|
|
336
|
+
"Use browser_upload with a public image `url` — it stages the file and mints the handle for you.");
|
|
337
|
+
}
|
|
338
|
+
const base = (serverBase || "").replace(/\/+$/, "");
|
|
339
|
+
if (!base)
|
|
340
|
+
throw new UploadSourceError("this run has no server base configured, so a staged handle cannot be resolved — pass a public image `url` instead");
|
|
341
|
+
const { body } = await guardedGet(`${base}/browser-media/${m[1]}.${m[2].toLowerCase()}`, {
|
|
342
|
+
maxBytes: UPLOAD_MAX_BYTES, timeoutMs: 30_000, guardPrivate: false, // the user's own server base may be private in dev
|
|
343
|
+
});
|
|
344
|
+
const ext = validateImage(body, m[2].toLowerCase(), "the staged file");
|
|
345
|
+
return toTemp(body, ext);
|
|
346
|
+
}
|
|
347
|
+
/** URL source: guarded direct download from the runner machine. */
|
|
348
|
+
export async function resolveUrlSource(url) {
|
|
349
|
+
const { body, contentType, finalUrl } = await guardedGet(url, {
|
|
350
|
+
maxBytes: UPLOAD_MAX_BYTES, timeoutMs: 30_000, guardPrivate: true,
|
|
351
|
+
});
|
|
352
|
+
const hint = extForContentType(contentType) ?? extOfName(new URL(finalUrl).pathname);
|
|
353
|
+
const ext = validateImage(body, hint, `the file at ${url}`);
|
|
354
|
+
return toTemp(body, ext);
|
|
355
|
+
}
|
|
356
|
+
/** Base64 data-URL source: decoded locally (no network). Same image-only
|
|
357
|
+
* allowlist, sniff and cap. NOTE the local bridge's request body cap bounds
|
|
358
|
+
* how much base64 can arrive this way; large files should come as a staged
|
|
359
|
+
* handle or a url. */
|
|
360
|
+
export async function resolveDataUrlSource(dataUrl) {
|
|
361
|
+
const m = /^data:([a-z0-9.+/-]+)(?:;[a-z0-9=.-]+)*;base64,([A-Za-z0-9+/=\s]+)$/i.exec((dataUrl || "").trim());
|
|
362
|
+
if (!m)
|
|
363
|
+
throw new UploadSourceError('expected a base64 data URL like "data:image/png;base64,..."');
|
|
364
|
+
const b64 = m[2].replace(/\s+/g, "");
|
|
365
|
+
if (b64.length > Math.ceil((UPLOAD_MAX_BYTES * 4) / 3) + 8) {
|
|
366
|
+
throw new UploadSourceError(`the file exceeds the ${Math.round(UPLOAD_MAX_BYTES / 1024 / 1024)}MB upload cap`);
|
|
367
|
+
}
|
|
368
|
+
const buf = Buffer.from(b64, "base64");
|
|
369
|
+
const ext = validateImage(buf, extForContentType(m[1]), "the data URL payload");
|
|
370
|
+
return toTemp(buf, ext);
|
|
371
|
+
}
|
|
372
|
+
/** Local-path source (LOCAL RUNNER ONLY): an image file already on this
|
|
373
|
+
* machine. Image-gated by magic bytes, so this cannot be used to lift an
|
|
374
|
+
* arbitrary local file (keys, configs, code) onto a website. */
|
|
375
|
+
export async function resolvePathSource(path) {
|
|
376
|
+
const p = (path || "").trim();
|
|
377
|
+
if (!p)
|
|
378
|
+
throw new UploadSourceError("empty path");
|
|
379
|
+
let size;
|
|
380
|
+
try {
|
|
381
|
+
size = (await stat(p)).size;
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
throw new UploadSourceError(`no file at '${p}' on the runner machine`);
|
|
385
|
+
}
|
|
386
|
+
if (size > UPLOAD_MAX_BYTES)
|
|
387
|
+
throw new UploadSourceError(`the file exceeds the ${Math.round(UPLOAD_MAX_BYTES / 1024 / 1024)}MB upload cap`);
|
|
388
|
+
const buf = await readFile(p);
|
|
389
|
+
const ext = validateImage(buf, extOfName(p), `the file at ${p}`);
|
|
390
|
+
// The file already exists locally with the right bytes — attach it in place
|
|
391
|
+
// (no temp copy) so nothing else has to be cleaned up.
|
|
392
|
+
return { path: p, name: `upload.${ext}`, bytes: buf.length, temp: false };
|
|
393
|
+
}
|