Phase 2d: profile posts signed by the posting identity
Display metadata (display_name, bio, avatar_cid) is no longer broadcast via
the ProfileUpdate direct push when the user edits their name. It travels as
a signed public post with VisibilityIntent::Profile, authored by the posting
identity, and propagates through the normal neighbor-manifest CDN path.
Core pieces:
- `types::ProfilePostContent` — JSON payload serialized into the post's
content field. Ed25519 signature by the posting secret over length-prefixed
display_name + bio + 32-byte avatar_cid (or zeros) + timestamp.
- `crypto::{sign,verify}_profile` with strict length prefixing to prevent
extension attacks.
- New `profile` module: `build_profile_post`, `verify_profile_post`,
`apply_profile_post_if_applicable`. Last-writer-wins by timestamp.
- `control::receive_post` now verifies Profile-intent posts upfront (same
as Control) so bogus signatures never enter storage, and applies them
after store so the `profiles` row updates atomically with the insert.
Node API:
- `Node::set_profile` rewritten: builds a signed Profile post, stores under
intent=Profile, applies it locally (upserts the profiles row keyed by the
posting identity), then propagates via `update_neighbor_manifests_as`.
Stops calling `network.push_profile` — display changes no longer trigger
a direct wire push.
- `Node::my_profile` / `has_profile` read by `default_posting_id` instead of
`node_id`, matching where the row is written now.
ProfileUpdate (0x50) and push_profile stay for now — they still carry
routing-only data (anchors, recent_peers, preferred_peers) via
`sanitized_for_network_broadcast` and are used by `set_anchors` /
`set_public_visible`. Removing the routing fields would be a broader
cleanup; scoped out of this phase.
Tests: roundtrip verify+store, wrong-author rejection (not stored), and
older-timestamp ignored. 114 / 114 core tests pass.
This commit is contained in:
parent
eabdb7ba4f
commit
8b2881d84a
6 changed files with 339 additions and 51 deletions
|
|
@ -1221,48 +1221,77 @@ impl Node {
|
|||
|
||||
// ---- Profiles ----
|
||||
|
||||
/// Set the default posting identity's profile (display_name, bio,
|
||||
/// preserving any existing avatar). Creates a signed
|
||||
/// `VisibilityIntent::Profile` post authored by the posting identity and
|
||||
/// propagates it via the normal neighbor-manifest CDN path. The locally
|
||||
/// stored profile row is keyed by the posting identity — peers who pull
|
||||
/// the profile post apply the same update on their side.
|
||||
pub async fn set_profile(&self, display_name: String, bio: String) -> anyhow::Result<PublicProfile> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_millis() as u64;
|
||||
let posting_id = self.default_posting_id;
|
||||
let posting_secret = self.default_posting_secret;
|
||||
|
||||
let recent_peers = self.current_recent_peers().await;
|
||||
// Profile is keyed by the network NodeId — that's how peers route to
|
||||
// us. Broadcasts strip display_name / bio / avatar before going on
|
||||
// the wire (see Network::push_profile). The locally stored profile
|
||||
// retains the name for the user's own UI.
|
||||
let profile = {
|
||||
// Preserve existing avatar if present.
|
||||
let avatar_cid = {
|
||||
let storage = self.storage.get().await;
|
||||
let existing_anchors = storage.get_peer_anchors(&self.node_id).unwrap_or_default();
|
||||
let preferred_peers = storage.list_preferred_peers().unwrap_or_default();
|
||||
|
||||
let (existing_visible, existing_avatar) = storage.get_profile(&self.node_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|p| (p.public_visible, p.avatar_cid))
|
||||
.unwrap_or((true, None));
|
||||
|
||||
let profile = PublicProfile {
|
||||
node_id: self.node_id,
|
||||
display_name,
|
||||
bio,
|
||||
updated_at: now,
|
||||
anchors: existing_anchors,
|
||||
recent_peers,
|
||||
preferred_peers,
|
||||
public_visible: existing_visible,
|
||||
avatar_cid: existing_avatar,
|
||||
};
|
||||
|
||||
storage.store_profile(&profile)?;
|
||||
profile
|
||||
storage.get_profile(&posting_id).ok().flatten().and_then(|p| p.avatar_cid)
|
||||
};
|
||||
|
||||
let pushed = self.network.push_profile(&profile).await;
|
||||
if pushed > 0 {
|
||||
info!(pushed, "Pushed profile update to peers");
|
||||
let profile_post = crate::profile::build_profile_post(
|
||||
&posting_id,
|
||||
&posting_secret,
|
||||
&display_name,
|
||||
&bio,
|
||||
avatar_cid,
|
||||
);
|
||||
let profile_post_id = crate::content::compute_post_id(&profile_post);
|
||||
let timestamp_ms = profile_post.timestamp_ms;
|
||||
|
||||
// Store post with VisibilityIntent::Profile + apply (upserts profile row).
|
||||
{
|
||||
let storage = self.storage.get().await;
|
||||
storage.store_post_with_intent(
|
||||
&profile_post_id,
|
||||
&profile_post,
|
||||
&PostVisibility::Public,
|
||||
&VisibilityIntent::Profile,
|
||||
)?;
|
||||
crate::profile::apply_profile_post_if_applicable(
|
||||
&*storage,
|
||||
&profile_post,
|
||||
Some(&VisibilityIntent::Profile),
|
||||
)?;
|
||||
}
|
||||
|
||||
// Propagate via neighbor-manifest header diffs like any other post.
|
||||
self.update_neighbor_manifests_as(
|
||||
&posting_id,
|
||||
&posting_secret,
|
||||
&profile_post_id,
|
||||
timestamp_ms,
|
||||
).await;
|
||||
|
||||
let profile = {
|
||||
let storage = self.storage.get().await;
|
||||
storage.get_profile(&posting_id)?
|
||||
.unwrap_or_else(|| PublicProfile {
|
||||
node_id: posting_id,
|
||||
display_name: display_name.clone(),
|
||||
bio: bio.clone(),
|
||||
updated_at: timestamp_ms,
|
||||
anchors: vec![],
|
||||
recent_peers: vec![],
|
||||
preferred_peers: vec![],
|
||||
public_visible: true,
|
||||
avatar_cid,
|
||||
})
|
||||
};
|
||||
|
||||
info!(
|
||||
posting_id = hex::encode(posting_id),
|
||||
profile_post_id = hex::encode(profile_post_id),
|
||||
"Published profile post"
|
||||
);
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
|
|
@ -1315,14 +1344,17 @@ impl Node {
|
|||
storage.get_profile(node_id)
|
||||
}
|
||||
|
||||
/// v0.6.2: the user's own display profile lives under the default
|
||||
/// posting identity (published as a signed Profile post), not the
|
||||
/// network NodeId.
|
||||
pub async fn my_profile(&self) -> anyhow::Result<Option<PublicProfile>> {
|
||||
let storage = self.storage.get().await;
|
||||
storage.get_profile(&self.node_id)
|
||||
storage.get_profile(&self.default_posting_id)
|
||||
}
|
||||
|
||||
pub async fn has_profile(&self) -> anyhow::Result<bool> {
|
||||
let storage = self.storage.get().await;
|
||||
Ok(storage.get_profile(&self.node_id)?.is_some())
|
||||
Ok(storage.get_profile(&self.default_posting_id)?.is_some())
|
||||
}
|
||||
|
||||
pub async fn get_display_name(&self, node_id: &NodeId) -> anyhow::Result<Option<String>> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue