@decentnetwork/beagle 0.1.21 → 0.1.22

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.20";
1
+ window.__DK_UI_VERSION="0.1.21";
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"/>',
@@ -2957,14 +2957,66 @@ function DkEnsCard({ T, me, onRecord }) {
2957
2957
  const field = { flex: 1, minWidth: 0, height: 34, borderRadius: 8, border: "1px solid var(--line)", background: "var(--panel-2)", color: "var(--text)", fontFamily: "var(--mono)", fontSize: 13, padding: "0 10px", outline: "none" };
2958
2958
  const row = { display: "flex", alignItems: "center", gap: 10, padding: "12px 16px", borderBottom: "1px solid var(--line)" };
2959
2959
  const [picking, setPicking] = React.useState(false);
2960
+ const fileRef = React.useRef(null);
2960
2961
  const rec = st.record;
2961
2962
  const boundEth = rec && rec.addresses && rec.addresses["60"];
2962
2963
  const boundSol = rec && rec.addresses && rec.addresses["501"];
2963
2964
  const punkId = rec && rec.nft === "CryptoPunks" && rec.nftid > 0 ? rec.nftid : null;
2965
+ const upAvatar = rec && rec.texts && rec.texts.avatar || null;
2964
2966
  const pickPunk = (id) => {
2965
2967
  setPicking(false);
2966
2968
  post("/api/ens-avatar", { nftid: id });
2967
2969
  };
2970
+ const onUploadFile = (e) => {
2971
+ const f = e.target.files && e.target.files[0];
2972
+ e.target.value = "";
2973
+ if (!f)
2974
+ return;
2975
+ setBusy(true);
2976
+ setMsg(null);
2977
+ const img = new Image();
2978
+ const url = URL.createObjectURL(f);
2979
+ const fail = (text) => {
2980
+ setBusy(false);
2981
+ setMsg({ tone: "err", text });
2982
+ };
2983
+ img.onload = () => {
2984
+ URL.revokeObjectURL(url);
2985
+ try {
2986
+ const S = 256;
2987
+ const c = document.createElement("canvas");
2988
+ c.width = S;
2989
+ c.height = S;
2990
+ const ctx = c.getContext("2d");
2991
+ const side = Math.min(img.width, img.height);
2992
+ ctx.drawImage(img, (img.width - side) / 2, (img.height - side) / 2, side, side, 0, 0, S, S);
2993
+ const enc = (q) => {
2994
+ let d = c.toDataURL("image/webp", q);
2995
+ if (!d.startsWith("data:image/webp"))
2996
+ d = c.toDataURL("image/jpeg", q);
2997
+ return d;
2998
+ };
2999
+ let dataUrl = null;
3000
+ for (const q of [0.9, 0.75, 0.55, 0.4]) {
3001
+ dataUrl = enc(q);
3002
+ if (dataUrl.length < 13e4)
3003
+ break;
3004
+ }
3005
+ if (!dataUrl || dataUrl.length >= 13e4) {
3006
+ fail(T.ensTooBig || "image too large even after scaling");
3007
+ return;
3008
+ }
3009
+ post("/api/ens-avatar-upload", { dataUrl });
3010
+ } catch (err) {
3011
+ fail(String(err && err.message || err));
3012
+ }
3013
+ };
3014
+ img.onerror = () => {
3015
+ URL.revokeObjectURL(url);
3016
+ fail(T.ensBadImage || "cannot read that image");
3017
+ };
3018
+ img.src = url;
3019
+ };
2968
3020
  const walletRow = (lbl, chain, bound, onBind, last) => /* @__PURE__ */ React.createElement("div", { style: { ...row, borderBottom: last ? "none" : row.borderBottom } }, /* @__PURE__ */ React.createElement("span", { style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, lbl), bound ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, bound), /* @__PURE__ */ React.createElement(CopyBtn, { value: bound, copiedText: T.copied, copyFailedText: T.copyFailed, copyTitle: T.copy }), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !st.mineOwned, onClick: () => bind(chain, "") }, T.ensUnbind || "unbind")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)" } }, T.ensNotBound || "not bound"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !st.mineOwned, onClick: onBind }, T.ensBind || "bind")));
2969
3021
  return /* @__PURE__ */ React.createElement(Card, { label: T.ensCard || "Name \xB7 beagles.eth" }, st.loading ? /* @__PURE__ */ React.createElement("div", { style: { padding: "14px 16px", fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, T.dirLoading || "loading\u2026") : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { style: row }, /* @__PURE__ */ React.createElement("span", { style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensName || "name"), /* @__PURE__ */ React.createElement(
2970
3022
  "input",
@@ -2979,14 +3031,15 @@ function DkEnsCard({ T, me, onRecord }) {
2979
3031
  },
2980
3032
  style: field
2981
3033
  }
2982
- ), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--dim)", flexShrink: 0 } }, ".beagles.eth"), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", disabled: busy || !label.trim(), onClick: register }, busy ? "\u2026" : st.registered && st.mineOwned ? T.ensUpdate || "update" : T.ensRegister || "register")), st.registered && !st.mineOwned && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", borderBottom: "1px solid var(--line)", fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--faint)" } }, (T.ensWalletOwned || "registered via your mobile wallet as") + " ", /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)" } }, rec && rec.name)), st.registered && /* @__PURE__ */ React.createElement("div", { style: row }, /* @__PURE__ */ React.createElement("span", { style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensAvatar || "avatar"), punkId != null ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: punkId, size: 34, radius: 9, fallbackSeed: me.userId }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)" } }, `CryptoPunk #${punkId}`), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !st.mineOwned, onClick: () => post("/api/ens-avatar", { nftid: null }) }, T.ensClear || "clear")) : /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)" } }, T.ensNotSet || "not set"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !st.mineOwned, onClick: () => setPicking(true) }, T.ensChoose || "choose")), walletRow(T.ensEth || "ethereum", "eth", boundEth, bindEth, false), walletRow(T.ensSol || "solana", "sol", boundSol, bindSol, !msg), msg && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", fontFamily: "var(--ui)", fontSize: 12, color: msg.tone === "ok" ? "var(--online)" : "var(--danger)" } }, msg.text)), picking && /* @__PURE__ */ React.createElement(DkPunkPicker, { T, onPick: pickPunk, onClose: () => setPicking(false) }));
3034
+ ), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--dim)", flexShrink: 0 } }, ".beagles.eth"), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", disabled: busy || !label.trim(), onClick: register }, busy ? "\u2026" : st.registered && st.mineOwned ? T.ensUpdate || "update" : T.ensRegister || "register")), st.registered && !st.mineOwned && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", borderBottom: "1px solid var(--line)", fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--faint)" } }, (T.ensWalletOwned || "registered via your mobile wallet as") + " ", /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)" } }, rec && rec.name)), st.registered && /* @__PURE__ */ React.createElement("div", { style: row }, /* @__PURE__ */ React.createElement("span", { style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensAvatar || "avatar"), upAvatar ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("img", { src: upAvatar, alt: "", width: 34, height: 34, style: { width: 34, height: 34, borderRadius: 9, objectFit: "cover", flexShrink: 0, background: "var(--panel-2)" } }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--text)" } }, T.ensCustom || "custom image"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !st.mineOwned, onClick: () => post("/api/ens-avatar", { nftid: null }) }, T.ensClear || "clear")) : punkId != null ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: punkId, size: 34, radius: 9, fallbackSeed: me.userId }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)" } }, `CryptoPunk #${punkId}`), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !st.mineOwned, onClick: () => post("/api/ens-avatar", { nftid: null }) }, T.ensClear || "clear")) : /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)" } }, T.ensNotSet || "not set"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !st.mineOwned, onClick: () => fileRef.current && fileRef.current.click() }, T.ensUpload || "upload"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !st.mineOwned, onClick: () => setPicking(true) }, T.ensChoose || "choose"), /* @__PURE__ */ React.createElement("input", { ref: fileRef, type: "file", accept: "image/*", onChange: onUploadFile, style: { display: "none" } })), walletRow(T.ensEth || "ethereum", "eth", boundEth, bindEth, false), walletRow(T.ensSol || "solana", "sol", boundSol, bindSol, !msg), msg && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", fontFamily: "var(--ui)", fontSize: 12, color: msg.tone === "ok" ? "var(--online)" : "var(--danger)" } }, msg.text)), picking && /* @__PURE__ */ React.createElement(DkPunkPicker, { T, onPick: pickPunk, onClose: () => setPicking(false) }));
2983
3035
  }
2984
3036
  function ProfileTab({ T, me, onEdit }) {
2985
3037
  const [qr, setQr] = React.useState(null);
2986
3038
  const [editing, setEditing] = React.useState(false);
2987
3039
  const [ensRec, setEnsRec] = React.useState(null);
2988
3040
  const punkId = ensRec && ensRec.nft === "CryptoPunks" && ensRec.nftid > 0 ? ensRec.nftid : null;
2989
- return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 24 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 18, padding: "20px 22px", borderRadius: 14, background: "var(--panel)", border: "1px solid var(--line)" } }, /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, punkId != null ? /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: punkId, size: 68, radius: 16, fallbackSeed: me.userId }) : /* @__PURE__ */ React.createElement(DkIdenticon, { seed: me.userId, size: 68, radius: 16 }), /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", right: -3, bottom: -3, width: 18, height: 18, borderRadius: 999, background: "var(--online)", border: "3px solid var(--panel)" } })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 22, fontWeight: 700, letterSpacing: -0.5, color: "var(--text)" } }, me.name), /* @__PURE__ */ React.createElement(Tag, { tone: "ok" }, "online"), me.isExit && /* @__PURE__ */ React.createElement(Tag, { tone: "warn" }, "exit", me.exitRegion ? ` \xB7 ${me.exitRegion.toUpperCase()}` : "")), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13, color: "var(--dim)", marginTop: 4 } }, me.handle), me.description ? /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", marginTop: 3 } }, me.description) : null), /* @__PURE__ */ React.createElement(Btn, { icon: "edit", onClick: () => setEditing(true) }, T.editProfile)), /* @__PURE__ */ React.createElement(Card, { label: T.identity }, /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.userId, value: me.userId, copy: true, qr: true, onQr: (v, l) => setQr({ value: v, label: l }) }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.carrierAddr, value: me.carrier, copy: true, qr: true, onQr: (v, l) => setQr({ value: v, label: l }) }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.netKey, value: me.netKey, copy: true, last: true })), /* @__PURE__ */ React.createElement(DkEnsCard, { T, me, onRecord: setEnsRec }), typeof me.autoAccept === "boolean" && /* @__PURE__ */ React.createElement(Card, { label: T && T.friendsSettings || "Friends" }, /* @__PURE__ */ React.createElement("label", { style: { display: "flex", alignItems: "center", gap: 13, padding: "14px 16px", cursor: "pointer" } }, /* @__PURE__ */ React.createElement(
3041
+ const upAvatar = ensRec && ensRec.texts && ensRec.texts.avatar || null;
3042
+ return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, overflow: "auto", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement("div", { style: { maxWidth: 760, margin: "0 auto", padding: "24px 28px 60px", display: "flex", flexDirection: "column", gap: 24 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 18, padding: "20px 22px", borderRadius: 14, background: "var(--panel)", border: "1px solid var(--line)" } }, /* @__PURE__ */ React.createElement("div", { style: { position: "relative" } }, upAvatar ? /* @__PURE__ */ React.createElement("img", { src: upAvatar, alt: "", width: 68, height: 68, style: { width: 68, height: 68, borderRadius: 16, objectFit: "cover", background: "var(--panel-2)" } }) : punkId != null ? /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: punkId, size: 68, radius: 16, fallbackSeed: me.userId }) : /* @__PURE__ */ React.createElement(DkIdenticon, { seed: me.userId, size: 68, radius: 16 }), /* @__PURE__ */ React.createElement("span", { style: { position: "absolute", right: -3, bottom: -3, width: 18, height: 18, borderRadius: 999, background: "var(--online)", border: "3px solid var(--panel)" } })), /* @__PURE__ */ React.createElement("div", { style: { flex: 1, minWidth: 0 } }, /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 22, fontWeight: 700, letterSpacing: -0.5, color: "var(--text)" } }, me.name), /* @__PURE__ */ React.createElement(Tag, { tone: "ok" }, "online"), me.isExit && /* @__PURE__ */ React.createElement(Tag, { tone: "warn" }, "exit", me.exitRegion ? ` \xB7 ${me.exitRegion.toUpperCase()}` : "")), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13, color: "var(--dim)", marginTop: 4 } }, me.handle), me.description ? /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)", marginTop: 3 } }, me.description) : null), /* @__PURE__ */ React.createElement(Btn, { icon: "edit", onClick: () => setEditing(true) }, T.editProfile)), /* @__PURE__ */ React.createElement(Card, { label: T.identity }, /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.userId, value: me.userId, copy: true, qr: true, onQr: (v, l) => setQr({ value: v, label: l }) }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.carrierAddr, value: me.carrier, copy: true, qr: true, onQr: (v, l) => setQr({ value: v, label: l }) }), /* @__PURE__ */ React.createElement(FieldRow, { T, label: T.netKey, value: me.netKey, copy: true, last: true })), /* @__PURE__ */ React.createElement(DkEnsCard, { T, me, onRecord: setEnsRec }), typeof me.autoAccept === "boolean" && /* @__PURE__ */ React.createElement(Card, { label: T && T.friendsSettings || "Friends" }, /* @__PURE__ */ React.createElement("label", { style: { display: "flex", alignItems: "center", gap: 13, padding: "14px 16px", cursor: "pointer" } }, /* @__PURE__ */ React.createElement(
2990
3043
  "input",
2991
3044
  {
2992
3045
  type: "checkbox",
@@ -3007,7 +3060,7 @@ function ProfileTab({ T, me, onEdit }) {
3007
3060
  Object.assign(window, { ProfileTab, DkPunkAvatar });
3008
3061
  function DkDirAvatar({ p, size }) {
3009
3062
  const [broken, setBroken] = React.useState(false);
3010
- if (p.punkId != null) {
3063
+ if (p.punkId != null && !(p.avatar && !broken)) {
3011
3064
  return /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: p.punkId, size, radius: Math.round(size / 4), fallbackSeed: p.userid || p.name });
3012
3065
  }
3013
3066
  if (p.avatar && !broken) {
@@ -4033,6 +4086,10 @@ const STR = {
4033
4086
  ensChoose: "choose",
4034
4087
  ensClear: "clear",
4035
4088
  ensNotSet: "not set",
4089
+ ensUpload: "upload",
4090
+ ensCustom: "custom image",
4091
+ ensTooBig: "image too large even after scaling",
4092
+ ensBadImage: "cannot read that image",
4036
4093
  ensPickPunk: "choose a punk",
4037
4094
  ensShuffle: "shuffle",
4038
4095
  pt_any: "any",
@@ -4170,6 +4227,10 @@ const STR = {
4170
4227
  ensChoose: "\u9009\u62E9",
4171
4228
  ensClear: "\u6E05\u9664",
4172
4229
  ensNotSet: "\u672A\u8BBE\u7F6E",
4230
+ ensUpload: "\u4E0A\u4F20",
4231
+ ensCustom: "\u81EA\u5B9A\u4E49\u56FE\u7247",
4232
+ ensTooBig: "\u56FE\u7247\u538B\u7F29\u540E\u4ECD\u8D85\u8FC7 100KB",
4233
+ ensBadImage: "\u65E0\u6CD5\u8BFB\u53D6\u8BE5\u56FE\u7247",
4173
4234
  ensPickPunk: "\u9009\u4E00\u4E2A punk",
4174
4235
  ensShuffle: "\u6362\u4E00\u6279",
4175
4236
  pt_any: "\u5168\u90E8",
package/dist/server.js CHANGED
@@ -102,6 +102,9 @@ async function ensSignAndSet(call, record) {
102
102
  if (!r.ok || !d?.success) {
103
103
  return { ok: false, error: typeof d?.error === "string" ? d.error : `gateway HTTP ${r.status}` };
104
104
  }
105
+ // The record changed — drop the cached /names listing so the Names tab
106
+ // reflects it immediately instead of after the 5-min TTL.
107
+ discoverCache.delete(`${ENS_GATEWAY}/names`);
105
108
  return { ok: true };
106
109
  }
107
110
  catch (e) {
@@ -132,6 +135,25 @@ async function punksFetch(path) {
132
135
  clearTimeout(timer);
133
136
  }
134
137
  }
138
+ /** Best-effort removal of the R2 avatar object when the record stops
139
+ * referencing it (punk picked / avatar cleared). Failure is harmless —
140
+ * an orphaned object just sits unused. */
141
+ async function ensAvatarDelete(call, fullName) {
142
+ try {
143
+ const name = fullName.toLowerCase();
144
+ const ts = Math.floor(Date.now() / 1000);
145
+ const signed = await call({ op: "sign", text: `beagle-avatar\ndelete\n${name}\n${ts}` });
146
+ if (!signed.ok || !signed.data?.sig)
147
+ return;
148
+ await fetch(`${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}`, {
149
+ method: "DELETE",
150
+ headers: { "x-avatar-ts": String(ts), "x-avatar-sig": String(signed.data.sig) },
151
+ });
152
+ }
153
+ catch {
154
+ // best-effort only
155
+ }
156
+ }
135
157
  /** True only when the request came from the local machine. Used to gate the
136
158
  * "Sign in with Decent" routes so binding the UI to a LAN IP can't expose
137
159
  * identity signing to other hosts. The popup always runs in the local user's
@@ -449,7 +471,7 @@ export function startBeagleServer(opts) {
449
471
  // Register/update this node's *.beagles.eth name and bind wallet
450
472
  // addresses into the record. The write routes trigger identity
451
473
  // signatures, so they're LOCALHOST-ONLY like /connect.
452
- if (url === "/api/ens-register" || url === "/api/ens-bind-wallet" || url === "/api/ens-avatar") {
474
+ if (url === "/api/ens-register" || url === "/api/ens-bind-wallet" || url === "/api/ens-avatar" || url === "/api/ens-avatar-upload") {
453
475
  if (!isLocalRequest(req)) {
454
476
  sendJson(res, 403, { ok: false, error: "identity signing is available only on this machine (localhost)" });
455
477
  return;
@@ -508,18 +530,87 @@ export function startBeagleServer(opts) {
508
530
  sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — pick the avatar there" });
509
531
  return;
510
532
  }
533
+ // One avatar at a time: picking a punk (or clearing) also drops any
534
+ // uploaded custom avatar from the record.
535
+ const texts = { ...(mine.texts ?? {}) };
536
+ const hadUpload = !!texts.avatar;
537
+ delete texts.avatar;
511
538
  const r = await ensSignAndSet(opts.call, {
512
539
  name: mine.name,
513
540
  owner: identity.userid,
514
541
  addresses: mine.addresses ?? {},
515
- texts: mine.texts ?? {},
542
+ texts,
516
543
  referee: mine.referee ?? "",
517
544
  nft: clear ? "" : "CryptoPunks",
518
545
  nftid: clear ? 0 : nftid,
519
546
  });
547
+ if (r.ok && hadUpload)
548
+ void ensAvatarDelete(opts.call, mine.name); // best-effort R2 cleanup
520
549
  sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, nftid: clear ? null : nftid } : { ok: false, error: r.error });
521
550
  return;
522
551
  }
552
+ // Upload a custom avatar image: the browser sends a pre-scaled data URL,
553
+ // we push the bytes to the gateway's R2 (auth = identity signature) and
554
+ // point texts.avatar at it.
555
+ if (req.method === "POST" && url === "/api/ens-avatar-upload") {
556
+ const body = await readBody(req);
557
+ const m = /^data:(image\/(?:webp|png|jpeg));base64,([A-Za-z0-9+/=]+)$/.exec(String(body.dataUrl ?? ""));
558
+ if (!m) {
559
+ sendJson(res, 400, { ok: false, error: "expected a webp/png/jpeg data URL" });
560
+ return;
561
+ }
562
+ const bytes = Buffer.from(m[2], "base64");
563
+ if (bytes.length === 0 || bytes.length > 100 * 1024) {
564
+ sendJson(res, 413, { ok: false, error: "image must be ≤100 KB after scaling" });
565
+ return;
566
+ }
567
+ const diag = await opts.call({ op: "diag" });
568
+ const identity = (diag.ok ? diag.data?.identity : null) ?? {};
569
+ if (!identity.userid) {
570
+ sendJson(res, 502, { ok: false, error: "identity unavailable" });
571
+ return;
572
+ }
573
+ const mine = await ensFetchJson(`/getAddress/${encodeURIComponent(identity.userid)}`);
574
+ if (!mine) {
575
+ sendJson(res, 404, { ok: false, error: "register a name first" });
576
+ return;
577
+ }
578
+ if (mine.owner !== identity.userid) {
579
+ sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — set the avatar there" });
580
+ return;
581
+ }
582
+ const name = mine.name.toLowerCase();
583
+ const ts = Math.floor(Date.now() / 1000);
584
+ const signed = await opts.call({ op: "sign", text: `beagle-avatar\nupload\n${name}\n${ts}` });
585
+ if (!signed.ok || !signed.data?.sig) {
586
+ sendJson(res, 502, { ok: false, error: "identity sign failed" });
587
+ return;
588
+ }
589
+ const up = await fetch(`${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}`, {
590
+ method: "POST",
591
+ headers: { "content-type": m[1], "x-avatar-ts": String(ts), "x-avatar-sig": String(signed.data.sig) },
592
+ body: bytes,
593
+ });
594
+ const upBody = (await up.json().catch(() => null));
595
+ if (!up.ok || !upBody?.success) {
596
+ sendJson(res, 502, { ok: false, error: upBody?.error || `avatar upload HTTP ${up.status}` });
597
+ return;
598
+ }
599
+ // Point the record at it (?v= busts caches on re-upload) and drop any
600
+ // punk pick — one avatar at a time.
601
+ const avatarUrl = `${ENS_GATEWAY}/avatar/${encodeURIComponent(name)}?v=${ts}`;
602
+ const r = await ensSignAndSet(opts.call, {
603
+ name: mine.name,
604
+ owner: identity.userid,
605
+ addresses: mine.addresses ?? {},
606
+ texts: { ...(mine.texts ?? {}), avatar: avatarUrl },
607
+ referee: mine.referee ?? "",
608
+ nft: "",
609
+ nftid: 0,
610
+ });
611
+ sendJson(res, r.ok ? 200 : 502, r.ok ? { ok: true, avatar: avatarUrl } : { ok: false, error: r.error });
612
+ return;
613
+ }
523
614
  if (req.method === "GET" && url === "/api/ens-name") {
524
615
  const diag = await opts.call({ op: "diag" });
525
616
  const identity = (diag.ok ? diag.data?.identity : null) ?? {};
@@ -931,6 +1022,8 @@ export function startBeagleServer(opts) {
931
1022
  name: t.displayName || ens,
932
1023
  ens,
933
1024
  description: t.description || null,
1025
+ // custom uploaded avatar (R2 URL in texts) beats the punk pick
1026
+ avatar: typeof t.avatar === "string" && /^https:\/\//.test(t.avatar) ? t.avatar : null,
934
1027
  // punk avatar convention: nft="CryptoPunks" + nftid>0 (0 = unset)
935
1028
  punkId: v?.nft === "CryptoPunks" && typeof v?.nftid === "number" && v.nftid > 0 ? v.nftid : null,
936
1029
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
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",