Phase 2b: control-post flow (delete/visibility) + remove BlobDeleteNotice
Replaces two persona-signed direct pushes with CDN-propagated control posts:
a single `VisibilityIntent::Control` post type whose content is a signed
`ControlOp` the receiver verifies and applies. Deletes and visibility updates
now flow through the same neighbor-manifest CDN path as regular content — no
direct recipient push needed for persona-signed ops.
Core pieces:
- `VisibilityIntent::Control` + `VisibilityIntent::Profile` variants.
- `ControlOp::DeletePost` / `ControlOp::UpdateVisibility` (JSON, ed25519-signed
by the target post's author over op-specific byte strings).
- `crypto::{sign,verify}_control_{delete,visibility}` signing primitives.
- `control::build_delete_control_post` + `build_visibility_control_post`
for authors to construct control posts.
- `control::receive_post` — unified incoming-post path used by all 6 receive
sites. Verifies control signatures BEFORE storing, so bogus controls never
enter storage and can't be re-propagated via neighbor-manifest diffs.
- `control::apply_control_post_if_applicable` — executes the op under the
same storage guard as the insert.
Feed filter:
- Feeds (`get_feed`, `get_feed_page`, `list_posts_page`,
`list_posts_reverse_chron`) now exclude `Control` and `Profile` posts so
they propagate + tombstone without surfacing.
- Sync/export path (`list_posts_with_visibility`) keeps its own unfiltered
query so control posts still propagate via CDN.
Wire protocol:
- `SyncPost` carries `intent: Option<VisibilityIntent>` so control posts
arrive with their intent preserved.
- `BlobDeleteNotice` (0x95) removed — orphan blobs on remote holders evict
naturally via LRU rather than via a persona-signed push. Code path,
payload, sender, tests, and `delete_blob_with_cdn_notify` all gone.
Tests: control delete roundtrip (apply + tombstone) and wrong-author
rejection (not stored, not applied). 112/112 core tests pass.
This commit is contained in:
parent
4da6a8dc85
commit
36b6a466d2
10 changed files with 585 additions and 196 deletions
|
|
@ -1395,12 +1395,20 @@ impl ConnectionManager {
|
|||
for sp in &response.posts {
|
||||
if s.is_deleted(&sp.id)? { continue; }
|
||||
if verify_post_id(&sp.id, &sp.post) {
|
||||
if s.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility)? {
|
||||
match crate::control::receive_post(&s, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) {
|
||||
Ok(true) => {
|
||||
new_post_ids.push(sp.id);
|
||||
posts_received += 1;
|
||||
}
|
||||
synced_authors.insert(sp.post.author);
|
||||
}
|
||||
Ok(false) => {
|
||||
synced_authors.insert(sp.post.author);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1961,13 +1969,19 @@ impl ConnectionManager {
|
|||
let storage = self.storage.get().await;
|
||||
for sp in &response.posts {
|
||||
if verify_post_id(&sp.id, &sp.post) && !storage.is_deleted(&sp.id)? {
|
||||
let _ = storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility);
|
||||
match crate::control::receive_post(&storage, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) {
|
||||
Ok(_) => {
|
||||
new_post_ids.push(sp.id);
|
||||
synced_authors.insert(sp.post.author);
|
||||
if sp.id == notification.post_id {
|
||||
stored = true;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lock RELEASED
|
||||
|
|
@ -2069,12 +2083,20 @@ impl ConnectionManager {
|
|||
continue;
|
||||
}
|
||||
if verify_post_id(&sp.id, &sp.post) {
|
||||
if storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility)? {
|
||||
match crate::control::receive_post(&storage, &sp.id, &sp.post, &sp.visibility, sp.intent.as_ref()) {
|
||||
Ok(true) => {
|
||||
new_post_ids.push(sp.id);
|
||||
posts_received += 1;
|
||||
}
|
||||
synced_authors.insert(sp.post.author);
|
||||
}
|
||||
Ok(false) => {
|
||||
synced_authors.insert(sp.post.author);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(post_id = hex::encode(sp.id), error = %e, "rejecting post");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lock RELEASED
|
||||
|
|
@ -2294,12 +2316,15 @@ impl ConnectionManager {
|
|||
}
|
||||
}
|
||||
|
||||
// Phase 3: Brief re-lock for is_deleted checks on filtered posts
|
||||
// Phase 3: Brief re-lock for is_deleted checks + intent fetch on filtered posts
|
||||
let (posts, vis_updates) = {
|
||||
let s = storage.get().await;
|
||||
let posts_to_send: Vec<SyncPost> = candidates_to_send.into_iter()
|
||||
.filter(|(id, _, _)| !s.is_deleted(id).unwrap_or(false))
|
||||
.map(|(id, post, visibility)| SyncPost { id, post, visibility })
|
||||
.map(|(id, post, visibility)| {
|
||||
let intent = s.get_post_intent(&id).ok().flatten();
|
||||
SyncPost { id, post, visibility, intent }
|
||||
})
|
||||
.collect();
|
||||
(posts_to_send, vis_updates_to_send)
|
||||
};
|
||||
|
|
@ -4943,24 +4968,11 @@ impl ConnectionManager {
|
|||
}
|
||||
}
|
||||
|
||||
// Gather connections for CDN delete notices under lock, then send outside
|
||||
let mut delete_notices: Vec<(iroh::endpoint::Connection, crate::protocol::BlobDeleteNoticePayload)> = Vec::new();
|
||||
for (cid, holders) in &blob_cleanup {
|
||||
let payload = crate::protocol::BlobDeleteNoticePayload { cid: *cid, upstream_node: None };
|
||||
for (peer, _addrs) in holders {
|
||||
if let Some(pc) = cm.connections_ref().get(peer) {
|
||||
delete_notices.push((pc.connection.clone(), payload.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(cm);
|
||||
// Send outside lock
|
||||
for (conn, payload) in &delete_notices {
|
||||
if let Ok(mut send) = conn.open_uni().await {
|
||||
let _ = write_typed_message(&mut send, MessageType::BlobDeleteNotice, payload).await;
|
||||
let _ = send.finish();
|
||||
}
|
||||
}
|
||||
// BlobDeleteNotice removed in v0.6.2: orphaned blobs on remote
|
||||
// holders are evicted naturally via LRU rather than by a
|
||||
// persona-signed push.
|
||||
let _ = blob_cleanup;
|
||||
}
|
||||
MessageType::VisibilityUpdate => {
|
||||
let payload: crate::protocol::VisibilityUpdatePayload =
|
||||
|
|
@ -5014,11 +5026,14 @@ impl ConnectionManager {
|
|||
&& storage.get_post(&push.post.id)?.is_none()
|
||||
&& crate::content::verify_post_id(&push.post.id, &push.post.post)
|
||||
{
|
||||
let _ = storage.store_post_with_visibility(
|
||||
match crate::control::receive_post(
|
||||
&storage,
|
||||
&push.post.id,
|
||||
&push.post.post,
|
||||
&push.post.visibility,
|
||||
);
|
||||
push.post.intent.as_ref(),
|
||||
) {
|
||||
Ok(_) => {
|
||||
let _ = storage.touch_file_holder(
|
||||
&push.post.id,
|
||||
&remote_node_id,
|
||||
|
|
@ -5031,6 +5046,11 @@ impl ConnectionManager {
|
|||
"Received direct post push"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(post_id = hex::encode(push.post.id), error = %e, "rejecting pushed post");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageType::AudienceRequest => {
|
||||
|
|
@ -5237,7 +5257,14 @@ impl ConnectionManager {
|
|||
let stored = {
|
||||
let cm = cm_arc.lock().await;
|
||||
let storage = cm.storage.get().await;
|
||||
if storage.store_post_with_visibility(&sync_post.id, &sync_post.post, &sync_post.visibility).unwrap_or(false) {
|
||||
let newly_stored = crate::control::receive_post(
|
||||
&storage,
|
||||
&sync_post.id,
|
||||
&sync_post.post,
|
||||
&sync_post.visibility,
|
||||
sync_post.intent.as_ref(),
|
||||
).unwrap_or(false);
|
||||
if newly_stored {
|
||||
let _ = storage.touch_file_holder(
|
||||
&sync_post.id,
|
||||
&sender_id,
|
||||
|
|
@ -5327,24 +5354,6 @@ impl ConnectionManager {
|
|||
"Received social disconnect notice"
|
||||
);
|
||||
}
|
||||
MessageType::BlobDeleteNotice => {
|
||||
let payload: crate::protocol::BlobDeleteNoticePayload =
|
||||
read_payload(recv, MAX_PAYLOAD).await?;
|
||||
let cm = conn_mgr.lock().await;
|
||||
let storage = cm.storage.get().await;
|
||||
let cid = payload.cid;
|
||||
|
||||
// Flat-holder model: drop the sender as a holder of this file.
|
||||
// The author's DeleteRecord (separate signed message) is what
|
||||
// triggers the actual blob removal for followers.
|
||||
let _ = storage.remove_file_holder(&cid, &remote_node_id);
|
||||
|
||||
info!(
|
||||
peer = hex::encode(remote_node_id),
|
||||
cid = hex::encode(cid),
|
||||
"Received blob delete notice"
|
||||
);
|
||||
}
|
||||
MessageType::GroupKeyDistribute => {
|
||||
let payload: GroupKeyDistributePayload = read_payload(recv, MAX_PAYLOAD).await?;
|
||||
let cm = conn_mgr.lock().await;
|
||||
|
|
@ -5675,11 +5684,13 @@ impl ConnectionManager {
|
|||
};
|
||||
let result = {
|
||||
let store = storage.get().await;
|
||||
store.get_post_with_visibility(&payload.post_id).ok().flatten()
|
||||
let pv = store.get_post_with_visibility(&payload.post_id).ok().flatten();
|
||||
let intent = store.get_post_intent(&payload.post_id).ok().flatten();
|
||||
pv.map(|(p, v)| (p, v, intent))
|
||||
};
|
||||
let resp = if let Some((post, visibility)) = result {
|
||||
let resp = if let Some((post, visibility, intent)) = result {
|
||||
if matches!(visibility, PostVisibility::Public) {
|
||||
crate::protocol::PostFetchResponsePayload { post_id: payload.post_id, found: true, post: Some(SyncPost { id: payload.post_id, post, visibility }) }
|
||||
crate::protocol::PostFetchResponsePayload { post_id: payload.post_id, found: true, post: Some(SyncPost { id: payload.post_id, post, visibility, intent }) }
|
||||
} else {
|
||||
crate::protocol::PostFetchResponsePayload { post_id: payload.post_id, found: false, post: None }
|
||||
}
|
||||
|
|
@ -6195,7 +6206,13 @@ impl ConnectionManager {
|
|||
let post_author = sp.post.author;
|
||||
let cm = cm_arc.lock().await;
|
||||
let storage = cm.storage.get().await;
|
||||
let _ = storage.store_post_with_visibility(&sp.id, &sp.post, &sp.visibility);
|
||||
let _ = crate::control::receive_post(
|
||||
&storage,
|
||||
&sp.id,
|
||||
&sp.post,
|
||||
&sp.visibility,
|
||||
sp.intent.as_ref(),
|
||||
);
|
||||
let _ = storage.touch_file_holder(
|
||||
&sp.id,
|
||||
&sender,
|
||||
|
|
|
|||
247
crates/core/src/control.rs
Normal file
247
crates/core/src/control.rs
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
//! Control posts: signed protocol operations carried as public posts that
|
||||
//! receivers apply to local state (delete, update visibility) without
|
||||
//! rendering in feeds.
|
||||
//!
|
||||
//! Wire flow:
|
||||
//! 1. Author creates a `Post { author, content = ControlOp JSON, ... }` with
|
||||
//! `VisibilityIntent::Control`.
|
||||
//! 2. Post propagates via CDN like any other post (header-diffs on neighbor
|
||||
//! posts ship the reference; receivers pull the control post).
|
||||
//! 3. On receive, callers invoke `apply_control_post_if_applicable` to
|
||||
//! decode, verify the ControlOp's signature against the post's author,
|
||||
//! confirm the target post's author matches, and apply.
|
||||
//!
|
||||
//! Control posts themselves are stored with `VisibilityIntent::Control`; feed
|
||||
//! queries exclude them. They remain in storage as tombstones so we can
|
||||
//! re-propagate them to peers and so future arrivals of the target post are
|
||||
//! rejected via the delete tombstone.
|
||||
|
||||
use crate::crypto;
|
||||
use crate::storage::Storage;
|
||||
use crate::types::{ControlOp, DeleteRecord, NodeId, Post, PostId, PostVisibility, VisibilityIntent};
|
||||
|
||||
/// Parse the post's content as a `ControlOp`, verify its signature against
|
||||
/// the post's author, verify target ownership, and apply to local storage.
|
||||
/// No-op (returns Ok) if the post is not a control post. Returns an error
|
||||
/// on a control post with an invalid signature or mismatched target author.
|
||||
/// Callers pass an existing storage guard so the apply happens under the
|
||||
/// same lock as the post-store that triggered the call.
|
||||
pub fn apply_control_post_if_applicable(
|
||||
s: &Storage,
|
||||
post: &Post,
|
||||
intent: Option<&VisibilityIntent>,
|
||||
) -> anyhow::Result<()> {
|
||||
if !matches!(intent, Some(VisibilityIntent::Control)) {
|
||||
return Ok(());
|
||||
}
|
||||
let op: ControlOp = serde_json::from_str(&post.content)
|
||||
.map_err(|e| anyhow::anyhow!("control post content is not a valid ControlOp: {}", e))?;
|
||||
match op {
|
||||
ControlOp::DeletePost { post_id, timestamp_ms, signature } => {
|
||||
if !crypto::verify_control_delete(&post.author, &post_id, timestamp_ms, &signature) {
|
||||
anyhow::bail!("invalid control-delete signature");
|
||||
}
|
||||
if let Some(target) = s.get_post(&post_id)? {
|
||||
if target.author != post.author {
|
||||
anyhow::bail!("control-delete author does not match target post's author");
|
||||
}
|
||||
}
|
||||
let record = DeleteRecord {
|
||||
post_id,
|
||||
author: post.author,
|
||||
timestamp_ms,
|
||||
signature: signature.clone(),
|
||||
};
|
||||
let _ = s.store_delete(&record);
|
||||
let _ = s.apply_delete(&record);
|
||||
Ok(())
|
||||
}
|
||||
ControlOp::UpdateVisibility { post_id, new_visibility, timestamp_ms, signature } => {
|
||||
if !crypto::verify_control_visibility(&post.author, &post_id, &new_visibility, timestamp_ms, &signature) {
|
||||
anyhow::bail!("invalid control-visibility signature");
|
||||
}
|
||||
if let Some(target) = s.get_post(&post_id)? {
|
||||
if target.author != post.author {
|
||||
anyhow::bail!("control-visibility author does not match target post's author");
|
||||
}
|
||||
let _ = s.update_post_visibility(&post_id, &new_visibility);
|
||||
}
|
||||
let _ = (timestamp_ms, new_visibility);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified receive path: for every incoming post, call this instead of
|
||||
/// `store_post_with_visibility` / `store_post_with_intent`. If the post is a
|
||||
/// control post, the op is verified and applied atomically under the same
|
||||
/// storage guard; if verification fails the post is NOT stored (so we don't
|
||||
/// propagate bogus controls to other peers via neighbor-manifest diffs).
|
||||
///
|
||||
/// Returns Ok(true) if the post was newly stored, Ok(false) if already known,
|
||||
/// and an error for control posts with invalid signatures or mismatched
|
||||
/// target authors.
|
||||
pub fn receive_post(
|
||||
s: &Storage,
|
||||
id: &PostId,
|
||||
post: &Post,
|
||||
visibility: &PostVisibility,
|
||||
intent: Option<&VisibilityIntent>,
|
||||
) -> anyhow::Result<bool> {
|
||||
if matches!(intent, Some(VisibilityIntent::Control)) {
|
||||
// Verify the ControlOp signature before storing. A bogus control post
|
||||
// with an invalid signature should never enter storage.
|
||||
let op: ControlOp = serde_json::from_str(&post.content).map_err(|e| {
|
||||
anyhow::anyhow!("control post content is not a valid ControlOp: {}", e)
|
||||
})?;
|
||||
match &op {
|
||||
ControlOp::DeletePost { post_id, timestamp_ms, signature } => {
|
||||
if !crypto::verify_control_delete(&post.author, post_id, *timestamp_ms, signature) {
|
||||
anyhow::bail!("invalid control-delete signature");
|
||||
}
|
||||
}
|
||||
ControlOp::UpdateVisibility { post_id, new_visibility, timestamp_ms, signature } => {
|
||||
if !crypto::verify_control_visibility(&post.author, post_id, new_visibility, *timestamp_ms, signature) {
|
||||
anyhow::bail!("invalid control-visibility signature");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stored = if let Some(intent) = intent {
|
||||
s.store_post_with_intent(id, post, visibility, intent)?
|
||||
} else {
|
||||
s.store_post_with_visibility(id, post, visibility)?
|
||||
};
|
||||
if stored {
|
||||
apply_control_post_if_applicable(s, post, intent)?;
|
||||
}
|
||||
Ok(stored)
|
||||
}
|
||||
|
||||
/// Build a Post representing a control-delete operation. Caller is
|
||||
/// responsible for storing and propagating it.
|
||||
pub fn build_delete_control_post(
|
||||
author: &NodeId,
|
||||
author_secret: &[u8; 32],
|
||||
target_post_id: &crate::types::PostId,
|
||||
) -> Post {
|
||||
let timestamp_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let signature = crypto::sign_control_delete(author_secret, target_post_id, timestamp_ms);
|
||||
let op = ControlOp::DeletePost {
|
||||
post_id: *target_post_id,
|
||||
timestamp_ms,
|
||||
signature,
|
||||
};
|
||||
Post {
|
||||
author: *author,
|
||||
content: serde_json::to_string(&op).unwrap_or_default(),
|
||||
attachments: vec![],
|
||||
timestamp_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Post representing a control-update-visibility operation. Caller
|
||||
/// is responsible for storing and propagating it.
|
||||
pub fn build_visibility_control_post(
|
||||
author: &NodeId,
|
||||
author_secret: &[u8; 32],
|
||||
target_post_id: &crate::types::PostId,
|
||||
new_visibility: &PostVisibility,
|
||||
) -> Post {
|
||||
let timestamp_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let signature = crypto::sign_control_visibility(author_secret, target_post_id, new_visibility, timestamp_ms);
|
||||
let op = ControlOp::UpdateVisibility {
|
||||
post_id: *target_post_id,
|
||||
new_visibility: new_visibility.clone(),
|
||||
timestamp_ms,
|
||||
signature,
|
||||
};
|
||||
Post {
|
||||
author: *author,
|
||||
content: serde_json::to_string(&op).unwrap_or_default(),
|
||||
attachments: vec![],
|
||||
timestamp_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::storage::Storage;
|
||||
use ed25519_dalek::SigningKey;
|
||||
|
||||
fn temp_storage() -> Storage {
|
||||
Storage::open(":memory:").unwrap()
|
||||
}
|
||||
|
||||
fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) {
|
||||
let seed = [seed_byte; 32];
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let public = signing_key.verifying_key();
|
||||
(seed, *public.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_delete_roundtrip_verifies_and_applies() {
|
||||
let s = temp_storage();
|
||||
let (author_sec, author_pub) = make_keypair(7);
|
||||
|
||||
let post = Post {
|
||||
author: author_pub,
|
||||
content: "hello".to_string(),
|
||||
attachments: vec![],
|
||||
timestamp_ms: 1000,
|
||||
};
|
||||
let post_id = crate::content::compute_post_id(&post);
|
||||
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();
|
||||
|
||||
let control = build_delete_control_post(&author_pub, &author_sec, &post_id);
|
||||
let control_id = crate::content::compute_post_id(&control);
|
||||
let stored = receive_post(
|
||||
&s,
|
||||
&control_id,
|
||||
&control,
|
||||
&PostVisibility::Public,
|
||||
Some(&VisibilityIntent::Control),
|
||||
).unwrap();
|
||||
assert!(stored);
|
||||
assert!(s.is_deleted(&post_id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_delete_rejects_wrong_author() {
|
||||
let s = temp_storage();
|
||||
let (_author_sec, author_pub) = make_keypair(7);
|
||||
let (other_sec, _other_pub) = make_keypair(9);
|
||||
|
||||
let post = Post {
|
||||
author: author_pub,
|
||||
content: "hello".to_string(),
|
||||
attachments: vec![],
|
||||
timestamp_ms: 1000,
|
||||
};
|
||||
let post_id = crate::content::compute_post_id(&post);
|
||||
s.store_post_with_visibility(&post_id, &post, &PostVisibility::Public).unwrap();
|
||||
|
||||
// Sign with wrong secret → invalid signature for `author_pub`.
|
||||
let control = build_delete_control_post(&author_pub, &other_sec, &post_id);
|
||||
let control_id = crate::content::compute_post_id(&control);
|
||||
let res = receive_post(
|
||||
&s,
|
||||
&control_id,
|
||||
&control,
|
||||
&PostVisibility::Public,
|
||||
Some(&VisibilityIntent::Control),
|
||||
);
|
||||
assert!(res.is_err());
|
||||
assert!(s.get_post(&control_id).unwrap().is_none());
|
||||
assert!(!s.is_deleted(&post_id).unwrap());
|
||||
}
|
||||
}
|
||||
|
|
@ -289,6 +289,78 @@ pub fn sign_delete(seed: &[u8; 32], post_id: &PostId) -> Vec<u8> {
|
|||
sig.to_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Canonical bytes for a ControlOp::DeletePost signature.
|
||||
fn control_delete_bytes(post_id: &PostId, timestamp_ms: u64) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(12 + 32 + 8);
|
||||
buf.extend_from_slice(b"ctrl:delete:");
|
||||
buf.extend_from_slice(post_id);
|
||||
buf.extend_from_slice(×tamp_ms.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
/// Sign a control-post DeletePost operation.
|
||||
pub fn sign_control_delete(seed: &[u8; 32], post_id: &PostId, timestamp_ms: u64) -> Vec<u8> {
|
||||
let signing_key = SigningKey::from_bytes(seed);
|
||||
let sig = signing_key.sign(&control_delete_bytes(post_id, timestamp_ms));
|
||||
sig.to_bytes().to_vec()
|
||||
}
|
||||
|
||||
pub fn verify_control_delete(
|
||||
author: &NodeId,
|
||||
post_id: &PostId,
|
||||
timestamp_ms: u64,
|
||||
signature: &[u8],
|
||||
) -> bool {
|
||||
if signature.len() != 64 { return false; }
|
||||
let sig_bytes: [u8; 64] = match signature.try_into() { Ok(b) => b, Err(_) => return false };
|
||||
let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
|
||||
let Ok(vk) = VerifyingKey::from_bytes(author) else { return false };
|
||||
vk.verify_strict(&control_delete_bytes(post_id, timestamp_ms), &sig).is_ok()
|
||||
}
|
||||
|
||||
/// Canonical bytes for a ControlOp::UpdateVisibility signature. Uses JSON
|
||||
/// round-trip on the visibility payload because PostVisibility is an enum
|
||||
/// with variable shape; callers must pass the exact same bytes when verifying.
|
||||
fn control_visibility_bytes(
|
||||
post_id: &PostId,
|
||||
new_visibility_canonical: &[u8],
|
||||
timestamp_ms: u64,
|
||||
) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(10 + 32 + new_visibility_canonical.len() + 8);
|
||||
buf.extend_from_slice(b"ctrl:vis:");
|
||||
buf.extend_from_slice(post_id);
|
||||
buf.extend_from_slice(new_visibility_canonical);
|
||||
buf.extend_from_slice(×tamp_ms.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn sign_control_visibility(
|
||||
seed: &[u8; 32],
|
||||
post_id: &PostId,
|
||||
new_visibility: &crate::types::PostVisibility,
|
||||
timestamp_ms: u64,
|
||||
) -> Vec<u8> {
|
||||
let canon = serde_json::to_vec(new_visibility).unwrap_or_default();
|
||||
let signing_key = SigningKey::from_bytes(seed);
|
||||
let sig = signing_key.sign(&control_visibility_bytes(post_id, &canon, timestamp_ms));
|
||||
sig.to_bytes().to_vec()
|
||||
}
|
||||
|
||||
pub fn verify_control_visibility(
|
||||
author: &NodeId,
|
||||
post_id: &PostId,
|
||||
new_visibility: &crate::types::PostVisibility,
|
||||
timestamp_ms: u64,
|
||||
signature: &[u8],
|
||||
) -> bool {
|
||||
if signature.len() != 64 { return false; }
|
||||
let sig_bytes: [u8; 64] = match signature.try_into() { Ok(b) => b, Err(_) => return false };
|
||||
let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
|
||||
let Ok(vk) = VerifyingKey::from_bytes(author) else { return false };
|
||||
let canon = match serde_json::to_vec(new_visibility) { Ok(v) => v, Err(_) => return false };
|
||||
vk.verify_strict(&control_visibility_bytes(post_id, &canon, timestamp_ms), &sig).is_ok()
|
||||
}
|
||||
|
||||
/// Verify an ed25519 delete signature: the author's public key signed the post_id.
|
||||
pub fn verify_delete_signature(author: &NodeId, post_id: &PostId, signature: &[u8]) -> bool {
|
||||
if signature.len() != 64 {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ pub mod activity;
|
|||
pub mod blob;
|
||||
pub mod connection;
|
||||
pub mod content;
|
||||
pub mod control;
|
||||
pub mod crypto;
|
||||
pub mod http;
|
||||
pub mod export;
|
||||
|
|
|
|||
|
|
@ -1049,29 +1049,7 @@ impl Network {
|
|||
sent
|
||||
}
|
||||
|
||||
/// Send blob delete notices to all known holders of a file.
|
||||
/// Second argument kept as Option for signature stability; flat-holder
|
||||
/// model doesn't need separate upstream handling.
|
||||
pub async fn send_blob_delete_notices(
|
||||
&self,
|
||||
cid: &[u8; 32],
|
||||
holders: &[(NodeId, Vec<String>)],
|
||||
_legacy_upstream: Option<&(NodeId, Vec<String>)>,
|
||||
) -> usize {
|
||||
let payload = crate::protocol::BlobDeleteNoticePayload {
|
||||
cid: *cid,
|
||||
upstream_node: None,
|
||||
};
|
||||
let mut sent = 0;
|
||||
for (peer, _addrs) in holders {
|
||||
if self.send_to_peer_uni(peer, MessageType::BlobDeleteNotice, &payload).await.is_ok() {
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
sent
|
||||
}
|
||||
|
||||
/// Request a manifest refresh from the upstream peer for a blob CID.
|
||||
/// Request a manifest refresh from the upstream peer for a blob CID.
|
||||
/// Returns the updated manifest if the upstream has a newer version.
|
||||
pub async fn request_manifest_refresh(
|
||||
&self,
|
||||
|
|
@ -1136,6 +1114,7 @@ impl Network {
|
|||
id: *post_id,
|
||||
post: post.clone(),
|
||||
visibility: visibility.clone(),
|
||||
intent: None, // PostPush is only for public posts; no intent carried
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1074,6 +1074,9 @@ impl Node {
|
|||
VisibilityIntent::Friends => storage.list_public_follows(),
|
||||
VisibilityIntent::Circle(name) => storage.get_circle_members(name),
|
||||
VisibilityIntent::Direct(ids) => Ok(ids.clone()),
|
||||
// Control and Profile posts are always Public on the wire; they
|
||||
// never go through encryption recipient resolution.
|
||||
VisibilityIntent::Control | VisibilityIntent::Profile => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2155,39 +2158,32 @@ impl Node {
|
|||
// ---- Delete / Revocation ----
|
||||
|
||||
pub async fn delete_post(&self, post_id: &PostId) -> anyhow::Result<()> {
|
||||
let post = {
|
||||
// Load the target post and the posting identity of its author. Only
|
||||
// the author can delete their own content, so the signing key must be
|
||||
// one we hold in posting_identities.
|
||||
let (target_author, author_secret) = {
|
||||
let storage = self.storage.get().await;
|
||||
storage
|
||||
let post = storage
|
||||
.get_post(post_id)?
|
||||
.ok_or_else(|| anyhow::anyhow!("post not found"))?
|
||||
};
|
||||
if post.author != self.node_id {
|
||||
anyhow::bail!("cannot delete: you are not the author");
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_millis() as u64;
|
||||
let signature = crypto::sign_delete(&self.default_posting_secret, post_id);
|
||||
|
||||
let record = DeleteRecord {
|
||||
post_id: *post_id,
|
||||
author: self.default_posting_id,
|
||||
timestamp_ms: now,
|
||||
signature,
|
||||
.ok_or_else(|| anyhow::anyhow!("post not found"))?;
|
||||
let pi = storage
|
||||
.get_posting_identity(&post.author)?
|
||||
.ok_or_else(|| anyhow::anyhow!("cannot delete: not authored by a persona on this device"))?;
|
||||
(pi.node_id, pi.secret_seed)
|
||||
};
|
||||
|
||||
// Collect blob CIDs + known holders before cleanup (for delete notices)
|
||||
let blob_cdn_info: Vec<([u8; 32], Vec<(NodeId, Vec<String>)>, Option<(NodeId, Vec<String>)>)> = {
|
||||
let storage = self.storage.get().await;
|
||||
let cids = storage.get_blobs_for_post(post_id).unwrap_or_default();
|
||||
cids.into_iter().map(|cid| {
|
||||
let holders = storage.get_file_holders(&cid).unwrap_or_default();
|
||||
(cid, holders, None::<(NodeId, Vec<String>)>)
|
||||
}).collect()
|
||||
};
|
||||
// Build the control-delete post signed by the target's author.
|
||||
let control_post = crate::control::build_delete_control_post(
|
||||
&target_author,
|
||||
&author_secret,
|
||||
post_id,
|
||||
);
|
||||
let control_post_id = crate::content::compute_post_id(&control_post);
|
||||
let now = control_post.timestamp_ms;
|
||||
|
||||
// Clean up blobs (DB metadata + CDN metadata + filesystem)
|
||||
// Clean up blob storage local-side. Blobs in remote holders become
|
||||
// orphans and get evicted naturally via LRU — BlobDeleteNotice is
|
||||
// gone in v0.6.2.
|
||||
let blob_cids = {
|
||||
let storage = self.storage.get().await;
|
||||
let cids = storage.delete_blobs_for_post(post_id)?;
|
||||
|
|
@ -2202,19 +2198,42 @@ impl Node {
|
|||
}
|
||||
}
|
||||
|
||||
// Store the control post locally with VisibilityIntent::Control so
|
||||
// feeds filter it and propagation queries find it. Apply the op under
|
||||
// the same guard so delete recording + target cleanup happen with the
|
||||
// control-post insert atomically.
|
||||
{
|
||||
let storage = self.storage.get().await;
|
||||
storage.store_delete(&record)?;
|
||||
storage.apply_delete(&record)?;
|
||||
storage.store_post_with_intent(
|
||||
&control_post_id,
|
||||
&control_post,
|
||||
&PostVisibility::Public,
|
||||
&VisibilityIntent::Control,
|
||||
)?;
|
||||
crate::control::apply_control_post_if_applicable(
|
||||
&*storage,
|
||||
&control_post,
|
||||
Some(&VisibilityIntent::Control),
|
||||
)?;
|
||||
}
|
||||
|
||||
// Send CDN delete notices for each blob
|
||||
for (cid, downstream, upstream) in &blob_cdn_info {
|
||||
self.network.send_blob_delete_notices(cid, downstream, upstream.as_ref()).await;
|
||||
}
|
||||
// Propagate via the normal neighbor-manifest CDN path: include the
|
||||
// control post in the author's other posts' `following_posts` lists
|
||||
// and push manifest diffs to their file_holders. Peers who follow
|
||||
// any of the author's posts pick up the control post and apply it.
|
||||
self.update_neighbor_manifests_as(
|
||||
&target_author,
|
||||
&author_secret,
|
||||
&control_post_id,
|
||||
now,
|
||||
).await;
|
||||
|
||||
let pushed = self.network.push_delete(&record).await;
|
||||
info!(post_id = hex::encode(post_id), pushed, blobs_removed = blob_cids.len(), "Deleted post");
|
||||
info!(
|
||||
post_id = hex::encode(post_id),
|
||||
control_post_id = hex::encode(control_post_id),
|
||||
blobs_removed = blob_cids.len(),
|
||||
"Deleted post via control post",
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -2269,13 +2288,38 @@ impl Node {
|
|||
storage.update_post_visibility(post_id, &new_vis)?;
|
||||
}
|
||||
|
||||
let update = VisibilityUpdate {
|
||||
post_id: *post_id,
|
||||
author: self.default_posting_id,
|
||||
visibility: new_vis,
|
||||
// Propagate via a signed control-visibility post rather than a
|
||||
// direct push. Only the target's author can make such a post.
|
||||
let author_secret = {
|
||||
let s = self.storage.get().await;
|
||||
s.get_posting_identity(&post.author)?
|
||||
.map(|pi| pi.secret_seed)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing posting secret for post author"))?
|
||||
};
|
||||
let pushed = self.network.push_visibility(&update).await;
|
||||
info!(post_id = hex::encode(post_id), pushed, "Revoked access (sync mode)");
|
||||
let control_post = crate::control::build_visibility_control_post(
|
||||
&post.author,
|
||||
&author_secret,
|
||||
post_id,
|
||||
&new_vis,
|
||||
);
|
||||
let control_post_id = crate::content::compute_post_id(&control_post);
|
||||
let now = control_post.timestamp_ms;
|
||||
{
|
||||
let storage = self.storage.get().await;
|
||||
storage.store_post_with_intent(
|
||||
&control_post_id,
|
||||
&control_post,
|
||||
&PostVisibility::Public,
|
||||
&VisibilityIntent::Control,
|
||||
)?;
|
||||
}
|
||||
self.update_neighbor_manifests_as(
|
||||
&post.author,
|
||||
&author_secret,
|
||||
&control_post_id,
|
||||
now,
|
||||
).await;
|
||||
info!(post_id = hex::encode(post_id), control_post_id = hex::encode(control_post_id), "Revoked access (sync mode) via control post");
|
||||
Ok(None)
|
||||
}
|
||||
RevocationMode::ReEncrypt => {
|
||||
|
|
@ -3477,25 +3521,14 @@ impl Node {
|
|||
compute_blob_priority_standalone(candidate, &self.node_id, follows, audience_members, now_ms)
|
||||
}
|
||||
|
||||
/// Delete a blob with CDN notifications to known holders.
|
||||
pub async fn delete_blob_with_cdn_notify(&self, cid: &[u8; 32]) -> anyhow::Result<()> {
|
||||
// Gather known holders before cleanup
|
||||
let holders = {
|
||||
let storage = self.storage.get().await;
|
||||
storage.get_file_holders(cid).unwrap_or_default()
|
||||
};
|
||||
|
||||
// Send CDN delete notices to all holders
|
||||
self.network.send_blob_delete_notices(cid, &holders, None).await;
|
||||
|
||||
// Clean up local storage
|
||||
/// Delete a blob locally. BlobDeleteNotice was removed in v0.6.2; remote
|
||||
/// holders notice eviction via their own LRU / replica-miss handling.
|
||||
pub async fn delete_blob_local(&self, cid: &[u8; 32]) -> anyhow::Result<()> {
|
||||
{
|
||||
let storage = self.storage.get().await;
|
||||
storage.cleanup_cdn_for_blob(cid)?;
|
||||
storage.remove_blob(cid)?;
|
||||
}
|
||||
|
||||
// Delete from filesystem
|
||||
let _ = self.blob_store.delete(cid);
|
||||
|
||||
Ok(())
|
||||
|
|
@ -3542,7 +3575,7 @@ impl Node {
|
|||
if bytes_freed >= target_free {
|
||||
break;
|
||||
}
|
||||
if let Err(e) = self.delete_blob_with_cdn_notify(&candidate.cid).await {
|
||||
if let Err(e) = self.delete_blob_local(&candidate.cid).await {
|
||||
warn!(cid = hex::encode(candidate.cid), error = %e, "Failed to evict blob");
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ pub struct SyncPost {
|
|||
pub id: PostId,
|
||||
pub post: Post,
|
||||
pub visibility: PostVisibility,
|
||||
/// Optional originator's intent, so receivers can filter control posts
|
||||
/// out of the feed and process their ControlOp payload. Absent on
|
||||
/// pre-v0.6.2 senders; receivers treat as "unknown"/regular post.
|
||||
#[serde(default)]
|
||||
pub intent: Option<crate::types::VisibilityIntent>,
|
||||
}
|
||||
|
||||
/// Message type byte for stream multiplexing
|
||||
|
|
@ -45,7 +50,7 @@ pub enum MessageType {
|
|||
ManifestRefreshRequest = 0x92,
|
||||
ManifestRefreshResponse = 0x93,
|
||||
ManifestPush = 0x94,
|
||||
BlobDeleteNotice = 0x95,
|
||||
// 0x95 (BlobDeleteNotice) retired in v0.6.2 — remote holders evict via LRU.
|
||||
GroupKeyDistribute = 0xA0,
|
||||
GroupKeyRequest = 0xA1,
|
||||
GroupKeyResponse = 0xA2,
|
||||
|
|
@ -102,7 +107,6 @@ impl MessageType {
|
|||
0x92 => Some(Self::ManifestRefreshRequest),
|
||||
0x93 => Some(Self::ManifestRefreshResponse),
|
||||
0x94 => Some(Self::ManifestPush),
|
||||
0x95 => Some(Self::BlobDeleteNotice),
|
||||
0xA0 => Some(Self::GroupKeyDistribute),
|
||||
0xA1 => Some(Self::GroupKeyRequest),
|
||||
0xA2 => Some(Self::GroupKeyResponse),
|
||||
|
|
@ -411,15 +415,6 @@ pub struct ManifestPushEntry {
|
|||
pub manifest: CdnManifest,
|
||||
}
|
||||
|
||||
/// Notify upstream/downstream that a blob has been deleted (uni-stream)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BlobDeleteNoticePayload {
|
||||
pub cid: [u8; 32],
|
||||
/// If sender was upstream and is providing their own upstream for tree healing
|
||||
#[serde(default)]
|
||||
pub upstream_node: Option<PeerWithAddress>,
|
||||
}
|
||||
|
||||
// --- Group key distribution payloads ---
|
||||
|
||||
/// Admin pushes wrapped group key to a member (uni-stream)
|
||||
|
|
@ -792,7 +787,6 @@ mod tests {
|
|||
MessageType::ManifestRefreshRequest,
|
||||
MessageType::ManifestRefreshResponse,
|
||||
MessageType::ManifestPush,
|
||||
MessageType::BlobDeleteNotice,
|
||||
MessageType::GroupKeyDistribute,
|
||||
MessageType::GroupKeyRequest,
|
||||
MessageType::GroupKeyResponse,
|
||||
|
|
@ -835,36 +829,6 @@ mod tests {
|
|||
assert!(MessageType::from_byte(0x06).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_delete_notice_payload_roundtrip() {
|
||||
use crate::types::PeerWithAddress;
|
||||
|
||||
// Without upstream
|
||||
let payload = BlobDeleteNoticePayload {
|
||||
cid: [42u8; 32],
|
||||
upstream_node: None,
|
||||
};
|
||||
let json = serde_json::to_string(&payload).unwrap();
|
||||
let decoded: BlobDeleteNoticePayload = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.cid, [42u8; 32]);
|
||||
assert!(decoded.upstream_node.is_none());
|
||||
|
||||
// With upstream
|
||||
let payload_with_up = BlobDeleteNoticePayload {
|
||||
cid: [99u8; 32],
|
||||
upstream_node: Some(PeerWithAddress {
|
||||
n: hex::encode([1u8; 32]),
|
||||
a: vec!["10.0.0.1:4433".to_string()],
|
||||
}),
|
||||
};
|
||||
let json2 = serde_json::to_string(&payload_with_up).unwrap();
|
||||
let decoded2: BlobDeleteNoticePayload = serde_json::from_str(&json2).unwrap();
|
||||
assert_eq!(decoded2.cid, [99u8; 32]);
|
||||
assert!(decoded2.upstream_node.is_some());
|
||||
let up = decoded2.upstream_node.unwrap();
|
||||
assert_eq!(up.a, vec!["10.0.0.1:4433".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relay_introduce_payload_roundtrip() {
|
||||
let payload = RelayIntroducePayload {
|
||||
|
|
|
|||
|
|
@ -833,7 +833,9 @@ impl Storage {
|
|||
/// All posts, newest first (with visibility)
|
||||
pub fn list_posts_reverse_chron(&self) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts ORDER BY timestamp_ms DESC",
|
||||
"SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts
|
||||
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"'))
|
||||
ORDER BY timestamp_ms DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let id_bytes: Vec<u8> = row.get(0)?;
|
||||
|
|
@ -869,6 +871,7 @@ impl Storage {
|
|||
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
|
||||
FROM posts p
|
||||
INNER JOIN follows f ON p.author = f.node_id
|
||||
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"'))
|
||||
ORDER BY p.timestamp_ms DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
|
|
@ -905,10 +908,12 @@ impl Storage {
|
|||
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
|
||||
FROM posts p INNER JOIN follows f ON p.author = f.node_id
|
||||
WHERE p.timestamp_ms < ?1
|
||||
AND (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"'))
|
||||
ORDER BY p.timestamp_ms DESC LIMIT ?2"
|
||||
} else {
|
||||
"SELECT p.id, p.author, p.content, p.attachments, p.timestamp_ms, p.visibility
|
||||
FROM posts p INNER JOIN follows f ON p.author = f.node_id
|
||||
WHERE (p.visibility_intent IS NULL OR (p.visibility_intent != '\"Control\"' AND p.visibility_intent != '\"Profile\"'))
|
||||
ORDER BY p.timestamp_ms DESC LIMIT ?2"
|
||||
};
|
||||
let mut stmt = self.conn.prepare(sql)?;
|
||||
|
|
@ -924,11 +929,15 @@ impl Storage {
|
|||
pub fn list_posts_page(&self, before_ms: Option<u64>, limit: usize) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
|
||||
let sql = if before_ms.is_some() {
|
||||
"SELECT id, author, content, attachments, timestamp_ms, visibility
|
||||
FROM posts WHERE timestamp_ms < ?1
|
||||
FROM posts
|
||||
WHERE timestamp_ms < ?1
|
||||
AND (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"'))
|
||||
ORDER BY timestamp_ms DESC LIMIT ?2"
|
||||
} else {
|
||||
"SELECT id, author, content, attachments, timestamp_ms, visibility
|
||||
FROM posts ORDER BY timestamp_ms DESC LIMIT ?2"
|
||||
FROM posts
|
||||
WHERE (visibility_intent IS NULL OR (visibility_intent != '\"Control\"' AND visibility_intent != '\"Profile\"'))
|
||||
ORDER BY timestamp_ms DESC LIMIT ?2"
|
||||
};
|
||||
let mut stmt = self.conn.prepare(sql)?;
|
||||
let rows = if let Some(bms) = before_ms {
|
||||
|
|
@ -1051,9 +1060,39 @@ impl Storage {
|
|||
Ok(posts)
|
||||
}
|
||||
|
||||
/// All posts with visibility (for sync protocol)
|
||||
/// All posts with visibility (for sync protocol and export).
|
||||
/// Includes control/profile posts — they need to propagate through the
|
||||
/// CDN like any other post.
|
||||
pub fn list_posts_with_visibility(&self) -> anyhow::Result<Vec<(PostId, Post, PostVisibility)>> {
|
||||
self.list_posts_reverse_chron()
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, author, content, attachments, timestamp_ms, visibility FROM posts ORDER BY timestamp_ms DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let id_bytes: Vec<u8> = row.get(0)?;
|
||||
let author_bytes: Vec<u8> = row.get(1)?;
|
||||
let content: String = row.get(2)?;
|
||||
let attachments_json: String = row.get(3)?;
|
||||
let timestamp_ms: i64 = row.get(4)?;
|
||||
let vis_json: String = row.get(5)?;
|
||||
Ok((id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json))
|
||||
})?;
|
||||
let mut posts = Vec::new();
|
||||
for row in rows {
|
||||
let (id_bytes, author_bytes, content, attachments_json, timestamp_ms, vis_json) = row?;
|
||||
let attachments: Vec<Attachment> = serde_json::from_str(&attachments_json).unwrap_or_default();
|
||||
let visibility: PostVisibility = serde_json::from_str(&vis_json).unwrap_or_default();
|
||||
posts.push((
|
||||
blob_to_postid(id_bytes)?,
|
||||
Post {
|
||||
author: blob_to_nodeid(author_bytes)?,
|
||||
content,
|
||||
attachments,
|
||||
timestamp_ms: timestamp_ms as u64,
|
||||
},
|
||||
visibility,
|
||||
));
|
||||
}
|
||||
Ok(posts)
|
||||
}
|
||||
|
||||
// ---- Follows ----
|
||||
|
|
|
|||
|
|
@ -251,7 +251,15 @@ pub struct WrappedKey {
|
|||
pub wrapped_cek: Vec<u8>,
|
||||
}
|
||||
|
||||
/// User-facing intent for post visibility (resolved to recipients before encryption)
|
||||
/// User-facing intent for post visibility (resolved to recipients before encryption).
|
||||
///
|
||||
/// A few variants exist for structural distinctions rather than visibility:
|
||||
/// - `Control` — the post carries a signed operation (delete / visibility
|
||||
/// update) that receivers apply. Wire visibility is Public; the post is
|
||||
/// filtered out of feeds and rendered nowhere.
|
||||
/// - `Profile` — the post carries persona display metadata (display_name,
|
||||
/// bio, avatar). Wire visibility is Public; the post is not shown in the
|
||||
/// feed but consulted when rendering the author's name on other posts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum VisibilityIntent {
|
||||
Public,
|
||||
|
|
@ -261,6 +269,33 @@ pub enum VisibilityIntent {
|
|||
Circle(String),
|
||||
/// Specific recipients
|
||||
Direct(Vec<NodeId>),
|
||||
/// Protocol-control post (delete / visibility change).
|
||||
Control,
|
||||
/// Persona profile post (display_name, bio, avatar).
|
||||
Profile,
|
||||
}
|
||||
|
||||
/// Content payload of a `VisibilityIntent::Control` post, serialized as JSON
|
||||
/// into the post's content field.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum ControlOp {
|
||||
/// Delete post `post_id`. Signature is over `b"ctrl:delete:" || post_id
|
||||
/// || timestamp_ms (LE)`, by the target post's author.
|
||||
DeletePost {
|
||||
post_id: PostId,
|
||||
timestamp_ms: u64,
|
||||
/// 64-byte ed25519 signature
|
||||
signature: Vec<u8>,
|
||||
},
|
||||
/// Update post `post_id` visibility. Signature is over
|
||||
/// `b"ctrl:vis:" || post_id || canonical(new_visibility) || timestamp_ms (LE)`.
|
||||
UpdateVisibility {
|
||||
post_id: PostId,
|
||||
new_visibility: PostVisibility,
|
||||
timestamp_ms: u64,
|
||||
signature: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A named group of recipients
|
||||
|
|
|
|||
|
|
@ -238,6 +238,8 @@ async fn post_to_dto(
|
|||
VisibilityIntent::Friends => "friends".to_string(),
|
||||
VisibilityIntent::Circle(_) => "circle".to_string(),
|
||||
VisibilityIntent::Direct(_) => "direct".to_string(),
|
||||
VisibilityIntent::Control => "control".to_string(),
|
||||
VisibilityIntent::Profile => "profile".to_string(),
|
||||
},
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue