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:
Dorian
2026-04-20 10:03:38 -04:00
co-authored by Claude Opus 4.7
parent 508f8e1786
commit a9d8895395
11 changed files with 335 additions and 71 deletions
+3 -3
View File
@@ -357,7 +357,7 @@ async function downloadUpdate() {
total_bytes: number
downloaded_bytes: number
components_downloaded: number
}>({ method: 'update.download' })
}>({ method: 'update.download', timeout: 1_800_000 })
downloadPercent.value = 100
downloaded.value = true
const sizeMB = (res.downloaded_bytes / 1_048_576).toFixed(1)
@@ -403,7 +403,7 @@ async function applyUpdateGit() {
applying.value = true
statusMessage.value = ''
try {
await rpcClient.call({ method: 'update.git-apply' })
await rpcClient.call({ method: 'update.git-apply', timeout: 900_000 })
showStatus(t('systemUpdate.gitApplyStarted'))
updateInfo.value = null
} catch (e) {
@@ -418,7 +418,7 @@ async function applyUpdate() {
applying.value = true
statusMessage.value = ''
try {
await rpcClient.call({ method: 'update.apply' })
await rpcClient.call({ method: 'update.apply', timeout: 300_000 })
showStatus(t('systemUpdate.applySuccess'))
updateInfo.value = null
downloaded.value = false
+9 -32
View File
@@ -359,8 +359,7 @@
<div v-if="profileSuccess" class="mt-3 alert-success"><p class="text-xs">{{ profileSuccess }}</p></div>
<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="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 ? 'Publishing...' : 'Save & Publish' }}</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>
</div>
</div>
</div>
@@ -405,16 +404,14 @@ const keyViewerCopied = ref<string | null>(null)
// Profile editor
const profileEditorIdentity = ref<ManagedIdentity | null>(null)
const profileForm = ref<IdentityProfile>({})
const profileSaving = ref(false)
const profilePublishing = ref(false)
const avatarUploading = ref(false)
const bannerUploading = ref(false)
// Upload to local blob store + set the corresponding profile URL so
// the kind:0 event (publish) includes a reachable picture/banner. The
// returned `self_test_url` is a capability-signed /blob/<cid>?cap=…
// path — works locally. For external nostr clients to see the image,
// swap to a public image host later.
// Upload to the node's blob store and drop the returned public URL into
// the profile field. The /api/blob endpoint marks these blobs public, so
// the URL served back (`public_url`, onion-rooted when Tor is up) is
// reachable by external Nostr clients fetching kind:0 metadata.
async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
const input = ev.target as HTMLInputElement
const file = input?.files?.[0]
@@ -435,10 +432,10 @@ async function uploadAsset(ev: Event, field: 'picture' | 'banner') {
body: buf,
})
if (!resp.ok) throw new Error(`upload failed: HTTP ${resp.status}`)
const { self_test_url } = await resp.json() as { self_test_url?: string }
if (!self_test_url) throw new Error('blob API returned no URL')
// Assign and let the <img> in the header preview react.
profileForm.value[field] = self_test_url
const { public_url, self_test_url } = await resp.json() as { public_url?: string; self_test_url?: string }
const url = public_url || self_test_url
if (!url) throw new Error('blob API returned no URL')
profileForm.value[field] = url
} catch (e: unknown) {
profileError.value = e instanceof Error ? e.message : `${field} upload failed`
} finally {
@@ -577,26 +574,6 @@ function closeProfileEditor() {
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() {
if (!profileEditorIdentity.value || profilePublishing.value) return
profilePublishing.value = true