@decentnetwork/beagle 0.1.37 → 0.1.39

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.36";
1
+ window.__DK_UI_VERSION="0.1.38";
2
2
  const ICON_PATHS = {
3
3
  // ---- tab bar (the four must feel like one set) ----
4
4
  users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
@@ -2056,33 +2056,68 @@ function Msg({ m, peer, T, onTheater, onDelete, onCancel, onRetry, onReveal, onC
2056
2056
  const meta = /* @__PURE__ */ React.createElement("div", { style: { minWidth: 0, flex: 1 } }, /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 12.5, fontWeight: 600, color: mine ? "#fff" : "var(--text)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, m.file.name), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 11, color: mine ? "rgba(255,255,255,0.7)" : "var(--faint)", marginTop: 1 } }, busy === "cancel" ? `${m.file.size} \xB7 ${T.cancelling || "Cancelling\u2026"}` : busy === "retry" ? `${m.file.size} \xB7 ${T.retrying || "Retrying\u2026"}` : m.file.status === "queued" ? `${m.file.size} \xB7 ${T.queued || "queued"}` : m.file.status === "sending" ? `${m.file.size} \xB7 ${dkPct(m.file.pct)}${m.file.kbps ? " \xB7 " + dkSpeed(m.file.kbps) : ""}` : m.file.status === "failed" ? `${m.file.size} \xB7 failed` : m.file.status === "cancelled" ? `${m.file.size} \xB7 ${T.cancelled || "cancelled"}` : mine ? `${m.file.size} \xB7 sent` : `${m.file.size} \xB7 ${T.open || "open"}`));
2057
2057
  if (mine) {
2058
2058
  const btnBusy = !!busy;
2059
- return /* @__PURE__ */ React.createElement("div", { style: { ...cardStyle, opacity: btnBusy ? 0.85 : 1 } }, icon, meta, busy === "cancel" || busy === "retry" ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : m.file.status === "queued" ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : m.file.status === "sending" ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, color: "rgba(255,255,255,0.9)", whiteSpace: "nowrap" } }, dkPct(m.file.pct), m.file.kbps ? " \xB7 " + dkSpeed(m.file.kbps) : "") : m.file.status === "failed" || m.file.status === "cancelled" ? /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 16, stroke: 2.4, color: "rgba(255,200,190,0.95)" }) : /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }), (m.file.status === "sending" || m.file.status === "queued" || busy === "cancel") && m.id && /* @__PURE__ */ React.createElement(
2060
- "button",
2059
+ return /* @__PURE__ */ React.createElement(
2060
+ "div",
2061
2061
  {
2062
- title: T.cancel || "cancel",
2063
- disabled: btnBusy,
2064
- onClick: (e) => {
2065
- e.stopPropagation();
2066
- if (!btnBusy)
2067
- onCancel(m.id);
2068
- },
2069
- style: { background: "rgba(255,255,255,0.16)", border: "none", borderRadius: 6, cursor: btnBusy ? "wait" : "pointer", padding: 3, marginLeft: 2, display: "inline-flex", flexShrink: 0, opacity: btnBusy ? 0.5 : 1 }
2062
+ style: { ...cardStyle, cursor: "pointer", opacity: btnBusy ? 0.85 : 1 },
2063
+ title: T.open || "open",
2064
+ onClick: () => window.open(dkFileUrl(m.file.name), "_blank", "noopener")
2070
2065
  },
2071
- /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 13, stroke: 2.4, color: "#fff" })
2072
- ), m.file.status !== "sent" && m.id && /* @__PURE__ */ React.createElement(
2073
- "button",
2074
- {
2075
- title: T.retry || "retry",
2076
- disabled: btnBusy,
2077
- onClick: (e) => {
2078
- e.stopPropagation();
2079
- if (!btnBusy)
2080
- onRetry(m.id);
2066
+ icon,
2067
+ meta,
2068
+ onReveal && /* @__PURE__ */ React.createElement(
2069
+ "button",
2070
+ {
2071
+ title: T.revealInFinder || "\u5728\u6587\u4EF6\u7BA1\u7406\u5668\u4E2D\u663E\u793A / Show in file manager",
2072
+ onClick: (e) => {
2073
+ e.stopPropagation();
2074
+ onReveal(m.file.name);
2075
+ },
2076
+ style: { background: "none", border: "none", cursor: "pointer", padding: 0, display: "inline-flex", flexShrink: 0 }
2081
2077
  },
2082
- style: { background: "rgba(255,255,255,0.16)", border: "none", borderRadius: 6, cursor: btnBusy ? "wait" : "pointer", padding: 3, marginLeft: 2, display: "inline-flex", flexShrink: 0, opacity: btnBusy ? 0.5 : 1 }
2083
- },
2084
- /* @__PURE__ */ React.createElement(Icon, { name: "refresh", size: 13, stroke: 2.2, color: "#fff" })
2085
- ));
2078
+ /* @__PURE__ */ React.createElement(Icon, { name: "folderOpen", size: 15, stroke: 2, color: "rgba(255,255,255,0.85)" })
2079
+ ),
2080
+ /* @__PURE__ */ React.createElement(
2081
+ "a",
2082
+ {
2083
+ href: dkFileDownloadUrl(m.file.name),
2084
+ download: m.file.name,
2085
+ title: "download",
2086
+ onClick: (e) => e.stopPropagation(),
2087
+ style: { display: "inline-flex", flexShrink: 0 }
2088
+ },
2089
+ /* @__PURE__ */ React.createElement(Icon, { name: "download", size: 15, stroke: 2, color: "rgba(255,255,255,0.85)" })
2090
+ ),
2091
+ busy === "cancel" || busy === "retry" ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : m.file.status === "queued" ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }) : m.file.status === "sending" ? /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, fontWeight: 700, color: "rgba(255,255,255,0.9)", whiteSpace: "nowrap" } }, dkPct(m.file.pct), m.file.kbps ? " \xB7 " + dkSpeed(m.file.kbps) : "") : m.file.status === "failed" || m.file.status === "cancelled" ? /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 16, stroke: 2.4, color: "rgba(255,200,190,0.95)" }) : /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 16, stroke: 2, color: "rgba(255,255,255,0.85)" }),
2092
+ (m.file.status === "sending" || m.file.status === "queued" || busy === "cancel") && m.id && /* @__PURE__ */ React.createElement(
2093
+ "button",
2094
+ {
2095
+ title: T.cancel || "cancel",
2096
+ disabled: btnBusy,
2097
+ onClick: (e) => {
2098
+ e.stopPropagation();
2099
+ if (!btnBusy)
2100
+ onCancel(m.id);
2101
+ },
2102
+ style: { background: "rgba(255,255,255,0.16)", border: "none", borderRadius: 6, cursor: btnBusy ? "wait" : "pointer", padding: 3, marginLeft: 2, display: "inline-flex", flexShrink: 0, opacity: btnBusy ? 0.5 : 1 }
2103
+ },
2104
+ /* @__PURE__ */ React.createElement(Icon, { name: "x", size: 13, stroke: 2.4, color: "#fff" })
2105
+ ),
2106
+ m.file.status !== "sent" && m.id && /* @__PURE__ */ React.createElement(
2107
+ "button",
2108
+ {
2109
+ title: T.retry || "retry",
2110
+ disabled: btnBusy,
2111
+ onClick: (e) => {
2112
+ e.stopPropagation();
2113
+ if (!btnBusy)
2114
+ onRetry(m.id);
2115
+ },
2116
+ style: { background: "rgba(255,255,255,0.16)", border: "none", borderRadius: 6, cursor: btnBusy ? "wait" : "pointer", padding: 3, marginLeft: 2, display: "inline-flex", flexShrink: 0, opacity: btnBusy ? 0.5 : 1 }
2117
+ },
2118
+ /* @__PURE__ */ React.createElement(Icon, { name: "refresh", size: 13, stroke: 2.2, color: "#fff" })
2119
+ )
2120
+ );
2086
2121
  }
2087
2122
  return /* @__PURE__ */ React.createElement(
2088
2123
  "div",
package/dist/server.js CHANGED
@@ -305,6 +305,39 @@ async function agentnetRestart() {
305
305
  }
306
306
  return { ok: false, error: lastErr };
307
307
  }
308
+ // ── saved-files locations ───────────────────────────────────────────────────
309
+ // The daemon (often running as root) owns downloads/ — on such machines this
310
+ // user-level process gets EACCES writing there. Files WE save (sent copies,
311
+ // browser-WebRTC receives) then go to a sibling `sent/` dir we own; readers
312
+ // (open / download / reveal) look in downloads first, then sent.
313
+ function sentDirFor(downloadsDir) {
314
+ return join(dirname(downloadsDir), "sent");
315
+ }
316
+ async function pickWritableSaveDir(downloadsDir) {
317
+ const fsp = await import("node:fs/promises");
318
+ try {
319
+ await fsp.mkdir(downloadsDir, { recursive: true });
320
+ await fsp.access(downloadsDir, (await import("node:fs")).constants.W_OK);
321
+ return downloadsDir;
322
+ }
323
+ catch {
324
+ const alt = sentDirFor(downloadsDir);
325
+ await fsp.mkdir(alt, { recursive: true });
326
+ return alt;
327
+ }
328
+ }
329
+ /** Resolve a saved file by name across downloads/ and sent/, with a
330
+ * path-traversal guard. Returns null when absent from both. */
331
+ function findSavedFile(downloadsDir, name) {
332
+ if (!name)
333
+ return null;
334
+ for (const dir of [downloadsDir, sentDirFor(downloadsDir)]) {
335
+ const f = join(dir, name);
336
+ if (f.startsWith(dir) && existsSync(f))
337
+ return f;
338
+ }
339
+ return null;
340
+ }
308
341
  /** True only when the request came from the local machine. Used to gate the
309
342
  * "Sign in with Decent" routes so binding the UI to a LAN IP can't expose
310
343
  * identity signing to other hosts. The popup always runs in the local user's
@@ -956,8 +989,10 @@ export function startBeagleServer(opts) {
956
989
  }
957
990
  // ── File transfer ──────────────────────────────────────────────────
958
991
  // Upload: the browser POSTs raw file bytes with ?userid=&name= in the
959
- // query. We stream them to a temp file (no in-memory buffering of large
960
- // files) and hand the daemon that path over the existing file-send IPC.
992
+ // query. The upload is saved INTO downloads/ (collision-renamed) and
993
+ // kept the sender's own chip can then open / reveal / download the
994
+ // file just like the receiver's. (It used to stream to a temp file
995
+ // deleted right after send, which made every sent chip a dead end.)
961
996
  if (req.method === "POST" && url === "/api/file-send") {
962
997
  const q = new URL(req.url || "", "http://x").searchParams;
963
998
  const userid = q.get("userid") || "";
@@ -970,17 +1005,36 @@ export function startBeagleServer(opts) {
970
1005
  const os = await import("node:os");
971
1006
  const fs = await import("node:fs");
972
1007
  const fsp = await import("node:fs/promises");
973
- const tmpDir = await fsp.mkdtemp(join(os.tmpdir(), "agentnet-up-"));
974
- const tmpPath = join(tmpDir, safe);
1008
+ // Keep the copy under downloads when available (normal case); fall
1009
+ // back to the old ephemeral temp only when downloads are disabled.
1010
+ const keep = !!opts.downloadsDir;
1011
+ let destDir;
1012
+ if (keep) {
1013
+ destDir = await pickWritableSaveDir(opts.downloadsDir);
1014
+ }
1015
+ else {
1016
+ destDir = await fsp.mkdtemp(join(os.tmpdir(), "agentnet-up-"));
1017
+ }
1018
+ let destName = safe;
1019
+ let destPath = join(destDir, destName);
1020
+ if (keep) {
1021
+ const dot = safe.lastIndexOf(".");
1022
+ const base = dot > 0 ? safe.slice(0, dot) : safe;
1023
+ const ext = dot > 0 ? safe.slice(dot) : "";
1024
+ for (let i = 1; existsSync(destPath); i++) {
1025
+ destName = `${base}-${i}${ext}`;
1026
+ destPath = join(destDir, destName);
1027
+ }
1028
+ }
975
1029
  try {
976
1030
  await new Promise((resolve2, reject) => {
977
- const ws = fs.createWriteStream(tmpPath);
1031
+ const ws = fs.createWriteStream(destPath);
978
1032
  req.pipe(ws);
979
1033
  req.on("error", reject);
980
1034
  ws.on("error", reject);
981
1035
  ws.on("finish", () => resolve2());
982
1036
  });
983
- const r = await opts.call({ op: "file-send", userid, path: tmpPath });
1037
+ const r = await opts.call({ op: "file-send", userid, path: destPath });
984
1038
  if (!r.ok) {
985
1039
  sendJson(res, 502, r);
986
1040
  return;
@@ -991,7 +1045,8 @@ export function startBeagleServer(opts) {
991
1045
  sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
992
1046
  }
993
1047
  finally {
994
- await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
1048
+ if (!keep)
1049
+ await fsp.rm(destDir, { recursive: true, force: true }).catch(() => undefined);
995
1050
  }
996
1051
  return;
997
1052
  }
@@ -1013,16 +1068,15 @@ export function startBeagleServer(opts) {
1013
1068
  return;
1014
1069
  }
1015
1070
  const fs = await import("node:fs");
1016
- const fsp = await import("node:fs/promises");
1017
- await fsp.mkdir(opts.downloadsDir, { recursive: true });
1071
+ const saveDir = await pickWritableSaveDir(opts.downloadsDir);
1018
1072
  let finalName = safe;
1019
- let finalPath = join(opts.downloadsDir, finalName);
1073
+ let finalPath = join(saveDir, finalName);
1020
1074
  const dot = safe.lastIndexOf(".");
1021
1075
  const base = dot > 0 ? safe.slice(0, dot) : safe;
1022
1076
  const ext = dot > 0 ? safe.slice(dot) : "";
1023
1077
  for (let i = 1; existsSync(finalPath); i++) {
1024
1078
  finalName = `${base}-${i}${ext}`;
1025
- finalPath = join(opts.downloadsDir, finalName);
1079
+ finalPath = join(saveDir, finalName);
1026
1080
  }
1027
1081
  try {
1028
1082
  await new Promise((resolve2, reject) => {
@@ -1065,8 +1119,8 @@ export function startBeagleServer(opts) {
1065
1119
  }
1066
1120
  const body = await readBody(req);
1067
1121
  const name = (typeof body.name === "string" ? body.name : "").replace(/[/\\]/g, "");
1068
- const file = join(opts.downloadsDir, name);
1069
- if (!name || !file.startsWith(opts.downloadsDir) || !existsSync(file)) {
1122
+ const file = findSavedFile(opts.downloadsDir, name);
1123
+ if (!file) {
1070
1124
  sendJson(res, 404, { ok: false, error: "not found" });
1071
1125
  return;
1072
1126
  }
@@ -1082,9 +1136,9 @@ export function startBeagleServer(opts) {
1082
1136
  }
1083
1137
  const q = new URL(req.url || "", "http://x").searchParams;
1084
1138
  const name = (q.get("name") || "").replace(/[/\\]/g, "");
1085
- const file = join(opts.downloadsDir, name);
1086
- // Path-traversal guard: the resolved path must stay under downloadsDir.
1087
- if (!name || !file.startsWith(opts.downloadsDir) || !existsSync(file)) {
1139
+ // Looks in downloads/ then sent/ (traversal-guarded in the helper).
1140
+ const file = findSavedFile(opts.downloadsDir, name);
1141
+ if (!file) {
1088
1142
  res.writeHead(404);
1089
1143
  res.end("not found");
1090
1144
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
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",