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:
co-authored by
Claude Opus 4.7
parent
508f8e1786
commit
a9d8895395
@@ -9,12 +9,33 @@ use super::{build_response, ApiHandler};
|
||||
use crate::blobs::BlobStore;
|
||||
use anyhow::Result;
|
||||
use hyper::{Body, HeaderMap, Response, StatusCode};
|
||||
use std::path::Path;
|
||||
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 {
|
||||
pub(super) async fn handle_blob_upload(
|
||||
store: &Arc<BlobStore>,
|
||||
self_pubkey_hex: &str,
|
||||
data_dir: &Path,
|
||||
headers: &HeaderMap,
|
||||
body: hyper::body::Bytes,
|
||||
) -> Result<Response<Body>> {
|
||||
@@ -29,10 +50,13 @@ impl ApiHandler {
|
||||
.map(|s| s.to_string());
|
||||
|
||||
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) => {
|
||||
// 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 =
|
||||
(chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
|
||||
let cap = store.issue_capability(&meta.cid, self_pubkey_hex, exp);
|
||||
@@ -40,11 +64,19 @@ impl ApiHandler {
|
||||
"/blob/{}?cap={}&exp={}&peer={}",
|
||||
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!({
|
||||
"cid": meta.cid,
|
||||
"size": meta.size,
|
||||
"mime": meta.mime,
|
||||
"filename": meta.filename,
|
||||
"public_url": public_url,
|
||||
"self_test_url": self_test_url,
|
||||
});
|
||||
Ok(build_response(
|
||||
@@ -83,7 +115,7 @@ impl ApiHandler {
|
||||
.map(|s| s.to_string());
|
||||
|
||||
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,
|
||||
Err(e) => {
|
||||
return Ok(build_response(
|
||||
@@ -134,34 +166,41 @@ impl ApiHandler {
|
||||
));
|
||||
}
|
||||
|
||||
// Parse query params: cap, exp, peer (all required)
|
||||
let mut cap = None;
|
||||
let mut exp: Option<u64> = None;
|
||||
let mut peer = None;
|
||||
for pair in query.split('&') {
|
||||
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"),
|
||||
));
|
||||
};
|
||||
// Public blobs (profile pictures, banners) bypass the capability
|
||||
// check — their CID is published on Nostr relays where any reader
|
||||
// can see it, and external readers have no way to obtain a cap.
|
||||
// Only blobs explicitly marked public at upload time qualify.
|
||||
let is_public = store.meta(cid).await.map(|m| m.public).unwrap_or(false);
|
||||
|
||||
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)),
|
||||
));
|
||||
if !is_public {
|
||||
let mut cap = None;
|
||||
let mut exp: Option<u64> = None;
|
||||
let mut peer = None;
|
||||
for pair in query.split('&') {
|
||||
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 {
|
||||
|
||||
@@ -291,6 +291,7 @@ impl ApiHandler {
|
||||
Self::handle_blob_upload(
|
||||
&self.blob_store,
|
||||
&self.self_pubkey_hex,
|
||||
&self.config.data_dir,
|
||||
&headers,
|
||||
body_bytes,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user