@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.
- package/dist/desktop/app.js +65 -4
- package/dist/server.js +95 -2
- package/package.json +1 -1
package/dist/desktop/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
window.__DK_UI_VERSION="0.1.
|
|
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
|
-
|
|
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
|
|
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