v0.3.5: Private blob encryption, blob prefetch, intent-based filtering, crypto refactoring
Private blob encryption:
- Encrypted posts (Friends/Circle/Direct) now encrypt attachment blobs with same CEK
- Public blobs unchanged, CID computed on ciphertext for private
- decrypt_blob_for_post/get_blob_for_post for transparent decryption on retrieval
Blob prefetch:
- Pull cycle and sync_with eagerly fetch missing blobs after post sync
- prefetch_blobs_from_peer scans for missing attachments, fetches via fallback chain
- Runs outside conn_mgr lock at Node level
Crypto refactoring:
- Extracted: encrypt/decrypt_bytes_with_cek, wrap/unwrap_cek_for_recipients
- unwrap_cek_for_recipient, unwrap_group_cek, random_cek
- encrypt_post_with_cek, encrypt_post_for_group_with_cek variants
- All existing functions refactored to delegate, 19 crypto tests pass
Intent-based filtering:
- intent_kind field on PostDto ("public"/"friends"/"circle"/"direct"/"unknown")
- Feed/MyPosts filter on intentKind !== 'direct' instead of visibility
- Messages filter with backward-compatible fallback for pre-intent posts
- get_post_intent storage method
IPC updates:
- resolve_blob_data helper using get_blob_for_post with network fallback
- sanitize_download_filename prevents path traversal
- get_blob_path accepts optional post_id_hex
Website:
- Mobile hamburger nav on all pages
- Mesh/Non-mesh N1 labels in network diagnostics
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0abc244ee9
commit
a41b11c0b8
14 changed files with 562 additions and 325 deletions
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "itsgoin-desktop"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ struct PostDto {
|
|||
is_me: bool,
|
||||
/// "public", "encrypted", or "encrypted-for-me"
|
||||
visibility: String,
|
||||
/// The original intent: "public", "friends", "circle", "direct", or "unknown"
|
||||
intent_kind: String,
|
||||
/// Decrypted plaintext if we can decrypt; None for public or if we're not a recipient
|
||||
decrypted_content: Option<String>,
|
||||
attachments: Vec<AttachmentDto>,
|
||||
|
|
@ -158,13 +160,32 @@ async fn post_to_dto(
|
|||
decrypted: Option<&str>,
|
||||
node: &Node,
|
||||
) -> PostDto {
|
||||
let is_me = &post.author == &node.node_id;
|
||||
let author_name = match node.resolve_display_name(&post.author).await {
|
||||
Ok((name, _, _)) if !name.is_empty() => Some(name),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Resolve intent kind from storage
|
||||
let intent_kind = {
|
||||
let storage = node.storage.lock().await;
|
||||
match storage.get_post_intent(id) {
|
||||
Ok(Some(intent)) => match intent {
|
||||
VisibilityIntent::Public => "public".to_string(),
|
||||
VisibilityIntent::Friends => "friends".to_string(),
|
||||
VisibilityIntent::Circle(_) => "circle".to_string(),
|
||||
VisibilityIntent::Direct(_) => "direct".to_string(),
|
||||
},
|
||||
_ => "unknown".to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
// For own encrypted posts, show "encrypted" not "encrypted-for-me"
|
||||
// since intent_kind handles DM filtering now
|
||||
let (visibility, decrypted_content) = match vis {
|
||||
PostVisibility::Public => ("public".to_string(), None),
|
||||
PostVisibility::Encrypted { .. } | PostVisibility::GroupEncrypted { .. } => match decrypted {
|
||||
Some(text) if is_me => ("encrypted".to_string(), Some(text.to_string())),
|
||||
Some(text) => ("encrypted-for-me".to_string(), Some(text.to_string())),
|
||||
None => ("encrypted".to_string(), None),
|
||||
},
|
||||
|
|
@ -203,8 +224,9 @@ async fn post_to_dto(
|
|||
author_name,
|
||||
content: post.content.clone(),
|
||||
timestamp_ms: post.timestamp_ms,
|
||||
is_me: &post.author == &node.node_id,
|
||||
is_me,
|
||||
visibility,
|
||||
intent_kind,
|
||||
decrypted_content,
|
||||
attachments,
|
||||
recipients,
|
||||
|
|
@ -394,16 +416,95 @@ async fn create_post_with_files(
|
|||
}
|
||||
|
||||
/// Return the filesystem path of a blob if it exists locally (for streaming video/media).
|
||||
/// For non-public (encrypted) posts, returns None since raw encrypted blobs can't be served
|
||||
/// via the asset protocol — the frontend must use IPC-based get_blob instead.
|
||||
#[tauri::command]
|
||||
async fn get_blob_path(
|
||||
state: State<'_, AppState>,
|
||||
cid_hex: String,
|
||||
post_id_hex: Option<String>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let node = state.inner();
|
||||
let cid_bytes = hex::decode(&cid_hex).map_err(|e| e.to_string())?;
|
||||
let cid: [u8; 32] = cid_bytes
|
||||
.try_into()
|
||||
.map_err(|_| "CID must be 32 bytes".to_string())?;
|
||||
Ok(state.blob_store.file_path(&cid).map(|p| p.to_string_lossy().to_string()))
|
||||
|
||||
// If a post_id is provided, check if the post is encrypted — if so, can't serve raw file
|
||||
if let Some(ref pid_hex) = post_id_hex {
|
||||
if let Ok(pid_bytes) = hex::decode(pid_hex) {
|
||||
if let Ok(post_id) = <[u8; 32]>::try_from(pid_bytes.as_slice()) {
|
||||
let storage = node.storage.lock().await;
|
||||
if let Ok(Some((_post, vis))) = storage.get_post_with_visibility(&post_id) {
|
||||
if !matches!(vis, PostVisibility::Public) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(node.blob_store.file_path(&cid).map(|p| p.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
/// Sanitize a filename for safe download: strip path separators and control characters.
|
||||
fn sanitize_download_filename(filename: &str) -> String {
|
||||
filename
|
||||
.replace(['/', '\\', '\0'], "_")
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
/// Helper: resolve a blob with optional decryption via post context.
|
||||
/// First tries local (with post-based decryption), then falls back to network fetch + decrypt.
|
||||
async fn resolve_blob_data(
|
||||
node: &Node,
|
||||
cid: &[u8; 32],
|
||||
post_id_hex: Option<&str>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
// Parse post_id if provided
|
||||
let post_id = if let Some(pid_hex) = post_id_hex {
|
||||
let pid_bytes = hex::decode(pid_hex).map_err(|e| e.to_string())?;
|
||||
Some(<[u8; 32]>::try_from(pid_bytes.as_slice()).map_err(|_| "bad post_id".to_string())?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Try local blob with decryption
|
||||
if let Some(ref pid) = post_id {
|
||||
if let Some(data) = node.get_blob_for_post(cid, pid).await.map_err(|e| e.to_string())? {
|
||||
return Ok(data);
|
||||
}
|
||||
} else if let Some(data) = node.get_blob(cid).await.map_err(|e| e.to_string())? {
|
||||
return Ok(data);
|
||||
}
|
||||
|
||||
// Try fetching from network if post_id provided
|
||||
if let Some(pid) = post_id {
|
||||
let post = {
|
||||
let storage = node.storage.lock().await;
|
||||
storage.get_post(&pid).map_err(|e| e.to_string())?
|
||||
};
|
||||
if let Some(post) = post {
|
||||
let mime = post.attachments.iter()
|
||||
.find(|a| a.cid == *cid)
|
||||
.map(|a| a.mime_type.as_str())
|
||||
.unwrap_or("application/octet-stream");
|
||||
if let Some(_fetched) = node
|
||||
.fetch_blob_with_fallback(cid, &pid, &post.author, mime, post.timestamp_ms)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
// Re-read with decryption
|
||||
if let Some(data) = node.get_blob_for_post(cid, &pid).await.map_err(|e| e.to_string())? {
|
||||
return Ok(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("blob not found".to_string())
|
||||
}
|
||||
|
||||
/// Save a blob to the Downloads folder and open it with the system handler.
|
||||
|
|
@ -420,36 +521,14 @@ async fn save_and_open_blob(
|
|||
.try_into()
|
||||
.map_err(|_| "CID must be 32 bytes".to_string())?;
|
||||
|
||||
// Get blob data (local or fetch from network)
|
||||
let data = if let Some(d) = node.get_blob(&cid).await.map_err(|e| e.to_string())? {
|
||||
d
|
||||
} else if let Some(pid_hex) = post_id_hex {
|
||||
let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?;
|
||||
let post_id: [u8; 32] = pid_bytes.try_into().map_err(|_| "bad post_id".to_string())?;
|
||||
let post = {
|
||||
let storage = node.storage.lock().await;
|
||||
storage.get_post(&post_id).map_err(|e| e.to_string())?
|
||||
};
|
||||
if let Some(post) = post {
|
||||
let mime = post.attachments.iter()
|
||||
.find(|a| a.cid == cid)
|
||||
.map(|a| a.mime_type.as_str())
|
||||
.unwrap_or("application/octet-stream");
|
||||
node.fetch_blob_with_fallback(&cid, &post_id, &post.author, mime, post.timestamp_ms)
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "blob not found".to_string())?
|
||||
} else {
|
||||
return Err("post not found".to_string());
|
||||
}
|
||||
} else {
|
||||
return Err("blob not found".to_string());
|
||||
};
|
||||
let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?;
|
||||
let safe_name = sanitize_download_filename(&filename);
|
||||
|
||||
// Save to Downloads
|
||||
let downloads = dirs::download_dir()
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
||||
let dest = downloads.join(&filename);
|
||||
let dest = downloads.join(&safe_name);
|
||||
tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Open with system handler
|
||||
|
|
@ -472,34 +551,13 @@ async fn save_blob(
|
|||
.try_into()
|
||||
.map_err(|_| "CID must be 32 bytes".to_string())?;
|
||||
|
||||
let data = if let Some(d) = node.get_blob(&cid).await.map_err(|e| e.to_string())? {
|
||||
d
|
||||
} else if let Some(pid_hex) = post_id_hex {
|
||||
let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?;
|
||||
let post_id: [u8; 32] = pid_bytes.try_into().map_err(|_| "bad post_id".to_string())?;
|
||||
let post = {
|
||||
let storage = node.storage.lock().await;
|
||||
storage.get_post(&post_id).map_err(|e| e.to_string())?
|
||||
};
|
||||
if let Some(post) = post {
|
||||
let mime = post.attachments.iter()
|
||||
.find(|a| a.cid == cid)
|
||||
.map(|a| a.mime_type.as_str())
|
||||
.unwrap_or("application/octet-stream");
|
||||
node.fetch_blob_with_fallback(&cid, &post_id, &post.author, mime, post.timestamp_ms)
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| "blob not found".to_string())?
|
||||
} else {
|
||||
return Err("post not found".to_string());
|
||||
}
|
||||
} else {
|
||||
return Err("blob not found".to_string());
|
||||
};
|
||||
let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?;
|
||||
let safe_name = sanitize_download_filename(&filename);
|
||||
|
||||
let downloads = dirs::download_dir()
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
|
||||
let dest = downloads.join(&filename);
|
||||
let dest = downloads.join(&safe_name);
|
||||
tokio::fs::write(&dest, &data).await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(dest.to_string_lossy().to_string())
|
||||
|
|
@ -517,41 +575,9 @@ async fn get_blob(
|
|||
.try_into()
|
||||
.map_err(|_| "CID must be 32 bytes".to_string())?;
|
||||
|
||||
// Check local first (also touches last_accessed_at)
|
||||
if let Some(data) = node.get_blob(&cid).await.map_err(|e| e.to_string())? {
|
||||
use base64::Engine;
|
||||
return Ok(base64::engine::general_purpose::STANDARD.encode(&data));
|
||||
}
|
||||
|
||||
// Try fetching from author → replica peers if post_id provided
|
||||
if let Some(pid_hex) = post_id_hex {
|
||||
let pid_bytes = hex::decode(&pid_hex).map_err(|e| e.to_string())?;
|
||||
let post_id: [u8; 32] = pid_bytes
|
||||
.try_into()
|
||||
.map_err(|_| "post_id must be 32 bytes".to_string())?;
|
||||
let post = {
|
||||
let storage = node.storage.lock().await;
|
||||
storage.get_post(&post_id).map_err(|e| e.to_string())?
|
||||
};
|
||||
if let Some(post) = post {
|
||||
// Find the mime type from the post's attachments
|
||||
let mime_type = post.attachments.iter()
|
||||
.find(|a| a.cid == cid)
|
||||
.map(|a| a.mime_type.as_str())
|
||||
.unwrap_or("application/octet-stream");
|
||||
|
||||
if let Some(data) = node
|
||||
.fetch_blob_with_fallback(&cid, &post_id, &post.author, mime_type, post.timestamp_ms)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
{
|
||||
use base64::Engine;
|
||||
return Ok(base64::engine::general_purpose::STANDARD.encode(&data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("blob not found".to_string())
|
||||
let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?;
|
||||
use base64::Engine;
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(&data))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"productName": "itsgoin",
|
||||
"version": "0.3.4",
|
||||
"version": "0.3.5",
|
||||
"identifier": "com.itsgoin.app",
|
||||
"build": {
|
||||
"frontendDist": "../../frontend",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue