feat(mesh): ContentRef typed variant + send/fetch RPCs (Phase 3b)

Adds attachment sharing over the mesh: a ContentRef envelope (variant 19)
carries the blob CID, size, mime, optional thumb/caption, and a per-peer
HMAC capability URL so the recipient fetches the full blob out-of-band via
`GET {sender_onion}/blob/{cid}?cap=..&exp=..&peer=..`. BlobStore is shared
from ApiHandler into RpcHandler so mesh.send-content and mesh.fetch-content
(reqwest via TOR_SOCKS_PROXY) hit the same store and cap_key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-13 11:10:49 -04:00
co-authored by Claude Opus 4.6
parent e8a729a4c7
commit dce5084451
6 changed files with 289 additions and 1 deletions
@@ -277,6 +277,22 @@ pub(super) async fn handle_typed_message(
dispatch_tx_confirmation(&envelope, sender_contact_id, sender_name, state).await;
}
Some(MeshMessageType::ContentRef) => {
match message_types::decode_payload::<message_types::ContentRefPayload>(&envelope.v) {
Ok(content) => {
let text = match (&content.filename, &content.caption) {
(Some(fname), Some(c)) => format!("📎 {}{}", fname, c),
(Some(fname), None) => format!("📎 {}", fname),
(None, Some(c)) => format!("📎 {}", c),
(None, None) => format!("📎 {} ({} bytes)", content.mime, content.size),
};
let json = payload_to_json(&content);
store_typed_message(state, sender_contact_id, sender_name, &text, "content_ref", json, Some(envelope.seq)).await;
}
Err(e) => warn!("Failed to decode content_ref payload: {}", e),
}
}
Some(MeshMessageType::Text) => {
// Typed text message — extract and store as plain text
let text = String::from_utf8_lossy(&envelope.v).to_string();
@@ -42,6 +42,8 @@ pub enum MeshMessageType {
LightningRelayResponse = 11,
/// Confirmation update for a relayed transaction (1, 2, 3 confs).
TxConfirmation = 12,
/// Attachment/file reference: CID of a blob held by the sender, fetched out-of-band.
ContentRef = 19,
}
impl MeshMessageType {
@@ -60,6 +62,7 @@ impl MeshMessageType {
10 => Some(Self::LightningRelay),
11 => Some(Self::LightningRelayResponse),
12 => Some(Self::TxConfirmation),
19 => Some(Self::ContentRef),
_ => None,
}
}
@@ -79,6 +82,7 @@ impl MeshMessageType {
Self::LightningRelay => "lightning_relay",
Self::LightningRelayResponse => "lightning_relay_response",
Self::TxConfirmation => "tx_confirmation",
Self::ContentRef => "content_ref",
}
}
}
@@ -316,6 +320,28 @@ pub struct LightningRelayResponsePayload {
pub error: Option<String>,
}
/// Content/attachment reference: points at a blob held by the sender that
/// recipients fetch out-of-band via `GET {sender_onion}/blob/{cid}?cap=..&exp=..&peer=..`.
/// Thumb bytes (≤60B) may be inlined for immediate display; full blob is lazy.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentRefPayload {
pub cid: String,
pub size: u64,
pub mime: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumb_bytes: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
/// Sender's onion host (no scheme, no trailing slash) — `<host>.onion`.
pub sender_onion: String,
/// HMAC capability token scoped to (cid, recipient_pubkey, exp).
pub cap_token: String,
/// Capability expiry (unix seconds).
pub cap_exp: u64,
}
/// Transaction confirmation update (relay node → originator).
/// Sent after each new confirmation (1, 2, 3) until fully confirmed.
#[derive(Debug, Clone, Serialize, Deserialize)]