Phase 2e: rich comments — optional ref_post_id with signed preview
A comment can now reference a separate Post that carries the full body (long text, attachments, rich formatting). The inline comment's `content` becomes a short preview string; the referenced post propagates through the normal CDN and readers fetch it lazily when rendering the expanded view. Type change: - `InlineComment` gains `ref_post_id: Option<PostId>` (#[serde(default)]). When None, `content` is the full comment text (v0.6.1 shape — unchanged on the wire). When Some, `content` is the preview. Signature binding: - `crypto::sign_comment` / `verify_comment_signature` now take `ref_post_id: Option<&PostId>`. The signed digest appends `b"ref:" || ref_post_id` only when a ref is present, so plain comments produce the same digest as the v0.6.1 scheme and remain verifiable without a migration. When a ref is present the signature binds it, so a peer can't strip or swap the reference without re-signing. Storage: - `comments` table gets a `ref_post_id BLOB` column (nullable). Added to both the CREATE TABLE statement and a conditional ALTER TABLE migration so upgraded DBs pick it up automatically. - `store_comment`, `get_comments`, `get_comments_with_tombstones` read and write the column. Node API: - `comment_on_post` stays as the plain-comment entry point (calls the inner helper with `ref_post_id = None`). - New `comment_on_post_with_ref(post_id, preview, ref_post_id)` for rich comments. Both share a single inner helper that signs, stores, and propagates via BlobHeaderDiff. connection.rs BlobHeaderDiff handler passes `comment.ref_post_id.as_ref()` to the signature verify so forged or rewritten refs are rejected. Tests: new crypto test asserting the signature binds ref_post_id (strip / swap / drop all fail); new storage test asserting ref_post_id roundtrips through live + tombstone reads. 116 / 116 core tests pass. Client-side UX (pulling the ref post on expand, composing rich comments) is frontend work that will land with the next UI iteration.
This commit is contained in:
parent
8b2881d84a
commit
88d5cc9f23
5 changed files with 166 additions and 27 deletions
|
|
@ -6117,6 +6117,7 @@ impl ConnectionManager {
|
||||||
&comment.content,
|
&comment.content,
|
||||||
comment.timestamp_ms,
|
comment.timestamp_ms,
|
||||||
&comment.signature,
|
&comment.signature,
|
||||||
|
comment.ref_post_id.as_ref(),
|
||||||
) {
|
) {
|
||||||
continue; // Skip forged comments
|
continue; // Skip forged comments
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -728,20 +728,37 @@ pub fn decrypt_private_reaction(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sign a comment: ed25519 over BLAKE3(author || post_id || content || timestamp_ms).
|
/// Sign a comment: ed25519 over BLAKE3(author || post_id || content || timestamp_ms).
|
||||||
|
fn comment_digest(
|
||||||
|
author: &NodeId,
|
||||||
|
post_id: &PostId,
|
||||||
|
content: &str,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
ref_post_id: Option<&PostId>,
|
||||||
|
) -> blake3::Hash {
|
||||||
|
let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT);
|
||||||
|
hasher.update(author);
|
||||||
|
hasher.update(post_id);
|
||||||
|
hasher.update(content.as_bytes());
|
||||||
|
hasher.update(×tamp_ms.to_le_bytes());
|
||||||
|
// Domain-separated append: `None` yields the same digest as the v0.6.1
|
||||||
|
// scheme, so plain comments keep verifying; `Some(ref)` adds the ref id.
|
||||||
|
if let Some(rid) = ref_post_id {
|
||||||
|
hasher.update(b"ref:");
|
||||||
|
hasher.update(rid);
|
||||||
|
}
|
||||||
|
hasher.finalize()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn sign_comment(
|
pub fn sign_comment(
|
||||||
seed: &[u8; 32],
|
seed: &[u8; 32],
|
||||||
author: &NodeId,
|
author: &NodeId,
|
||||||
post_id: &PostId,
|
post_id: &PostId,
|
||||||
content: &str,
|
content: &str,
|
||||||
timestamp_ms: u64,
|
timestamp_ms: u64,
|
||||||
|
ref_post_id: Option<&PostId>,
|
||||||
) -> Vec<u8> {
|
) -> Vec<u8> {
|
||||||
let signing_key = SigningKey::from_bytes(seed);
|
let signing_key = SigningKey::from_bytes(seed);
|
||||||
let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT);
|
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id);
|
||||||
hasher.update(author);
|
|
||||||
hasher.update(post_id);
|
|
||||||
hasher.update(content.as_bytes());
|
|
||||||
hasher.update(×tamp_ms.to_le_bytes());
|
|
||||||
let digest = hasher.finalize();
|
|
||||||
signing_key.sign(digest.as_bytes()).to_bytes().to_vec()
|
signing_key.sign(digest.as_bytes()).to_bytes().to_vec()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -752,6 +769,7 @@ pub fn verify_comment_signature(
|
||||||
content: &str,
|
content: &str,
|
||||||
timestamp_ms: u64,
|
timestamp_ms: u64,
|
||||||
signature: &[u8],
|
signature: &[u8],
|
||||||
|
ref_post_id: Option<&PostId>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let Ok(verifying_key) = VerifyingKey::from_bytes(author) else {
|
let Ok(verifying_key) = VerifyingKey::from_bytes(author) else {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -759,12 +777,7 @@ pub fn verify_comment_signature(
|
||||||
let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else {
|
let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT);
|
let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id);
|
||||||
hasher.update(author);
|
|
||||||
hasher.update(post_id);
|
|
||||||
hasher.update(content.as_bytes());
|
|
||||||
hasher.update(×tamp_ms.to_le_bytes());
|
|
||||||
let digest = hasher.finalize();
|
|
||||||
verifying_key.verify(digest.as_bytes(), &sig).is_ok()
|
verifying_key.verify(digest.as_bytes(), &sig).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -999,6 +1012,29 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comment_signature_binds_ref_post_id() {
|
||||||
|
let (seed, nid) = make_keypair(7);
|
||||||
|
let post_id = [1u8; 32];
|
||||||
|
let ref_post = [2u8; 32];
|
||||||
|
let content = "preview";
|
||||||
|
let ts = 1000u64;
|
||||||
|
|
||||||
|
// Signature including ref_post_id.
|
||||||
|
let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post));
|
||||||
|
// Verifies only when the ref is supplied.
|
||||||
|
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post)));
|
||||||
|
// Same signature must NOT verify when the ref is dropped (binding).
|
||||||
|
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None));
|
||||||
|
// Nor when the ref is swapped.
|
||||||
|
let other_ref = [3u8; 32];
|
||||||
|
assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref)));
|
||||||
|
|
||||||
|
// Plain-comment signature still works (backward compat with v0.6.1).
|
||||||
|
let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None);
|
||||||
|
assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sign_verify_manifest() {
|
fn test_sign_verify_manifest() {
|
||||||
use crate::types::{AuthorManifest, ManifestEntry};
|
use crate::types::{AuthorManifest, ManifestEntry};
|
||||||
|
|
|
||||||
|
|
@ -3801,11 +3801,36 @@ impl Node {
|
||||||
Ok(counts)
|
Ok(counts)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a comment to a post (signed with our key).
|
/// Add a plain inline comment to a post (signed with our posting key).
|
||||||
|
/// The comment's `content` is the full text; `ref_post_id` is None.
|
||||||
pub async fn comment_on_post(
|
pub async fn comment_on_post(
|
||||||
&self,
|
&self,
|
||||||
post_id: PostId,
|
post_id: PostId,
|
||||||
content: String,
|
content: String,
|
||||||
|
) -> anyhow::Result<crate::types::InlineComment> {
|
||||||
|
self.comment_on_post_inner(post_id, content, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a rich comment: the full body lives in `ref_post_id` (typically a
|
||||||
|
/// newly-created public post by the commenter that carries attachments
|
||||||
|
/// or a long body). The inline `preview` text appears in the parent
|
||||||
|
/// post's header-diff and is what most clients render by default; the
|
||||||
|
/// expanded view fetches the referenced post. Signature binds the
|
||||||
|
/// preview + ref_post_id so a peer can't rewrite either independently.
|
||||||
|
pub async fn comment_on_post_with_ref(
|
||||||
|
&self,
|
||||||
|
post_id: PostId,
|
||||||
|
preview: String,
|
||||||
|
ref_post_id: PostId,
|
||||||
|
) -> anyhow::Result<crate::types::InlineComment> {
|
||||||
|
self.comment_on_post_inner(post_id, preview, Some(ref_post_id)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn comment_on_post_inner(
|
||||||
|
&self,
|
||||||
|
post_id: PostId,
|
||||||
|
content: String,
|
||||||
|
ref_post_id: Option<PostId>,
|
||||||
) -> anyhow::Result<crate::types::InlineComment> {
|
) -> anyhow::Result<crate::types::InlineComment> {
|
||||||
let our_node_id = self.default_posting_id;
|
let our_node_id = self.default_posting_id;
|
||||||
let seed = self.default_posting_secret;
|
let seed = self.default_posting_secret;
|
||||||
|
|
@ -3813,7 +3838,14 @@ impl Node {
|
||||||
.duration_since(std::time::UNIX_EPOCH)?
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
.as_millis() as u64;
|
.as_millis() as u64;
|
||||||
|
|
||||||
let signature = crate::crypto::sign_comment(&seed, &our_node_id, &post_id, &content, now);
|
let signature = crate::crypto::sign_comment(
|
||||||
|
&seed,
|
||||||
|
&our_node_id,
|
||||||
|
&post_id,
|
||||||
|
&content,
|
||||||
|
now,
|
||||||
|
ref_post_id.as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
let comment = crate::types::InlineComment {
|
let comment = crate::types::InlineComment {
|
||||||
author: our_node_id,
|
author: our_node_id,
|
||||||
|
|
@ -3822,13 +3854,14 @@ impl Node {
|
||||||
timestamp_ms: now,
|
timestamp_ms: now,
|
||||||
signature,
|
signature,
|
||||||
deleted_at: None,
|
deleted_at: None,
|
||||||
|
ref_post_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let storage = self.storage.get().await;
|
let storage = self.storage.get().await;
|
||||||
storage.store_comment(&comment)?;
|
storage.store_comment(&comment)?;
|
||||||
drop(storage);
|
drop(storage);
|
||||||
|
|
||||||
// Propagate via BlobHeaderDiff to downstream + upstream
|
// Propagate via BlobHeaderDiff to the target post's known holders.
|
||||||
{
|
{
|
||||||
let network = &self.network;
|
let network = &self.network;
|
||||||
let diff = crate::protocol::BlobHeaderDiffPayload {
|
let diff = crate::protocol::BlobHeaderDiffPayload {
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,7 @@ impl Storage {
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
timestamp_ms INTEGER NOT NULL,
|
timestamp_ms INTEGER NOT NULL,
|
||||||
signature BLOB NOT NULL,
|
signature BLOB NOT NULL,
|
||||||
|
ref_post_id BLOB,
|
||||||
PRIMARY KEY (author, post_id, timestamp_ms)
|
PRIMARY KEY (author, post_id, timestamp_ms)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id);
|
CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id);
|
||||||
|
|
@ -636,6 +637,17 @@ impl Storage {
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.6.2: add ref_post_id for rich comments (preview-inline,
|
||||||
|
// full-body-in-referenced-post). NULL for plain comments.
|
||||||
|
let has_ref_post_id = self.conn.prepare(
|
||||||
|
"SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='ref_post_id'"
|
||||||
|
)?.query_row([], |row| row.get::<_, i64>(0))?;
|
||||||
|
if has_ref_post_id == 0 {
|
||||||
|
self.conn.execute_batch(
|
||||||
|
"ALTER TABLE comments ADD COLUMN ref_post_id BLOB DEFAULT NULL;"
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
// Add device_role column to peers if missing (Active CDN replication)
|
// Add device_role column to peers if missing (Active CDN replication)
|
||||||
let has_device_role = self.conn.prepare(
|
let has_device_role = self.conn.prepare(
|
||||||
"SELECT COUNT(*) FROM pragma_table_info('peers') WHERE name='device_role'"
|
"SELECT COUNT(*) FROM pragma_table_info('peers') WHERE name='device_role'"
|
||||||
|
|
@ -4541,11 +4553,12 @@ impl Storage {
|
||||||
/// deleted_at tombstone, store it so the tombstone propagates.
|
/// deleted_at tombstone, store it so the tombstone propagates.
|
||||||
pub fn store_comment(&self, comment: &InlineComment) -> anyhow::Result<()> {
|
pub fn store_comment(&self, comment: &InlineComment) -> anyhow::Result<()> {
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at)
|
"INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||||
ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET
|
ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET
|
||||||
content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END,
|
content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END,
|
||||||
deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END",
|
deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END,
|
||||||
|
ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id)",
|
||||||
params![
|
params![
|
||||||
comment.author.as_slice(),
|
comment.author.as_slice(),
|
||||||
comment.post_id.as_slice(),
|
comment.post_id.as_slice(),
|
||||||
|
|
@ -4553,6 +4566,7 @@ impl Storage {
|
||||||
comment.timestamp_ms as i64,
|
comment.timestamp_ms as i64,
|
||||||
comment.signature,
|
comment.signature,
|
||||||
comment.deleted_at.map(|v| v as i64),
|
comment.deleted_at.map(|v| v as i64),
|
||||||
|
comment.ref_post_id.as_ref().map(|r| r.as_slice()),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -4579,7 +4593,7 @@ impl Storage {
|
||||||
/// Get live (non-tombstoned) comments for a post. Used for UI display.
|
/// Get live (non-tombstoned) comments for a post. Used for UI display.
|
||||||
pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
|
pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT author, post_id, content, timestamp_ms, signature
|
"SELECT author, post_id, content, timestamp_ms, signature, ref_post_id
|
||||||
FROM comments WHERE post_id = ?1 AND deleted_at IS NULL ORDER BY timestamp_ms ASC"
|
FROM comments WHERE post_id = ?1 AND deleted_at IS NULL ORDER BY timestamp_ms ASC"
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![post_id.as_slice()], |row| {
|
let rows = stmt.query_map(params![post_id.as_slice()], |row| {
|
||||||
|
|
@ -4588,13 +4602,18 @@ impl Storage {
|
||||||
let content: String = row.get(2)?;
|
let content: String = row.get(2)?;
|
||||||
let ts: i64 = row.get(3)?;
|
let ts: i64 = row.get(3)?;
|
||||||
let sig: Vec<u8> = row.get(4)?;
|
let sig: Vec<u8> = row.get(4)?;
|
||||||
Ok((author, pid, content, ts, sig))
|
let ref_post: Option<Vec<u8>> = row.get(5)?;
|
||||||
|
Ok((author, pid, content, ts, sig, ref_post))
|
||||||
})?;
|
})?;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (author_bytes, pid_bytes, content, ts, sig) = row?;
|
let (author_bytes, pid_bytes, content, ts, sig, ref_post) = row?;
|
||||||
let author = blob_to_nodeid(author_bytes)?;
|
let author = blob_to_nodeid(author_bytes)?;
|
||||||
let post_id = blob_to_postid(pid_bytes)?;
|
let post_id = blob_to_postid(pid_bytes)?;
|
||||||
|
let ref_post_id = match ref_post {
|
||||||
|
Some(b) => Some(blob_to_postid(b)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
result.push(InlineComment {
|
result.push(InlineComment {
|
||||||
author,
|
author,
|
||||||
post_id,
|
post_id,
|
||||||
|
|
@ -4602,6 +4621,7 @@ impl Storage {
|
||||||
timestamp_ms: ts as u64,
|
timestamp_ms: ts as u64,
|
||||||
signature: sig,
|
signature: sig,
|
||||||
deleted_at: None,
|
deleted_at: None,
|
||||||
|
ref_post_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
|
|
@ -4611,7 +4631,7 @@ impl Storage {
|
||||||
/// so tombstones propagate through pull-based sync.
|
/// so tombstones propagate through pull-based sync.
|
||||||
pub fn get_comments_with_tombstones(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
|
pub fn get_comments_with_tombstones(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
|
||||||
let mut stmt = self.conn.prepare(
|
let mut stmt = self.conn.prepare(
|
||||||
"SELECT author, post_id, content, timestamp_ms, signature, deleted_at
|
"SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id
|
||||||
FROM comments WHERE post_id = ?1 ORDER BY timestamp_ms ASC"
|
FROM comments WHERE post_id = ?1 ORDER BY timestamp_ms ASC"
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![post_id.as_slice()], |row| {
|
let rows = stmt.query_map(params![post_id.as_slice()], |row| {
|
||||||
|
|
@ -4621,13 +4641,18 @@ impl Storage {
|
||||||
let ts: i64 = row.get(3)?;
|
let ts: i64 = row.get(3)?;
|
||||||
let sig: Vec<u8> = row.get(4)?;
|
let sig: Vec<u8> = row.get(4)?;
|
||||||
let del: Option<i64> = row.get(5)?;
|
let del: Option<i64> = row.get(5)?;
|
||||||
Ok((author, pid, content, ts, sig, del))
|
let ref_post: Option<Vec<u8>> = row.get(6)?;
|
||||||
|
Ok((author, pid, content, ts, sig, del, ref_post))
|
||||||
})?;
|
})?;
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (author_bytes, pid_bytes, content, ts, sig, del) = row?;
|
let (author_bytes, pid_bytes, content, ts, sig, del, ref_post) = row?;
|
||||||
let author = blob_to_nodeid(author_bytes)?;
|
let author = blob_to_nodeid(author_bytes)?;
|
||||||
let post_id = blob_to_postid(pid_bytes)?;
|
let post_id = blob_to_postid(pid_bytes)?;
|
||||||
|
let ref_post_id = match ref_post {
|
||||||
|
Some(b) => Some(blob_to_postid(b)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
result.push(InlineComment {
|
result.push(InlineComment {
|
||||||
author,
|
author,
|
||||||
post_id,
|
post_id,
|
||||||
|
|
@ -4635,6 +4660,7 @@ impl Storage {
|
||||||
timestamp_ms: ts as u64,
|
timestamp_ms: ts as u64,
|
||||||
signature: sig,
|
signature: sig,
|
||||||
deleted_at: del.map(|v| v as u64),
|
deleted_at: del.map(|v| v as u64),
|
||||||
|
ref_post_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
|
|
@ -6045,6 +6071,7 @@ mod tests {
|
||||||
timestamp_ms: 1000,
|
timestamp_ms: 1000,
|
||||||
signature: vec![0u8; 64],
|
signature: vec![0u8; 64],
|
||||||
deleted_at: None,
|
deleted_at: None,
|
||||||
|
ref_post_id: None,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
s.store_comment(&InlineComment {
|
s.store_comment(&InlineComment {
|
||||||
|
|
@ -6054,6 +6081,7 @@ mod tests {
|
||||||
timestamp_ms: 1001,
|
timestamp_ms: 1001,
|
||||||
signature: vec![1u8; 64],
|
signature: vec![1u8; 64],
|
||||||
deleted_at: None,
|
deleted_at: None,
|
||||||
|
ref_post_id: None,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
|
||||||
let comments = s.get_comments(&post_id).unwrap();
|
let comments = s.get_comments(&post_id).unwrap();
|
||||||
|
|
@ -6063,6 +6091,33 @@ mod tests {
|
||||||
assert_eq!(s.get_comment_count(&post_id).unwrap(), 2);
|
assert_eq!(s.get_comment_count(&post_id).unwrap(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rich_comment_ref_post_id_roundtrip() {
|
||||||
|
use crate::types::InlineComment;
|
||||||
|
let s = temp_storage();
|
||||||
|
let post_id = make_post_id(1);
|
||||||
|
let author = make_node_id(5);
|
||||||
|
let ref_post = make_post_id(42);
|
||||||
|
|
||||||
|
s.store_comment(&InlineComment {
|
||||||
|
author,
|
||||||
|
post_id,
|
||||||
|
content: "(preview of a long body)".to_string(),
|
||||||
|
timestamp_ms: 2000,
|
||||||
|
signature: vec![9u8; 64],
|
||||||
|
deleted_at: None,
|
||||||
|
ref_post_id: Some(ref_post),
|
||||||
|
}).unwrap();
|
||||||
|
|
||||||
|
let live = s.get_comments(&post_id).unwrap();
|
||||||
|
assert_eq!(live.len(), 1);
|
||||||
|
assert_eq!(live[0].ref_post_id, Some(ref_post));
|
||||||
|
|
||||||
|
let all = s.get_comments_with_tombstones(&post_id).unwrap();
|
||||||
|
assert_eq!(all.len(), 1);
|
||||||
|
assert_eq!(all[0].ref_post_id, Some(ref_post));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn comment_policy_crud() {
|
fn comment_policy_crud() {
|
||||||
use crate::types::{CommentPermission, CommentPolicy, ModerationMode, ReactPermission};
|
use crate::types::{CommentPermission, CommentPolicy, ModerationMode, ReactPermission};
|
||||||
|
|
|
||||||
|
|
@ -777,22 +777,36 @@ pub struct Reaction {
|
||||||
pub signature: Vec<u8>,
|
pub signature: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An inline comment on a post
|
/// An inline comment on a post.
|
||||||
|
///
|
||||||
|
/// v0.6.2 adds `ref_post_id`: when present, `content` is a short preview
|
||||||
|
/// string and the full comment body (long text, attachments, rich formatting)
|
||||||
|
/// lives in a separate referenced Post authored by the commenter. Clients
|
||||||
|
/// pull the referenced post lazily when rendering the expanded view.
|
||||||
|
/// When `ref_post_id` is `None`, `content` is the complete comment text
|
||||||
|
/// (the v0.6.1 shape).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct InlineComment {
|
pub struct InlineComment {
|
||||||
/// Comment author
|
/// Comment author
|
||||||
pub author: NodeId,
|
pub author: NodeId,
|
||||||
/// Which post this comment is on
|
/// Which post this comment is on
|
||||||
pub post_id: PostId,
|
pub post_id: PostId,
|
||||||
/// Comment text
|
/// Either the full comment text (short comments) or a short preview of
|
||||||
|
/// the referenced post (when `ref_post_id` is set).
|
||||||
pub content: String,
|
pub content: String,
|
||||||
/// When the comment was created (ms)
|
/// When the comment was created (ms)
|
||||||
pub timestamp_ms: u64,
|
pub timestamp_ms: u64,
|
||||||
/// ed25519 signature over BLAKE3(author || post_id || content || timestamp_ms)
|
/// ed25519 signature. Binds author/post_id/content/timestamp_ms, plus
|
||||||
|
/// `ref_post_id` when present. See `crypto::sign_comment`.
|
||||||
pub signature: Vec<u8>,
|
pub signature: Vec<u8>,
|
||||||
/// Tombstone timestamp — if set, this comment has been soft-deleted
|
/// Tombstone timestamp — if set, this comment has been soft-deleted
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub deleted_at: Option<u64>,
|
pub deleted_at: Option<u64>,
|
||||||
|
/// Optional reference to a full-content Post (long body + attachments).
|
||||||
|
/// When set, `content` is a preview; readers fetch the referenced post
|
||||||
|
/// for the expanded view.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ref_post_id: Option<PostId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Permission level for comments on a post
|
/// Permission level for comments on a post
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue