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.
This commit is contained in:
parent
db81f8dfda
commit
a6329d5640
13 changed files with 812 additions and 29 deletions
|
|
@ -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 (
|
||||
<span className={`avatar avatar-${size} avatar-picture`} aria-hidden="true">
|
||||
<img src={picture} alt="" loading="lazy" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`avatar avatar-${size}`}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
BackendResponse,
|
||||
FeedItem,
|
||||
LinkPreview,
|
||||
MetadataPublishReport,
|
||||
PickedImage,
|
||||
ProfileSummary,
|
||||
PublishReport,
|
||||
|
|
@ -47,9 +48,16 @@ async function call<T>(method: string, params: Record<string, unknown> = {}): Pr
|
|||
export const api = {
|
||||
init: () => call<AppState>('init'),
|
||||
getState: () => call<AppState>('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<AppState>('select_profile', { npub }),
|
||||
publishProfileMetadata: (npub: string) =>
|
||||
call<MetadataPublishReport>('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<PublishReport>('publish_note', { content }),
|
||||
feedGet: (limit?: number, contactsOnly = false) =>
|
||||
call<FeedItem[]>('feed_get', {
|
||||
|
|
@ -82,5 +90,9 @@ export const api = {
|
|||
signerStatus: () => call<SignerStatus>('signer_status'),
|
||||
signerApprove: (id: string, approved: boolean) =>
|
||||
call<SignerStatus>('signer_approve', { id, approved }),
|
||||
|
||||
deleteProfile: (npub: string) => call<ProfileSummary>('delete_profile', { npub }),
|
||||
undoDelete: () => call<ProfileSummary>('undo_delete'),
|
||||
|
||||
copyText: (text: string) => window.backend.copyText(text),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [publishing, setPublishing] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(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<PictureTarget | null>(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) {
|
|||
<header className="page-head">
|
||||
<h1>Profiles</h1>
|
||||
</header>
|
||||
{lastDeleted && (
|
||||
<div
|
||||
className="undo-bar"
|
||||
style={{
|
||||
margin: '12px 0',
|
||||
padding: '8px 12px',
|
||||
background: 'var(--token-item-bg, #f0f0f0)',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
style={{ marginRight: '8px' }}
|
||||
size="sm"
|
||||
onClick={() => void undoDelete()}
|
||||
>
|
||||
<Icon name="refresh" size={14} /> Restore {lastDeleted.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<EmptyState
|
||||
icon={<Icon name="users" size={30} />}
|
||||
title="No profiles yet"
|
||||
|
|
@ -77,6 +121,11 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
</header>
|
||||
|
||||
{error && <ErrorText id={errorId}>{error}</ErrorText>}
|
||||
{notice && (
|
||||
<p className="muted" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="profile-grid">
|
||||
{profiles.map((profile) => (
|
||||
|
|
@ -85,7 +134,12 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
className={`profile-card${profile.is_active ? ' is-active' : ''}`}
|
||||
>
|
||||
<div className="profile-card-top">
|
||||
<Avatar npub={profile.npub} label={profile.label} size="lg" />
|
||||
<Avatar
|
||||
npub={profile.npub}
|
||||
label={profile.label}
|
||||
size="lg"
|
||||
picture={profile.picture}
|
||||
/>
|
||||
<div className="profile-card-meta">
|
||||
<h3>{profile.label}</h3>
|
||||
<code className="mono" title={profile.npub}>
|
||||
|
|
@ -99,6 +153,39 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
|
||||
<div className="profile-card-actions">
|
||||
<CopyButton text={profile.npub} label="public key" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={publishing === profile.npub}
|
||||
onClick={() => void onPublishName(profile.npub, profile.label)}
|
||||
>
|
||||
<Icon name="publish" size={14} /> Publish name
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setPictureTarget({
|
||||
npub: profile.npub,
|
||||
label: profile.label,
|
||||
url: profile.picture ?? '',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon name="edit" size={14} /> Picture
|
||||
</Button>
|
||||
{/* Delete button - appears for all profiles */}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete profile "${profile.label}"?`)) {
|
||||
void deleteProfile(profile.npub);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" size={16} /> Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -131,7 +218,126 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
profile={revealTarget}
|
||||
onClose={() => setRevealTarget(null)}
|
||||
/>
|
||||
|
||||
{pictureTarget && (
|
||||
<PictureModal
|
||||
target={pictureTarget}
|
||||
onClose={() => setPictureTarget(null)}
|
||||
onSaved={(message) => {
|
||||
setNotice(message);
|
||||
setPictureTarget(null);
|
||||
}}
|
||||
onError={setError}
|
||||
onSavingChange={setPublishing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal open title={`Profile picture — ${target.label}`} onClose={onClose}>
|
||||
<div className="picture-modal">
|
||||
<Avatar npub={target.npub} label={target.label} size="lg" picture={canSave ? url : null} />
|
||||
<p className="muted">
|
||||
The picture is stored as a public URL inside your Nostr profile and shown by every client.
|
||||
</p>
|
||||
<div className="field">
|
||||
<label htmlFor="picture-url">Picture URL</label>
|
||||
<input
|
||||
id="picture-url"
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="https://…/avatar.png"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => void onUpload()} loading={uploading}>
|
||||
<Icon name="plus" size={14} /> Upload an image instead
|
||||
</Button>
|
||||
<div className="modal-actions">
|
||||
{target.url && (
|
||||
<Button variant="danger" onClick={() => void save(null)} disabled={saving || uploading}>
|
||||
Remove picture
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void save(url.trim())}
|
||||
loading={saving}
|
||||
disabled={!canSave}
|
||||
>
|
||||
Save & publish
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
AppState,
|
||||
FeedItem,
|
||||
LinkPreview,
|
||||
MetadataPublishReport,
|
||||
PickedImage,
|
||||
ProfileSummary,
|
||||
PublishReport,
|
||||
|
|
@ -38,6 +39,8 @@ interface AppContextValue {
|
|||
refresh: () => Promise<void>;
|
||||
createProfile: (label: string) => Promise<ProfileSummary>;
|
||||
selectProfile: (npub: string) => Promise<void>;
|
||||
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
|
||||
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
|
||||
publishNote: (content: string) => Promise<PublishReport>;
|
||||
recordPublishFailure: (message: string, details?: string | null) => void;
|
||||
clearLastPublish: () => void;
|
||||
|
|
@ -62,6 +65,8 @@ interface AppContextValue {
|
|||
signerDisconnect: () => Promise<SignerStatus>;
|
||||
signerStatus: () => Promise<SignerStatus>;
|
||||
signerApprove: (id: string, approved: boolean) => Promise<SignerStatus>;
|
||||
deleteProfile: (npub: string) => Promise<ProfileSummary>;
|
||||
undoDelete: () => Promise<ProfileSummary>;
|
||||
copyText: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -101,17 +106,34 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const createProfile = useCallback(async (label: string): Promise<ProfileSummary> => {
|
||||
const result = await api.createProfile(label);
|
||||
const createProfile = useCallback(
|
||||
async (label: string): Promise<ProfileSummary> => {
|
||||
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<MetadataPublishReport> => {
|
||||
const result = await api.setProfilePicture(npub, url);
|
||||
setState(result.state);
|
||||
return result.report;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const publishNote = useCallback(async (content: string): Promise<PublishReport> => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
35
src/app.rs
35
src/app.rs
|
|
@ -35,6 +35,9 @@ pub struct AppStateView {
|
|||
pub active_profile: Option<ProfileSummary>,
|
||||
pub profiles: Vec<ProfileSummary>,
|
||||
pub settings: Settings,
|
||||
/// Recently deleted profiles, newest last, for undo.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub undo_history: Vec<ProfileSummary>,
|
||||
}
|
||||
|
||||
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,7 +368,12 @@ mod tests {
|
|||
app.lock();
|
||||
|
||||
let key = app.vault_key().copied();
|
||||
let err = profiles::create_profile(&mut app.vault, "Carol".to_string(), key.as_ref())
|
||||
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!(
|
||||
|
|
|
|||
34
src/ipc.rs
34
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<String>,
|
||||
},
|
||||
PublishNote {
|
||||
content: String,
|
||||
},
|
||||
|
|
@ -282,7 +293,8 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
|||
Request::CreateProfile { label } => {
|
||||
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<serde_json::Val
|
|||
Ok(json!(app.state_view()))
|
||||
}
|
||||
|
||||
Request::PublishProfileMetadata { npub } => {
|
||||
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())
|
||||
|
|
|
|||
62
src/main.rs
62
src/main.rs
|
|
@ -19,6 +19,8 @@ Commands:
|
|||
list List stored profiles (no secret keys)
|
||||
switch <npub> Select the active profile
|
||||
publish <npub> <content> Publish a text note as a specific profile
|
||||
publish-name <npub> Publish the profile's stored name so other clients show it
|
||||
set-picture <npub> <url> 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 <light|dark|system>
|
||||
settings set confirm <true|false>
|
||||
settings set shorten <true|false>
|
||||
delete-profile <npub> 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<String, AppError> {
|
|||
.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<String, AppError> {
|
|||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn cli_set_picture(args: &[String]) -> Result<String, AppError> {
|
||||
let [_, _, npub, url] = args else {
|
||||
return Err(AppError::config(
|
||||
"Usage: nostr-manager-backend set-picture <npub> <url>",
|
||||
));
|
||||
};
|
||||
|
||||
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<String, AppError> {
|
||||
let npub = args
|
||||
.get(2)
|
||||
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend publish-name <npub>"))?;
|
||||
|
||||
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<String, AppError> {
|
||||
let mut contacts = false;
|
||||
let mut rest = &args[2..];
|
||||
|
|
@ -495,6 +553,7 @@ fn delete_profile_direct(vault: &mut Vault, npub: &str) -> Result<ProfileSummary
|
|||
npub: stored.public_key,
|
||||
created_at: stored.created_at,
|
||||
is_active: false,
|
||||
picture: stored.picture,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -527,6 +586,7 @@ fn cli_undo_delete() -> Result<String, AppError> {
|
|||
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() {
|
||||
|
|
|
|||
365
src/profiles.rs
365
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<String>,
|
||||
}
|
||||
|
||||
/// 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<ProfileSummary, AppError> {
|
||||
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<String>,
|
||||
/// Relays that rejected or timed out.
|
||||
pub failed: Vec<RelayFailure>,
|
||||
}
|
||||
|
||||
/// 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<MetadataPublishReport, AppError> {
|
||||
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<String>,
|
||||
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<String>,
|
||||
relay_urls: Vec<String>,
|
||||
) -> 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<String>,
|
||||
relay_urls: Vec<String>,
|
||||
) -> 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<ProfileSummary> {
|
||||
|
|
@ -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<ProfileSummary, AppError> {
|
||||
|
|
@ -422,6 +774,7 @@ mod tests {
|
|||
npub: stored.public_key,
|
||||
created_at: stored.created_at,
|
||||
is_active: false,
|
||||
picture: stored.picture,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue