204 lines
7.1 KiB
Rust
Raw Normal View History

feat(identity,update): default avatars, public blobs, long-running downloads Follow-up to 1fb71b4b on the same v1.7.0-alpha line. Identity avatars • New module `avatar.rs` generates two deterministic SVG styles keyed off the pubkey: a 5×5 mirrored identicon for sub-identities and a hexagonal-network motif for the master (seed index 0) identity. Both returned as base64 data URLs, so a fresh identity has a recognisable picture before the user uploads anything. • `IdentityManager::create()` and `create_from_seed()` populate `profile.picture` on creation. Index 0 gets the node SVG; all other seed-derived + ad-hoc identities get the identicon. Blob store — public flag for profile assets • `BlobMeta.public` (default false) added; `BlobStore::put()` takes a `public: bool`. Missing in legacy meta files = false. • `POST /api/blob` now stores uploads with public=true and returns `public_url` alongside `self_test_url`. public_url is `http://<node-onion>/blob/<cid>` (no cap) if Tor has published the archipelago hidden service, else falls back to the local path. • `GET /blob/<cid>` bypasses the HMAC capability check when the requested blob is flagged public — external Nostr clients fetching a kind-0 `picture` URL can't hold a cap. • Mesh callers (content_ref attachments, dispatch rehydration) pin public=false explicitly so nothing leaks out of the mesh path. Profile editor UX • Collapsed Save + Save & Publish into one button — the Save action now persists locally AND publishes the kind-0 metadata event in one step. Uploads store `public_url` into `profile.picture` / `profile.banner` so the published URL is reachable by external clients. Update client — the 15-second cliff • Frontend `rpcClient.call` for `update.download` now has an explicit 30-minute timeout (was falling back to the default 15 s). `update.apply` gets 5 min, `update.git-apply` gets 15 min. Matches what the backend is actually willing to wait for. • Backend `load_state()` reconciles `state.current_version` with `CARGO_PKG_VERSION` on every start. Sideloaded or reflashed nodes were stuck advertising the old version even with a new binary in place, which kept re-offering the same release as an update. Manifest changelog rewritten for fleet readers per the saved feedback (no function names, no file paths). Artefacts refreshed: binary 12f838c5…5ba82d 40381864 frontend dc3b63af…e9a8370 76984288 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 10:03:38 -04:00
//! Deterministic default avatars derived from a Nostr/Ed25519 pubkey.
//!
//! Two flavours are generated as base64-encoded SVG data URLs so they can
//! live directly in `IdentityProfile.picture` without any blob-store round
//! trip:
//!
//! - [`identicon`] — a 5×5 symmetric grid (GitHub-style) for sub-identities.
//! - [`master_node_svg`] — a hexagonal-network motif for the primary
//! seed-derived identity (derivation index 0). Distinct at a glance from
//! the identicons so the user can tell their own node at 48 px.
//!
//! Both read the first 8 bytes of the hex pubkey, so the same key always
//! produces the same avatar — useful for reconstructing history without
//! storing the blob.
use base64::Engine;
/// Convert a byte to an HSL triple biased toward readable foregrounds on
/// dark backgrounds (saturation 6085%, lightness 5270%).
fn hue_color(seed: u8) -> String {
let hue = (seed as u16) * 360 / 256;
format!("hsl({}, 72%, 60%)", hue)
}
fn accent_color(seed: u8) -> String {
let hue = (seed as u16) * 360 / 256;
format!("hsl({}, 80%, 68%)", hue)
}
fn encode_svg(svg: &str) -> String {
let b64 = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
format!("data:image/svg+xml;base64,{}", b64)
}
/// Parse the first 8 bytes from a hex pubkey. Returns `[0u8; 8]` if the
/// input is too short or malformed — callers get a consistent default
/// avatar rather than an error.
fn seed_bytes(pubkey_hex: &str) -> [u8; 8] {
let mut out = [0u8; 8];
let clean: String = pubkey_hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
for (i, byte) in out.iter_mut().enumerate() {
let lo = i * 2;
if clean.len() >= lo + 2 {
*byte = u8::from_str_radix(&clean[lo..lo + 2], 16).unwrap_or(0);
}
}
out
}
/// 5×5 mirrored identicon. ~700 bytes of SVG, ~1 KB as a data URL.
pub fn identicon(pubkey_hex: &str) -> String {
let bytes = seed_bytes(pubkey_hex);
let fg = hue_color(bytes[0]);
let bg = "#171a24";
// 15 bit slots (3 visible columns × 5 rows). Mirror to 5×5.
// Use bytes[1..=2] as 16 bits, drop the MSB so we get 15.
let bits = u16::from_be_bytes([bytes[1], bytes[2]]) & 0x7fff;
let mut cells = String::with_capacity(512);
let cell_px: u32 = 16;
for row in 0..5u32 {
for col in 0..5u32 {
let src_col = if col < 3 { col } else { 4 - col };
let bit_idx = row * 3 + src_col;
if (bits >> bit_idx) & 1 == 1 {
let x = col * cell_px;
let y = row * cell_px;
cells.push_str(&format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"/>",
x, y, cell_px, cell_px
));
}
}
}
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 80 80\" \
shape-rendering=\"crispEdges\">\
<rect width=\"80\" height=\"80\" fill=\"{bg}\"/>\
<g fill=\"{fg}\">{cells}</g>\
</svg>"
);
encode_svg(&svg)
}
/// Hex-network motif for the master (seed-index-0) identity. Central hex
/// plus six ring hexes connected by faint edges, with an accent colour
/// derived from the pubkey. Distinct silhouette from the 5×5 identicon so
/// the node identity reads differently at every size.
pub fn master_node_svg(pubkey_hex: &str) -> String {
let bytes = seed_bytes(pubkey_hex);
let accent = accent_color(bytes[0]);
let accent2 = accent_color(bytes[0].wrapping_add(64));
let pattern = bytes[3] & 0x3f; // 6 bits — one per ring hex
// Hexagon vertices (point-up) at radius 16, centred on (c, c).
let hex_path = |cx: f64, cy: f64, r: f64| -> String {
let mut pts = String::new();
for i in 0..6 {
let theta = std::f64::consts::FRAC_PI_3 * (i as f64) - std::f64::consts::FRAC_PI_2;
let x = cx + r * theta.cos();
let y = cy + r * theta.sin();
if i == 0 {
pts.push_str(&format!("M{:.2},{:.2}", x, y));
} else {
pts.push_str(&format!(" L{:.2},{:.2}", x, y));
}
}
pts.push_str(" Z");
pts
};
let c = 64.0;
let ring_r = 36.0;
// Ring centres (6 hexes at 60° intervals around centre).
let ring_centres: Vec<(f64, f64)> = (0..6)
.map(|i| {
let theta = std::f64::consts::FRAC_PI_3 * (i as f64) - std::f64::consts::FRAC_PI_2;
(c + ring_r * theta.cos(), c + ring_r * theta.sin())
})
.collect();
let mut ring_hexes = String::new();
let mut edges = String::new();
for (i, (rx, ry)) in ring_centres.iter().enumerate() {
// Alternate fill/stroke based on pattern bits so two nodes never
// share the same ring silhouette.
let filled = (pattern >> i) & 1 == 1;
let fill = if filled { &accent } else { "none" };
let stroke_w = if filled { 0.0 } else { 1.4 };
ring_hexes.push_str(&format!(
"<path d=\"{}\" fill=\"{}\" stroke=\"{}\" stroke-width=\"{}\" opacity=\"0.92\"/>",
hex_path(*rx, *ry, 10.5),
fill,
&accent,
stroke_w
));
// Edge from centre to this ring node.
edges.push_str(&format!(
"<line x1=\"{:.2}\" y1=\"{:.2}\" x2=\"{:.2}\" y2=\"{:.2}\" \
stroke=\"{}\" stroke-width=\"1\" opacity=\"0.35\"/>",
c, c, rx, ry, &accent2
));
}
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 128 128\">\
<defs>\
<radialGradient id=\"bg\" cx=\"50%\" cy=\"50%\" r=\"60%\">\
<stop offset=\"0%\" stop-color=\"#1c2030\"/>\
<stop offset=\"100%\" stop-color=\"#0a0d16\"/>\
</radialGradient>\
</defs>\
<rect width=\"128\" height=\"128\" fill=\"url(#bg)\"/>\
{edges}\
{ring_hexes}\
<path d=\"{centre_hex}\" fill=\"{accent}\" stroke=\"#ffffff\" stroke-width=\"1.5\"/>\
</svg>",
centre_hex = hex_path(c, c, 16.0),
);
encode_svg(&svg)
}
/// Build a default [`IdentityProfile`]-shaped picture for the given
/// identity. The master (seed index 0) gets the node SVG; everyone else
/// gets the identicon.
pub fn default_picture(pubkey_hex: &str, is_master: bool) -> String {
if is_master {
master_node_svg(pubkey_hex)
} else {
identicon(pubkey_hex)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identicon_is_deterministic() {
let a = identicon("aabbccddeeff0011");
let b = identicon("aabbccddeeff0011");
assert_eq!(a, b);
assert!(a.starts_with("data:image/svg+xml;base64,"));
}
#[test]
fn master_is_distinct_from_identicon() {
let pk = "aabbccddeeff0011";
assert_ne!(identicon(pk), master_node_svg(pk));
}
#[test]
fn handles_short_or_malformed_hex() {
// Shouldn't panic, should still return a valid data URL.
let a = identicon("");
assert!(a.starts_with("data:image/svg+xml;base64,"));
let b = master_node_svg("xyz!!!");
assert!(b.starts_with("data:image/svg+xml;base64,"));
}
}