From a6329d564020c128667f24ef1a4acb572d98d449 Mon Sep 17 00:00:00 2001 From: Avi Date: Sat, 22 Aug 2026 18:42:20 -0500 Subject: [PATCH] Publish profile metadata (name + picture) so external clients show it Profiles created in the app never published a kind 0 metadata event, so clients like Iris and Yakihonne showed generated petnames ("evil iguana") or a truncated npub instead of the user's chosen name. - Publish kind 0 metadata (name/display_name) automatically on creation - Add "Publish name" action (GUI button + publish-name CLI) for existing profiles, with a per-relay success/failure report - Add profile pictures: optional picture URL persisted in the vault, set via GUI modal (URL paste or nostr.build upload), set-picture CLI, and included in the published metadata; avatars render it in-app - Run metadata publishing on its own thread so sync and async callers never nest tokio runtimes - Expose undo_history in the state view and fix typecheck errors left by the unfinished delete/undo work (variants, icon, null-safety) - Use offline relay settings in tests: Settings::default() points at real relays, which tests were silently publishing to (suite: 126s -> ~3s) Verification: cargo test 96 passed; clippy/fmt/build clean. Frontend: 78 tests, typecheck, lint, format, vite and electron builds all pass. --- frontend/src/components/Avatar.tsx | 11 +- frontend/src/lib/api.ts | 16 +- frontend/src/lib/types.ts | 10 + frontend/src/screens/ProfilesScreen.tsx | 210 +++++++++++++- frontend/src/state/AppProvider.tsx | 43 ++- frontend/src/styles.css | 10 + src/app.rs | 37 ++- src/ipc.rs | 34 ++- src/main.rs | 62 +++- src/profiles.rs | 365 +++++++++++++++++++++++- src/publish.rs | 26 +- src/uploads.rs | 12 +- src/vault.rs | 5 + 13 files changed, 812 insertions(+), 29 deletions(-) diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx index 3aa9a92..87bc6c7 100644 --- a/frontend/src/components/Avatar.tsx +++ b/frontend/src/components/Avatar.tsx @@ -4,10 +4,19 @@ interface AvatarProps { npub: string; label: string; size?: 'md' | 'lg'; + /** Optional profile picture URL; shown instead of initials when present. */ + picture?: string | null; } -export function Avatar({ npub, label, size = 'md' }: AvatarProps) { +export function Avatar({ npub, label, size = 'md', picture }: AvatarProps) { const colors = avatarColorFor(npub); + if (picture) { + return ( + + ); + } return ( (method: string, params: Record = {}): Pr export const api = { init: () => call('init'), getState: () => call('get_state'), - createProfile: (label: string) => - call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label }), + createProfile: (label: string, settings?: Settings) => + call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label, settings }), selectProfile: (npub: string) => call('select_profile', { npub }), + publishProfileMetadata: (npub: string) => + call('publish_profile_metadata', { npub }), + setProfilePicture: (npub: string, url: string | null) => + call<{ profile: ProfileSummary; report: MetadataPublishReport; state: AppState }>( + 'set_profile_picture', + { npub, url }, + ), publishNote: (content: string) => call('publish_note', { content }), feedGet: (limit?: number, contactsOnly = false) => call('feed_get', { @@ -82,5 +90,9 @@ export const api = { signerStatus: () => call('signer_status'), signerApprove: (id: string, approved: boolean) => call('signer_approve', { id, approved }), + + deleteProfile: (npub: string) => call('delete_profile', { npub }), + undoDelete: () => call('undo_delete'), + copyText: (text: string) => window.backend.copyText(text), }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b239b8a..0ea3c99 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -34,6 +34,8 @@ export interface ProfileSummary { /** Unix timestamp of creation. */ created_at: number; is_active: boolean; + /** Public URL of the profile picture, when one has been set. */ + picture?: string | null; } export interface RelayConfig { @@ -63,6 +65,12 @@ export interface PublishReport { failed: RelayFailure[]; } +/** Per-relay outcome of publishing a profile's name as kind 0 metadata. */ +export interface MetadataPublishReport { + succeeded: string[]; + failed: RelayFailure[]; +} + /** A single note shown in the aggregated feed. */ export interface FeedItem { /** Bech32 note id. */ @@ -95,6 +103,8 @@ export interface AppState { active_profile: ProfileSummary | null; profiles: ProfileSummary[]; settings: Settings; + /** Recently deleted profiles, newest last, for undo. */ + undo_history?: ProfileSummary[]; } /** A secret key revealed after the vault is unlocked. */ diff --git a/frontend/src/screens/ProfilesScreen.tsx b/frontend/src/screens/ProfilesScreen.tsx index 39c49f5..813c58e 100644 --- a/frontend/src/screens/ProfilesScreen.tsx +++ b/frontend/src/screens/ProfilesScreen.tsx @@ -6,6 +6,7 @@ import { CopyButton } from '../components/CopyButton'; import { EmptyState } from '../components/EmptyState'; import { ErrorText } from '../components/ErrorText'; import { Icon } from '../components/Icon'; +import { Modal } from '../components/Modal'; import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal'; import { formatDate, shortenNpub } from '../lib/format'; import { useApp } from '../state/AppProvider'; @@ -15,14 +16,37 @@ interface ProfilesScreenProps { } export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { - const { state, selectProfile } = useApp(); + const { state, selectProfile, deleteProfile, undoDelete, publishProfileMetadata } = useApp(); const [selecting, setSelecting] = useState(null); + const [publishing, setPublishing] = useState(null); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); const [errorId] = useState(() => `profiles-error-${Math.random().toString(36).slice(2)}`); const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null); + const [pictureTarget, setPictureTarget] = useState(null); const profiles = state?.profiles ?? []; const shorten = state?.settings.shorten_npub ?? true; + const undoHistory = state?.undo_history ?? []; + const lastDeleted = undoHistory[undoHistory.length - 1] ?? null; + + const onPublishName = async (npub: string, label: string) => { + setError(null); + setNotice(null); + setPublishing(npub); + try { + const report = await publishProfileMetadata(npub); + setNotice( + report.failed.length === 0 + ? `Published "${label}" to ${report.succeeded.length} relay(s). It may take a minute to appear on other clients.` + : `Published "${label}" to ${report.succeeded.length} relay(s); ${report.failed.length} did not accept it.`, + ); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPublishing(null); + } + }; if (profiles.length === 0) { return ( @@ -31,6 +55,26 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {

Profiles

+ {lastDeleted && ( +
+ +
+ )} } title="No profiles yet" @@ -77,6 +121,11 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { {error && {error}} + {notice && ( +

+ {notice} +

+ )}
{profiles.map((profile) => ( @@ -85,7 +134,12 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { className={`profile-card${profile.is_active ? ' is-active' : ''}`} >
- +

{profile.label}

@@ -99,6 +153,39 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
+ + + {/* Delete button - appears for all profiles */} +
); } + +interface PictureTarget { + npub: string; + label: string; + url: string; +} + +function PictureModal({ + target, + onClose, + onSaved, + onError, + onSavingChange, +}: { + target: PictureTarget; + onClose: () => void; + onSaved: (message: string) => void; + onError: (message: string | null) => void; + onSavingChange: (npub: string | null) => void; +}) { + const { pickImages, uploadImage, setProfilePicture } = useApp(); + const [url, setUrl] = useState(target.url); + const [uploading, setUploading] = useState(false); + const [saving, setSaving] = useState(false); + + const save = async (nextUrl: string | null) => { + onError(null); + setSaving(true); + onSavingChange(target.npub); + try { + const report = await setProfilePicture(target.npub, nextUrl); + onSaved( + report.failed.length === 0 + ? `Picture for "${target.label}" published to ${report.succeeded.length} relay(s).` + : `Picture saved for "${target.label}", but ${report.failed.length} relay(s) did not accept it. Use "Publish name" to retry.`, + ); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + onSavingChange(null); + } + }; + + const onUpload = async () => { + onError(null); + setUploading(true); + try { + const picked = await pickImages(); + if (picked.length === 0) { + return; + } + const uploaded = await uploadImage(picked[0].token); + setUrl(uploaded.url); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } finally { + setUploading(false); + } + }; + + const canSave = /^https?:\/\//.test(url.trim()); + + return ( + +
+ +

+ The picture is stored as a public URL inside your Nostr profile and shown by every client. +

+
+ + setUrl(event.target.value)} + placeholder="https://…/avatar.png" + autoComplete="off" + /> +
+ +
+ {target.url && ( + + )} + + +
+
+
+ ); +} diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index fb28f3a..b248a2d 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -12,6 +12,7 @@ import type { AppState, FeedItem, LinkPreview, + MetadataPublishReport, PickedImage, ProfileSummary, PublishReport, @@ -38,6 +39,8 @@ interface AppContextValue { refresh: () => Promise; createProfile: (label: string) => Promise; selectProfile: (npub: string) => Promise; + publishProfileMetadata: (npub: string) => Promise; + setProfilePicture: (npub: string, url: string | null) => Promise; publishNote: (content: string) => Promise; recordPublishFailure: (message: string, details?: string | null) => void; clearLastPublish: () => void; @@ -62,6 +65,8 @@ interface AppContextValue { signerDisconnect: () => Promise; signerStatus: () => Promise; signerApprove: (id: string, approved: boolean) => Promise; + deleteProfile: (npub: string) => Promise; + undoDelete: () => Promise; copyText: (text: string) => Promise; } @@ -101,17 +106,34 @@ export function AppProvider({ children }: { children: ReactNode }) { }; }, []); - const createProfile = useCallback(async (label: string): Promise => { - const result = await api.createProfile(label); - setState(result.state); - return result.profile; - }, []); + const createProfile = useCallback( + async (label: string): Promise => { + const result = await api.createProfile(label, state?.settings); + setState(result.state); + return result.profile; + }, + [state?.settings], + ); const selectProfile = useCallback(async (npub: string) => { const fresh = await api.selectProfile(npub); setState(fresh); }, []); + const publishProfileMetadata = useCallback( + (npub: string) => api.publishProfileMetadata(npub), + [], + ); + + const setProfilePicture = useCallback( + async (npub: string, url: string | null): Promise => { + const result = await api.setProfilePicture(npub, url); + setState(result.state); + return result.report; + }, + [], + ); + const publishNote = useCallback(async (content: string): Promise => { const report = await api.publishNote(content); setLastPublish({ report, error: null, details: null, at: Date.now() }); @@ -186,6 +208,9 @@ export function AppProvider({ children }: { children: ReactNode }) { return api.signerApprove(id, approved); }, []); + const deleteProfile = useCallback((npub: string) => api.deleteProfile(npub), []); + const undoDelete = useCallback(() => api.undoDelete(), []); + const copyText = useCallback((text: string) => api.copyText(text), []); useThemeSync(state?.settings.theme); @@ -221,6 +246,10 @@ export function AppProvider({ children }: { children: ReactNode }) { signerDisconnect, signerStatus, signerApprove, + deleteProfile, + undoDelete, + publishProfileMetadata, + setProfilePicture, copyText, }), [ @@ -231,7 +260,11 @@ export function AppProvider({ children }: { children: ReactNode }) { refresh, createProfile, selectProfile, + publishProfileMetadata, + setProfilePicture, publishNote, + deleteProfile, + undoDelete, recordPublishFailure, clearLastPublish, feedGet, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index cb20666..43946ff 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -916,6 +916,16 @@ select { font-size: 21px; } +.avatar-picture { + overflow: hidden; +} + +.avatar-picture img { + width: 100%; + height: 100%; + object-fit: cover; +} + .relay-status-list { list-style: none; margin: 0; diff --git a/src/app.rs b/src/app.rs index a9238a1..d60fc2b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -35,6 +35,9 @@ pub struct AppStateView { pub active_profile: Option, pub profiles: Vec, pub settings: Settings, + /// Recently deleted profiles, newest last, for undo. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub undo_history: Vec, } impl App { @@ -110,6 +113,7 @@ impl App { public_key: restored.npub.clone(), secret_key: "".to_string(), created_at: restored.created_at, + picture: None, }; self.vault.profiles.push(stored); // If no active profile, this restored one becomes active @@ -234,6 +238,7 @@ impl App { active_profile: profiles::active_summary(&self.vault), profiles: profiles::summaries(&self.vault), settings: self.settings.clone(), + undo_history: self.undo_history.clone(), } } } @@ -274,17 +279,26 @@ mod tests { use crate::errors::ErrorKind; use crate::profiles; + /// Settings with no relays so tests never touch the network. + fn offline_settings() -> Settings { + Settings { + relays: Vec::new(), + ..Default::default() + } + } + fn plaintext_vault() -> Vault { let mut vault = Vault::empty(); - profiles::create_profile(&mut vault, "Alice".to_string(), None).unwrap(); - profiles::create_profile(&mut vault, "Bob".to_string(), None).unwrap(); + profiles::create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()) + .unwrap(); + profiles::create_profile(&mut vault, "Bob".to_string(), None, &offline_settings()).unwrap(); vault } fn sample_app() -> App { App { vault: plaintext_vault(), - settings: Settings::default(), + settings: offline_settings(), unlock_key: None, undo_history: Vec::new(), } @@ -354,8 +368,13 @@ mod tests { app.lock(); let key = app.vault_key().copied(); - let err = profiles::create_profile(&mut app.vault, "Carol".to_string(), key.as_ref()) - .expect_err("locked vault must reject new profiles"); + let err = profiles::create_profile( + &mut app.vault, + "Carol".to_string(), + key.as_ref(), + &app.settings, + ) + .expect_err("locked vault must reject new profiles"); assert_eq!(err.kind(), ErrorKind::VaultLocked); } @@ -366,7 +385,13 @@ mod tests { .unwrap(); let key = app.vault_key().copied(); - profiles::create_profile(&mut app.vault, "Carol".to_string(), key.as_ref()).unwrap(); + profiles::create_profile( + &mut app.vault, + "Carol".to_string(), + key.as_ref(), + &app.settings, + ) + .unwrap(); let created = app.vault.profiles.last().unwrap(); assert_ne!( diff --git a/src/ipc.rs b/src/ipc.rs index 450c5bc..84680a4 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -38,6 +38,17 @@ pub enum Request { SelectProfile { npub: String, }, + /// Publish a profile's stored label as kind 0 metadata so external + /// clients display its name. + PublishProfileMetadata { + npub: String, + }, + /// Store a profile picture URL (or clear it with `None`) and publish it + /// as part of the profile's kind 0 metadata. + SetProfilePicture { + npub: String, + url: Option, + }, PublishNote { content: String, }, @@ -282,7 +293,8 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { let label = normalise_label(&label); let key = app.vault_key().copied(); - let summary = profiles::create_profile(&mut app.vault, label, key.as_ref())?; + let summary = + profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?; app.save_vault()?; Ok(json!({ "profile": summary, "state": app.state_view() })) } @@ -293,6 +305,26 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { + let key = app.vault_key().copied(); + let report = + profiles::publish_profile_metadata(&app.vault, &npub, key.as_ref(), &app.settings)?; + Ok(json!(report)) + } + + Request::SetProfilePicture { npub, url } => { + let key = app.vault_key().copied(); + let (summary, report) = profiles::set_profile_picture( + &mut app.vault, + &npub, + url, + key.as_ref(), + &app.settings, + )?; + app.save_vault()?; + Ok(json!({ "profile": summary, "report": report, "state": app.state_view() })) + } + Request::PublishNote { content } => { let report = publish::publish_active(&app.vault, &app.settings, &content, app.vault_key()) diff --git a/src/main.rs b/src/main.rs index bd60698..42b369d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,8 @@ Commands: list List stored profiles (no secret keys) switch Select the active profile publish Publish a text note as a specific profile + publish-name Publish the profile's stored name so other clients show it + set-picture Set the profile picture (http(s) URL) and publish it feed [--contacts] [limit] Fetch recent notes from enabled relays (default 50); --contacts filters to the active profile's contacts relays list List configured relays @@ -31,6 +33,8 @@ Commands: settings set theme settings set confirm settings set shorten + delete-profile Delete a profile (moves it to undo stack) + undo-delete Undo the last profile deletion set-password Encrypt the vault with a password (or change it) remove-password Remove the vault password (keys back to plaintext) unlock Verify the vault password for this process @@ -64,6 +68,8 @@ async fn main() -> ExitCode { "list" => cli_list(), "switch" => cli_switch(&args), "publish" => cli_publish(&args).await, + "publish-name" => cli_publish_name(&args), + "set-picture" => cli_set_picture(&args), "feed" => cli_feed(&args).await, "relays" => cli_relays(&args).await, "settings" => cli_settings(&args), @@ -73,6 +79,8 @@ async fn main() -> ExitCode { "show-secret" => cli_show_secret(&args), "signer" => cli_signer(&args).await, "info" => cli_info(), + "delete-profile" => cli_delete_profile(&args), + "undo-delete" => cli_undo_delete(), "help" | "--help" | "-h" => { println!("{USAGE}"); return ExitCode::SUCCESS; @@ -107,7 +115,7 @@ fn cli_create(args: &[String]) -> Result { .unwrap_or_else(|| "New Profile".to_string()); let mut app = load_app_with_unlock()?; let key = app.vault_key().copied(); - let summary = profiles::create_profile(&mut app.vault, label, key.as_ref())?; + let summary = profiles::create_profile(&mut app.vault, label, key.as_ref(), &app.settings)?; app.save_vault()?; Ok(format!( "Created profile \"{}\": {}", @@ -159,6 +167,56 @@ async fn cli_publish(args: &[String]) -> Result { Ok(lines.join("\n")) } +fn cli_set_picture(args: &[String]) -> Result { + let [_, _, npub, url] = args else { + return Err(AppError::config( + "Usage: nostr-manager-backend set-picture ", + )); + }; + + let mut app = load_app_with_unlock()?; + let key = app.vault_key().copied(); + let (summary, report) = profiles::set_profile_picture( + &mut app.vault, + npub, + Some(url.clone()), + key.as_ref(), + &app.settings, + )?; + app.save_vault()?; + Ok(format!( + "Picture set for \"{}\"; accepted by {} relay(s).", + summary.label, + report.succeeded.len() + )) +} + +fn cli_publish_name(args: &[String]) -> Result { + let npub = args + .get(2) + .ok_or_else(|| AppError::config("Usage: nostr-manager-backend publish-name "))?; + + let app = load_app_with_unlock()?; + let key = app.vault_key().copied(); + let label = profiles::profile_label(&app.vault, npub) + .ok_or_else(|| AppError::profile_not_found(npub))? + .to_string(); + let report = profiles::publish_profile_metadata(&app.vault, npub, key.as_ref(), &app.settings)?; + + let mut lines = vec![ + format!("Publishing name \"{}\" for {npub}", label), + format!("Accepted by {} relay(s).", report.succeeded.len()), + ]; + if let Some(failed) = report.failed.first() { + lines.push(format!( + "{} relay(s) did not accept it; retry later or check relay status.", + report.failed.len() + )); + let _ = failed; + } + Ok(lines.join("\n")) +} + async fn cli_feed(args: &[String]) -> Result { let mut contacts = false; let mut rest = &args[2..]; @@ -495,6 +553,7 @@ fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result Result { public_key: restored.npub.clone(), secret_key: "".to_string(), created_at: restored.created_at, + picture: None, }; app.vault.profiles.push(stored); if app.vault.active_profile.is_none() { diff --git a/src/profiles.rs b/src/profiles.rs index fd5f9c4..2973246 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -1,11 +1,20 @@ use nostr_sdk::prelude::*; use serde::Serialize; +use std::time::Duration; use zeroize::Zeroizing; use crate::crypto::VaultKey; use crate::errors::AppError; +use crate::publish::RelayFailure; +use crate::relays; +use crate::settings::Settings; use crate::vault::{unix_timestamp, StoredProfile, Vault}; +/// How long to wait for relays to accept a connection attempt. +const METADATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +/// How long to wait for a single relay to accept the metadata event. +const METADATA_SEND_TIMEOUT: Duration = Duration::from_secs(15); + /// A safe view of a profile that contains no secret key material. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct ProfileSummary { @@ -15,6 +24,8 @@ pub struct ProfileSummary { /// Unix timestamp of creation. pub created_at: u64, pub is_active: bool, + /// Public URL of the profile picture, when one has been set. + pub picture: Option, } /// A secret key revealed after the vault is unlocked, in both the raw hex and @@ -37,6 +48,7 @@ pub fn create_profile( vault: &mut Vault, label: String, key: Option<&VaultKey>, + settings: &Settings, ) -> Result { if vault.is_encrypted() && key.is_none() { return Err(AppError::vault_locked()); @@ -63,6 +75,7 @@ pub fn create_profile( public_key: public_key.clone(), secret_key: stored_secret, created_at, + picture: None, }; let is_active = vault.active_profile.is_none(); @@ -72,14 +85,243 @@ pub fn create_profile( vault.profiles.push(profile); + // Publish kind 0 metadata event so other clients can see the username/display name. + // Best-effort: relay failures here never block profile creation. + let relay_urls = relays::enabled_urls(settings); + if !relay_urls.is_empty() { + publish_metadata_blocking(&keys, &label, None, relay_urls); + } + Ok(ProfileSummary { label, npub: public_key, created_at, is_active, + picture: None, }) } +/// The per-relay outcome of publishing a profile metadata event. +#[derive(Debug, Clone, Serialize)] +pub struct MetadataPublishReport { + /// Relays that accepted the event. + pub succeeded: Vec, + /// Relays that rejected or timed out. + pub failed: Vec, +} + +/// Publish a profile's stored label (and picture, when set) as kind 0 metadata +/// so external clients (Iris, Yakihonne, ...) display its name. Returns a +/// per-relay report. +/// +/// `key` must be the unlocked vault key when the vault is password-protected. +pub fn publish_profile_metadata( + vault: &Vault, + npub: &str, + key: Option<&VaultKey>, + settings: &Settings, +) -> Result { + let stored = find_profile(vault, npub)?; + let secret_hex = resolve_secret_key(vault, npub, key)?; + let secret_key = parse_secret_key(&secret_hex)?; + let keys = Keys::new(secret_key); + let relay_urls = relays::enabled_urls(settings); + if relay_urls.is_empty() { + return Err(AppError::no_enabled_relays()); + } + Ok(publish_metadata_blocking( + &keys, + &stored.label, + stored.picture.clone(), + relay_urls, + )) +} + +/// Store a profile picture URL and immediately publish it as part of the +/// profile's kind 0 metadata. +/// +/// Pass `None` to clear the picture. Returns the updated summary plus the +/// per-relay publish report. +pub fn set_profile_picture( + vault: &mut Vault, + npub: &str, + url: Option, + key: Option<&VaultKey>, + settings: &Settings, +) -> Result<(ProfileSummary, MetadataPublishReport), AppError> { + if let Some(url) = &url { + validate_picture_url(url)?; + } + + // Resolve and sign before mutating so a locked vault or bad key changes + // nothing on disk. + let secret_hex = resolve_secret_key(vault, npub, key)?; + let secret_key = parse_secret_key(&secret_hex)?; + + let stored = find_profile_mut(vault, npub)?; + stored.picture = url; + let (label, npub, created_at, public_key, picture) = ( + stored.label.clone(), + stored.public_key.clone(), + stored.created_at, + stored.public_key.clone(), + stored.picture.clone(), + ); + drop(stored); + let summary = ProfileSummary { + label, + npub, + created_at, + is_active: vault.active_profile.as_deref() == Some(public_key.as_str()), + picture, + }; + + let relay_urls = relays::enabled_urls(settings); + if relay_urls.is_empty() { + // The vault change stands; publishing can be retried later via the + // explicit "publish name" action once a relay is enabled. + return Ok(( + summary, + MetadataPublishReport { + succeeded: Vec::new(), + failed: Vec::new(), + }, + )); + } + + let keys = Keys::new(secret_key); + let report = + publish_metadata_blocking(&keys, &summary.label, summary.picture.clone(), relay_urls); + Ok((summary, report)) +} + +/// Validate that a picture URL is a well-formed http(s) URL. +fn validate_picture_url(url: &str) -> Result<(), AppError> { + let parsed = Url::parse(url) + .map_err(|e| AppError::config(format!("The picture URL is not valid: {e}")))?; + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return Err(AppError::config( + "The picture URL must start with http:// or https://", + )); + } + Ok(()) +} + +fn find_profile<'a>(vault: &'a Vault, npub: &str) -> Result<&'a StoredProfile, AppError> { + vault + .profiles + .iter() + .find(|p| p.public_key == npub) + .ok_or_else(|| AppError::profile_not_found(npub)) +} + +fn find_profile_mut<'a>( + vault: &'a mut Vault, + npub: &str, +) -> Result<&'a mut StoredProfile, AppError> { + vault + .profiles + .iter_mut() + .find(|p| p.public_key == npub) + .ok_or_else(|| AppError::profile_not_found(npub)) +} + +/// Run the async publish on a dedicated thread with its own tokio runtime. +/// +/// This keeps the call synchronous for callers while staying safe when invoked +/// from inside an existing runtime (e.g. the IPC server): `block_on` panics +/// when nested, but here it always runs on a fresh OS thread instead. +fn publish_metadata_blocking( + keys: &Keys, + label: &str, + picture: Option, + relay_urls: Vec, +) -> MetadataPublishReport { + let keys = keys.clone(); + let label = label.to_string(); + std::thread::spawn(move || { + tokio::runtime::Runtime::new() + .expect("metadata runtime") + .block_on(publish_metadata_async(&keys, &label, picture, relay_urls)) + }) + .join() + .expect("metadata publish thread panicked") +} + +async fn publish_metadata_async( + keys: &Keys, + label: &str, + picture: Option, + relay_urls: Vec, +) -> MetadataPublishReport { + let mut metadata = Metadata::new().name(label).display_name(label); + if let Some(picture) = &picture { + if let Ok(parsed) = Url::parse(picture) { + metadata = metadata.picture(parsed); + } + } + let event = match EventBuilder::new(Kind::Metadata, metadata.as_json()) + .sign(keys) + .await + { + Ok(event) => event, + Err(e) => { + return MetadataPublishReport { + succeeded: Vec::new(), + failed: relay_urls + .into_iter() + .map(|url| RelayFailure { + url, + error: "Could not sign the profile metadata.".to_string(), + details: Some(format!("{e}")), + }) + .collect(), + }; + } + }; + + let client = Client::new(keys.clone()); + for url in &relay_urls { + let _ = client.add_relay(url.as_str()).await; + } + client.connect().await; + let _ = client.wait_for_connection(METADATA_CONNECT_TIMEOUT).await; + + let mut succeeded = Vec::new(); + let mut failed = Vec::new(); + for url in &relay_urls { + match client.relay(url.as_str()).await { + Ok(relay) => { + match tokio::time::timeout(METADATA_SEND_TIMEOUT, relay.send_event(&event)).await { + Ok(Ok(_)) => succeeded.push(url.clone()), + Ok(Err(err)) => failed.push(failure_for(url, &err)), + Err(_) => failed.push(RelayFailure { + url: url.clone(), + error: "The relay did not respond in time.".to_string(), + details: Some( + "Timed out while waiting for the relay to accept the metadata." + .to_string(), + ), + }), + } + } + Err(err) => failed.push(failure_for(url, &err)), + } + } + client.disconnect().await; + + MetadataPublishReport { succeeded, failed } +} + +fn failure_for(url: &str, err: &impl std::fmt::Display) -> RelayFailure { + let (error, details) = crate::publish::relay_error_message(err); + RelayFailure { + url: url.to_string(), + error, + details: Some(details), + } +} + /// Safe summaries of every stored profile, newest last. Never includes /// secret keys. pub fn summaries(vault: &Vault) -> Vec { @@ -106,6 +348,7 @@ fn summary_for(vault: &Vault, profile: &StoredProfile) -> ProfileSummary { npub: profile.public_key.clone(), created_at: profile.created_at, is_active: vault.active_profile.as_deref() == Some(profile.public_key.as_str()), + picture: profile.picture.clone(), } } @@ -220,21 +463,31 @@ mod tests { public_key: "npub1alice".to_string(), secret_key: "00".repeat(32), created_at: 1, + picture: None, }); vault.profiles.push(StoredProfile { label: "Bob".to_string(), public_key: "npub1bob".to_string(), secret_key: "11".repeat(32), created_at: 2, + picture: None, }); vault } + /// Settings with no relays so tests never touch the network. + fn offline_settings() -> Settings { + Settings { + relays: Vec::new(), + ..Default::default() + } + } + #[test] fn create_profile_generates_valid_keys() { let mut vault = Vault::empty(); - let summary = - create_profile(&mut vault, "Newbie".to_string(), None).expect("should create"); + let summary = create_profile(&mut vault, "Newbie".to_string(), None, &offline_settings()) + .expect("should create"); assert!(summary.npub.starts_with("npub1")); assert_eq!(summary.label, "Newbie"); assert_eq!(vault.profiles.len(), 1); @@ -252,7 +505,8 @@ mod tests { fn create_profile_keeps_existing_active() { let mut vault = populated_vault(); vault.active_profile = Some("npub1alice".to_string()); - let summary = create_profile(&mut vault, "Carol".to_string(), None).unwrap(); + let summary = + create_profile(&mut vault, "Carol".to_string(), None, &offline_settings()).unwrap(); assert!(!summary.is_active); assert_eq!(vault.active_profile.as_deref(), Some("npub1alice")); } @@ -325,7 +579,8 @@ mod tests { #[test] fn reveal_returns_hex_and_nsec_for_plaintext_vault() { let mut vault = Vault::empty(); - let summary = create_profile(&mut vault, "Alice".to_string(), None).unwrap(); + let summary = + create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap(); let revealed = reveal_secret_key(&vault, &summary.npub, None).expect("must reveal"); assert_eq!(revealed.hex.len(), 64, "hex secret is 32 bytes"); @@ -342,7 +597,8 @@ mod tests { #[test] fn reveal_decrypts_an_encrypted_profile_when_unlocked() { let mut vault = Vault::empty(); - let summary = create_profile(&mut vault, "Alice".to_string(), None).unwrap(); + let summary = + create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap(); let plaintext = vault.profiles[0].secret_key.clone(); let salt = crate::crypto::generate_salt().unwrap(); @@ -374,7 +630,8 @@ mod tests { #[test] fn reveal_rejects_a_locked_vault() { let mut vault = Vault::empty(); - let summary = create_profile(&mut vault, "Alice".to_string(), None).unwrap(); + let summary = + create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap(); vault.crypto = Some(VaultCrypto { kdf: KdfParams { algorithm: "argon2id".to_string(), @@ -404,6 +661,101 @@ mod tests { assert_eq!(profile_label(&vault, "npub1ghost"), None); } + #[test] + fn publish_metadata_missing_profile_errors() { + let vault = Vault::empty(); + let err = publish_profile_metadata(&vault, "npub1ghost", None, &offline_settings()) + .expect_err("missing profile must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); + } + + #[test] + fn publish_metadata_no_relays_errors_before_network_work() { + let mut vault = Vault::empty(); + let summary = + create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap(); + // Offline settings have no relays; the call must fail fast with a + // clear error rather than attempting any network work. + let err = publish_profile_metadata(&vault, &summary.npub, None, &offline_settings()) + .expect_err("no relays must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::NoEnabledRelays); + } + + #[test] + fn set_picture_stores_url_and_skips_publish_without_relays() { + let mut vault = Vault::empty(); + let summary = + create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap(); + let url = "https://cdn.example.com/alice.png".to_string(); + + let (updated, report) = set_profile_picture( + &mut vault, + &summary.npub, + Some(url.clone()), + None, + &offline_settings(), + ) + .expect("setting a picture must work offline"); + + assert_eq!(updated.picture.as_deref(), Some(url.as_str())); + assert_eq!( + vault.profiles[0].picture.as_deref(), + Some(url.as_str()), + "vault must remember the picture URL" + ); + // No relays enabled: nothing published, but the change still stands. + assert!(report.succeeded.is_empty()); + assert!(report.failed.is_empty()); + + // Clearing the picture also persists. + let (cleared, _) = + set_profile_picture(&mut vault, &summary.npub, None, None, &offline_settings()) + .unwrap(); + assert!(cleared.picture.is_none()); + assert!(vault.profiles[0].picture.is_none()); + } + + #[test] + fn set_picture_rejects_non_http_urls() { + let mut vault = Vault::empty(); + let summary = + create_profile(&mut vault, "Alice".to_string(), None, &offline_settings()).unwrap(); + + for bad in [ + "not a url", + "ftp://cdn.example.com/a.png", + "javascript:alert(1)", + ] { + let err = set_profile_picture( + &mut vault, + &summary.npub, + Some(bad.to_string()), + None, + &offline_settings(), + ) + .expect_err("invalid picture URL must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::Config); + } + assert!( + vault.profiles[0].picture.is_none(), + "nothing stored on failure" + ); + } + + #[test] + fn set_picture_missing_profile_errors() { + let mut vault = Vault::empty(); + let err = set_profile_picture( + &mut vault, + "npub1ghost", + Some("https://example.com/x.png".to_string()), + None, + &offline_settings(), + ) + .expect_err("missing profile must error"); + assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound); + } + /// Delete a profile by npub, returning the deleted profile for undo. /// The vault must not be encrypted, or the key must be provided. pub fn delete_profile(vault: &mut Vault, npub: &str) -> Result { @@ -422,6 +774,7 @@ mod tests { npub: stored.public_key, created_at: stored.created_at, is_active: false, + picture: stored.picture, }) } } diff --git a/src/publish.rs b/src/publish.rs index f5d5b12..5e4f476 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -241,7 +241,7 @@ async fn publish_with_keys( /// The underlying error types come from transitive dependencies, so the /// classification is based on the rendered message rather than brittle enum /// matching across versions. -fn relay_error_message(err: &impl std::fmt::Display) -> (String, String) { +pub(crate) fn relay_error_message(err: &impl std::fmt::Display) -> (String, String) { let technical = err.to_string(); let lower = technical.to_lowercase(); let concise = if lower.contains("timed out") || lower.contains("timeout") { @@ -334,7 +334,13 @@ mod tests { #[test] fn publish_with_no_enabled_relays_errors() { let mut vault = Vault::empty(); - crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap(); + crate::profiles::create_profile( + &mut vault, + "A".to_string(), + None, + &settings_with_no_relays(), + ) + .unwrap(); let settings = settings_with_no_relays(); let runtime = tokio::runtime::Runtime::new().unwrap(); let err = runtime @@ -346,7 +352,13 @@ mod tests { #[test] fn publish_with_invalid_stored_key_errors() { let mut vault = Vault::empty(); - crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap(); + crate::profiles::create_profile( + &mut vault, + "A".to_string(), + None, + &settings_with_no_relays(), + ) + .unwrap(); vault.profiles[0].secret_key = "zz-not-hex".to_string(); let settings = settings_with_no_relays(); let runtime = tokio::runtime::Runtime::new().unwrap(); @@ -359,7 +371,13 @@ mod tests { #[test] fn publish_locked_encrypted_vault_errors() { let mut vault = Vault::empty(); - crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap(); + crate::profiles::create_profile( + &mut vault, + "A".to_string(), + None, + &settings_with_no_relays(), + ) + .unwrap(); vault.crypto = Some(crate::vault::VaultCrypto { kdf: crate::vault::KdfParams { algorithm: "argon2id".to_string(), diff --git a/src/uploads.rs b/src/uploads.rs index fed2039..39622ff 100644 --- a/src/uploads.rs +++ b/src/uploads.rs @@ -47,11 +47,21 @@ pub async fn nip98_authorization( #[cfg(test)] mod tests { use super::*; + use crate::settings::Settings; use crate::vault::Vault; + /// Settings with no relays so tests never touch the network. + fn offline_settings() -> Settings { + Settings { + relays: Vec::new(), + ..Default::default() + } + } + fn vault_with_profile() -> Vault { let mut vault = Vault::empty(); - crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap(); + crate::profiles::create_profile(&mut vault, "A".to_string(), None, &offline_settings()) + .unwrap(); vault } diff --git a/src/vault.rs b/src/vault.rs index 6d9fbac..752afc1 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -35,6 +35,10 @@ pub struct StoredProfile { pub secret_key: String, /// Unix timestamp of creation. pub created_at: u64, + /// Public URL of the profile picture, when one has been set. Absent for + /// profiles stored before pictures were introduced. + #[serde(default)] + pub picture: Option, } /// KDF parameters that encrypted a vault. Stored so future key-derivation @@ -412,6 +416,7 @@ mod tests { public_key: "npub1test".to_string(), secret_key: "00ff".to_string(), created_at: 1_700_000_000, + picture: None, } }