@decentnetwork/beagle 0.1.124 → 0.1.126
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/desktop/app.js +1 -1
- package/dist/global-prefix.d.ts +13 -0
- package/dist/global-prefix.js +25 -0
- package/dist/server.js +128 -45
- package/dist/upload-settle.d.ts +13 -0
- package/dist/upload-settle.js +42 -0
- package/package.json +1 -1
package/dist/desktop/app.js
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The npm global prefix a file inside a globally installed package lives
|
|
3
|
+
* under — the value `npm i -g --prefix` needs to replace THAT copy.
|
|
4
|
+
*
|
|
5
|
+
* /Users/x/.nvm/versions/node/v24.2.0/lib/node_modules/@decentnetwork/beagle → /Users/x/.nvm/versions/node/v24.2.0
|
|
6
|
+
* /usr/local/lib/node_modules/@decentnetwork/lan/dist/cli/index.js → /usr/local
|
|
7
|
+
* C:\Users\x\AppData\Roaming\npm\node_modules\@decentnetwork\beagle → C:\Users\x\AppData\Roaming\npm
|
|
8
|
+
*
|
|
9
|
+
* Empty when the path is not inside a global node_modules (dev checkout, npx).
|
|
10
|
+
* The FIRST match is the global root; a nested dependency's node_modules
|
|
11
|
+
* sits below it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function globalPrefixOf(path: string, s?: string): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { sep } from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* The npm global prefix a file inside a globally installed package lives
|
|
4
|
+
* under — the value `npm i -g --prefix` needs to replace THAT copy.
|
|
5
|
+
*
|
|
6
|
+
* /Users/x/.nvm/versions/node/v24.2.0/lib/node_modules/@decentnetwork/beagle → /Users/x/.nvm/versions/node/v24.2.0
|
|
7
|
+
* /usr/local/lib/node_modules/@decentnetwork/lan/dist/cli/index.js → /usr/local
|
|
8
|
+
* C:\Users\x\AppData\Roaming\npm\node_modules\@decentnetwork\beagle → C:\Users\x\AppData\Roaming\npm
|
|
9
|
+
*
|
|
10
|
+
* Empty when the path is not inside a global node_modules (dev checkout, npx).
|
|
11
|
+
* The FIRST match is the global root; a nested dependency's node_modules
|
|
12
|
+
* sits below it.
|
|
13
|
+
*/
|
|
14
|
+
export function globalPrefixOf(path, s = sep) {
|
|
15
|
+
const posix = `${s}lib${s}node_modules${s}`;
|
|
16
|
+
const i = path.indexOf(posix);
|
|
17
|
+
if (i > 0)
|
|
18
|
+
return path.slice(0, i);
|
|
19
|
+
if (s === "\\") {
|
|
20
|
+
const w = path.indexOf(`${s}node_modules${s}`);
|
|
21
|
+
if (w > 0)
|
|
22
|
+
return path.slice(0, w);
|
|
23
|
+
}
|
|
24
|
+
return "";
|
|
25
|
+
}
|
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,8 @@ 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";
|
|
33
|
+
import { globalPrefixOf } from "./global-prefix.js";
|
|
32
34
|
// Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
|
|
33
35
|
// scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
|
|
34
36
|
const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
|
|
@@ -344,9 +346,15 @@ function verLt(a, b) {
|
|
|
344
346
|
* different node than this app (nvm: launchd starts v24.2.0 while the shell's
|
|
345
347
|
* npm is v24.3.0), and a global install lands in the prefix of whichever npm
|
|
346
348
|
* ran it — the wrong one leaves the daemon on the old code with no error. */
|
|
347
|
-
|
|
349
|
+
/** `targetPrefix` is the global prefix the RUNNING copy lives in. Pinning node
|
|
350
|
+
* is not enough: an nvm npm-cli.js runs under whatever node its caller picks,
|
|
351
|
+
* and npm on PATH (pm2's PATH on mac-dev: v24.3.0) installs into its own
|
|
352
|
+
* prefix — beagle 0.1.124 ran from v24.2.0 while every click of Update put
|
|
353
|
+
* the new version into v24.3.0, reported success, and restarted onto the old
|
|
354
|
+
* code. An explicit --prefix puts the files where the restart will load them. */
|
|
355
|
+
async function npmGlobalInstall(pkg, nodePath, targetPrefix) {
|
|
348
356
|
const { spawn } = await import("node:child_process");
|
|
349
|
-
const args = ["i", "-g", `${pkg}@latest
|
|
357
|
+
const args = ["i", "-g", `${pkg}@latest`, ...(targetPrefix ? ["--prefix", targetPrefix] : [])];
|
|
350
358
|
const cliFor = (node) => join(dirname(node), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
|
351
359
|
const npmCli = cliFor(process.execPath);
|
|
352
360
|
const pinned = nodePath && existsSync(nodePath) && existsSync(cliFor(nodePath)) ? cliFor(nodePath) : "";
|
|
@@ -356,21 +364,45 @@ async function npmGlobalInstall(pkg, nodePath) {
|
|
|
356
364
|
{ cmd: "npm", args },
|
|
357
365
|
...(existsSync(npmCli) ? [{ cmd: process.execPath, args: [npmCli, ...args] }] : []),
|
|
358
366
|
];
|
|
367
|
+
const runOnce = (cmd, cmdArgs, timeoutMs = 5 * 60_000) => new Promise((resolveRun) => {
|
|
368
|
+
const ch = spawn(cmd, cmdArgs, { shell: process.platform === "win32" });
|
|
369
|
+
let out = "";
|
|
370
|
+
const timer = setTimeout(() => ch.kill("SIGKILL"), timeoutMs);
|
|
371
|
+
ch.stdout?.on("data", (c) => (out += c));
|
|
372
|
+
ch.stderr?.on("data", (c) => (out += c));
|
|
373
|
+
ch.on("error", (e) => { clearTimeout(timer); resolveRun({ code: null, out, spawnErr: e.message }); });
|
|
374
|
+
ch.on("close", (code) => { clearTimeout(timer); resolveRun({ code, out }); });
|
|
375
|
+
});
|
|
359
376
|
for (const a of attempts) {
|
|
360
|
-
const r = await
|
|
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
|
-
});
|
|
377
|
+
const r = await runOnce(a.cmd, a.args);
|
|
369
378
|
if (r.spawnErr)
|
|
370
379
|
continue; // npm not found this way — try the next resolver
|
|
371
380
|
const tail = r.out.slice(-2000);
|
|
372
381
|
if (r.code === 0)
|
|
373
382
|
return { ok: true, log: tail };
|
|
383
|
+
// Root-owned prefix, but this account may run root commands without a
|
|
384
|
+
// password (WSL's default user, cloud images). A privileged install is then
|
|
385
|
+
// something the person can already do with no prompt, and going through
|
|
386
|
+
// `sudo -n` EVERY time keeps the layout consistent: the dir stays
|
|
387
|
+
// root-owned and the next click takes the same path, so no loop. Never
|
|
388
|
+
// prompts (-n), never changes permissions. -H gives npm root's own cache,
|
|
389
|
+
// so no root-owned files land in ~/.npm. Absolute node + npm-cli and an
|
|
390
|
+
// explicit --prefix, because sudo resets PATH and HOME and root's npm
|
|
391
|
+
// could otherwise resolve a different global prefix than this user's.
|
|
392
|
+
if (/EACCES|permission denied/i.test(r.out) && (await canSudoNonInteractive())) {
|
|
393
|
+
const npmCmd = a.cmd === "npm" ? resolveNpmOnPath(npmCli) : [a.cmd, a.args[0]];
|
|
394
|
+
if (npmCmd.length) {
|
|
395
|
+
const [exe, ...pre0] = npmCmd;
|
|
396
|
+
const pre = targetPrefix ? undefined : await runOnce(exe, [...pre0, "prefix", "-g"], 15_000);
|
|
397
|
+
const prefix = targetPrefix || (pre?.code === 0 ? pre.out.trim().split("\n").pop()?.trim() : "");
|
|
398
|
+
if (prefix) {
|
|
399
|
+
const s = await runOnce("sudo", ["-n", "-H", ...npmCmd, ...args, ...(targetPrefix ? [] : ["--prefix", prefix])]);
|
|
400
|
+
if (s.code === 0)
|
|
401
|
+
return { ok: true, log: s.out.slice(-2000) };
|
|
402
|
+
return { ok: false, error: `sudo -n npm exited ${s.code}`, log: s.out.slice(-2000) };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
374
406
|
// Do NOT tell people to re-run it with sudo. `sudo npm i -g` succeeds and
|
|
375
407
|
// leaves the package directory owned by root, so the NEXT unprivileged
|
|
376
408
|
// update fails exactly the same way — the advice was the thing keeping
|
|
@@ -412,9 +444,48 @@ async function globalNpmWritable() {
|
|
|
412
444
|
return { ok: true };
|
|
413
445
|
}
|
|
414
446
|
catch {
|
|
447
|
+
// Not writable by us — but npmGlobalInstall falls back to `sudo -n` when
|
|
448
|
+
// that needs no password, so the button does work there.
|
|
449
|
+
if (await canSudoNonInteractive())
|
|
450
|
+
return { ok: true };
|
|
415
451
|
return { ok: false, reason: await globalNpmOwnershipHint(BEAGLE_PKG) };
|
|
416
452
|
}
|
|
417
453
|
}
|
|
454
|
+
/** The `npm` this process would run from PATH, as an absolute command sudo can
|
|
455
|
+
* run after it resets PATH: `[node, npm-cli.js]` when npm is a symlink to the
|
|
456
|
+
* JS entry (nvm, nodejs.org, Debian's /usr/bin/npm → /usr/share/nodejs/npm),
|
|
457
|
+
* so the same node runs it; else the resolved file itself. Falls back to the
|
|
458
|
+
* npm-cli.js beside the running node. Empty when none is found. */
|
|
459
|
+
function resolveNpmOnPath(besideNode) {
|
|
460
|
+
for (const dir of (process.env.PATH ?? "").split(":")) {
|
|
461
|
+
if (!dir)
|
|
462
|
+
continue;
|
|
463
|
+
const p = join(dir, "npm");
|
|
464
|
+
if (!existsSync(p))
|
|
465
|
+
continue;
|
|
466
|
+
try {
|
|
467
|
+
const real = realpathSync(p);
|
|
468
|
+
return real.endsWith(".js") ? [process.execPath, real] : [real];
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return existsSync(besideNode) ? [process.execPath, besideNode] : [];
|
|
475
|
+
}
|
|
476
|
+
/** True when `sudo -n true` succeeds: this account runs root commands without
|
|
477
|
+
* a password prompt. Never prompts. Cached for a minute. */
|
|
478
|
+
let sudoNoPassCache = null;
|
|
479
|
+
async function canSudoNonInteractive() {
|
|
480
|
+
if (process.platform === "win32" || process.getuid?.() === 0)
|
|
481
|
+
return false;
|
|
482
|
+
if (sudoNoPassCache && Date.now() - sudoNoPassCache.at < 60_000)
|
|
483
|
+
return sudoNoPassCache.ok;
|
|
484
|
+
const { execFile } = await import("node:child_process");
|
|
485
|
+
const ok = await new Promise((res) => execFile("sudo", ["-n", "true"], { timeout: 5000 }, (err) => res(!err)));
|
|
486
|
+
sudoNoPassCache = { at: Date.now(), ok };
|
|
487
|
+
return ok;
|
|
488
|
+
}
|
|
418
489
|
/**
|
|
419
490
|
* What to tell someone whose global npm directory this process cannot write.
|
|
420
491
|
*
|
|
@@ -491,13 +562,31 @@ async function npmQuery(args) {
|
|
|
491
562
|
/** The node binary the running daemon was started with, from its pid's
|
|
492
563
|
* command line (`/…/bin/node /…/bin/agentnet up …`). Empty when unknown. */
|
|
493
564
|
async function daemonNodePath(dataDir) {
|
|
565
|
+
const first = (await daemonCommand(dataDir))[0] ?? "";
|
|
566
|
+
return /(^|\/)node(\.exe)?$/.test(first) && existsSync(first) ? first : "";
|
|
567
|
+
}
|
|
568
|
+
/** The global prefix the running daemon's lan was loaded from: its script
|
|
569
|
+
* (`/…/bin/agentnet`, a symlink into `<prefix>/lib/node_modules/…`) resolved.
|
|
570
|
+
* Empty when unknown — the install then falls back to npm's own prefix. */
|
|
571
|
+
async function daemonLanPrefix(dataDir) {
|
|
572
|
+
const script = (await daemonCommand(dataDir))[1] ?? "";
|
|
573
|
+
if (!script || !existsSync(script))
|
|
574
|
+
return "";
|
|
575
|
+
try {
|
|
576
|
+
return globalPrefixOf(realpathSync(script));
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
return "";
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/** The daemon's command line split on whitespace, from its pidfile's pid. */
|
|
583
|
+
async function daemonCommand(dataDir) {
|
|
494
584
|
const pid = dataDir ? readDaemonPid(dataDir) : "";
|
|
495
585
|
if (!/^\d+$/.test(pid) || process.platform === "win32")
|
|
496
|
-
return
|
|
586
|
+
return [];
|
|
497
587
|
const { execFile } = await import("node:child_process");
|
|
498
588
|
const cmd = await new Promise((res) => execFile("ps", ["-o", "command=", "-p", pid], { timeout: 5000 }, (err, out) => res(err ? "" : String(out).trim())));
|
|
499
|
-
|
|
500
|
-
return /(^|\/)node(\.exe)?$/.test(first) && existsSync(first) ? first : "";
|
|
589
|
+
return cmd ? cmd.split(/\s+/) : [];
|
|
501
590
|
}
|
|
502
591
|
async function agentnetRestart(dataDir) {
|
|
503
592
|
const pidBefore = dataDir ? readDaemonPid(dataDir) : "";
|
|
@@ -1425,42 +1514,33 @@ export function startBeagleServer(opts) {
|
|
|
1425
1514
|
else {
|
|
1426
1515
|
destDir = await fsp.mkdtemp(join(os.tmpdir(), "agentnet-up-"));
|
|
1427
1516
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
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.
|
|
1517
|
+
// The upload ALWAYS lands in a fresh temp file first, never on an
|
|
1518
|
+
// existing path. It used to open the same-name, same-size file in
|
|
1519
|
+
// downloads/ for writing ("re-send dedupe") — and the most natural
|
|
1520
|
+
// way to forward a received file is to pick exactly that file. The
|
|
1521
|
+
// write truncated the very file the browser was still streaming the
|
|
1522
|
+
// upload from: a 29.7 MB recording was cut to 832 KB on disk and the
|
|
1523
|
+
// 832 KB was sent as "delivered" (mac-dev → nv4090-wsl, 2026-11-02).
|
|
1441
1524
|
const uploadSize = Number(req.headers["content-length"] ?? NaN);
|
|
1442
|
-
|
|
1443
|
-
|
|
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
|
-
}
|
|
1525
|
+
const tmpPath = join(destDir, `.${safe}.${process.pid}-${Date.now()}.part`);
|
|
1526
|
+
let destPath = tmpPath;
|
|
1456
1527
|
try {
|
|
1457
1528
|
await new Promise((resolve2, reject) => {
|
|
1458
|
-
const ws = fs.createWriteStream(
|
|
1529
|
+
const ws = fs.createWriteStream(tmpPath);
|
|
1459
1530
|
req.pipe(ws);
|
|
1460
1531
|
req.on("error", reject);
|
|
1532
|
+
// A browser that gives up mid-upload closes the socket; without
|
|
1533
|
+
// this the partial file could still be handed to the daemon.
|
|
1534
|
+
req.on("close", () => { if (!req.complete)
|
|
1535
|
+
reject(new Error("upload aborted by the browser")); });
|
|
1461
1536
|
ws.on("error", reject);
|
|
1462
1537
|
ws.on("finish", () => resolve2());
|
|
1463
1538
|
});
|
|
1539
|
+
const got = statSync(tmpPath).size;
|
|
1540
|
+
if (Number.isFinite(uploadSize) && got !== uploadSize) {
|
|
1541
|
+
throw new Error(`upload incomplete (${got} of ${uploadSize} bytes) — nothing was sent`);
|
|
1542
|
+
}
|
|
1543
|
+
destPath = keep ? await settleUpload(tmpPath, destDir, safe) : tmpPath;
|
|
1464
1544
|
const r = await opts.call({ op: "file-send", userid, path: destPath });
|
|
1465
1545
|
if (!r.ok) {
|
|
1466
1546
|
sendJson(res, 502, r);
|
|
@@ -1474,6 +1554,8 @@ export function startBeagleServer(opts) {
|
|
|
1474
1554
|
finally {
|
|
1475
1555
|
if (!keep)
|
|
1476
1556
|
await fsp.rm(destDir, { recursive: true, force: true }).catch(() => undefined);
|
|
1557
|
+
else
|
|
1558
|
+
await fsp.rm(tmpPath, { force: true }).catch(() => undefined); // no-op once settled
|
|
1477
1559
|
}
|
|
1478
1560
|
return;
|
|
1479
1561
|
}
|
|
@@ -1838,7 +1920,7 @@ export function startBeagleServer(opts) {
|
|
|
1838
1920
|
// lan (the agentnet daemon) updates independently of beagle's own
|
|
1839
1921
|
// install mode: global reinstall, then ask the daemon to re-exec.
|
|
1840
1922
|
if (body.pkg === "lan") {
|
|
1841
|
-
const inst = await npmGlobalInstall("@decentnetwork/lan", await daemonNodePath(opts.lanHost?.dataDir));
|
|
1923
|
+
const inst = await npmGlobalInstall("@decentnetwork/lan", await daemonNodePath(opts.lanHost?.dataDir), await daemonLanPrefix(opts.lanHost?.dataDir));
|
|
1842
1924
|
if (!inst.ok) {
|
|
1843
1925
|
sendJson(res, 502, inst);
|
|
1844
1926
|
return;
|
|
@@ -1862,7 +1944,8 @@ export function startBeagleServer(opts) {
|
|
|
1862
1944
|
sendJson(res, 200, { ok: true, npx: true });
|
|
1863
1945
|
return;
|
|
1864
1946
|
}
|
|
1865
|
-
|
|
1947
|
+
// Into the prefix THIS copy runs from, with this node's npm — see npmGlobalInstall.
|
|
1948
|
+
const r = await npmGlobalInstall(BEAGLE_PKG, process.execPath, globalPrefixOf(BEAGLE_ROOT) || undefined);
|
|
1866
1949
|
updateCache = null; // next check reflects the new install
|
|
1867
1950
|
sendJson(res, r.ok ? 200 : 502, r);
|
|
1868
1951
|
return;
|
|
@@ -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