rechrome 1.38.3 → 1.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -42
- package/listeners.js +15 -6
- package/listeners.ts +3 -1
- package/package.json +3 -2
- package/rechrome.js +25 -10
- package/rechrome.ts +25 -10
- package/scoped-upload.js +164 -35
- package/scoped-upload.ts +172 -33
- package/serve.js +3 -2
- package/serve.ts +3 -2
package/README.md
CHANGED
|
@@ -359,56 +359,59 @@ cp .env.example .env.local
|
|
|
359
359
|
>
|
|
360
360
|
> Shell-set `PLAYWRIGHT_MCP_*` variables take priority over `.env.local`, so you can always override per-command without editing files.
|
|
361
361
|
|
|
362
|
-
###
|
|
362
|
+
### Uploading files
|
|
363
363
|
|
|
364
|
-
|
|
365
|
-
|
|
364
|
+
`rech upload <file>` puts a local image into the page: a file picker, an
|
|
365
|
+
`<input type=file>` (hidden ones too), a drag-and-drop zone, or an editor that
|
|
366
|
+
takes pasted images. The file is read on **your** machine and only its bytes are
|
|
367
|
+
sent, so it works the same against a remote host.
|
|
366
368
|
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
369
|
+
```bash
|
|
370
|
+
rech upload ./shot.png # the chooser a click opened, else the page's only file input
|
|
371
|
+
rech upload ./shot.png --into e42 # a file input, or the button that opens the picker (clicks it)
|
|
372
|
+
rech upload ./shot.png --into 'input#avatar'
|
|
373
|
+
rech upload ./shot.png --drop e17 # a drag-and-drop zone
|
|
374
|
+
rech upload ./shot.png --paste e9 # an editor that takes pasted images (default: the focused element)
|
|
375
|
+
rech upload --name shot.png < ./shot.png # bytes from stdin instead of a path
|
|
372
376
|
```
|
|
373
377
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
listener reload mechanism.
|
|
378
|
+
Targets are snapshot refs (`rech snapshot`) or selectors, the same as `rech click`.
|
|
379
|
+
The usual flow for a site's "add image" button is `rech snapshot`, then
|
|
380
|
+
`rech upload ./shot.png --into <the button's ref>`: rech clicks it and fills the file
|
|
381
|
+
chooser that opens. Run the steps in one session (`-s=<name>`) when they span
|
|
382
|
+
several commands.
|
|
380
383
|
|
|
381
|
-
|
|
382
|
-
|
|
384
|
+
**Types and size:** PNG, JPEG, GIF, WebP and SVG, up to **10 MiB**. Both the client
|
|
385
|
+
and the host check the size, and check the bytes against the type (PNG/JPEG/GIF/WebP
|
|
386
|
+
signatures; UTF-8 SVG without DTD/entities). These checks identify the format; they
|
|
387
|
+
don't sanitize SVG or scan for malware. The host never renders the file.
|
|
388
|
+
|
|
389
|
+
**Turning uploads on:** they are always on through the local management listener.
|
|
390
|
+
On a shared (scoped) listener they are **off** until the host turns them on:
|
|
383
391
|
|
|
384
392
|
```bash
|
|
385
|
-
rech
|
|
386
|
-
|
|
393
|
+
rech listener set <listener> --uploads # --no-uploads turns them off again
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
That writes an `uploads` object to the listener's entry in `~/.rechrome/listeners.json`.
|
|
397
|
+
The host can tune it by hand, lowering the byte limit or narrowing the types, but not
|
|
398
|
+
going above the 10 MiB cap or adding other types:
|
|
399
|
+
|
|
400
|
+
```json
|
|
401
|
+
"uploads": { "maxBytes": 2097152, "contentTypes": ["image/png", "image/jpeg"] }
|
|
387
402
|
```
|
|
388
403
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
file
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
that generated path to the existing file-chooser command and removes the directory
|
|
401
|
-
on success, error, or timeout. Neither filenames nor payload fields can select a
|
|
402
|
-
host file. A crash or forced process kill can interrupt cleanup; the files remain
|
|
403
|
-
private OS temporary files. Permission modes apply on POSIX hosts.
|
|
404
|
-
|
|
405
|
-
Each upload produces one `upload:` JSON audit record with listener, profile,
|
|
406
|
-
original name, byte size, SHA-256, page origin observed immediately before upload,
|
|
407
|
-
and result. It contains no file contents, credentials, page path, query, or fragment.
|
|
408
|
-
Requests rejected before inspection have null values for unavailable fields.
|
|
409
|
-
An HTTP(S) origin must be determined before an upload is attempted. As with other
|
|
410
|
-
browser commands, avoid concurrent tab navigation in the same session. Scoped
|
|
411
|
-
`eval`, `run-code`, host paths, and CLI option overrides remain unavailable.
|
|
404
|
+
**On the host:** the bytes are written under their original file name inside a fresh
|
|
405
|
+
private temporary directory (0700, file 0600), outside the downloadable output tree,
|
|
406
|
+
and the directory is removed afterwards (on success, error or timeout). Neither the
|
|
407
|
+
name, the target nor any other payload field can select a host file or inject code:
|
|
408
|
+
the drop/paste/input steps run as a server-built script that embeds them as JSON
|
|
409
|
+
data. Each upload writes one `upload:` JSON audit record: listener, profile,
|
|
410
|
+
original name, size, SHA-256, delivery mode, the page origin just before the
|
|
411
|
+
upload, and the result. It never contains file contents, credentials, or the page's
|
|
412
|
+
path, query or fragment. An HTTP(S) origin must be known before an upload is
|
|
413
|
+
attempted. Scoped `eval`, `run-code`, host paths and CLI option overrides stay
|
|
414
|
+
unavailable; enabling uploads doesn't change that.
|
|
412
415
|
|
|
413
416
|
### Remote access
|
|
414
417
|
|
|
@@ -475,7 +478,7 @@ git clone https://github.com/snomiao/rechrome.git
|
|
|
475
478
|
cd rechrome
|
|
476
479
|
bun install # `prepare` also builds vendor/ from vendor-src/, so the checkout has a working CLI
|
|
477
480
|
bun link # makes this checkout the global rechrome / rech
|
|
478
|
-
bun
|
|
481
|
+
bun run test # rechrome's own tests (plain `bun test` also finds the vendored forks' suites)
|
|
479
482
|
```
|
|
480
483
|
|
|
481
484
|
To use a different playwright-cli, set `PLAYWRIGHT_CLI=<cmd>` in `.env.local` (for example a
|
package/listeners.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// scoped-upload.ts
|
|
3
|
-
var MAX_UPLOAD_BYTES =
|
|
4
|
-
var MAX_REQUEST_BYTES =
|
|
5
|
-
var UPLOAD_TYPES = ["image/png", "image/svg+xml"];
|
|
3
|
+
var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
4
|
+
var MAX_REQUEST_BYTES = 14 * 1024 * 1024;
|
|
5
|
+
var UPLOAD_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"];
|
|
6
6
|
function validateUploadPolicy(value) {
|
|
7
7
|
const p = value;
|
|
8
8
|
if (!p || typeof p !== "object" || Array.isArray(p) || Object.keys(p).some((k) => !["maxBytes", "contentTypes"].includes(k)) || p.maxBytes !== undefined && (!Number.isSafeInteger(p.maxBytes) || p.maxBytes < 1 || p.maxBytes > MAX_UPLOAD_BYTES) || p.contentTypes !== undefined && (!Array.isArray(p.contentTypes) || !p.contentTypes.length || p.contentTypes.some((t) => !UPLOAD_TYPES.includes(t))))
|
|
9
|
-
throw new Error(
|
|
10
|
-
}
|
|
9
|
+
throw new Error(`uploads must be an object with optional maxBytes (1..${MAX_UPLOAD_BYTES}) and contentTypes (${UPLOAD_TYPES.join(", ")})`);
|
|
10
|
+
}
|
|
11
|
+
var UPLOAD_USAGE = `Usage: rech upload <file> [how] (the file is read on this machine; only its bytes are sent)
|
|
12
|
+
(no flag) the file chooser a click opened, else the page's only <input type=file>
|
|
13
|
+
--into <ref|selector> a file input (hidden ones too), or a button that opens a file chooser
|
|
14
|
+
--drop <ref|selector> a drag-and-drop zone
|
|
15
|
+
--paste [ref|selector] an editor that takes pasted images (default: the focused element)
|
|
16
|
+
--name <file> read the bytes from stdin instead: rech upload --name shot.png < shot.png
|
|
17
|
+
Types: ${UPLOAD_TYPES.join(", ")}, up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB. Get refs from rech snapshot.`;
|
|
11
18
|
|
|
12
19
|
// listeners.ts
|
|
13
20
|
import { isIP } from "net";
|
|
@@ -119,7 +126,9 @@ function authorizeProfileRequest(listener, body) {
|
|
|
119
126
|
if (body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY && body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY !== profile)
|
|
120
127
|
throw new Error("Profile override does not match request identity");
|
|
121
128
|
const args = body.args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
|
|
122
|
-
if (
|
|
129
|
+
if (args[0] === "upload" && listener.uploads === undefined)
|
|
130
|
+
throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
|
|
131
|
+
if (args[0] !== "upload" && !SCOPED_COMMANDS.has(args[0]))
|
|
123
132
|
throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
|
|
124
133
|
if (body.args.filter((a) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain=")))))
|
|
125
134
|
throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
|
package/listeners.ts
CHANGED
|
@@ -103,7 +103,9 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
|
|
|
103
103
|
if (typeof profile !== "string" || listener.profiles === "*" || !listener.profiles.includes(profile)) throw new Error("Profile is not allowed on this listener");
|
|
104
104
|
if (body.env?.PLAYWRIGHT_MCP_PROFILE_DIRECTORY && body.env.PLAYWRIGHT_MCP_PROFILE_DIRECTORY !== profile) throw new Error("Profile override does not match request identity");
|
|
105
105
|
const args = body.args.filter((a: string) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a) === false);
|
|
106
|
-
if (
|
|
106
|
+
if (args[0] === "upload" && listener.uploads === undefined)
|
|
107
|
+
throw new Error(`Uploads are off on listener "${listener.name}". The host can turn them on: rech listener set ${listener.name} --uploads`);
|
|
108
|
+
if (args[0] !== "upload" && !SCOPED_COMMANDS.has(args[0])) throw new Error("Command is unavailable on profile-scoped listeners; use the local management listener for arbitrary code or host access");
|
|
107
109
|
if (body.args.filter((a: string) => a.startsWith("-s=")).length > 1 || args.slice(1).some((a: string) => a.startsWith("-") && !(args[0] === "screenshot" && a === "--full-page") && !(args[0] === "console" && a === "--clear") && !(args[0] === "fill-secret" && (a === "--submit" || a === "--totp" || a.startsWith("--allow-domain="))))) throw new Error("CLI option overrides are unavailable on profile-scoped listeners");
|
|
108
110
|
if (["open", "goto", "tab-new"].includes(args[0]) && args[1] && !/^https?:\/\//i.test(args[1]) && args[1] !== "about:blank") throw new Error("Scoped navigation accepts only HTTP(S) or about:blank");
|
|
109
111
|
// Upload is handled by the byte-only receiver; the caller cannot supply host paths.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rechrome",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.39.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/snomiao/rechrome.git"
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"type": "module",
|
|
46
46
|
"scripts": {
|
|
47
47
|
"serve": "bun run rechrome.ts serve",
|
|
48
|
-
"test": "bun test",
|
|
48
|
+
"test": "bun test ./*.test.ts ./*.spec.ts ./scripts/*.test.ts",
|
|
49
|
+
"build-playwright": "bash scripts/build-playwright.sh",
|
|
49
50
|
"vendor-cli": "bash scripts/vendor-cli.sh",
|
|
50
51
|
"prepare": "bash scripts/vendor-cli.sh --prepare",
|
|
51
52
|
"check-pack": "bash scripts/check-pack.sh",
|
package/rechrome.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
2
|
+
import { prepareUpload, UPLOAD_USAGE, MAX_UPLOAD_BYTES, type UploadPayload } from "./scoped-upload.js";
|
|
3
3
|
import { readListeners, writeListeners as writeListenersFile, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener, type ListenerConfig } from "./listeners.js";
|
|
4
4
|
|
|
5
5
|
import { file } from "bun";
|
|
@@ -2177,8 +2177,14 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2177
2177
|
process.exit(2);
|
|
2178
2178
|
}
|
|
2179
2179
|
} else if (args.find(a => !/^-s=[a-zA-Z0-9_-]{1,64}$/.test(a)) === "upload") {
|
|
2180
|
-
if (
|
|
2181
|
-
|
|
2180
|
+
if (args.some(a => a === "--help" || a === "-h")) { console.log(UPLOAD_USAGE); process.exit(0); }
|
|
2181
|
+
try {
|
|
2182
|
+
// A path argument is read here; without one the bytes come from stdin (never a terminal).
|
|
2183
|
+
({ args, upload } = await prepareUpload(args, process.stdin.isTTY ? null : process.stdin));
|
|
2184
|
+
} catch (err) {
|
|
2185
|
+
console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${UPLOAD_USAGE}`);
|
|
2186
|
+
process.exit(2);
|
|
2187
|
+
}
|
|
2182
2188
|
}
|
|
2183
2189
|
const served = await callServe(url, args, overrideEnv, identity, false, upload, secret);
|
|
2184
2190
|
// The daemon already masks the value; this is a second line of defence for an older daemon.
|
|
@@ -3333,11 +3339,19 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3333
3339
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3334
3340
|
}
|
|
3335
3341
|
|
|
3336
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
|
|
3342
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void> {
|
|
3337
3343
|
const config = await requireListeners();
|
|
3338
|
-
|
|
3344
|
+
let listener = config.listeners.find(l => l.name === name);
|
|
3345
|
+
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
3346
|
+
if (opts.publicUrl !== undefined || opts.clearPublicUrl) listener = setPublicUrl(config, name, opts.clearPublicUrl ? null : opts.publicUrl ?? null);
|
|
3347
|
+
if (opts.uploads !== undefined) {
|
|
3348
|
+
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: uploads are always on there.`);
|
|
3349
|
+
// {} = the defaults (every supported image type, the full size cap); hand-tuned limits stay.
|
|
3350
|
+
if (opts.uploads) listener.uploads ??= {}; else delete listener.uploads;
|
|
3351
|
+
}
|
|
3339
3352
|
await writeListeners(config);
|
|
3340
|
-
console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3353
|
+
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3354
|
+
if (opts.uploads !== undefined) console.log(opts.uploads ? `${name}: uploads on (rech upload <file>; images up to ${(listener.uploads?.maxBytes ?? MAX_UPLOAD_BYTES) / 1024 / 1024} MiB)` : `${name}: uploads off`);
|
|
3341
3355
|
}
|
|
3342
3356
|
|
|
3343
3357
|
const hideKey = (url: string) => url.replace(/([#&?]key=)[^&]*/, "$1…");
|
|
@@ -4325,7 +4339,7 @@ export type RechHandlers = {
|
|
|
4325
4339
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4326
4340
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4327
4341
|
rotateKey(name: string): Promise<void>;
|
|
4328
|
-
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void>;
|
|
4342
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void>;
|
|
4329
4343
|
setup(opts: SetupOptions): Promise<void>;
|
|
4330
4344
|
tray(action?: string): Promise<void>;
|
|
4331
4345
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4467,12 +4481,13 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4467
4481
|
.command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
|
|
4468
4482
|
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
4469
4483
|
a => handlers.denyListener(a.name, a.profiles))
|
|
4470
|
-
.command("set <name>", "Record where a reverse proxy exposes a listener", y => y
|
|
4484
|
+
.command("set <name>", "Record where a reverse proxy exposes a listener, or turn uploads on/off", y => y
|
|
4471
4485
|
.positional("name", { type: "string", demandOption: true })
|
|
4472
4486
|
.option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.js.net/rechrome/" })
|
|
4473
4487
|
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
4474
|
-
.
|
|
4475
|
-
a =>
|
|
4488
|
+
.option("uploads", { type: "boolean", describe: "Let this listener's links upload images (rech upload); --no-uploads turns it off" })
|
|
4489
|
+
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, or --uploads/--no-uploads"),
|
|
4490
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}) }))
|
|
4476
4491
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4477
4492
|
a => handlers.listenerPort(a.name))
|
|
4478
4493
|
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
package/rechrome.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
2
|
+
import { prepareUpload, UPLOAD_USAGE, MAX_UPLOAD_BYTES, type UploadPayload } from "./scoped-upload.ts";
|
|
3
3
|
import { readListeners, writeListeners as writeListenersFile, listenerAddress, isLoopback, normalizePrefix, serviceUrl, allowProfiles, denyProfiles, rotateListenerKey, setPublicUrl, canonicalProfileKeys, planProfileRemoval, type Listener, type ListenerConfig } from "./listeners.ts";
|
|
4
4
|
|
|
5
5
|
import { file } from "bun";
|
|
@@ -2177,8 +2177,14 @@ async function run(url: string, args: string[], overrideEnv?: Record<string, str
|
|
|
2177
2177
|
process.exit(2);
|
|
2178
2178
|
}
|
|
2179
2179
|
} else if (args.find(a => !/^-s=[a-zA-Z0-9_-]{1,64}$/.test(a)) === "upload") {
|
|
2180
|
-
if (
|
|
2181
|
-
|
|
2180
|
+
if (args.some(a => a === "--help" || a === "-h")) { console.log(UPLOAD_USAGE); process.exit(0); }
|
|
2181
|
+
try {
|
|
2182
|
+
// A path argument is read here; without one the bytes come from stdin (never a terminal).
|
|
2183
|
+
({ args, upload } = await prepareUpload(args, process.stdin.isTTY ? null : process.stdin));
|
|
2184
|
+
} catch (err) {
|
|
2185
|
+
console.error(`[rech] ${err instanceof Error ? err.message : String(err)}\n${UPLOAD_USAGE}`);
|
|
2186
|
+
process.exit(2);
|
|
2187
|
+
}
|
|
2182
2188
|
}
|
|
2183
2189
|
const served = await callServe(url, args, overrideEnv, identity, false, upload, secret);
|
|
2184
2190
|
// The daemon already masks the value; this is a second line of defence for an older daemon.
|
|
@@ -3333,11 +3339,19 @@ async function rotateKey(name: string): Promise<void> {
|
|
|
3333
3339
|
console.log(`New key for ${name}; URLs carrying the old key stop working now. Print new ones with: rech share <profile> --listener ${name}`);
|
|
3334
3340
|
}
|
|
3335
3341
|
|
|
3336
|
-
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void> {
|
|
3342
|
+
async function setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void> {
|
|
3337
3343
|
const config = await requireListeners();
|
|
3338
|
-
|
|
3344
|
+
let listener = config.listeners.find(l => l.name === name);
|
|
3345
|
+
if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
|
|
3346
|
+
if (opts.publicUrl !== undefined || opts.clearPublicUrl) listener = setPublicUrl(config, name, opts.clearPublicUrl ? null : opts.publicUrl ?? null);
|
|
3347
|
+
if (opts.uploads !== undefined) {
|
|
3348
|
+
if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener: uploads are always on there.`);
|
|
3349
|
+
// {} = the defaults (every supported image type, the full size cap); hand-tuned limits stay.
|
|
3350
|
+
if (opts.uploads) listener.uploads ??= {}; else delete listener.uploads;
|
|
3351
|
+
}
|
|
3339
3352
|
await writeListeners(config);
|
|
3340
|
-
console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3353
|
+
if (opts.publicUrl !== undefined || opts.clearPublicUrl) console.log(listener.publicUrl ? `${name} is reachable at ${listener.publicUrl}` : `${name}: public URL cleared`);
|
|
3354
|
+
if (opts.uploads !== undefined) console.log(opts.uploads ? `${name}: uploads on (rech upload <file>; images up to ${(listener.uploads?.maxBytes ?? MAX_UPLOAD_BYTES) / 1024 / 1024} MiB)` : `${name}: uploads off`);
|
|
3341
3355
|
}
|
|
3342
3356
|
|
|
3343
3357
|
const hideKey = (url: string) => url.replace(/([#&?]key=)[^&]*/, "$1…");
|
|
@@ -4325,7 +4339,7 @@ export type RechHandlers = {
|
|
|
4325
4339
|
allowListener(name: string, profiles: string[]): Promise<void>;
|
|
4326
4340
|
denyListener(name: string, profiles: string[]): Promise<void>;
|
|
4327
4341
|
rotateKey(name: string): Promise<void>;
|
|
4328
|
-
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean }): Promise<void>;
|
|
4342
|
+
setListener(name: string, opts: { publicUrl?: string; clearPublicUrl?: boolean; uploads?: boolean }): Promise<void>;
|
|
4329
4343
|
setup(opts: SetupOptions): Promise<void>;
|
|
4330
4344
|
tray(action?: string): Promise<void>;
|
|
4331
4345
|
provisionProfile(name: string, opts: { headed: boolean; experimental: boolean }): Promise<void>;
|
|
@@ -4467,12 +4481,13 @@ export function rechCli(argv: string[], handlers: RechHandlers) {
|
|
|
4467
4481
|
.command("deny <name> <profiles..>", "Remove profiles from a listener", y => y
|
|
4468
4482
|
.positional("name", { type: "string", demandOption: true }).positional("profiles", { type: "string", array: true, demandOption: true }),
|
|
4469
4483
|
a => handlers.denyListener(a.name, a.profiles))
|
|
4470
|
-
.command("set <name>", "Record where a reverse proxy exposes a listener", y => y
|
|
4484
|
+
.command("set <name>", "Record where a reverse proxy exposes a listener, or turn uploads on/off", y => y
|
|
4471
4485
|
.positional("name", { type: "string", demandOption: true })
|
|
4472
4486
|
.option("public-url", { type: "string", requiresArg: true, describe: "e.g. https://host.example.ts.net/rechrome/" })
|
|
4473
4487
|
.option("clear-public-url", { type: "boolean", conflicts: "public-url" })
|
|
4474
|
-
.
|
|
4475
|
-
a =>
|
|
4488
|
+
.option("uploads", { type: "boolean", describe: "Let this listener's links upload images (rech upload); --no-uploads turns it off" })
|
|
4489
|
+
.check(a => a.publicUrl !== undefined || a.clearPublicUrl || a.uploads !== undefined ? true : "Pass --public-url <url>, --clear-public-url, or --uploads/--no-uploads"),
|
|
4490
|
+
a => handlers.setListener(a.name, { publicUrl: a.publicUrl, clearPublicUrl: a.clearPublicUrl, ...(a.uploads !== undefined ? { uploads: a.uploads } : {}) }))
|
|
4476
4491
|
.command("port [name]", "Print a listener's port (for a proxy command)", y => y.positional("name", { type: "string" }),
|
|
4477
4492
|
a => handlers.listenerPort(a.name))
|
|
4478
4493
|
.command("rotate-key <name>", "Give a listener a new key (old URLs stop working)", y => y.positional("name", { type: "string", demandOption: true }),
|
package/scoped-upload.js
CHANGED
|
@@ -2,20 +2,31 @@
|
|
|
2
2
|
// scoped-upload.ts
|
|
3
3
|
import { createHash, randomBytes } from "crypto";
|
|
4
4
|
import { mkdtemp, writeFile, rm } from "fs/promises";
|
|
5
|
-
import { join } from "path";
|
|
5
|
+
import { basename, join } from "path";
|
|
6
6
|
import { tmpdir } from "os";
|
|
7
|
-
var MAX_UPLOAD_BYTES =
|
|
8
|
-
var MAX_REQUEST_BYTES =
|
|
9
|
-
var UPLOAD_TYPES = ["image/png", "image/svg+xml"];
|
|
7
|
+
var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
var MAX_REQUEST_BYTES = 14 * 1024 * 1024;
|
|
9
|
+
var UPLOAD_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"];
|
|
10
|
+
var EXTENSION_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml" };
|
|
11
|
+
var UPLOAD_MODES = ["auto", "chooser", "into", "drop", "paste"];
|
|
10
12
|
function validateUploadPolicy(value) {
|
|
11
13
|
const p = value;
|
|
12
14
|
if (!p || typeof p !== "object" || Array.isArray(p) || Object.keys(p).some((k) => !["maxBytes", "contentTypes"].includes(k)) || p.maxBytes !== undefined && (!Number.isSafeInteger(p.maxBytes) || p.maxBytes < 1 || p.maxBytes > MAX_UPLOAD_BYTES) || p.contentTypes !== undefined && (!Array.isArray(p.contentTypes) || !p.contentTypes.length || p.contentTypes.some((t) => !UPLOAD_TYPES.includes(t))))
|
|
13
|
-
throw new Error(
|
|
15
|
+
throw new Error(`uploads must be an object with optional maxBytes (1..${MAX_UPLOAD_BYTES}) and contentTypes (${UPLOAD_TYPES.join(", ")})`);
|
|
14
16
|
}
|
|
15
17
|
function validName(name) {
|
|
16
18
|
return typeof name === "string" && name.length > 0 && name.length <= 128 && name !== "." && name !== ".." && !name.startsWith("-") && !/[\\/:\x00-\x1f\x7f]/.test(name);
|
|
17
19
|
}
|
|
20
|
+
function validTarget(target) {
|
|
21
|
+
return typeof target === "string" && target.length > 0 && target.length <= 512 && !/[\x00-\x1f\x7f]/.test(target);
|
|
22
|
+
}
|
|
18
23
|
function matchesType(bytes, type) {
|
|
24
|
+
if (type === "image/jpeg")
|
|
25
|
+
return bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
|
|
26
|
+
if (type === "image/gif")
|
|
27
|
+
return bytes.length >= 6 && ["GIF87a", "GIF89a"].includes(bytes.toString("ascii", 0, 6));
|
|
28
|
+
if (type === "image/webp")
|
|
29
|
+
return bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP";
|
|
19
30
|
if (type === "image/png")
|
|
20
31
|
return bytes.length >= 33 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && bytes.readUInt32BE(8) === 13 && bytes.toString("ascii", 12, 16) === "IHDR";
|
|
21
32
|
if (type === "image/svg+xml") {
|
|
@@ -32,39 +43,133 @@ function matchesType(bytes, type) {
|
|
|
32
43
|
}
|
|
33
44
|
return false;
|
|
34
45
|
}
|
|
35
|
-
|
|
46
|
+
var UPLOAD_USAGE = `Usage: rech upload <file> [how] (the file is read on this machine; only its bytes are sent)
|
|
47
|
+
(no flag) the file chooser a click opened, else the page's only <input type=file>
|
|
48
|
+
--into <ref|selector> a file input (hidden ones too), or a button that opens a file chooser
|
|
49
|
+
--drop <ref|selector> a drag-and-drop zone
|
|
50
|
+
--paste [ref|selector] an editor that takes pasted images (default: the focused element)
|
|
51
|
+
--name <file> read the bytes from stdin instead: rech upload --name shot.png < shot.png
|
|
52
|
+
Types: ${UPLOAD_TYPES.join(", ")}, up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB. Get refs from rech snapshot.`;
|
|
53
|
+
async function prepareUpload(args, stdin, readLocal = (path) => Bun.file(path).bytes()) {
|
|
36
54
|
const sessions = args.filter((a) => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a));
|
|
37
55
|
const options = args.filter((a) => !sessions.includes(a));
|
|
38
56
|
if (options.shift() !== "upload")
|
|
39
57
|
throw new Error("Expected upload command");
|
|
40
58
|
const fields = {};
|
|
59
|
+
let path, mode = "auto", target;
|
|
60
|
+
const setMode = (next) => {
|
|
61
|
+
if (mode !== "auto")
|
|
62
|
+
throw new Error(`Pick one of --into, --drop, --paste.`);
|
|
63
|
+
mode = next;
|
|
64
|
+
};
|
|
41
65
|
while (options.length) {
|
|
42
66
|
const option = options.shift();
|
|
43
|
-
const match = option.match(/^--(name|content-type)(?:=(.*))?$/);
|
|
44
|
-
if (!match
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
const match = option.match(/^--(name|content-type|into|drop|paste|chooser)(?:=(.*))?$/);
|
|
68
|
+
if (!match) {
|
|
69
|
+
if (option.startsWith("-") || path !== undefined)
|
|
70
|
+
throw new Error(path !== undefined ? `One file per upload; unexpected ${option}` : `Unknown option ${option}`);
|
|
71
|
+
path = option;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const [, flag, inline] = match;
|
|
75
|
+
if (flag === "chooser") {
|
|
76
|
+
setMode("chooser");
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const value = inline ?? (flag === "paste" && (!options[0] || options[0].startsWith("-")) ? "" : options.shift());
|
|
80
|
+
if (value === undefined || value === "" && flag !== "paste" || value.startsWith("--"))
|
|
81
|
+
throw new Error(`Missing value for --${flag}.`);
|
|
82
|
+
if (flag === "into" || flag === "drop" || flag === "paste") {
|
|
83
|
+
setMode(flag);
|
|
84
|
+
if (value)
|
|
85
|
+
target = value;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (fields[flag] !== undefined)
|
|
89
|
+
throw new Error(`--${flag} given twice`);
|
|
90
|
+
fields[flag] = value;
|
|
50
91
|
}
|
|
51
|
-
if (!
|
|
52
|
-
throw new Error("
|
|
53
|
-
|
|
92
|
+
if (target !== undefined && !validTarget(target))
|
|
93
|
+
throw new Error("Upload target must be a snapshot ref (e12) or a selector");
|
|
94
|
+
if (path === undefined && !stdin)
|
|
95
|
+
throw new Error(`upload needs a file.`);
|
|
96
|
+
const name = fields.name ?? (path !== undefined ? basename(path) : undefined);
|
|
97
|
+
if (!validName(name))
|
|
98
|
+
throw new Error(path !== undefined ? "The file name must be a plain name; rename the file or pass --name" : "Reading stdin needs --name with a plain filename (no path)");
|
|
99
|
+
const contentType = fields["content-type"] ?? EXTENSION_TYPES[name.toLowerCase().split(".").pop() ?? ""] ?? "";
|
|
54
100
|
if (!UPLOAD_TYPES.includes(contentType))
|
|
55
|
-
throw new Error(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
101
|
+
throw new Error(`Upload content type must be one of ${UPLOAD_TYPES.join(", ")} (from the file extension, or --content-type)`);
|
|
102
|
+
let bytes;
|
|
103
|
+
if (path !== undefined) {
|
|
104
|
+
bytes = Buffer.from(await readLocal(path).catch((e) => {
|
|
105
|
+
throw new Error(`Cannot read ${path}: ${e?.code === "ENOENT" ? "no such file" : e?.message ?? e}`);
|
|
106
|
+
}));
|
|
107
|
+
if (bytes.length > MAX_UPLOAD_BYTES)
|
|
108
|
+
throw new Error(`Upload exceeds ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB`);
|
|
109
|
+
} else {
|
|
110
|
+
const chunks = [];
|
|
111
|
+
let size = 0;
|
|
112
|
+
for await (const chunk of stdin) {
|
|
113
|
+
size += chunk.byteLength;
|
|
114
|
+
if (size > MAX_UPLOAD_BYTES)
|
|
115
|
+
throw new Error(`Upload exceeds ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB`);
|
|
116
|
+
chunks.push(Buffer.from(chunk));
|
|
117
|
+
}
|
|
118
|
+
bytes = Buffer.concat(chunks);
|
|
63
119
|
}
|
|
64
|
-
const bytes = Buffer.concat(chunks);
|
|
65
120
|
if (!matchesType(bytes, contentType))
|
|
66
|
-
throw new Error(
|
|
67
|
-
|
|
121
|
+
throw new Error(`Upload bytes are not ${contentType} (SVG must be UTF-8 without DTD/entities)`);
|
|
122
|
+
const upload = { name, contentType, data: bytes.toString("base64") };
|
|
123
|
+
if (mode !== "auto")
|
|
124
|
+
upload.mode = mode;
|
|
125
|
+
if (target !== undefined)
|
|
126
|
+
upload.target = target;
|
|
127
|
+
return { args: ["upload", ...sessions], upload };
|
|
128
|
+
}
|
|
129
|
+
var prepareStdinUpload = (args, input) => prepareUpload(args, input, () => Promise.reject(new Error("stdin only")));
|
|
130
|
+
var CLICKED = "rech-upload: clicked, filling the file chooser";
|
|
131
|
+
var locatorFor = (target) => `page.locator(${JSON.stringify(/^(f\d+)?e\d+$/.test(target) ? `aria-ref=${target}` : target)})`;
|
|
132
|
+
function uploadScript(mode, target, file, payload) {
|
|
133
|
+
const fileJson = JSON.stringify(file);
|
|
134
|
+
const bytes = `{ data: ${JSON.stringify(payload.data)}, name: ${JSON.stringify(payload.name)}, type: ${JSON.stringify(payload.contentType)} }`;
|
|
135
|
+
const makeTransfer = `({ data, name, type }) => { const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0)); const dt = new DataTransfer(); dt.items.add(new File([bytes], name, { type })); return dt; }`;
|
|
136
|
+
if (mode === "auto")
|
|
137
|
+
return `async page => {
|
|
138
|
+
const inputs = page.locator('input[type=file]');
|
|
139
|
+
const count = await inputs.count();
|
|
140
|
+
if (count !== 1) throw new Error(count ? count + ' file inputs on this page: pick one with --into <ref>' : 'No file chooser is open and the page has no file input. Name the upload button: rech upload <file> --into <ref>');
|
|
141
|
+
await inputs.setInputFiles(${fileJson});
|
|
142
|
+
return "Set on the page's file input";
|
|
143
|
+
}`;
|
|
144
|
+
if (mode === "into")
|
|
145
|
+
return `async page => {
|
|
146
|
+
const target = ${locatorFor(target)};
|
|
147
|
+
if (await target.evaluate(el => el instanceof HTMLInputElement && el.type === 'file')) {
|
|
148
|
+
await target.setInputFiles(${fileJson});
|
|
149
|
+
return 'Set on the file input';
|
|
150
|
+
}
|
|
151
|
+
// Not an input: click it. playwright-cli takes the file chooser that opens as its modal state,
|
|
152
|
+
// which the follow-up \`upload\` fills (see receiveUpload).
|
|
153
|
+
await target.click();
|
|
154
|
+
return ${JSON.stringify(CLICKED)};
|
|
155
|
+
}`;
|
|
156
|
+
if (mode === "drop")
|
|
157
|
+
return `async page => {
|
|
158
|
+
const target = ${locatorFor(target)};
|
|
159
|
+
const transfer = await page.evaluateHandle(${makeTransfer}, ${bytes});
|
|
160
|
+
for (const type of ['dragenter', 'dragover', 'drop']) await target.dispatchEvent(type, { dataTransfer: transfer });
|
|
161
|
+
return 'Dropped onto the target';
|
|
162
|
+
}`;
|
|
163
|
+
return `async page => {
|
|
164
|
+
${target ? `const target = ${locatorFor(target)}; await target.focus(); const element = await target.elementHandle();` : `const element = await page.evaluateHandle(() => document.activeElement || document.body);`}
|
|
165
|
+
const handled = await element.evaluate((el, file) => {
|
|
166
|
+
const make = ${makeTransfer};
|
|
167
|
+
const event = new ClipboardEvent('paste', { clipboardData: make(file), bubbles: true, cancelable: true });
|
|
168
|
+
el.dispatchEvent(event);
|
|
169
|
+
return event.defaultPrevented;
|
|
170
|
+
}, ${bytes});
|
|
171
|
+
return handled ? 'Pasted; the page handled it' : 'Pasted, but nothing on the page handled the paste: try --drop or --into';
|
|
172
|
+
}`;
|
|
68
173
|
}
|
|
69
174
|
function currentPageOrigin(output) {
|
|
70
175
|
const current = output.split(`
|
|
@@ -77,6 +182,9 @@ function currentPageOrigin(output) {
|
|
|
77
182
|
throw new Error("Uploads require an HTTP(S) page");
|
|
78
183
|
return parsed.origin;
|
|
79
184
|
}
|
|
185
|
+
function cleanOutput(text, path, dir, name) {
|
|
186
|
+
return text.replace(/### Ran Playwright code\n```js\n[\s\S]*?\n```\n*/g, "").replaceAll(path, name).replaceAll(dir, "<upload>");
|
|
187
|
+
}
|
|
80
188
|
async function receiveUpload(options) {
|
|
81
189
|
const record = { name: null, size: null, sha256: null, origin: null, result: "rejected" };
|
|
82
190
|
let dir;
|
|
@@ -84,12 +192,14 @@ async function receiveUpload(options) {
|
|
|
84
192
|
const p = options.payload;
|
|
85
193
|
if (validName(p?.name))
|
|
86
194
|
record.name = p.name;
|
|
195
|
+
if (UPLOAD_MODES.includes(p?.mode))
|
|
196
|
+
record.mode = p.mode;
|
|
87
197
|
if (options.policy === undefined)
|
|
88
198
|
throw new Error("Uploads are disabled on this listener");
|
|
89
199
|
validateUploadPolicy(options.policy);
|
|
90
200
|
if (options.args.length !== 1 || options.args[0] !== "upload")
|
|
91
|
-
throw new Error("Upload takes
|
|
92
|
-
if (!p || !validName(p.name) || typeof p.contentType !== "string" || typeof p.data !== "string" || Object.keys(p).some((k) => !["name", "contentType", "data"].includes(k)))
|
|
201
|
+
throw new Error("Upload takes file bytes, not host paths or CLI options");
|
|
202
|
+
if (!p || !validName(p.name) || typeof p.contentType !== "string" || typeof p.data !== "string" || Object.keys(p).some((k) => !["name", "contentType", "data", "mode", "target"].includes(k)) || p.mode !== undefined && !UPLOAD_MODES.includes(p.mode) || p.target !== undefined && !validTarget(p.target) || (p.mode === "into" || p.mode === "drop") && p.target === undefined || (p.mode === "auto" || p.mode === "chooser") && p.target !== undefined)
|
|
93
203
|
throw new Error("Invalid upload payload");
|
|
94
204
|
const limit = options.policy.maxBytes ?? MAX_UPLOAD_BYTES;
|
|
95
205
|
if (p.data.length > 4 * Math.ceil(limit / 3) || /[^A-Za-z0-9+/=]/.test(p.data))
|
|
@@ -106,16 +216,31 @@ async function receiveUpload(options) {
|
|
|
106
216
|
throw new Error("Could not inspect the upload page");
|
|
107
217
|
record.origin = currentPageOrigin(tabs.stdout);
|
|
108
218
|
dir = await mkdtemp(join(tmpdir(), "rech-upload-"));
|
|
109
|
-
const path = join(dir,
|
|
219
|
+
const path = join(dir, p.name);
|
|
110
220
|
await writeFile(path, bytes, { mode: 384, flag: "wx" });
|
|
111
221
|
record.result = "failed";
|
|
112
|
-
const
|
|
113
|
-
|
|
222
|
+
const failed = (r) => r.status !== 0 || /^### Error\b/m.test(r.stdout);
|
|
223
|
+
const script = async (mode) => {
|
|
224
|
+
const file = join(dir, randomBytes(8).toString("hex") + ".js");
|
|
225
|
+
await writeFile(file, uploadScript(mode, p.target, path, p), { mode: 384, flag: "wx" });
|
|
226
|
+
return options.run(["run-code", "--filename", file]);
|
|
227
|
+
};
|
|
228
|
+
const mode = p.mode ?? "auto";
|
|
229
|
+
let result = mode === "auto" || mode === "chooser" ? await options.run(["upload", path]) : await script(mode);
|
|
230
|
+
const noChooser = (r) => /No file chooser visible|only be used when there is related modal state/.test(r.stdout + r.stderr);
|
|
231
|
+
if (mode === "auto" && failed(result) && noChooser(result))
|
|
232
|
+
result = await script("auto");
|
|
233
|
+
if (mode === "into" && !failed(result) && result.stdout.match(/^### Result\n(.*)$/m)?.[1]?.includes(CLICKED)) {
|
|
234
|
+
const filled = await options.run(["upload", path]);
|
|
235
|
+
result = noChooser(filled) ? { status: 1, stdout: "", stderr: `Clicking ${p.target} opened no file chooser. Point --into at the file input or the button that opens it (see rech snapshot).
|
|
236
|
+
` } : filled;
|
|
237
|
+
}
|
|
238
|
+
record.result = failed(result) ? "failed" : "completed";
|
|
114
239
|
return {
|
|
115
240
|
...result,
|
|
116
241
|
status: record.result === "completed" ? 0 : 1,
|
|
117
|
-
stdout: result.stdout
|
|
118
|
-
stderr: result.stderr
|
|
242
|
+
stdout: cleanOutput(result.stdout, path, dir, p.name),
|
|
243
|
+
stderr: cleanOutput(result.stderr, path, dir, p.name)
|
|
119
244
|
};
|
|
120
245
|
} catch (error) {
|
|
121
246
|
return { status: 1, stdout: "", stderr: (error instanceof Error ? error.message : "Upload failed") + `
|
|
@@ -142,7 +267,7 @@ async function readRequestJson(req) {
|
|
|
142
267
|
break;
|
|
143
268
|
size += value.byteLength;
|
|
144
269
|
if (size > MAX_REQUEST_BYTES)
|
|
145
|
-
throw new Error(
|
|
270
|
+
throw new Error(`Request exceeds ${MAX_REQUEST_BYTES / 1024 / 1024} MiB`);
|
|
146
271
|
chunks.push(value);
|
|
147
272
|
}
|
|
148
273
|
} catch (error) {
|
|
@@ -156,10 +281,14 @@ async function readRequestJson(req) {
|
|
|
156
281
|
export {
|
|
157
282
|
MAX_REQUEST_BYTES,
|
|
158
283
|
MAX_UPLOAD_BYTES,
|
|
284
|
+
UPLOAD_MODES,
|
|
159
285
|
UPLOAD_TYPES,
|
|
286
|
+
UPLOAD_USAGE,
|
|
160
287
|
currentPageOrigin,
|
|
161
288
|
prepareStdinUpload,
|
|
289
|
+
prepareUpload,
|
|
162
290
|
readRequestJson,
|
|
163
291
|
receiveUpload,
|
|
292
|
+
uploadScript,
|
|
164
293
|
validateUploadPolicy
|
|
165
294
|
};
|
package/scoped-upload.ts
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
|
|
6
|
-
export const MAX_UPLOAD_BYTES =
|
|
7
|
-
// Base64 plus metadata. Bound JSON input before parsing, not just decoded bytes.
|
|
8
|
-
export const MAX_REQUEST_BYTES =
|
|
9
|
-
export const UPLOAD_TYPES = ['image/png', 'image/svg+xml'] as const;
|
|
6
|
+
export const MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
7
|
+
// Base64 (4/3) plus metadata. Bound JSON input before parsing, not just decoded bytes.
|
|
8
|
+
export const MAX_REQUEST_BYTES = 14 * 1024 * 1024;
|
|
9
|
+
export const UPLOAD_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'] as const;
|
|
10
|
+
const EXTENSION_TYPES: Record<string, string> = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml' };
|
|
11
|
+
/**
|
|
12
|
+
* How the file reaches the page. auto: the open file chooser, else the page's only file input.
|
|
13
|
+
* chooser: the file chooser a previous `rech click` opened. into: a file input (hidden ones too),
|
|
14
|
+
* or a button that opens a file chooser when clicked. drop: a drag-and-drop zone. paste: an editor
|
|
15
|
+
* that takes pasted images (the target, else the focused element).
|
|
16
|
+
*/
|
|
17
|
+
export const UPLOAD_MODES = ['auto', 'chooser', 'into', 'drop', 'paste'] as const;
|
|
18
|
+
export type UploadMode = typeof UPLOAD_MODES[number];
|
|
10
19
|
export type UploadPolicy = { maxBytes?: number; contentTypes?: string[] };
|
|
11
|
-
export type UploadPayload = { name: string; contentType: string; data: string };
|
|
20
|
+
export type UploadPayload = { name: string; contentType: string; data: string; mode?: UploadMode; target?: string };
|
|
12
21
|
export type UploadResult = { status: number; stdout: string; stderr: string };
|
|
13
|
-
export type UploadAudit = { name: string | null; size: number | null; sha256: string | null; origin: string | null; result: string };
|
|
22
|
+
export type UploadAudit = { name: string | null; size: number | null; sha256: string | null; origin: string | null; result: string; mode?: UploadMode };
|
|
14
23
|
|
|
15
24
|
export function validateUploadPolicy(value: unknown): asserts value is UploadPolicy {
|
|
16
25
|
const p = value as UploadPolicy;
|
|
17
26
|
if (!p || typeof p !== 'object' || Array.isArray(p) || Object.keys(p).some(k => !['maxBytes', 'contentTypes'].includes(k))
|
|
18
27
|
|| (p.maxBytes !== undefined && (!Number.isSafeInteger(p.maxBytes) || p.maxBytes < 1 || p.maxBytes > MAX_UPLOAD_BYTES))
|
|
19
28
|
|| (p.contentTypes !== undefined && (!Array.isArray(p.contentTypes) || !p.contentTypes.length || p.contentTypes.some(t => !UPLOAD_TYPES.includes(t as any)))))
|
|
20
|
-
throw new Error(
|
|
29
|
+
throw new Error(`uploads must be an object with optional maxBytes (1..${MAX_UPLOAD_BYTES}) and contentTypes (${UPLOAD_TYPES.join(', ')})`);
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
function validName(name: unknown): name is string {
|
|
@@ -25,7 +34,15 @@ function validName(name: unknown): name is string {
|
|
|
25
34
|
&& name !== '.' && name !== '..' && !name.startsWith('-') && !/[\\/:\x00-\x1f\x7f]/.test(name);
|
|
26
35
|
}
|
|
27
36
|
|
|
37
|
+
/** A snapshot ref (e12, f1e3) or a selector, the targets `rech click` takes; never a host path. */
|
|
38
|
+
function validTarget(target: unknown): target is string {
|
|
39
|
+
return typeof target === 'string' && target.length > 0 && target.length <= 512 && !/[\x00-\x1f\x7f]/.test(target);
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
function matchesType(bytes: Buffer, type: string): boolean {
|
|
43
|
+
if (type === 'image/jpeg') return bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
|
44
|
+
if (type === 'image/gif') return bytes.length >= 6 && ['GIF87a', 'GIF89a'].includes(bytes.toString('ascii', 0, 6));
|
|
45
|
+
if (type === 'image/webp') return bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
|
|
29
46
|
if (type === 'image/png') return bytes.length >= 33 && bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10]))
|
|
30
47
|
&& bytes.readUInt32BE(8) === 13 && bytes.toString('ascii', 12, 16) === 'IHDR';
|
|
31
48
|
if (type === 'image/svg+xml') {
|
|
@@ -39,32 +56,123 @@ function matchesType(bytes: Buffer, type: string): boolean {
|
|
|
39
56
|
return false;
|
|
40
57
|
}
|
|
41
58
|
|
|
42
|
-
export
|
|
59
|
+
export const UPLOAD_USAGE = `Usage: rech upload <file> [how] (the file is read on this machine; only its bytes are sent)
|
|
60
|
+
(no flag) the file chooser a click opened, else the page's only <input type=file>
|
|
61
|
+
--into <ref|selector> a file input (hidden ones too), or a button that opens a file chooser
|
|
62
|
+
--drop <ref|selector> a drag-and-drop zone
|
|
63
|
+
--paste [ref|selector] an editor that takes pasted images (default: the focused element)
|
|
64
|
+
--name <file> read the bytes from stdin instead: rech upload --name shot.png < shot.png
|
|
65
|
+
Types: ${UPLOAD_TYPES.join(', ')}, up to ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB. Get refs from rech snapshot.`;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Client side of `rech upload`: read the file here (a path argument, or stdin with --name) and
|
|
69
|
+
* send only its bytes and metadata. The host never sees a client path, and no payload field can
|
|
70
|
+
* name a host file.
|
|
71
|
+
*/
|
|
72
|
+
export async function prepareUpload(args: string[], stdin: AsyncIterable<Uint8Array> | null, readLocal: (path: string) => Promise<Uint8Array> = path => Bun.file(path).bytes()): Promise<{ args: string[]; upload: UploadPayload }> {
|
|
43
73
|
const sessions = args.filter(a => /^-s=[a-zA-Z0-9_-]{1,64}$/.test(a));
|
|
44
74
|
const options = args.filter(a => !sessions.includes(a));
|
|
45
75
|
if (options.shift() !== 'upload') throw new Error('Expected upload command');
|
|
46
76
|
const fields: Record<string, string> = {};
|
|
77
|
+
let path: string | undefined, mode: UploadMode = 'auto', target: string | undefined;
|
|
78
|
+
const setMode = (next: UploadMode) => { if (mode !== 'auto') throw new Error(`Pick one of --into, --drop, --paste.`); mode = next; };
|
|
47
79
|
while (options.length) {
|
|
48
80
|
const option = options.shift()!;
|
|
49
|
-
const match = option.match(/^--(name|content-type)(?:=(.*))?$/);
|
|
50
|
-
if (!match
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
81
|
+
const match = option.match(/^--(name|content-type|into|drop|paste|chooser)(?:=(.*))?$/);
|
|
82
|
+
if (!match) {
|
|
83
|
+
if (option.startsWith('-') || path !== undefined) throw new Error(path !== undefined ? `One file per upload; unexpected ${option}` : `Unknown option ${option}`);
|
|
84
|
+
path = option;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const [, flag, inline] = match;
|
|
88
|
+
if (flag === 'chooser') { setMode('chooser'); continue; }
|
|
89
|
+
// --paste's target is optional (the focused element); the others need a value.
|
|
90
|
+
const value = inline ?? (flag === 'paste' && (!options[0] || options[0].startsWith('-')) ? '' : options.shift());
|
|
91
|
+
if (value === undefined || (value === '' && flag !== 'paste') || value.startsWith('--')) throw new Error(`Missing value for --${flag}.`);
|
|
92
|
+
if (flag === 'into' || flag === 'drop' || flag === 'paste') { setMode(flag); if (value) target = value; continue; }
|
|
93
|
+
if (fields[flag] !== undefined) throw new Error(`--${flag} given twice`);
|
|
94
|
+
fields[flag] = value;
|
|
54
95
|
}
|
|
55
|
-
if (!
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
96
|
+
if (target !== undefined && !validTarget(target)) throw new Error('Upload target must be a snapshot ref (e12) or a selector');
|
|
97
|
+
if (path === undefined && !stdin) throw new Error(`upload needs a file.`);
|
|
98
|
+
const name = fields.name ?? (path !== undefined ? basename(path) : undefined);
|
|
99
|
+
if (!validName(name)) throw new Error(path !== undefined ? 'The file name must be a plain name; rename the file or pass --name' : 'Reading stdin needs --name with a plain filename (no path)');
|
|
100
|
+
const contentType = fields['content-type'] ?? EXTENSION_TYPES[name.toLowerCase().split('.').pop() ?? ''] ?? '';
|
|
101
|
+
if (!UPLOAD_TYPES.includes(contentType as any)) throw new Error(`Upload content type must be one of ${UPLOAD_TYPES.join(', ')} (from the file extension, or --content-type)`);
|
|
102
|
+
let bytes: Buffer;
|
|
103
|
+
if (path !== undefined) {
|
|
104
|
+
bytes = Buffer.from(await readLocal(path).catch((e: any) => { throw new Error(`Cannot read ${path}: ${e?.code === 'ENOENT' ? 'no such file' : e?.message ?? e}`); }));
|
|
105
|
+
if (bytes.length > MAX_UPLOAD_BYTES) throw new Error(`Upload exceeds ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB`);
|
|
106
|
+
} else {
|
|
107
|
+
const chunks: Buffer[] = [];
|
|
108
|
+
let size = 0;
|
|
109
|
+
for await (const chunk of stdin!) {
|
|
110
|
+
size += chunk.byteLength;
|
|
111
|
+
if (size > MAX_UPLOAD_BYTES) throw new Error(`Upload exceeds ${MAX_UPLOAD_BYTES / 1024 / 1024} MiB`);
|
|
112
|
+
chunks.push(Buffer.from(chunk));
|
|
113
|
+
}
|
|
114
|
+
bytes = Buffer.concat(chunks);
|
|
64
115
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
116
|
+
if (!matchesType(bytes, contentType)) throw new Error(`Upload bytes are not ${contentType} (SVG must be UTF-8 without DTD/entities)`);
|
|
117
|
+
const upload: UploadPayload = { name, contentType, data: bytes.toString('base64') };
|
|
118
|
+
if (mode !== 'auto') upload.mode = mode;
|
|
119
|
+
if (target !== undefined) upload.target = target;
|
|
120
|
+
return { args: ['upload', ...sessions], upload };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @deprecated stdin form only; kept for callers of the old name. */
|
|
124
|
+
export const prepareStdinUpload = (args: string[], input: AsyncIterable<Uint8Array>) => prepareUpload(args, input, () => Promise.reject(new Error('stdin only')));
|
|
125
|
+
|
|
126
|
+
/** What the `into` script returns after clicking a button that should open a file chooser. */
|
|
127
|
+
const CLICKED = 'rech-upload: clicked, filling the file chooser';
|
|
128
|
+
|
|
129
|
+
/** The locator a CLI target names, as playwright-cli resolves it: refs use the snapshot's aria-ref engine. */
|
|
130
|
+
const locatorFor = (target: string) => `page.locator(${JSON.stringify(/^(f\d+)?e\d+$/.test(target) ? `aria-ref=${target}` : target)})`;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The run-code script that delivers a staged file for `into`, `drop`, `paste`, or the auto
|
|
134
|
+
* fallback (`auto`: the page's only file input). Every value is embedded with JSON.stringify,
|
|
135
|
+
* so neither the target nor the file name can inject code; `file` is a server-owned path.
|
|
136
|
+
*/
|
|
137
|
+
export function uploadScript(mode: Exclude<UploadMode, 'chooser'>, target: string | undefined, file: string, payload: Pick<UploadPayload, 'name' | 'contentType' | 'data'>): string {
|
|
138
|
+
const fileJson = JSON.stringify(file);
|
|
139
|
+
// The page gets the bytes as a File, as if the user dragged or pasted it from outside.
|
|
140
|
+
const bytes = `{ data: ${JSON.stringify(payload.data)}, name: ${JSON.stringify(payload.name)}, type: ${JSON.stringify(payload.contentType)} }`;
|
|
141
|
+
const makeTransfer = `({ data, name, type }) => { const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0)); const dt = new DataTransfer(); dt.items.add(new File([bytes], name, { type })); return dt; }`;
|
|
142
|
+
if (mode === 'auto') return `async page => {
|
|
143
|
+
const inputs = page.locator('input[type=file]');
|
|
144
|
+
const count = await inputs.count();
|
|
145
|
+
if (count !== 1) throw new Error(count ? count + ' file inputs on this page: pick one with --into <ref>' : 'No file chooser is open and the page has no file input. Name the upload button: rech upload <file> --into <ref>');
|
|
146
|
+
await inputs.setInputFiles(${fileJson});
|
|
147
|
+
return "Set on the page's file input";
|
|
148
|
+
}`;
|
|
149
|
+
if (mode === 'into') return `async page => {
|
|
150
|
+
const target = ${locatorFor(target!)};
|
|
151
|
+
if (await target.evaluate(el => el instanceof HTMLInputElement && el.type === 'file')) {
|
|
152
|
+
await target.setInputFiles(${fileJson});
|
|
153
|
+
return 'Set on the file input';
|
|
154
|
+
}
|
|
155
|
+
// Not an input: click it. playwright-cli takes the file chooser that opens as its modal state,
|
|
156
|
+
// which the follow-up \`upload\` fills (see receiveUpload).
|
|
157
|
+
await target.click();
|
|
158
|
+
return ${JSON.stringify(CLICKED)};
|
|
159
|
+
}`;
|
|
160
|
+
if (mode === 'drop') return `async page => {
|
|
161
|
+
const target = ${locatorFor(target!)};
|
|
162
|
+
const transfer = await page.evaluateHandle(${makeTransfer}, ${bytes});
|
|
163
|
+
for (const type of ['dragenter', 'dragover', 'drop']) await target.dispatchEvent(type, { dataTransfer: transfer });
|
|
164
|
+
return 'Dropped onto the target';
|
|
165
|
+
}`;
|
|
166
|
+
return `async page => {
|
|
167
|
+
${target ? `const target = ${locatorFor(target)}; await target.focus(); const element = await target.elementHandle();` : `const element = await page.evaluateHandle(() => document.activeElement || document.body);`}
|
|
168
|
+
const handled = await element.evaluate((el, file) => {
|
|
169
|
+
const make = ${makeTransfer};
|
|
170
|
+
const event = new ClipboardEvent('paste', { clipboardData: make(file), bubbles: true, cancelable: true });
|
|
171
|
+
el.dispatchEvent(event);
|
|
172
|
+
return event.defaultPrevented;
|
|
173
|
+
}, ${bytes});
|
|
174
|
+
return handled ? 'Pasted; the page handled it' : 'Pasted, but nothing on the page handled the paste: try --drop or --into';
|
|
175
|
+
}`;
|
|
68
176
|
}
|
|
69
177
|
|
|
70
178
|
export function currentPageOrigin(output: string): string {
|
|
@@ -76,6 +184,14 @@ export function currentPageOrigin(output: string): string {
|
|
|
76
184
|
return parsed.origin;
|
|
77
185
|
}
|
|
78
186
|
|
|
187
|
+
/**
|
|
188
|
+
* What the client gets back: the original name for the staged file, never the private dir, and
|
|
189
|
+
* without the echoed script (for drop/paste it embeds the whole file as base64).
|
|
190
|
+
*/
|
|
191
|
+
function cleanOutput(text: string, path: string, dir: string, name: string): string {
|
|
192
|
+
return text.replace(/### Ran Playwright code\n```js\n[\s\S]*?\n```\n*/g, '').replaceAll(path, name).replaceAll(dir, '<upload>');
|
|
193
|
+
}
|
|
194
|
+
|
|
79
195
|
/** No payload field is ever used as a host path. run() is a server-owned CLI runner,
|
|
80
196
|
* not caller-supplied code. Audit records the selected origin immediately before upload.
|
|
81
197
|
*/
|
|
@@ -88,11 +204,15 @@ export async function receiveUpload(options: {
|
|
|
88
204
|
try {
|
|
89
205
|
const p = options.payload as UploadPayload;
|
|
90
206
|
if (validName(p?.name)) record.name = p.name;
|
|
207
|
+
if (UPLOAD_MODES.includes(p?.mode as UploadMode)) record.mode = p.mode;
|
|
91
208
|
if (options.policy === undefined) throw new Error('Uploads are disabled on this listener');
|
|
92
209
|
validateUploadPolicy(options.policy);
|
|
93
|
-
if (options.args.length !== 1 || options.args[0] !== 'upload') throw new Error('Upload takes
|
|
210
|
+
if (options.args.length !== 1 || options.args[0] !== 'upload') throw new Error('Upload takes file bytes, not host paths or CLI options');
|
|
94
211
|
if (!p || !validName(p.name) || typeof p.contentType !== 'string' || typeof p.data !== 'string'
|
|
95
|
-
|| Object.keys(p).some(k => !['name', 'contentType', 'data'].includes(k))
|
|
212
|
+
|| Object.keys(p).some(k => !['name', 'contentType', 'data', 'mode', 'target'].includes(k))
|
|
213
|
+
|| (p.mode !== undefined && !UPLOAD_MODES.includes(p.mode)) || (p.target !== undefined && !validTarget(p.target))
|
|
214
|
+
|| ((p.mode === 'into' || p.mode === 'drop') && p.target === undefined) || ((p.mode === 'auto' || p.mode === 'chooser') && p.target !== undefined))
|
|
215
|
+
throw new Error('Invalid upload payload');
|
|
96
216
|
const limit = options.policy.maxBytes ?? MAX_UPLOAD_BYTES;
|
|
97
217
|
if (p.data.length > 4 * Math.ceil(limit / 3) || /[^A-Za-z0-9+/=]/.test(p.data))
|
|
98
218
|
throw new Error('Upload exceeds maxBytes or has invalid base64');
|
|
@@ -106,13 +226,32 @@ export async function receiveUpload(options: {
|
|
|
106
226
|
if (tabs.status !== 0) throw new Error('Could not inspect the upload page');
|
|
107
227
|
record.origin = currentPageOrigin(tabs.stdout);
|
|
108
228
|
dir = await mkdtemp(join(tmpdir(), 'rech-upload-')); // 0700; never served by /files
|
|
109
|
-
|
|
229
|
+
// The page sees the original name (validName: a plain name, so it stays inside the private dir).
|
|
230
|
+
const path = join(dir, p.name);
|
|
110
231
|
await writeFile(path, bytes, { mode: 0o600, flag: 'wx' });
|
|
111
232
|
record.result = 'failed';
|
|
112
|
-
const
|
|
113
|
-
|
|
233
|
+
const failed = (r: UploadResult) => r.status !== 0 || /^### Error\b/m.test(r.stdout);
|
|
234
|
+
const script = async (mode: Exclude<UploadMode, 'chooser'>) => {
|
|
235
|
+
const file = join(dir!, randomBytes(8).toString('hex') + '.js');
|
|
236
|
+
await writeFile(file, uploadScript(mode, p.target, path, p), { mode: 0o600, flag: 'wx' });
|
|
237
|
+
return options.run(['run-code', '--filename', file]);
|
|
238
|
+
};
|
|
239
|
+
const mode = p.mode ?? 'auto';
|
|
240
|
+
let result = mode === 'auto' || mode === 'chooser' ? await options.run(['upload', path]) : await script(mode);
|
|
241
|
+
// No chooser open: the page's only file input, the common case that needs no click at all.
|
|
242
|
+
// The CLI's wording for "no file chooser is open" (older builds: "No file chooser visible").
|
|
243
|
+
const noChooser = (r: UploadResult) => /No file chooser visible|only be used when there is related modal state/.test(r.stdout + r.stderr);
|
|
244
|
+
if (mode === 'auto' && failed(result) && noChooser(result)) result = await script('auto');
|
|
245
|
+
// The script's return value, not its echoed source (which contains the marker too).
|
|
246
|
+
if (mode === 'into' && !failed(result) && result.stdout.match(/^### Result\n(.*)$/m)?.[1]?.includes(CLICKED)) {
|
|
247
|
+
const filled = await options.run(['upload', path]);
|
|
248
|
+
result = noChooser(filled)
|
|
249
|
+
? { status: 1, stdout: '', stderr: `Clicking ${p.target} opened no file chooser. Point --into at the file input or the button that opens it (see rech snapshot).\n` }
|
|
250
|
+
: filled;
|
|
251
|
+
}
|
|
252
|
+
record.result = failed(result) ? 'failed' : 'completed';
|
|
114
253
|
return { ...result, status: record.result === 'completed' ? 0 : 1,
|
|
115
|
-
stdout: result.stdout
|
|
254
|
+
stdout: cleanOutput(result.stdout, path, dir, p.name), stderr: cleanOutput(result.stderr, path, dir, p.name) };
|
|
116
255
|
} catch (error) {
|
|
117
256
|
return { status: 1, stdout: '', stderr: (error instanceof Error ? error.message : 'Upload failed') + '\n' };
|
|
118
257
|
} finally {
|
|
@@ -131,7 +270,7 @@ export async function readRequestJson(req: Request): Promise<any> {
|
|
|
131
270
|
const { done, value } = await reader.read();
|
|
132
271
|
if (done) break;
|
|
133
272
|
size += value.byteLength;
|
|
134
|
-
if (size > MAX_REQUEST_BYTES) throw new Error(
|
|
273
|
+
if (size > MAX_REQUEST_BYTES) throw new Error(`Request exceeds ${MAX_REQUEST_BYTES / 1024 / 1024} MiB`);
|
|
135
274
|
chunks.push(value);
|
|
136
275
|
}
|
|
137
276
|
} catch (error) { await reader.cancel().catch(() => {}); throw error; }
|
package/serve.js
CHANGED
|
@@ -1038,7 +1038,8 @@ export async function serve() {
|
|
|
1038
1038
|
};
|
|
1039
1039
|
if (filteredArgs[0] === "upload") {
|
|
1040
1040
|
const result = await receiveUpload({
|
|
1041
|
-
|
|
1041
|
+
// The management key can already run any code, so uploads need no extra opt-in there.
|
|
1042
|
+
policy: listener.uploads ?? (listener.profiles === "*" ? {} : undefined), payload: body.upload, args: filteredArgs,
|
|
1042
1043
|
audit: event => log(`upload: ${JSON.stringify({ listener: listener.name, profile: scopedProfile ?? body.identity?.profile ?? null, ...event })}`),
|
|
1043
1044
|
run: async uploadArgs => {
|
|
1044
1045
|
const child = Bun.spawn([bin, ...binArgs, ...uploadArgs, `-s=${namespacedSession}`], {
|
|
@@ -1048,7 +1049,7 @@ export async function serve() {
|
|
|
1048
1049
|
try {
|
|
1049
1050
|
const result = Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
|
1050
1051
|
const [status, stdout, stderr] = await Promise.race([result, new Promise<never>((_, reject) => {
|
|
1051
|
-
timer = setTimeout(() => { child.kill(); reject(new Error("Upload command timed out")); },
|
|
1052
|
+
timer = setTimeout(() => { child.kill(); reject(new Error("Upload command timed out")); }, 60_000);
|
|
1052
1053
|
})]);
|
|
1053
1054
|
return { status, stdout, stderr };
|
|
1054
1055
|
} finally { clearTimeout(timer); }
|
package/serve.ts
CHANGED
|
@@ -1038,7 +1038,8 @@ export async function serve() {
|
|
|
1038
1038
|
};
|
|
1039
1039
|
if (filteredArgs[0] === "upload") {
|
|
1040
1040
|
const result = await receiveUpload({
|
|
1041
|
-
|
|
1041
|
+
// The management key can already run any code, so uploads need no extra opt-in there.
|
|
1042
|
+
policy: listener.uploads ?? (listener.profiles === "*" ? {} : undefined), payload: body.upload, args: filteredArgs,
|
|
1042
1043
|
audit: event => log(`upload: ${JSON.stringify({ listener: listener.name, profile: scopedProfile ?? body.identity?.profile ?? null, ...event })}`),
|
|
1043
1044
|
run: async uploadArgs => {
|
|
1044
1045
|
const child = Bun.spawn([bin, ...binArgs, ...uploadArgs, `-s=${namespacedSession}`], {
|
|
@@ -1048,7 +1049,7 @@ export async function serve() {
|
|
|
1048
1049
|
try {
|
|
1049
1050
|
const result = Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
|
1050
1051
|
const [status, stdout, stderr] = await Promise.race([result, new Promise<never>((_, reject) => {
|
|
1051
|
-
timer = setTimeout(() => { child.kill(); reject(new Error("Upload command timed out")); },
|
|
1052
|
+
timer = setTimeout(() => { child.kill(); reject(new Error("Upload command timed out")); }, 60_000);
|
|
1052
1053
|
})]);
|
|
1053
1054
|
return { status, stdout, stderr };
|
|
1054
1055
|
} finally { clearTimeout(timer); }
|