Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions desktop/src-tauri/src/commands/messages/forum.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,56 @@ pub(super) fn apply_link_preview_suppression(
}
}

/// Fold the latest kind:40003 edit content into an original event.
///
/// A 40003 edit is valid when its signer is the original's author or the
/// author's verified agent owner. Among valid edits targeting the same
/// original, the one with the highest `created_at` wins. The edit's content
/// replaces the original's content; the edit's tags (excluding the routing
/// `e` tag and any `link-preview` marker, which are already handled
/// separately) are not folded — only the text changes.
///
/// Returns the edited content when a valid edit exists, or `None` when the
/// original should be used as-is.
pub(super) fn fold_edit_content(
original: &nostr::Event,
edits: &[nostr::Event],
owner_pubkeys: &std::collections::HashMap<String, String>,
) -> Option<String> {
let original_author = original.pubkey.to_hex();
let original_id = original.id.to_hex();

let mut best: Option<&nostr::Event> = None;
for edit in edits {
if edit.kind.as_u16() != 40003 {
continue;
}
// Find the target event id from the e tag.
let target_id = edit.tags.iter().find_map(|tag| {
let values = tag.as_slice();
(values.first().map(String::as_str) == Some("e"))
.then(|| values.get(1).map(String::as_str))
.flatten()
});
if target_id != Some(original_id.as_str()) {
continue;
}
// Authorization: signer must be the original author or the
// author's verified agent owner.
let signer = edit.pubkey.to_hex();
let authorized = signer == original_author
|| owner_pubkeys.get(&original_author) == Some(&signer);
if !authorized {
continue;
}
// Latest edit wins.
if best.is_none_or(|b| edit.created_at > b.created_at) {
best = Some(edit);
}
}
best.map(|e| e.content.clone())
}

#[tauri::command]
pub async fn get_forum_posts(
channel_id: String,
Expand Down Expand Up @@ -191,6 +241,9 @@ pub async fn get_forum_posts(
.iter()
.map(|ev| {
let mut message = forum_message_from_event(ev, &channel_id);
if let Some(edited_content) = fold_edit_content(ev, &edits, &owner_pubkeys) {
message.content = edited_content;
}
apply_link_preview_suppression(&mut message.tags, &message.event_id, &suppressed);
message
})
Expand Down Expand Up @@ -248,10 +301,16 @@ pub async fn get_forum_thread(
for ev in &events {
if ev.id.to_hex() == event_id {
let mut message = forum_message_from_event(ev, &channel_id);
if let Some(edited_content) = fold_edit_content(ev, &edits, &owner_pubkeys) {
message.content = edited_content;
}
apply_link_preview_suppression(&mut message.tags, &message.event_id, &suppressed);
root = Some(message);
} else if ev.kind.as_u16() as u32 != 40003 {
let mut reply = forum_reply_from_event(ev, &channel_id, &event_id);
if let Some(edited_content) = fold_edit_content(ev, &edits, &owner_pubkeys) {
reply.content = edited_content;
}
apply_link_preview_suppression(&mut reply.tags, &reply.event_id, &suppressed);
replies.push(reply);
}
Expand Down Expand Up @@ -315,4 +374,92 @@ mod tests {
)
.is_empty());
}

fn edit_event(
keys: &Keys,
content: &str,
target_id: &str,
created_at: nostr::Timestamp,
) -> nostr::Event {
let tags = vec![nostr::Tag::parse(["e", target_id]).unwrap()];
EventBuilder::new(Kind::Custom(40003), content)
.tags(tags)
.custom_created_at(created_at)
.sign_with_keys(keys)
.expect("edit signs")
}

#[test]
fn fold_edit_content_applies_author_edit() {
let author = Keys::generate();
let original = signed_event(&author, 45001, Vec::new());
let edit = edit_event(&author, "edited body", &original.id.to_hex(), nostr::Timestamp::now());
let owners = std::collections::HashMap::new();
let result = fold_edit_content(&original, std::slice::from_ref(&edit), &owners);
assert_eq!(result.as_deref(), Some("edited body"));
}

#[test]
fn fold_edit_content_applies_owner_edit() {
let author = Keys::generate();
let owner = Keys::generate();
let original = signed_event(&author, 45001, Vec::new());
let edit = edit_event(&owner, "owner-edited", &original.id.to_hex(), nostr::Timestamp::now());
let owners = std::collections::HashMap::from([(
author.public_key().to_hex(),
owner.public_key().to_hex(),
)]);
let result = fold_edit_content(&original, std::slice::from_ref(&edit), &owners);
assert_eq!(result.as_deref(), Some("owner-edited"));
}

#[test]
fn fold_edit_content_rejects_unauthorized_signer() {
let author = Keys::generate();
let attacker = Keys::generate();
let original = signed_event(&author, 45001, Vec::new());
let edit = edit_event(&attacker, "hacked", &original.id.to_hex(), nostr::Timestamp::now());
let owners = std::collections::HashMap::new();
let result = fold_edit_content(&original, std::slice::from_ref(&edit), &owners);
assert!(result.is_none(), "unauthorized edit must not apply");
}

#[test]
fn fold_edit_content_latest_edit_wins() {
let author = Keys::generate();
let original = signed_event(&author, 45001, Vec::new());
let older = edit_event(&author, "first", &original.id.to_hex(), nostr::Timestamp::from_secs(100));
let newer = edit_event(&author, "second", &original.id.to_hex(), nostr::Timestamp::from_secs(200));
let owners = std::collections::HashMap::new();
let edits = [older, newer];
let result = fold_edit_content(&original, &edits, &owners);
assert_eq!(result.as_deref(), Some("second"));
}

#[test]
fn fold_edit_content_ignores_edit_targeting_different_event() {
let author = Keys::generate();
let original = signed_event(&author, 45001, Vec::new());
// Distinct created_at so `other` has a different event id from `original`.
// Without this, both events share the same author, kind, content, and
// timestamp, producing the same event id — the edit targeting `other`
// would then match `original`, defeating the test's purpose.
let other = EventBuilder::new(Kind::Custom(45001), "body")
.custom_created_at(nostr::Timestamp::from_secs(1))
.sign_with_keys(&author)
.expect("event signs");
let edit = edit_event(&author, "wrong target", &other.id.to_hex(), nostr::Timestamp::now());
let owners = std::collections::HashMap::new();
let result = fold_edit_content(&original, std::slice::from_ref(&edit), &owners);
assert!(result.is_none(), "edit targeting a different event must not apply");
}

#[test]
fn fold_edit_content_no_edits_returns_none() {
let author = Keys::generate();
let original = signed_event(&author, 45001, Vec::new());
let owners = std::collections::HashMap::new();
let result = fold_edit_content(&original, &[], &owners);
assert!(result.is_none());
}
}