@decentnetwork/beagle 0.1.123 → 0.1.125

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.
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.123";
1
+ window.__DK_UI_VERSION="0.1.125";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -1697,12 +1697,23 @@ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
1697
1697
  }, [append, lang, onReloadFriends]);
1698
1698
  return { thread, handle, removeFriends, adoptServicePeers };
1699
1699
  }
1700
+ var REQUEST_AVATAR_HOST = "ens-gateway.beaglechat.workers.dev";
1700
1701
  var MAX_URL = 2048;
1701
1702
  function requestAvatarUrl(r) {
1702
1703
  const u = r && r.avatarUrl;
1703
1704
  if (typeof u !== "string" || !u || u.length > MAX_URL)
1704
1705
  return null;
1705
- return /^https:\/\/[^\s"'<>]+$/i.test(u) ? u : null;
1706
+ let parsed;
1707
+ try {
1708
+ parsed = new URL(u);
1709
+ } catch {
1710
+ return null;
1711
+ }
1712
+ if (parsed.protocol !== "https:" || parsed.host !== REQUEST_AVATAR_HOST)
1713
+ return null;
1714
+ if (parsed.username || parsed.password)
1715
+ return null;
1716
+ return parsed.href;
1706
1717
  }
1707
1718
  function requestPunkId(r) {
1708
1719
  const p = r && r.punkId;
package/dist/server.js CHANGED
@@ -14,7 +14,7 @@
14
14
  //
15
15
  import http from "node:http";
16
16
  import net from "node:net";
17
- import { existsSync, readFileSync, writeFileSync, statSync, createReadStream } from "node:fs";
17
+ import { existsSync, readFileSync, writeFileSync, statSync, createReadStream, realpathSync } from "node:fs";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { dirname, join } from "node:path";
20
20
  import { INSTALL_COMMAND as LAN_INSTALL_COMMAND } from "./lan-handoff.js";
@@ -29,6 +29,7 @@ import { readFriendStore, pickPunk } from "./friend-store.js";
29
29
  import { desktopRequestRow } from "./pending-requests.js";
30
30
  import { createDiscoverFetch } from "./discover-cache.js";
31
31
  import { applyWalletBind } from "@decentnetwork/beagle-ui/wallet-proof";
32
+ import { settleUpload } from "./upload-settle.js";
32
33
  // Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
33
34
  // scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
34
35
  const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
@@ -356,21 +357,45 @@ async function npmGlobalInstall(pkg, nodePath) {
356
357
  { cmd: "npm", args },
357
358
  ...(existsSync(npmCli) ? [{ cmd: process.execPath, args: [npmCli, ...args] }] : []),
358
359
  ];
360
+ const runOnce = (cmd, cmdArgs, timeoutMs = 5 * 60_000) => new Promise((resolveRun) => {
361
+ const ch = spawn(cmd, cmdArgs, { shell: process.platform === "win32" });
362
+ let out = "";
363
+ const timer = setTimeout(() => ch.kill("SIGKILL"), timeoutMs);
364
+ ch.stdout?.on("data", (c) => (out += c));
365
+ ch.stderr?.on("data", (c) => (out += c));
366
+ ch.on("error", (e) => { clearTimeout(timer); resolveRun({ code: null, out, spawnErr: e.message }); });
367
+ ch.on("close", (code) => { clearTimeout(timer); resolveRun({ code, out }); });
368
+ });
359
369
  for (const a of attempts) {
360
- const r = await new Promise((resolveRun) => {
361
- const ch = spawn(a.cmd, a.args, { shell: process.platform === "win32" });
362
- let out = "";
363
- const timer = setTimeout(() => ch.kill("SIGKILL"), 5 * 60_000);
364
- ch.stdout?.on("data", (c) => (out += c));
365
- ch.stderr?.on("data", (c) => (out += c));
366
- ch.on("error", (e) => { clearTimeout(timer); resolveRun({ code: null, out, spawnErr: e.message }); });
367
- ch.on("close", (code) => { clearTimeout(timer); resolveRun({ code, out }); });
368
- });
370
+ const r = await runOnce(a.cmd, a.args);
369
371
  if (r.spawnErr)
370
372
  continue; // npm not found this way — try the next resolver
371
373
  const tail = r.out.slice(-2000);
372
374
  if (r.code === 0)
373
375
  return { ok: true, log: tail };
376
+ // Root-owned prefix, but this account may run root commands without a
377
+ // password (WSL's default user, cloud images). A privileged install is then
378
+ // something the person can already do with no prompt, and going through
379
+ // `sudo -n` EVERY time keeps the layout consistent: the dir stays
380
+ // root-owned and the next click takes the same path, so no loop. Never
381
+ // prompts (-n), never changes permissions. -H gives npm root's own cache,
382
+ // so no root-owned files land in ~/.npm. Absolute node + npm-cli and an
383
+ // explicit --prefix, because sudo resets PATH and HOME and root's npm
384
+ // could otherwise resolve a different global prefix than this user's.
385
+ if (/EACCES|permission denied/i.test(r.out) && (await canSudoNonInteractive())) {
386
+ const npmCmd = a.cmd === "npm" ? resolveNpmOnPath(npmCli) : [a.cmd, a.args[0]];
387
+ if (npmCmd.length) {
388
+ const [exe, ...pre0] = npmCmd;
389
+ const pre = await runOnce(exe, [...pre0, "prefix", "-g"], 15_000);
390
+ const prefix = pre.code === 0 ? pre.out.trim().split("\n").pop()?.trim() : "";
391
+ if (prefix) {
392
+ const s = await runOnce("sudo", ["-n", "-H", ...npmCmd, ...args, "--prefix", prefix]);
393
+ if (s.code === 0)
394
+ return { ok: true, log: s.out.slice(-2000) };
395
+ return { ok: false, error: `sudo -n npm exited ${s.code}`, log: s.out.slice(-2000) };
396
+ }
397
+ }
398
+ }
374
399
  // Do NOT tell people to re-run it with sudo. `sudo npm i -g` succeeds and
375
400
  // leaves the package directory owned by root, so the NEXT unprivileged
376
401
  // update fails exactly the same way — the advice was the thing keeping
@@ -412,9 +437,48 @@ async function globalNpmWritable() {
412
437
  return { ok: true };
413
438
  }
414
439
  catch {
440
+ // Not writable by us — but npmGlobalInstall falls back to `sudo -n` when
441
+ // that needs no password, so the button does work there.
442
+ if (await canSudoNonInteractive())
443
+ return { ok: true };
415
444
  return { ok: false, reason: await globalNpmOwnershipHint(BEAGLE_PKG) };
416
445
  }
417
446
  }
447
+ /** The `npm` this process would run from PATH, as an absolute command sudo can
448
+ * run after it resets PATH: `[node, npm-cli.js]` when npm is a symlink to the
449
+ * JS entry (nvm, nodejs.org, Debian's /usr/bin/npm → /usr/share/nodejs/npm),
450
+ * so the same node runs it; else the resolved file itself. Falls back to the
451
+ * npm-cli.js beside the running node. Empty when none is found. */
452
+ function resolveNpmOnPath(besideNode) {
453
+ for (const dir of (process.env.PATH ?? "").split(":")) {
454
+ if (!dir)
455
+ continue;
456
+ const p = join(dir, "npm");
457
+ if (!existsSync(p))
458
+ continue;
459
+ try {
460
+ const real = realpathSync(p);
461
+ return real.endsWith(".js") ? [process.execPath, real] : [real];
462
+ }
463
+ catch {
464
+ continue;
465
+ }
466
+ }
467
+ return existsSync(besideNode) ? [process.execPath, besideNode] : [];
468
+ }
469
+ /** True when `sudo -n true` succeeds: this account runs root commands without
470
+ * a password prompt. Never prompts. Cached for a minute. */
471
+ let sudoNoPassCache = null;
472
+ async function canSudoNonInteractive() {
473
+ if (process.platform === "win32" || process.getuid?.() === 0)
474
+ return false;
475
+ if (sudoNoPassCache && Date.now() - sudoNoPassCache.at < 60_000)
476
+ return sudoNoPassCache.ok;
477
+ const { execFile } = await import("node:child_process");
478
+ const ok = await new Promise((res) => execFile("sudo", ["-n", "true"], { timeout: 5000 }, (err) => res(!err)));
479
+ sudoNoPassCache = { at: Date.now(), ok };
480
+ return ok;
481
+ }
418
482
  /**
419
483
  * What to tell someone whose global npm directory this process cannot write.
420
484
  *
@@ -1425,42 +1489,33 @@ export function startBeagleServer(opts) {
1425
1489
  else {
1426
1490
  destDir = await fsp.mkdtemp(join(os.tmpdir(), "agentnet-up-"));
1427
1491
  }
1428
- let destName = safe;
1429
- let destPath = join(destDir, destName);
1430
- // Re-sending a file the user already has must not duplicate it.
1431
- // The -N suffix exists so two DIFFERENT files sharing a name both
1432
- // survive — but an identical re-send hit it too, so every retry of
1433
- // the same 52 MB video wrote another full copy (observed: four
1434
- // byte-identical copies of one file, and a 3.9 GB downloads dir).
1435
- // Reuse the existing path when the size already matches, so the
1436
- // upload overwrites that copy instead of growing a new one. Tradeoff:
1437
- // a different file with the same name AND the same byte count
1438
- // replaces the older copy rather than sitting beside it — the same
1439
- // thing any "save to downloads" does, and far better than unbounded
1440
- // duplication. Hashing instead would mean buffering the whole upload.
1492
+ // The upload ALWAYS lands in a fresh temp file first, never on an
1493
+ // existing path. It used to open the same-name, same-size file in
1494
+ // downloads/ for writing ("re-send dedupe") — and the most natural
1495
+ // way to forward a received file is to pick exactly that file. The
1496
+ // write truncated the very file the browser was still streaming the
1497
+ // upload from: a 29.7 MB recording was cut to 832 KB on disk and the
1498
+ // 832 KB was sent as "delivered" (mac-dev → nv4090-wsl, 2026-11-02).
1441
1499
  const uploadSize = Number(req.headers["content-length"] ?? NaN);
1442
- if (keep) {
1443
- const dot = safe.lastIndexOf(".");
1444
- const base = dot > 0 ? safe.slice(0, dot) : safe;
1445
- const ext = dot > 0 ? safe.slice(dot) : "";
1446
- for (let i = 1; existsSync(destPath); i++) {
1447
- // Same size is a cheap, reliable proxy here: these are the
1448
- // sender's own files, not adversarial input, and the full
1449
- // compare would mean buffering the upload to hash it.
1450
- if (Number.isFinite(uploadSize) && statSync(destPath).size === uploadSize)
1451
- break;
1452
- destName = `${base}-${i}${ext}`;
1453
- destPath = join(destDir, destName);
1454
- }
1455
- }
1500
+ const tmpPath = join(destDir, `.${safe}.${process.pid}-${Date.now()}.part`);
1501
+ let destPath = tmpPath;
1456
1502
  try {
1457
1503
  await new Promise((resolve2, reject) => {
1458
- const ws = fs.createWriteStream(destPath);
1504
+ const ws = fs.createWriteStream(tmpPath);
1459
1505
  req.pipe(ws);
1460
1506
  req.on("error", reject);
1507
+ // A browser that gives up mid-upload closes the socket; without
1508
+ // this the partial file could still be handed to the daemon.
1509
+ req.on("close", () => { if (!req.complete)
1510
+ reject(new Error("upload aborted by the browser")); });
1461
1511
  ws.on("error", reject);
1462
1512
  ws.on("finish", () => resolve2());
1463
1513
  });
1514
+ const got = statSync(tmpPath).size;
1515
+ if (Number.isFinite(uploadSize) && got !== uploadSize) {
1516
+ throw new Error(`upload incomplete (${got} of ${uploadSize} bytes) — nothing was sent`);
1517
+ }
1518
+ destPath = keep ? await settleUpload(tmpPath, destDir, safe) : tmpPath;
1464
1519
  const r = await opts.call({ op: "file-send", userid, path: destPath });
1465
1520
  if (!r.ok) {
1466
1521
  sendJson(res, 502, r);
@@ -1474,6 +1529,8 @@ export function startBeagleServer(opts) {
1474
1529
  finally {
1475
1530
  if (!keep)
1476
1531
  await fsp.rm(destDir, { recursive: true, force: true }).catch(() => undefined);
1532
+ else
1533
+ await fsp.rm(tmpPath, { force: true }).catch(() => undefined); // no-op once settled
1477
1534
  }
1478
1535
  return;
1479
1536
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Give a finished upload (already complete in `tmpPath`, inside `dir`) its
3
+ * final name, without ever writing over an existing file.
4
+ *
5
+ * Re-sending a file that is already in downloads/ must not grow another copy
6
+ * (observed: four byte-identical copies of one 52 MB video). The old dedupe
7
+ * did that by opening the same-name, same-size file for WRITING before the
8
+ * upload arrived — which truncated the source when that file was the one
9
+ * being uploaded (forwarding a received file). Now the comparison happens
10
+ * after the upload is safely on disk: an existing copy is reused only when
11
+ * its bytes are identical; a different file with the same name gets `-N`.
12
+ */
13
+ export declare function settleUpload(tmpPath: string, dir: string, name: string): Promise<string>;
@@ -0,0 +1,42 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream, existsSync, statSync } from "node:fs";
3
+ import { rename, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ /**
6
+ * Give a finished upload (already complete in `tmpPath`, inside `dir`) its
7
+ * final name, without ever writing over an existing file.
8
+ *
9
+ * Re-sending a file that is already in downloads/ must not grow another copy
10
+ * (observed: four byte-identical copies of one 52 MB video). The old dedupe
11
+ * did that by opening the same-name, same-size file for WRITING before the
12
+ * upload arrived — which truncated the source when that file was the one
13
+ * being uploaded (forwarding a received file). Now the comparison happens
14
+ * after the upload is safely on disk: an existing copy is reused only when
15
+ * its bytes are identical; a different file with the same name gets `-N`.
16
+ */
17
+ export async function settleUpload(tmpPath, dir, name) {
18
+ const dot = name.lastIndexOf(".");
19
+ const base = dot > 0 ? name.slice(0, dot) : name;
20
+ const ext = dot > 0 ? name.slice(dot) : "";
21
+ const size = statSync(tmpPath).size;
22
+ for (let i = 0;; i++) {
23
+ const candidate = join(dir, i === 0 ? name : `${base}-${i}${ext}`);
24
+ if (!existsSync(candidate)) {
25
+ await rename(tmpPath, candidate);
26
+ return candidate;
27
+ }
28
+ if (statSync(candidate).size === size && (await sha256File(candidate)) === (await sha256File(tmpPath))) {
29
+ await rm(tmpPath, { force: true });
30
+ return candidate;
31
+ }
32
+ }
33
+ }
34
+ function sha256File(path) {
35
+ return new Promise((resolve, reject) => {
36
+ const h = createHash("sha256");
37
+ createReadStream(path)
38
+ .on("data", (c) => h.update(c))
39
+ .on("error", reject)
40
+ .on("end", () => resolve(h.digest("hex")));
41
+ });
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.123",
3
+ "version": "0.1.125",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,7 +26,7 @@
26
26
  "start": "node dist/cli.js"
27
27
  },
28
28
  "dependencies": {
29
- "@decentnetwork/beagle-ui": "^0.2.34",
29
+ "@decentnetwork/beagle-ui": "^0.2.35",
30
30
  "@decentnetwork/chat-components": "^0.1.3",
31
31
  "@decentnetwork/lan": "^0.1.313",
32
32
  "@decentnetwork/peer": "^0.1.170",