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>
This commit is contained in:
parent
508f8e1786
commit
f8787f67e4
@ -9,12 +9,33 @@ use super::{build_response, ApiHandler};
|
|||||||
use crate::blobs::BlobStore;
|
use crate::blobs::BlobStore;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use hyper::{Body, HeaderMap, Response, StatusCode};
|
use hyper::{Body, HeaderMap, Response, StatusCode};
|
||||||
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Read the archipelago .onion address if Tor has published one, so uploads
|
||||||
|
/// that need to be publicly reachable (profile pictures, banners) can return
|
||||||
|
/// a URL a peer outside the LAN can actually fetch. Returns `None` before
|
||||||
|
/// onboarding or when Tor isn't running — callers fall back to the local
|
||||||
|
/// self-test URL.
|
||||||
|
async fn read_self_onion(data_dir: &Path) -> Option<String> {
|
||||||
|
let hostnames = data_dir.join("tor-hostnames").join("archipelago");
|
||||||
|
let legacy = Path::new("/var/lib/archipelago/tor-hostnames/archipelago");
|
||||||
|
for p in [hostnames.as_path(), legacy] {
|
||||||
|
if let Ok(s) = tokio::fs::read_to_string(p).await {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
impl ApiHandler {
|
impl ApiHandler {
|
||||||
pub(super) async fn handle_blob_upload(
|
pub(super) async fn handle_blob_upload(
|
||||||
store: &Arc<BlobStore>,
|
store: &Arc<BlobStore>,
|
||||||
self_pubkey_hex: &str,
|
self_pubkey_hex: &str,
|
||||||
|
data_dir: &Path,
|
||||||
headers: &HeaderMap,
|
headers: &HeaderMap,
|
||||||
body: hyper::body::Bytes,
|
body: hyper::body::Bytes,
|
||||||
) -> Result<Response<Body>> {
|
) -> Result<Response<Body>> {
|
||||||
@ -29,10 +50,13 @@ impl ApiHandler {
|
|||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
let bytes = body.to_vec();
|
let bytes = body.to_vec();
|
||||||
match store.put(&bytes, &mime, filename, None).await {
|
// Uploads through /api/blob come from the node owner's session and
|
||||||
|
// are almost always intended for external consumption (profile
|
||||||
|
// pictures, banners). Store them public so `/blob/<cid>` serves
|
||||||
|
// without a capability check — external Nostr clients fetching a
|
||||||
|
// kind-0 `picture` URL have no cap and can't get one.
|
||||||
|
match store.put(&bytes, &mime, filename, None, true).await {
|
||||||
Ok(meta) => {
|
Ok(meta) => {
|
||||||
// Include a self-signed capability URL so the UI can round-trip
|
|
||||||
// the upload end-to-end without any peer. 7-day expiry.
|
|
||||||
let exp =
|
let exp =
|
||||||
(chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
|
(chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
|
||||||
let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp);
|
let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp);
|
||||||
@ -40,11 +64,19 @@ impl ApiHandler {
|
|||||||
"/blob/{}?cap={}&exp={}&peer={}",
|
"/blob/{}?cap={}&exp={}&peer={}",
|
||||||
meta.cid, cap, exp, self_pubkey_hex
|
meta.cid, cap, exp, self_pubkey_hex
|
||||||
);
|
);
|
||||||
|
let public_url = match read_self_onion(data_dir).await {
|
||||||
|
Some(onion) => format!("http://{}/blob/{}", onion, meta.cid),
|
||||||
|
// Pre-onboarding / Tor-not-up: surface the local path so
|
||||||
|
// the UI doesn't break; publishing to Nostr should wait
|
||||||
|
// until Tor is live anyway.
|
||||||
|
None => format!("/blob/{}", meta.cid),
|
||||||
|
};
|
||||||
let resp = serde_json::json!({
|
let resp = serde_json::json!({
|
||||||
"cid": meta.cid,
|
"cid": meta.cid,
|
||||||
"size": meta.size,
|
"size": meta.size,
|
||||||
"mime": meta.mime,
|
"mime": meta.mime,
|
||||||
"filename": meta.filename,
|
"filename": meta.filename,
|
||||||
|
"public_url": public_url,
|
||||||
"self_test_url": self_test_url,
|
"self_test_url": self_test_url,
|
||||||
});
|
});
|
||||||
Ok(build_response(
|
Ok(build_response(
|
||||||
@ -83,7 +115,7 @@ impl ApiHandler {
|
|||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
let bytes = body.to_vec();
|
let bytes = body.to_vec();
|
||||||
let meta = match store.put(&bytes, &mime, filename, None).await {
|
let meta = match store.put(&bytes, &mime, filename, None, false).await {
|
||||||
Ok(m) => m,
|
Ok(m) => m,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Ok(build_response(
|
return Ok(build_response(
|
||||||
@ -134,34 +166,41 @@ impl ApiHandler {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse query params: cap, exp, peer (all required)
|
// Public blobs (profile pictures, banners) bypass the capability
|
||||||
let mut cap = None;
|
// check — their CID is published on Nostr relays where any reader
|
||||||
let mut exp: Option<u64> = None;
|
// can see it, and external readers have no way to obtain a cap.
|
||||||
let mut peer = None;
|
// Only blobs explicitly marked public at upload time qualify.
|
||||||
for pair in query.split('&') {
|
let is_public = store.meta(cid).await.map(|m| m.public).unwrap_or(false);
|
||||||
let mut it = pair.splitn(2, '=');
|
|
||||||
match (it.next(), it.next()) {
|
|
||||||
(Some("cap"), Some(v)) => cap = Some(v.to_string()),
|
|
||||||
(Some("exp"), Some(v)) => exp = v.parse().ok(),
|
|
||||||
(Some("peer"), Some(v)) => peer = Some(v.to_string()),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let (Some(cap), Some(exp), Some(peer)) = (cap, exp, peer) else {
|
|
||||||
return Ok(build_response(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
"text/plain",
|
|
||||||
Body::from("missing cap/exp/peer"),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = store.verify_capability(cid, &peer, exp, &cap) {
|
if !is_public {
|
||||||
tracing::warn!("blob cap rejected: cid={} peer={} reason={}", cid, peer, e);
|
let mut cap = None;
|
||||||
return Ok(build_response(
|
let mut exp: Option<u64> = None;
|
||||||
StatusCode::FORBIDDEN,
|
let mut peer = None;
|
||||||
"text/plain",
|
for pair in query.split('&') {
|
||||||
Body::from(format!("capability rejected: {}", e)),
|
let mut it = pair.splitn(2, '=');
|
||||||
));
|
match (it.next(), it.next()) {
|
||||||
|
(Some("cap"), Some(v)) => cap = Some(v.to_string()),
|
||||||
|
(Some("exp"), Some(v)) => exp = v.parse().ok(),
|
||||||
|
(Some("peer"), Some(v)) => peer = Some(v.to_string()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (Some(cap), Some(exp), Some(peer)) = (cap, exp, peer) else {
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"text/plain",
|
||||||
|
Body::from("missing cap/exp/peer"),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = store.verify_capability(cid, &peer, exp, &cap) {
|
||||||
|
tracing::warn!("blob cap rejected: cid={} peer={} reason={}", cid, peer, e);
|
||||||
|
return Ok(build_response(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"text/plain",
|
||||||
|
Body::from(format!("capability rejected: {}", e)),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let bytes = match store.get(cid).await {
|
let bytes = match store.get(cid).await {
|
||||||
|
|||||||
@ -291,6 +291,7 @@ impl ApiHandler {
|
|||||||
Self::handle_blob_upload(
|
Self::handle_blob_upload(
|
||||||
&self.blob_store,
|
&self.blob_store,
|
||||||
&self.self_pubkey_hex,
|
&self.self_pubkey_hex,
|
||||||
|
&self.config.data_dir,
|
||||||
&headers,
|
&headers,
|
||||||
body_bytes,
|
body_bytes,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -411,7 +411,7 @@ impl RpcHandler {
|
|||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
let meta = blob_store
|
let meta = blob_store
|
||||||
.put(&bytes, &mime, filename.clone(), None)
|
.put(&bytes, &mime, filename.clone(), None, false)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let service = self.mesh_service.read().await;
|
let service = self.mesh_service.read().await;
|
||||||
@ -761,7 +761,7 @@ impl RpcHandler {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("Read body failed: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Read body failed: {}", e))?;
|
||||||
|
|
||||||
let meta = blob_store.put(&bytes, &mime, filename_hint, None).await?;
|
let meta = blob_store.put(&bytes, &mime, filename_hint, None, false).await?;
|
||||||
if meta.cid != cid {
|
if meta.cid != cid {
|
||||||
anyhow::bail!("CID mismatch: expected {}, got {}", cid, meta.cid);
|
anyhow::bail!("CID mismatch: expected {}, got {}", cid, meta.cid);
|
||||||
}
|
}
|
||||||
|
|||||||
203
core/archipelago/src/avatar.rs
Normal file
203
core/archipelago/src/avatar.rs
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
//! 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 60–85%, lightness 52–70%).
|
||||||
|
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,"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,6 +34,11 @@ pub struct BlobMeta {
|
|||||||
/// Stored alongside meta so ContentRef senders don't re-fetch the blob.
|
/// Stored alongside meta so ContentRef senders don't re-fetch the blob.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub thumb_bytes: Option<Vec<u8>>,
|
pub thumb_bytes: Option<Vec<u8>>,
|
||||||
|
/// Public blobs (profile pictures, banners) are served at `/blob/<cid>`
|
||||||
|
/// without a capability check so external Nostr clients can fetch them.
|
||||||
|
/// Missing in legacy metadata = default false (cap required).
|
||||||
|
#[serde(default)]
|
||||||
|
pub public: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct BlobStore {
|
pub struct BlobStore {
|
||||||
@ -69,6 +74,7 @@ impl BlobStore {
|
|||||||
mime: &str,
|
mime: &str,
|
||||||
filename: Option<String>,
|
filename: Option<String>,
|
||||||
thumb_bytes: Option<Vec<u8>>,
|
thumb_bytes: Option<Vec<u8>>,
|
||||||
|
public: bool,
|
||||||
) -> Result<BlobMeta> {
|
) -> Result<BlobMeta> {
|
||||||
if bytes.len() as u64 > MAX_BLOB_SIZE {
|
if bytes.len() as u64 > MAX_BLOB_SIZE {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
@ -87,6 +93,7 @@ impl BlobStore {
|
|||||||
filename,
|
filename,
|
||||||
created_at: chrono::Utc::now().to_rfc3339(),
|
created_at: chrono::Utc::now().to_rfc3339(),
|
||||||
thumb_bytes,
|
thumb_bytes,
|
||||||
|
public,
|
||||||
};
|
};
|
||||||
|
|
||||||
let blob_path = self.path_for(&cid);
|
let blob_path = self.path_for(&cid);
|
||||||
|
|||||||
@ -162,6 +162,14 @@ impl IdentityManager {
|
|||||||
let id = uuid::Uuid::new_v4().to_string();
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
let created_at = chrono::Utc::now().to_rfc3339();
|
let created_at = chrono::Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
// Every new identity gets a deterministic default avatar derived from
|
||||||
|
// its pubkey. Non-seed identities aren't the master node, so they use
|
||||||
|
// the 5×5 identicon (never the hexagonal node silhouette).
|
||||||
|
let default_profile = IdentityProfile {
|
||||||
|
picture: Some(crate::avatar::identicon(&pubkey_hex)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
let identity_file = IdentityFile {
|
let identity_file = IdentityFile {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
@ -172,7 +180,7 @@ impl IdentityManager {
|
|||||||
created_at: created_at.clone(),
|
created_at: created_at.clone(),
|
||||||
nostr_secret_hex: None,
|
nostr_secret_hex: None,
|
||||||
nostr_pubkey_hex: None,
|
nostr_pubkey_hex: None,
|
||||||
profile: None,
|
profile: Some(default_profile),
|
||||||
derivation_index: None,
|
derivation_index: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -230,6 +238,14 @@ impl IdentityManager {
|
|||||||
let nostr_secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
let nostr_secret_hex = nostr_keys.secret_key().display_secret().to_string();
|
||||||
let nostr_pubkey_hex = nostr_keys.public_key().to_hex();
|
let nostr_pubkey_hex = nostr_keys.public_key().to_hex();
|
||||||
|
|
||||||
|
// Derivation index 0 is the primary seed-derived identity — the
|
||||||
|
// "master" node identity — and gets the distinctive hexagonal SVG.
|
||||||
|
// Later indices get the standard identicon.
|
||||||
|
let default_profile = IdentityProfile {
|
||||||
|
picture: Some(crate::avatar::default_picture(&pubkey_hex, index == 0)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
let identity_file = IdentityFile {
|
let identity_file = IdentityFile {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
@ -240,7 +256,7 @@ impl IdentityManager {
|
|||||||
created_at: created_at.clone(),
|
created_at: created_at.clone(),
|
||||||
nostr_secret_hex: Some(nostr_secret_hex),
|
nostr_secret_hex: Some(nostr_secret_hex),
|
||||||
nostr_pubkey_hex: Some(nostr_pubkey_hex),
|
nostr_pubkey_hex: Some(nostr_pubkey_hex),
|
||||||
profile: None,
|
profile: Some(default_profile),
|
||||||
derivation_index: Some(index),
|
derivation_index: Some(index),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -24,6 +24,7 @@ use tracing::info;
|
|||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod avatar;
|
||||||
mod backup;
|
mod backup;
|
||||||
mod bitcoin_rpc;
|
mod bitcoin_rpc;
|
||||||
mod blobs;
|
mod blobs;
|
||||||
|
|||||||
@ -602,6 +602,7 @@ pub(crate) async fn handle_typed_envelope_direct(
|
|||||||
&content.mime,
|
&content.mime,
|
||||||
content.filename.clone(),
|
content.filename.clone(),
|
||||||
None,
|
None,
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
@ -78,7 +78,26 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
|
|||||||
let data = fs::read_to_string(&path)
|
let data = fs::read_to_string(&path)
|
||||||
.await
|
.await
|
||||||
.context("Reading update state")?;
|
.context("Reading update state")?;
|
||||||
serde_json::from_str(&data).context("Parsing update state")
|
let mut state: UpdateState =
|
||||||
|
serde_json::from_str(&data).context("Parsing update state")?;
|
||||||
|
|
||||||
|
// Keep current_version in sync with the binary. Sideloaded nodes
|
||||||
|
// (ssh + cp /usr/local/bin/archipelago) don't touch the state file,
|
||||||
|
// so without this the running 1.7.0-alpha binary would keep seeing
|
||||||
|
// `current_version: "1.6.0-alpha"` and re-offer itself as an update.
|
||||||
|
let running = env!("CARGO_PKG_VERSION");
|
||||||
|
if state.current_version != running {
|
||||||
|
state.current_version = running.to_string();
|
||||||
|
// Clear any stale "available_update" that matched the old
|
||||||
|
// current_version — the new binary will re-check on its own.
|
||||||
|
if let Some(ref avail) = state.available_update {
|
||||||
|
if avail.version == running {
|
||||||
|
state.available_update = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
save_state(data_dir, &state).await?;
|
||||||
|
}
|
||||||
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
|
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
|
||||||
|
|||||||
@ -357,7 +357,7 @@ async function downloadUpdate() {
|
|||||||
total_bytes: number
|
total_bytes: number
|
||||||
downloaded_bytes: number
|
downloaded_bytes: number
|
||||||
components_downloaded: number
|
components_downloaded: number
|
||||||
}>({ method: 'update.download' })
|
}>({ method: 'update.download', timeout: 1_800_000 })
|
||||||
downloadPercent.value = 100
|
downloadPercent.value = 100
|
||||||
downloaded.value = true
|
downloaded.value = true
|
||||||
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
|
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
|
||||||
@ -403,7 +403,7 @@ async function applyUpdateGit() {
|
|||||||
applying.value = true
|
applying.value = true
|
||||||
statusMessage.value = ''
|
statusMessage.value = ''
|
||||||
try {
|
try {
|
||||||
await rpcClient.call({ method: 'update.git-apply' })
|
await rpcClient.call({ method: 'update.git-apply', timeout: 900_000 })
|
||||||
showStatus(t('systemUpdate.gitApplyStarted'))
|
showStatus(t('systemUpdate.gitApplyStarted'))
|
||||||
updateInfo.value = null
|
updateInfo.value = null
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -418,7 +418,7 @@ async function applyUpdate() {
|
|||||||
applying.value = true
|
applying.value = true
|
||||||
statusMessage.value = ''
|
statusMessage.value = ''
|
||||||
try {
|
try {
|
||||||
await rpcClient.call({ method: 'update.apply' })
|
await rpcClient.call({ method: 'update.apply', timeout: 300_000 })
|
||||||
showStatus(t('systemUpdate.applySuccess'))
|
showStatus(t('systemUpdate.applySuccess'))
|
||||||
updateInfo.value = null
|
updateInfo.value = null
|
||||||
downloaded.value = false
|
downloaded.value = false
|
||||||
|
|||||||
@ -359,8 +359,7 @@
|
|||||||
<div v-if="profileSuccess" class="mt-3 alert-success"><p class="text-xs">{{ profileSuccess }}</p></div>
|
<div v-if="profileSuccess" class="mt-3 alert-success"><p class="text-xs">{{ profileSuccess }}</p></div>
|
||||||
<div class="flex gap-3 mt-5">
|
<div class="flex gap-3 mt-5">
|
||||||
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
<button @click="closeProfileEditor" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||||
<button @click="saveProfile" :disabled="profileSaving" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium">{{ profileSaving ? 'Saving...' : 'Save' }}</button>
|
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">{{ profilePublishing ? 'Saving & publishing…' : 'Save' }}</button>
|
||||||
<button @click="publishProfile" :disabled="profilePublishing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium">{{ profilePublishing ? 'Publishing...' : 'Save & Publish' }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -405,16 +404,14 @@ const keyViewerCopied = ref<string | null>(null)
|
|||||||
// Profile editor
|
// Profile editor
|
||||||
const profileEditorIdentity = ref<ManagedIdentity | null>(null)
|
const profileEditorIdentity = ref<ManagedIdentity | null>(null)
|
||||||
const profileForm = ref<IdentityProfile>({})
|
const profileForm = ref<IdentityProfile>({})
|
||||||
const profileSaving = ref(false)
|
|
||||||
const profilePublishing = ref(false)
|
const profilePublishing = ref(false)
|
||||||
const avatarUploading = ref(false)
|
const avatarUploading = ref(false)
|
||||||
const bannerUploading = ref(false)
|
const bannerUploading = ref(false)
|
||||||
|
|
||||||
// Upload to local blob store + set the corresponding profile URL so
|
// Upload to the node's blob store and drop the returned public URL into
|
||||||
// the kind:0 event (publish) includes a reachable picture/banner. The
|
// the profile field. The /api/blob endpoint marks these blobs public, so
|
||||||
// returned `self_test_url` is a capability-signed /blob/<cid>?cap=…
|
// the URL served back (`public_url`, onion-rooted when Tor is up) is
|
||||||
// path — works locally. For external nostr clients to see the image,
|
// reachable by external Nostr clients fetching kind:0 metadata.
|
||||||
// swap to a public image host later.
|
|
||||||
async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
||||||
const input = ev.target as HTMLInputElement
|
const input = ev.target as HTMLInputElement
|
||||||
const file = input?.files?.[0]
|
const file = input?.files?.[0]
|
||||||
@ -435,10 +432,10 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
|
|||||||
body: buf,
|
body: buf,
|
||||||
})
|
})
|
||||||
if (!resp.ok) throw new Error(`upload failed: HTTP ${resp.status}`)
|
if (!resp.ok) throw new Error(`upload failed: HTTP ${resp.status}`)
|
||||||
const { self_test_url } = await resp.json() as { self_test_url?: string }
|
const { public_url, self_test_url } = await resp.json() as { public_url?: string; self_test_url?: string }
|
||||||
if (!self_test_url) throw new Error('blob API returned no URL')
|
const url = public_url || self_test_url
|
||||||
// Assign and let the <img> in the header preview react.
|
if (!url) throw new Error('blob API returned no URL')
|
||||||
profileForm.value[field] = self_test_url
|
profileForm.value[field] = url
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
|
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
|
||||||
} finally {
|
} finally {
|
||||||
@ -577,26 +574,6 @@ function closeProfileEditor() {
|
|||||||
profileSuccess.value = ''
|
profileSuccess.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveProfile() {
|
|
||||||
if (!profileEditorIdentity.value || profileSaving.value) return
|
|
||||||
profileSaving.value = true
|
|
||||||
profileError.value = ''
|
|
||||||
profileSuccess.value = ''
|
|
||||||
try {
|
|
||||||
await rpcClient.call({
|
|
||||||
method: 'identity.update-profile',
|
|
||||||
params: { id: profileEditorIdentity.value.id, ...profileForm.value },
|
|
||||||
})
|
|
||||||
await loadIdentities()
|
|
||||||
profileSuccess.value = 'Profile saved'
|
|
||||||
setTimeout(() => { profileSuccess.value = '' }, 3000)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
profileError.value = err instanceof Error ? err.message : 'Failed to save'
|
|
||||||
} finally {
|
|
||||||
profileSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishProfile() {
|
async function publishProfile() {
|
||||||
if (!profileEditorIdentity.value || profilePublishing.value) return
|
if (!profileEditorIdentity.value || profilePublishing.value) return
|
||||||
profilePublishing.value = true
|
profilePublishing.value = true
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user