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
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -2746,7 +2746,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "itsgoin-desktop"
|
||||
version = "0.3.4"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
|
|
|
|||
|
|
@ -37,40 +37,55 @@ fn derive_wrapping_key(shared_secret: &[u8; 32]) -> [u8; 32] {
|
|||
blake3::derive_key(CEK_WRAP_CONTEXT, shared_secret)
|
||||
}
|
||||
|
||||
/// Encrypt a post's plaintext content for the given recipients.
|
||||
///
|
||||
/// Returns `(base64_ciphertext, Vec<WrappedKey>)` where:
|
||||
/// - base64_ciphertext is `base64(nonce(12) || ciphertext || tag(16))` for the content
|
||||
/// - Each WrappedKey contains the CEK encrypted for one recipient
|
||||
///
|
||||
/// The author (our_seed's corresponding NodeId) is always included as a recipient.
|
||||
pub fn encrypt_post(
|
||||
plaintext: &str,
|
||||
// --- Crypto primitives ---
|
||||
|
||||
/// Generate a random 32-byte Content Encryption Key (CEK).
|
||||
fn random_cek() -> [u8; 32] {
|
||||
let mut cek = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut cek);
|
||||
cek
|
||||
}
|
||||
|
||||
/// Encrypt arbitrary bytes with a CEK using ChaCha20-Poly1305.
|
||||
/// Returns `nonce(12) || ciphertext || tag(16)`.
|
||||
pub fn encrypt_bytes_with_cek(bytes: &[u8], cek: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(cek)
|
||||
.map_err(|e| anyhow::anyhow!("cipher init: {}", e))?;
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::rng().fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(nonce, bytes)
|
||||
.map_err(|e| anyhow::anyhow!("encrypt: {}", e))?;
|
||||
let mut payload = Vec::with_capacity(12 + ciphertext.len());
|
||||
payload.extend_from_slice(&nonce_bytes);
|
||||
payload.extend_from_slice(&ciphertext);
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
/// Decrypt bytes that were encrypted with `encrypt_bytes_with_cek`.
|
||||
/// Expects `nonce(12) || ciphertext || tag(16)`.
|
||||
pub fn decrypt_bytes_with_cek(payload: &[u8], cek: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
if payload.len() < 12 + 16 {
|
||||
bail!("encrypted payload too short");
|
||||
}
|
||||
let nonce = Nonce::from_slice(&payload[..12]);
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(cek)
|
||||
.map_err(|e| anyhow::anyhow!("cipher init: {}", e))?;
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, &payload[12..])
|
||||
.map_err(|e| anyhow::anyhow!("decrypt: {}", e))?;
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Wrap a CEK for a set of recipients using X25519 DH.
|
||||
/// The author (our_node_id) is always included.
|
||||
fn wrap_cek_for_recipients(
|
||||
cek: &[u8; 32],
|
||||
our_seed: &[u8; 32],
|
||||
our_node_id: &NodeId,
|
||||
recipients: &[NodeId],
|
||||
) -> Result<(String, Vec<WrappedKey>)> {
|
||||
// Generate random 32-byte Content Encryption Key
|
||||
let mut cek = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut cek);
|
||||
|
||||
// Encrypt content with CEK using ChaCha20-Poly1305
|
||||
let content_cipher = ChaCha20Poly1305::new_from_slice(&cek)
|
||||
.map_err(|e| anyhow::anyhow!("cipher init: {}", e))?;
|
||||
let mut content_nonce_bytes = [0u8; 12];
|
||||
rand::rng().fill_bytes(&mut content_nonce_bytes);
|
||||
let content_nonce = Nonce::from_slice(&content_nonce_bytes);
|
||||
let ciphertext = content_cipher
|
||||
.encrypt(content_nonce, plaintext.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("encrypt: {}", e))?;
|
||||
|
||||
// base64(nonce || ciphertext_with_tag)
|
||||
let mut payload = Vec::with_capacity(12 + ciphertext.len());
|
||||
payload.extend_from_slice(&content_nonce_bytes);
|
||||
payload.extend_from_slice(&ciphertext);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&payload);
|
||||
|
||||
// Get our X25519 private key
|
||||
) -> Result<Vec<WrappedKey>> {
|
||||
let our_x25519_private = ed25519_seed_to_x25519_private(our_seed);
|
||||
|
||||
// Build recipient set (always include ourselves)
|
||||
|
|
@ -79,7 +94,6 @@ pub fn encrypt_post(
|
|||
all_recipients.push(*our_node_id);
|
||||
}
|
||||
|
||||
// Wrap CEK for each recipient
|
||||
let mut wrapped_keys = Vec::with_capacity(all_recipients.len());
|
||||
for recipient in &all_recipients {
|
||||
let their_x25519_pub = ed25519_pubkey_to_x25519_public(recipient)?;
|
||||
|
|
@ -106,20 +120,17 @@ pub fn encrypt_post(
|
|||
});
|
||||
}
|
||||
|
||||
Ok((encoded, wrapped_keys))
|
||||
Ok(wrapped_keys)
|
||||
}
|
||||
|
||||
/// Decrypt a post's content if we are among the recipients.
|
||||
///
|
||||
/// Returns `Ok(Some(plaintext))` if we can decrypt, `Ok(None)` if we're not a recipient.
|
||||
pub fn decrypt_post(
|
||||
encrypted_content_b64: &str,
|
||||
/// Unwrap a CEK from wrapped keys if we are a recipient.
|
||||
/// Returns `Ok(Some(cek))` if we can unwrap, `Ok(None)` if we're not a recipient.
|
||||
pub fn unwrap_cek_for_recipient(
|
||||
our_seed: &[u8; 32],
|
||||
our_node_id: &NodeId,
|
||||
sender_pubkey: &NodeId,
|
||||
wrapped_keys: &[WrappedKey],
|
||||
) -> Result<Option<String>> {
|
||||
// Find our wrapped key
|
||||
) -> Result<Option<[u8; 32]>> {
|
||||
let our_wk = match wrapped_keys.iter().find(|wk| &wk.recipient == our_node_id) {
|
||||
Some(wk) => wk,
|
||||
None => return Ok(None),
|
||||
|
|
@ -132,40 +143,142 @@ pub fn decrypt_post(
|
|||
);
|
||||
}
|
||||
|
||||
// DH with sender to get wrapping key
|
||||
let our_x25519_private = ed25519_seed_to_x25519_private(our_seed);
|
||||
let sender_x25519_pub = ed25519_pubkey_to_x25519_public(sender_pubkey)?;
|
||||
let shared_secret = x25519_dh(&our_x25519_private, &sender_x25519_pub);
|
||||
let wrapping_key = derive_wrapping_key(&shared_secret);
|
||||
|
||||
// Unwrap CEK
|
||||
let wrap_nonce = Nonce::from_slice(&our_wk.wrapped_cek[..12]);
|
||||
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
|
||||
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
|
||||
let cek = wrap_cipher
|
||||
let cek_vec = wrap_cipher
|
||||
.decrypt(wrap_nonce, &our_wk.wrapped_cek[12..])
|
||||
.map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?;
|
||||
|
||||
if cek.len() != 32 {
|
||||
bail!("unwrapped CEK wrong length: {}", cek.len());
|
||||
if cek_vec.len() != 32 {
|
||||
bail!("unwrapped CEK wrong length: {}", cek_vec.len());
|
||||
}
|
||||
let mut cek = [0u8; 32];
|
||||
cek.copy_from_slice(&cek_vec);
|
||||
Ok(Some(cek))
|
||||
}
|
||||
|
||||
/// Unwrap a group-encrypted CEK using the group seed and public key.
|
||||
pub fn unwrap_group_cek(
|
||||
group_seed: &[u8; 32],
|
||||
group_public_key: &[u8; 32],
|
||||
wrapped_cek: &[u8],
|
||||
) -> Result<[u8; 32]> {
|
||||
if wrapped_cek.len() != 60 {
|
||||
bail!("invalid wrapped_cek length: expected 60, got {}", wrapped_cek.len());
|
||||
}
|
||||
|
||||
let group_x25519_private = ed25519_seed_to_x25519_private(group_seed);
|
||||
let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?;
|
||||
let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public);
|
||||
let wrapping_key = derive_group_cek_wrapping_key(&shared_secret);
|
||||
|
||||
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
|
||||
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
|
||||
let wrap_nonce = Nonce::from_slice(&wrapped_cek[..12]);
|
||||
let cek_vec = wrap_cipher
|
||||
.decrypt(wrap_nonce, &wrapped_cek[12..])
|
||||
.map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?;
|
||||
|
||||
if cek_vec.len() != 32 {
|
||||
bail!("unwrapped CEK wrong length: {}", cek_vec.len());
|
||||
}
|
||||
let mut cek = [0u8; 32];
|
||||
cek.copy_from_slice(&cek_vec);
|
||||
Ok(cek)
|
||||
}
|
||||
|
||||
/// Encrypt a post with a provided CEK, wrapping for recipients.
|
||||
/// Returns `(base64_ciphertext, Vec<WrappedKey>)`.
|
||||
pub fn encrypt_post_with_cek(
|
||||
plaintext: &str,
|
||||
cek: &[u8; 32],
|
||||
our_seed: &[u8; 32],
|
||||
our_node_id: &NodeId,
|
||||
recipients: &[NodeId],
|
||||
) -> Result<(String, Vec<WrappedKey>)> {
|
||||
let payload = encrypt_bytes_with_cek(plaintext.as_bytes(), cek)?;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&payload);
|
||||
let wrapped_keys = wrap_cek_for_recipients(cek, our_seed, our_node_id, recipients)?;
|
||||
Ok((encoded, wrapped_keys))
|
||||
}
|
||||
|
||||
/// Encrypt a post for a group with a provided CEK.
|
||||
/// Returns `(base64_ciphertext, wrapped_cek_bytes)`.
|
||||
pub fn encrypt_post_for_group_with_cek(
|
||||
plaintext: &str,
|
||||
cek: &[u8; 32],
|
||||
group_seed: &[u8; 32],
|
||||
group_public_key: &[u8; 32],
|
||||
) -> Result<(String, Vec<u8>)> {
|
||||
let payload = encrypt_bytes_with_cek(plaintext.as_bytes(), cek)?;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&payload);
|
||||
|
||||
// Wrap CEK using group DH
|
||||
let group_x25519_private = ed25519_seed_to_x25519_private(group_seed);
|
||||
let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?;
|
||||
let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public);
|
||||
let wrapping_key = derive_group_cek_wrapping_key(&shared_secret);
|
||||
|
||||
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
|
||||
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
|
||||
let mut wrap_nonce_bytes = [0u8; 12];
|
||||
rand::rng().fill_bytes(&mut wrap_nonce_bytes);
|
||||
let wrap_nonce = Nonce::from_slice(&wrap_nonce_bytes);
|
||||
let wrapped = wrap_cipher
|
||||
.encrypt(wrap_nonce, cek.as_slice())
|
||||
.map_err(|e| anyhow::anyhow!("wrap CEK: {}", e))?;
|
||||
|
||||
let mut wrapped_cek = Vec::with_capacity(60);
|
||||
wrapped_cek.extend_from_slice(&wrap_nonce_bytes);
|
||||
wrapped_cek.extend_from_slice(&wrapped);
|
||||
|
||||
Ok((encoded, wrapped_cek))
|
||||
}
|
||||
|
||||
/// Encrypt a post's plaintext content for the given recipients.
|
||||
///
|
||||
/// Returns `(base64_ciphertext, Vec<WrappedKey>)` where:
|
||||
/// - base64_ciphertext is `base64(nonce(12) || ciphertext || tag(16))` for the content
|
||||
/// - Each WrappedKey contains the CEK encrypted for one recipient
|
||||
///
|
||||
/// The author (our_seed's corresponding NodeId) is always included as a recipient.
|
||||
pub fn encrypt_post(
|
||||
plaintext: &str,
|
||||
our_seed: &[u8; 32],
|
||||
our_node_id: &NodeId,
|
||||
recipients: &[NodeId],
|
||||
) -> Result<(String, Vec<WrappedKey>)> {
|
||||
let cek = random_cek();
|
||||
encrypt_post_with_cek(plaintext, &cek, our_seed, our_node_id, recipients)
|
||||
}
|
||||
|
||||
/// Decrypt a post's content if we are among the recipients.
|
||||
///
|
||||
/// Returns `Ok(Some(plaintext))` if we can decrypt, `Ok(None)` if we're not a recipient.
|
||||
pub fn decrypt_post(
|
||||
encrypted_content_b64: &str,
|
||||
our_seed: &[u8; 32],
|
||||
our_node_id: &NodeId,
|
||||
sender_pubkey: &NodeId,
|
||||
wrapped_keys: &[WrappedKey],
|
||||
) -> Result<Option<String>> {
|
||||
let cek = match unwrap_cek_for_recipient(our_seed, our_node_id, sender_pubkey, wrapped_keys)? {
|
||||
Some(cek) => cek,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// Decode base64 content
|
||||
let payload = base64::engine::general_purpose::STANDARD
|
||||
.decode(encrypted_content_b64)
|
||||
.map_err(|e| anyhow::anyhow!("base64 decode: {}", e))?;
|
||||
if payload.len() < 12 + 16 {
|
||||
bail!("encrypted payload too short");
|
||||
}
|
||||
|
||||
// Decrypt content
|
||||
let content_nonce = Nonce::from_slice(&payload[..12]);
|
||||
let content_cipher = ChaCha20Poly1305::new_from_slice(&cek)
|
||||
.map_err(|e| anyhow::anyhow!("content cipher init: {}", e))?;
|
||||
let plaintext = content_cipher
|
||||
.decrypt(content_nonce, &payload[12..])
|
||||
.map_err(|e| anyhow::anyhow!("decrypt content: {}", e))?;
|
||||
|
||||
let plaintext = decrypt_bytes_with_cek(&payload, &cek)?;
|
||||
Ok(Some(String::from_utf8(plaintext)?))
|
||||
}
|
||||
|
||||
|
|
@ -202,63 +315,12 @@ pub fn rewrap_visibility(
|
|||
existing_recipients: &[WrappedKey],
|
||||
new_recipient_ids: &[NodeId],
|
||||
) -> Result<Vec<WrappedKey>> {
|
||||
// Find our wrapped key
|
||||
let our_wk = existing_recipients
|
||||
.iter()
|
||||
.find(|wk| &wk.recipient == our_node_id)
|
||||
// Unwrap CEK using DH with ourselves (we are both sender and recipient here)
|
||||
let cek = unwrap_cek_for_recipient(our_seed, our_node_id, our_node_id, existing_recipients)?
|
||||
.ok_or_else(|| anyhow::anyhow!("we are not a recipient of this post"))?;
|
||||
|
||||
if our_wk.wrapped_cek.len() != 60 {
|
||||
bail!(
|
||||
"invalid wrapped_cek length: expected 60, got {}",
|
||||
our_wk.wrapped_cek.len()
|
||||
);
|
||||
}
|
||||
|
||||
// DH with ourselves (author DH with self) to unwrap CEK
|
||||
let our_x25519_private = ed25519_seed_to_x25519_private(our_seed);
|
||||
let our_x25519_pub = ed25519_pubkey_to_x25519_public(our_node_id)?;
|
||||
let shared_secret = x25519_dh(&our_x25519_private, &our_x25519_pub);
|
||||
let wrapping_key = derive_wrapping_key(&shared_secret);
|
||||
|
||||
let wrap_nonce = Nonce::from_slice(&our_wk.wrapped_cek[..12]);
|
||||
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
|
||||
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
|
||||
let cek = wrap_cipher
|
||||
.decrypt(wrap_nonce, &our_wk.wrapped_cek[12..])
|
||||
.map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?;
|
||||
|
||||
if cek.len() != 32 {
|
||||
bail!("unwrapped CEK wrong length: {}", cek.len());
|
||||
}
|
||||
|
||||
// Re-wrap for each new recipient
|
||||
let mut wrapped_keys = Vec::with_capacity(new_recipient_ids.len());
|
||||
for recipient in new_recipient_ids {
|
||||
let their_x25519_pub = ed25519_pubkey_to_x25519_public(recipient)?;
|
||||
let shared_secret = x25519_dh(&our_x25519_private, &their_x25519_pub);
|
||||
let wrapping_key = derive_wrapping_key(&shared_secret);
|
||||
|
||||
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
|
||||
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
|
||||
let mut wrap_nonce_bytes = [0u8; 12];
|
||||
rand::rng().fill_bytes(&mut wrap_nonce_bytes);
|
||||
let wrap_nonce = Nonce::from_slice(&wrap_nonce_bytes);
|
||||
let wrapped = wrap_cipher
|
||||
.encrypt(wrap_nonce, cek.as_slice())
|
||||
.map_err(|e| anyhow::anyhow!("wrap: {}", e))?;
|
||||
|
||||
let mut wrapped_cek = Vec::with_capacity(60);
|
||||
wrapped_cek.extend_from_slice(&wrap_nonce_bytes);
|
||||
wrapped_cek.extend_from_slice(&wrapped);
|
||||
|
||||
wrapped_keys.push(WrappedKey {
|
||||
recipient: *recipient,
|
||||
wrapped_cek,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(wrapped_keys)
|
||||
// Re-wrap for each new recipient (don't auto-add ourselves — caller controls the list)
|
||||
wrap_cek_for_recipients(&cek, our_seed, our_node_id, new_recipient_ids)
|
||||
}
|
||||
|
||||
// --- Group Key Encryption ---
|
||||
|
|
@ -352,45 +414,8 @@ pub fn encrypt_post_for_group(
|
|||
group_seed: &[u8; 32],
|
||||
group_public_key: &[u8; 32],
|
||||
) -> Result<(String, Vec<u8>)> {
|
||||
// Generate random CEK
|
||||
let mut cek = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut cek);
|
||||
|
||||
// Encrypt content with CEK
|
||||
let content_cipher = ChaCha20Poly1305::new_from_slice(&cek)
|
||||
.map_err(|e| anyhow::anyhow!("cipher init: {}", e))?;
|
||||
let mut content_nonce_bytes = [0u8; 12];
|
||||
rand::rng().fill_bytes(&mut content_nonce_bytes);
|
||||
let content_nonce = Nonce::from_slice(&content_nonce_bytes);
|
||||
let ciphertext = content_cipher
|
||||
.encrypt(content_nonce, plaintext.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("encrypt: {}", e))?;
|
||||
|
||||
let mut payload = Vec::with_capacity(12 + ciphertext.len());
|
||||
payload.extend_from_slice(&content_nonce_bytes);
|
||||
payload.extend_from_slice(&ciphertext);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(&payload);
|
||||
|
||||
// Wrap CEK using group DH: group_seed (as X25519 private) × group_public_key (as X25519 public)
|
||||
let group_x25519_private = ed25519_seed_to_x25519_private(group_seed);
|
||||
let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?;
|
||||
let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public);
|
||||
let wrapping_key = derive_group_cek_wrapping_key(&shared_secret);
|
||||
|
||||
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
|
||||
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
|
||||
let mut wrap_nonce_bytes = [0u8; 12];
|
||||
rand::rng().fill_bytes(&mut wrap_nonce_bytes);
|
||||
let wrap_nonce = Nonce::from_slice(&wrap_nonce_bytes);
|
||||
let wrapped = wrap_cipher
|
||||
.encrypt(wrap_nonce, cek.as_slice())
|
||||
.map_err(|e| anyhow::anyhow!("wrap CEK: {}", e))?;
|
||||
|
||||
let mut wrapped_cek = Vec::with_capacity(60);
|
||||
wrapped_cek.extend_from_slice(&wrap_nonce_bytes);
|
||||
wrapped_cek.extend_from_slice(&wrapped);
|
||||
|
||||
Ok((encoded, wrapped_cek))
|
||||
let cek = random_cek();
|
||||
encrypt_post_for_group_with_cek(plaintext, &cek, group_seed, group_public_key)
|
||||
}
|
||||
|
||||
/// Decrypt a group-encrypted post using the group seed and public key.
|
||||
|
|
@ -400,42 +425,14 @@ pub fn decrypt_group_post(
|
|||
group_public_key: &[u8; 32],
|
||||
wrapped_cek: &[u8],
|
||||
) -> Result<String> {
|
||||
if wrapped_cek.len() != 60 {
|
||||
bail!("invalid wrapped_cek length: expected 60, got {}", wrapped_cek.len());
|
||||
}
|
||||
|
||||
// Unwrap CEK using group DH
|
||||
let group_x25519_private = ed25519_seed_to_x25519_private(group_seed);
|
||||
let group_x25519_public = ed25519_pubkey_to_x25519_public(group_public_key)?;
|
||||
let shared_secret = x25519_dh(&group_x25519_private, &group_x25519_public);
|
||||
let wrapping_key = derive_group_cek_wrapping_key(&shared_secret);
|
||||
|
||||
let wrap_cipher = ChaCha20Poly1305::new_from_slice(&wrapping_key)
|
||||
.map_err(|e| anyhow::anyhow!("wrap cipher init: {}", e))?;
|
||||
let wrap_nonce = Nonce::from_slice(&wrapped_cek[..12]);
|
||||
let cek = wrap_cipher
|
||||
.decrypt(wrap_nonce, &wrapped_cek[12..])
|
||||
.map_err(|e| anyhow::anyhow!("unwrap CEK: {}", e))?;
|
||||
|
||||
if cek.len() != 32 {
|
||||
bail!("unwrapped CEK wrong length: {}", cek.len());
|
||||
}
|
||||
let cek = unwrap_group_cek(group_seed, group_public_key, wrapped_cek)?;
|
||||
|
||||
// Decode and decrypt content
|
||||
let payload = base64::engine::general_purpose::STANDARD
|
||||
.decode(encrypted_b64)
|
||||
.map_err(|e| anyhow::anyhow!("base64 decode: {}", e))?;
|
||||
if payload.len() < 12 + 16 {
|
||||
bail!("encrypted payload too short");
|
||||
}
|
||||
|
||||
let content_nonce = Nonce::from_slice(&payload[..12]);
|
||||
let content_cipher = ChaCha20Poly1305::new_from_slice(&cek)
|
||||
.map_err(|e| anyhow::anyhow!("content cipher init: {}", e))?;
|
||||
let plaintext = content_cipher
|
||||
.decrypt(content_nonce, &payload[12..])
|
||||
.map_err(|e| anyhow::anyhow!("decrypt content: {}", e))?;
|
||||
|
||||
let plaintext = decrypt_bytes_with_cek(&payload, &cek)?;
|
||||
Ok(String::from_utf8(plaintext)?)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -522,24 +522,20 @@ impl Node {
|
|||
}
|
||||
}
|
||||
|
||||
// Store blob files and build attachment metadata (DB records deferred until post_id known)
|
||||
let mut attachments = Vec::with_capacity(attachment_data.len());
|
||||
for (data, mime) in &attachment_data {
|
||||
let cid = crate::blob::compute_blob_id(data);
|
||||
self.blob_store.store(&cid, data)?;
|
||||
attachments.push(Attachment {
|
||||
cid,
|
||||
mime_type: mime.clone(),
|
||||
size_bytes: data.len() as u64,
|
||||
});
|
||||
}
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_millis() as u64;
|
||||
|
||||
let (final_content, visibility) = match &intent {
|
||||
VisibilityIntent::Public => (content, PostVisibility::Public),
|
||||
// Determine encryption parameters and generate CEK if needed.
|
||||
// The CEK is generated BEFORE both content and blob encryption so they share the same key.
|
||||
enum EncryptionMode {
|
||||
Public,
|
||||
Recipient { cek: [u8; 32], recipients: Vec<NodeId> },
|
||||
Group { cek: [u8; 32], group_id: [u8; 32], epoch: u64, group_seed: [u8; 32], group_pubkey: [u8; 32] },
|
||||
}
|
||||
|
||||
let mode = match &intent {
|
||||
VisibilityIntent::Public => EncryptionMode::Public,
|
||||
VisibilityIntent::Circle(circle_name) => {
|
||||
// Try group encryption first
|
||||
let group_info = {
|
||||
|
|
@ -551,30 +547,17 @@ impl Node {
|
|||
})
|
||||
};
|
||||
if let Some((group_id, epoch, group_seed, group_pubkey)) = group_info {
|
||||
let (encrypted, wrapped_cek) =
|
||||
crypto::encrypt_post_for_group(&content, &group_seed, &group_pubkey)?;
|
||||
(
|
||||
encrypted,
|
||||
PostVisibility::GroupEncrypted {
|
||||
group_id,
|
||||
epoch,
|
||||
wrapped_cek,
|
||||
},
|
||||
)
|
||||
let mut cek = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek);
|
||||
EncryptionMode::Group { cek, group_id, epoch, group_seed, group_pubkey }
|
||||
} else {
|
||||
// Fallback to per-recipient encryption
|
||||
let recipients = self.resolve_recipients(&intent).await?;
|
||||
if recipients.is_empty() {
|
||||
anyhow::bail!("no recipients resolved for this visibility");
|
||||
}
|
||||
let (encrypted, wrapped_keys) =
|
||||
crypto::encrypt_post(&content, &self.secret_seed, &self.node_id, &recipients)?;
|
||||
(
|
||||
encrypted,
|
||||
PostVisibility::Encrypted {
|
||||
recipients: wrapped_keys,
|
||||
},
|
||||
)
|
||||
let mut cek = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek);
|
||||
EncryptionMode::Recipient { cek, recipients }
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
|
@ -582,8 +565,41 @@ impl Node {
|
|||
if recipients.is_empty() {
|
||||
anyhow::bail!("no recipients resolved for this visibility");
|
||||
}
|
||||
let mut cek = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rng(), &mut cek);
|
||||
EncryptionMode::Recipient { cek, recipients }
|
||||
}
|
||||
};
|
||||
|
||||
// Store blob files — for encrypted posts, encrypt each blob with the shared CEK.
|
||||
// CID is computed on the ciphertext so peers can verify what they store.
|
||||
let mut attachments = Vec::with_capacity(attachment_data.len());
|
||||
for (data, mime) in &attachment_data {
|
||||
let (store_data, size) = match &mode {
|
||||
EncryptionMode::Public => {
|
||||
(data.clone(), data.len() as u64)
|
||||
}
|
||||
EncryptionMode::Recipient { cek, .. } | EncryptionMode::Group { cek, .. } => {
|
||||
let encrypted = crypto::encrypt_bytes_with_cek(data, cek)?;
|
||||
let sz = encrypted.len() as u64;
|
||||
(encrypted, sz)
|
||||
}
|
||||
};
|
||||
let cid = crate::blob::compute_blob_id(&store_data);
|
||||
self.blob_store.store(&cid, &store_data)?;
|
||||
attachments.push(Attachment {
|
||||
cid,
|
||||
mime_type: mime.clone(),
|
||||
size_bytes: size,
|
||||
});
|
||||
}
|
||||
|
||||
// Encrypt content and build visibility
|
||||
let (final_content, visibility) = match mode {
|
||||
EncryptionMode::Public => (content, PostVisibility::Public),
|
||||
EncryptionMode::Recipient { cek, recipients } => {
|
||||
let (encrypted, wrapped_keys) =
|
||||
crypto::encrypt_post(&content, &self.secret_seed, &self.node_id, &recipients)?;
|
||||
crypto::encrypt_post_with_cek(&content, &cek, &self.secret_seed, &self.node_id, &recipients)?;
|
||||
(
|
||||
encrypted,
|
||||
PostVisibility::Encrypted {
|
||||
|
|
@ -591,6 +607,18 @@ impl Node {
|
|||
},
|
||||
)
|
||||
}
|
||||
EncryptionMode::Group { cek, group_id, epoch, group_seed, group_pubkey } => {
|
||||
let (encrypted, wrapped_cek) =
|
||||
crypto::encrypt_post_for_group_with_cek(&content, &cek, &group_seed, &group_pubkey)?;
|
||||
(
|
||||
encrypted,
|
||||
PostVisibility::GroupEncrypted {
|
||||
group_id,
|
||||
epoch,
|
||||
wrapped_cek,
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let post = Post {
|
||||
|
|
@ -969,6 +997,136 @@ impl Node {
|
|||
Ok(data)
|
||||
}
|
||||
|
||||
/// Decrypt a blob in the context of a post's visibility.
|
||||
/// Public posts pass through unchanged. Encrypted/group-encrypted posts decrypt with the CEK.
|
||||
fn decrypt_blob_for_post(
|
||||
&self,
|
||||
data: Vec<u8>,
|
||||
post: &Post,
|
||||
visibility: &PostVisibility,
|
||||
group_seeds: &std::collections::HashMap<([u8; 32], u64), ([u8; 32], [u8; 32])>,
|
||||
) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
match visibility {
|
||||
PostVisibility::Public => Ok(Some(data)),
|
||||
PostVisibility::Encrypted { recipients } => {
|
||||
let cek = crypto::unwrap_cek_for_recipient(
|
||||
&self.secret_seed,
|
||||
&self.node_id,
|
||||
&post.author,
|
||||
recipients,
|
||||
)?;
|
||||
match cek {
|
||||
Some(cek) => {
|
||||
let plaintext = crypto::decrypt_bytes_with_cek(&data, &cek)?;
|
||||
Ok(Some(plaintext))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
PostVisibility::GroupEncrypted { group_id, epoch, wrapped_cek } => {
|
||||
if let Some((seed, pubkey)) = group_seeds.get(&(*group_id, *epoch)) {
|
||||
let cek = crypto::unwrap_group_cek(seed, pubkey, wrapped_cek)?;
|
||||
let plaintext = crypto::decrypt_bytes_with_cek(&data, &cek)?;
|
||||
Ok(Some(plaintext))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a blob by CID, decrypting it in the context of the given post.
|
||||
/// For public posts, returns raw blob data. For encrypted posts, decrypts with the post's CEK.
|
||||
pub async fn get_blob_for_post(
|
||||
&self,
|
||||
cid: &[u8; 32],
|
||||
post_id: &PostId,
|
||||
) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
// Get raw blob data (local)
|
||||
let raw_data = match self.blob_store.get(cid)? {
|
||||
Some(d) => d,
|
||||
None => return Ok(None),
|
||||
};
|
||||
{
|
||||
let storage = self.storage.lock().await;
|
||||
let _ = storage.touch_blob_access(cid);
|
||||
}
|
||||
|
||||
// Get post + visibility
|
||||
let (post, visibility) = {
|
||||
let storage = self.storage.lock().await;
|
||||
match storage.get_post_with_visibility(post_id)? {
|
||||
Some(pv) => pv,
|
||||
None => return Ok(Some(raw_data)), // No post context — return raw
|
||||
}
|
||||
};
|
||||
|
||||
match &visibility {
|
||||
PostVisibility::Public => Ok(Some(raw_data)),
|
||||
PostVisibility::Encrypted { .. } => {
|
||||
let empty_map = std::collections::HashMap::new();
|
||||
self.decrypt_blob_for_post(raw_data, &post, &visibility, &empty_map)
|
||||
}
|
||||
PostVisibility::GroupEncrypted { .. } => {
|
||||
let group_seeds = {
|
||||
let storage = self.storage.lock().await;
|
||||
storage.get_all_group_seeds_map().unwrap_or_default()
|
||||
};
|
||||
self.decrypt_blob_for_post(raw_data, &post, &visibility, &group_seeds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefetch blobs for recently synced posts from a peer.
|
||||
/// Queries storage for posts with attachments missing from the local blob store,
|
||||
/// then fetches each missing blob. Runs outside any locks.
|
||||
pub async fn prefetch_blobs_from_peer(&self, peer_id: &NodeId) {
|
||||
// Gather posts with missing blobs
|
||||
let missing: Vec<(PostId, NodeId, Vec<crate::types::Attachment>)> = {
|
||||
let storage = self.storage.lock().await;
|
||||
let post_ids = storage.list_post_ids().unwrap_or_default();
|
||||
let mut result = Vec::new();
|
||||
for pid in post_ids {
|
||||
if let Ok(Some(post)) = storage.get_post(&pid) {
|
||||
let missing_atts: Vec<_> = post.attachments.iter()
|
||||
.filter(|a| !self.blob_store.has(&a.cid))
|
||||
.cloned()
|
||||
.collect();
|
||||
if !missing_atts.is_empty() {
|
||||
result.push((pid, post.author, missing_atts));
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
};
|
||||
|
||||
if missing.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut fetched = 0usize;
|
||||
for (post_id, author, attachments) in &missing {
|
||||
for att in attachments {
|
||||
match self.fetch_blob_with_fallback(
|
||||
&att.cid, post_id, author, &att.mime_type, 0,
|
||||
).await {
|
||||
Ok(Some(_)) => { fetched += 1; }
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
cid = hex::encode(att.cid),
|
||||
error = %e,
|
||||
"Blob prefetch failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if fetched > 0 {
|
||||
tracing::info!(fetched, peer = hex::encode(peer_id), "Prefetched blobs after sync");
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a blob exists locally.
|
||||
pub fn has_blob(&self, cid: &[u8; 32]) -> bool {
|
||||
self.blob_store.has(cid)
|
||||
|
|
@ -2049,6 +2207,10 @@ impl Node {
|
|||
engagement_headers = engagement,
|
||||
"Sync complete"
|
||||
);
|
||||
// Prefetch blobs for posts we just received
|
||||
if stats.posts_received > 0 {
|
||||
self.prefetch_blobs_from_peer(&peer_id).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -2115,15 +2277,15 @@ impl Node {
|
|||
tokio::spawn(async move { network.run_accept_loop().await })
|
||||
}
|
||||
|
||||
/// Start pull cycle: every interval_secs, pull from connected peers.
|
||||
pub fn start_pull_cycle(&self, interval_secs: u64) -> tokio::task::JoinHandle<()> {
|
||||
let network = Arc::clone(&self.network);
|
||||
/// Start pull cycle: every interval_secs, pull from connected peers + prefetch blobs.
|
||||
pub fn start_pull_cycle(self: &Arc<Self>, interval_secs: u64) -> tokio::task::JoinHandle<()> {
|
||||
let node = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(interval_secs));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
match network.pull_from_all().await {
|
||||
match node.network.pull_from_all().await {
|
||||
Ok(stats) => {
|
||||
if stats.posts_received > 0 {
|
||||
tracing::debug!(
|
||||
|
|
@ -2131,6 +2293,11 @@ impl Node {
|
|||
peers = stats.peers_pulled,
|
||||
"Pull cycle complete"
|
||||
);
|
||||
// Prefetch blobs for newly received posts
|
||||
let peers = node.network.conn_handle().connected_peers().await;
|
||||
for peer_id in peers {
|
||||
node.prefetch_blobs_from_peer(&peer_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -644,6 +644,23 @@ impl Storage {
|
|||
}
|
||||
}
|
||||
|
||||
/// Get the visibility intent for a post (if stored).
|
||||
pub fn get_post_intent(&self, id: &PostId) -> anyhow::Result<Option<VisibilityIntent>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT visibility_intent FROM posts WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![id.as_slice()])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
let intent_json: Option<String> = row.get(0)?;
|
||||
match intent_json {
|
||||
Some(json) => Ok(serde_json::from_str(&json).ok()),
|
||||
None => Ok(None),
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_post_ids(&self) -> anyhow::Result<Vec<PostId>> {
|
||||
let mut stmt = self.conn.prepare("SELECT id FROM posts")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
|
|
|
|||
|
|
@ -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())? {
|
||||
let data = resolve_blob_data(node, &cid, post_id_hex.as_deref()).await?;
|
||||
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())
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -628,7 +628,7 @@ async function loadStats() {
|
|||
async function loadFeed(force) {
|
||||
try {
|
||||
const allPosts = await invoke('get_feed');
|
||||
const posts = allPosts.filter(p => p.visibility !== 'encrypted-for-me');
|
||||
const posts = allPosts.filter(p => p.intentKind !== 'direct');
|
||||
// Fingerprint: post IDs + reaction counts + comment counts
|
||||
const fp = posts.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
|
||||
if (!force && fp === _feedFingerprint) return;
|
||||
|
|
@ -705,7 +705,7 @@ async function loadFeed(force) {
|
|||
async function loadMyPosts(force) {
|
||||
try {
|
||||
const posts = await invoke('get_all_posts');
|
||||
const mine = posts.filter(p => p.isMe && p.visibility !== 'encrypted-for-me' && !(p.recipients && p.recipients.length > 0));
|
||||
const mine = posts.filter(p => p.isMe && p.intentKind !== 'direct');
|
||||
const fp = mine.map(p => `${p.id}:${(p.reactionCounts||[]).map(r=>r.emoji+r.count).join(',')}:${p.commentCount||0}`).join('|');
|
||||
if (!force && fp === _myPostsFingerprint) return;
|
||||
_myPostsFingerprint = fp;
|
||||
|
|
@ -743,10 +743,12 @@ async function loadMessages(force) {
|
|||
]);
|
||||
const followSet = new Set(follows.map(f => f.nodeId));
|
||||
|
||||
// Collect DMs: received encrypted-for-me OR my sent encrypted with recipients
|
||||
// Collect DMs: intent-based with fallback for old posts without intentKind
|
||||
const dms = posts.filter(p => {
|
||||
if (!p.isMe && p.visibility === 'encrypted-for-me') return true;
|
||||
if (p.isMe && p.recipients && p.recipients.length > 0) return true;
|
||||
if (p.intentKind === 'direct') return true;
|
||||
// Fallback for pre-intent posts
|
||||
if (p.intentKind === 'unknown' && !p.isMe && p.visibility === 'encrypted-for-me') return true;
|
||||
if (p.intentKind === 'unknown' && p.isMe && p.recipients && p.recipients.length > 0) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
|
|
@ -1402,8 +1404,8 @@ async function loadNetworkSummary() {
|
|||
networkSummaryEl.innerHTML = `<div class="diag-grid">
|
||||
<div class="diag-item"><span class="diag-value">${s.totalConnections}</span><span class="diag-label">Connections</span></div>
|
||||
<div class="diag-item"><span class="diag-value">${s.preferredCount}</span><span class="diag-label">Preferred</span></div>
|
||||
<div class="diag-item"><span class="diag-value">${s.localCount}</span><span class="diag-label">Local</span></div>
|
||||
<div class="diag-item"><span class="diag-value">${s.wideCount}</span><span class="diag-label">Wide</span></div>
|
||||
<div class="diag-item"><span class="diag-value">${s.localCount}</span><span class="diag-label">Mesh</span></div>
|
||||
<div class="diag-item"><span class="diag-value">${s.wideCount}</span><span class="diag-label">Non-mesh N1</span></div>
|
||||
<div class="diag-item"><span class="diag-value">${s.n2Distinct}</span><span class="diag-label">N2 Reach</span></div>
|
||||
<div class="diag-item"><span class="diag-value">${s.n3Distinct}</span><span class="diag-label">N3 Reach</span></div>
|
||||
</div>`;
|
||||
|
|
@ -1424,9 +1426,11 @@ async function loadConnections() {
|
|||
const icon = generateIdenticon(c.nodeId, 18);
|
||||
const slotClass = c.slotKind === 'Preferred' ? 'slot-preferred'
|
||||
: c.slotKind === 'Wide' ? 'slot-wide' : 'slot-local';
|
||||
const slotLabel = c.slotKind === 'Local' ? 'Mesh'
|
||||
: c.slotKind === 'Wide' ? 'Non-mesh N1' : c.slotKind;
|
||||
const duration = c.connectedAt ? relativeTime(c.connectedAt) : '';
|
||||
return `<div class="peer-card">
|
||||
<div class="peer-card-row">${icon} ${label} <span class="slot-badge ${slotClass}">${c.slotKind}</span></div>
|
||||
<div class="peer-card-row">${icon} ${label} <span class="slot-badge ${slotClass}">${slotLabel}</span></div>
|
||||
<div class="peer-card-meta"><span>${duration}</span></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
|
@ -1707,7 +1711,7 @@ async function loadPostMedia(container) {
|
|||
const postId = img.dataset.postId;
|
||||
const mime = img.dataset.mime || 'image/jpeg';
|
||||
try {
|
||||
const filePath = await invoke('get_blob_path', { cidHex: cid });
|
||||
const filePath = await invoke('get_blob_path', { cidHex: cid, postIdHex: postId });
|
||||
if (filePath && window.__TAURI__?.core?.convertFileSrc) {
|
||||
const assetUrl = window.__TAURI__.core.convertFileSrc(filePath);
|
||||
img.onerror = async () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<body>
|
||||
<nav>
|
||||
<a href="index.html" class="logo">ItsGoin</a>
|
||||
<button class="menu-toggle" onclick="this.parentElement.querySelector('.links').classList.toggle('open')" aria-label="Menu">☰</button>
|
||||
<div class="links">
|
||||
<a href="index.html">About</a>
|
||||
<a href="tech.html">How It Works</a>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
<body>
|
||||
<nav>
|
||||
<a href="index.html" class="logo">ItsGoin</a>
|
||||
<button class="menu-toggle" onclick="this.parentElement.querySelector('.links').classList.toggle('open')" aria-label="Menu">☰</button>
|
||||
<div class="links">
|
||||
<a href="index.html">About</a>
|
||||
<a href="tech.html">How It Works</a>
|
||||
|
|
@ -43,7 +44,9 @@
|
|||
<p>This is the canonical technical reference for ItsGoin. It describes the vision, the architecture, and the current state of every subsystem — with full implementation detail. This document is versioned; each update records what changed.</p>
|
||||
<div class="card" style="margin-top: 1rem;">
|
||||
<strong style="font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;">Changelog</strong>
|
||||
<p style="margin-top: 0.5rem;"><strong>v0.3.3</strong> (2026-03-16): Connection rate limiting — incoming auth failures rate-limited per source IP (3 attempts, exponential backoff to ~256s). Schema versioning — PRAGMA user_version tracks DB version with migration framework. N2/N3 freshness — TTL 7d→5h, full N1/N2 re-broadcast every 4h, startup sweep clears stale entries. Bootstrap isolation recovery — 24h check verifies bootstrap is in N1/N2/N3, reconnects + sticky N1 advertisement if absent. IPv6 HTTP address fix — nodes advertise actual public IPv6 (not 0.0.0.0) for share link redirects. Upstream tracking — post_upstream table records post source for engagement diff routing toward author. Video preload fix — share links and in-app videos use preload=auto. Following Online/Offline split. DM filter from My Posts. Any-type file attachments with download prompt + trust warning. Image lightbox. Audio player.</p>
|
||||
<p style="margin-top: 0.5rem;"><strong>v0.3.5</strong> (2026-03-20): Private blob encryption — attachments on encrypted posts (Friends/Circle/Direct) now encrypted with same CEK as post text; public blobs unchanged; CID on ciphertext. Blob prefetch on sync — attachments eagerly fetched after post pull for offline availability. Crypto refactoring — extracted reusable primitives (encrypt/decrypt_bytes_with_cek, unwrap_cek_for_recipient, unwrap_group_cek). Intent-based post filtering — feed/myposts/messages filter on intentKind instead of encryption state. Blob decryption API (get_blob_for_post). Download filename sanitization.</p>
|
||||
<p><strong>v0.3.4</strong> (2026-03-18): Comment edit & delete with trust-based propagation. Native notifications via Tauri plugin (messages, posts, reactions, comments). Forward-compatible BlobHeaderDiffOp::Unknown variant. Following Online/Offline lightbox. Comment threading scoping fix. Dropdown text legibility fix. Mobile hamburger nav for website.</p>
|
||||
<p><strong>v0.3.3</strong> (2026-03-16): Connection rate limiting — incoming auth failures rate-limited per source IP (3 attempts, exponential backoff to ~256s). Schema versioning — PRAGMA user_version tracks DB version with migration framework. N2/N3 freshness — TTL 7d→5h, full N1/N2 re-broadcast every 4h, startup sweep clears stale entries. Bootstrap isolation recovery — 24h check verifies bootstrap is in N1/N2/N3, reconnects + sticky N1 advertisement if absent. IPv6 HTTP address fix — nodes advertise actual public IPv6 (not 0.0.0.0) for share link redirects. Upstream tracking — post_upstream table records post source for engagement diff routing toward author. Video preload fix — share links and in-app videos use preload=auto. Following Online/Offline split. DM filter from My Posts. Any-type file attachments with download prompt + trust warning. Image lightbox. Audio player.</p>
|
||||
<p><strong>v0.3.2</strong> (2026-03-14): Bidirectional engagement propagation — BlobHeaderDiff flows upstream + downstream through CDN tree. Auto downstream registration on pull sync/push notification. TCP hole punch protocol (TcpPunchRequest/Result 0xD6/0xD7). Tiered web serving (redirect → TCP punch → QUIC proxy). Video playback fix (asset protocol + blob URL fallback). On-demand blob fetch for synced posts missing blob data.</p>
|
||||
<p><strong>v0.3.1</strong> (2026-03-13): Share links + QUIC proxy + content search. Share link format: <code>itsgoin.net/p/<postid_hex>/<author_nodeid_hex></code> — simple, no host encoding needed. itsgoin.net web handler acts as QUIC proxy: receives browser request, searches the network for the post, fetches it on-demand via PostFetch (0xD4/0xD5), renders HTML, serves to browser. No permanent storage of fetched content. Extended worm search — <code>WormQuery</code> now carries optional <code>post_id</code> and <code>blob_id</code> fields for unified node/post/blob search. Each peer checks local storage, CDN downstream tree (up to 100 hosts per post), and blob store. <code>WormResponse</code> gains <code>post_holder</code> and <code>blob_holder</code> fields. Nova fan-out pattern — burst peers include one N2 wide referral; referred peer does its own 101-burst, reaching ~10K nodes with ~202 relay hops. PostFetch (0xD4/0xD5) — lightweight single-post retrieval after worm finds a holder, much lighter than full PullSync. itsgoin.net node deployed as anchor + web handler (<code>--web 8080</code>). “Unavailable” page with honest network model explanation + install CTA. Universal Links / App Links planned for native app interception. | Engagement sync — pull sync now fetches reactions, comments, and policies via BlobHeaderRequest/Response after every sync. Profile push fix — profile updates now sent to all connected mesh peers (not just audience). Auto-sync on follow — following a peer triggers immediate post pull + engagement fetch. Popover UI — notifications settings, network diagnostics, and message threads now open as popovers. Notification settings — per-key settings table in SQLite, configurable message/post/nearby notifications with JS Notification API. Tiered DM polling — smart message refresh based on conversation recency. Reaction display — posts show top 5 most popular emoji + total response count. UI cleanup — removed Suggested Peers and Find Nearby sections, placeholder text changed to “How’s it goin?”, clickable node IDs in activity log.</p>
|
||||
<p><strong>v0.3.0</strong> (2026-03-12): Full rename distsoc → ItsGoin. ALPN, crypto contexts, data paths, Android package ID all changed. Clean break — incompatible with prior versions.</p>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<body>
|
||||
<nav>
|
||||
<a href="index.html" class="logo">ItsGoin</a>
|
||||
<button class="menu-toggle" onclick="this.parentElement.querySelector('.links').classList.toggle('open')" aria-label="Menu">☰</button>
|
||||
<div class="links">
|
||||
<a href="index.html">About</a>
|
||||
<a href="tech.html">How It Works</a>
|
||||
|
|
@ -24,16 +25,16 @@
|
|||
<section>
|
||||
<h1 style="font-size: 2rem; font-weight: 800; letter-spacing: -0.03em; margin-bottom: 0.25rem;">Download ItsGoin</h1>
|
||||
<p>Available for Android and Linux. Free and open source.</p>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.3.4 — March 15, 2026</p>
|
||||
<p style="color: var(--text-muted); font-size: 0.85rem;">Version 0.3.5 — March 15, 2026</p>
|
||||
|
||||
<div class="downloads">
|
||||
<a href="itsgoin-0.3.4.apk" class="download-btn btn-android">
|
||||
<a href="itsgoin-0.3.5.apk" class="download-btn btn-android">
|
||||
Android APK
|
||||
<span class="sub">v0.3.4</span>
|
||||
<span class="sub">v0.3.5</span>
|
||||
</a>
|
||||
<a href="itsgoin_0.3.4_amd64.AppImage" class="download-btn btn-linux">
|
||||
<a href="itsgoin_0.3.5_amd64.AppImage" class="download-btn btn-linux">
|
||||
Linux AppImage
|
||||
<span class="sub">v0.3.4</span>
|
||||
<span class="sub">v0.3.5</span>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -45,7 +46,7 @@
|
|||
<h3 style="color: var(--accent);">Android</h3>
|
||||
<ol class="steps">
|
||||
<li><strong>Download the APK</strong> — Tap the button above. Your browser may warn that this type of file can be harmful — tap <strong>Download anyway</strong>.</li>
|
||||
<li><strong>Open the file</strong> — When the download finishes, tap the notification or find <code>itsgoin-0.3.4.apk</code> in your Downloads folder and tap it.</li>
|
||||
<li><strong>Open the file</strong> — When the download finishes, tap the notification or find <code>itsgoin-0.3.5.apk</code> in your Downloads folder and tap it.</li>
|
||||
<li><strong>Allow installation</strong> — Android will ask you to allow installs from this source. Tap <strong>Settings</strong>, toggle <strong>"Allow from this source"</strong>, then go back and tap <strong>Install</strong>.</li>
|
||||
<li><strong>Launch the app</strong> — Once installed, tap <strong>Open</strong> or find ItsGoin in your app drawer.</li>
|
||||
</ol>
|
||||
|
|
@ -58,8 +59,8 @@
|
|||
<h3 style="color: var(--green);">Linux (AppImage)</h3>
|
||||
<ol class="steps">
|
||||
<li><strong>Download the AppImage</strong> — Click the button above to download.</li>
|
||||
<li><strong>Make it executable</strong> — Open a terminal and run:<br><code>chmod +x itsgoin_0.3.4_amd64.AppImage</code></li>
|
||||
<li><strong>Run it</strong> — Double-click the file, or from the terminal:<br><code>./itsgoin_0.3.4_amd64.AppImage</code></li>
|
||||
<li><strong>Make it executable</strong> — Open a terminal and run:<br><code>chmod +x itsgoin_0.3.5_amd64.AppImage</code></li>
|
||||
<li><strong>Run it</strong> — Double-click the file, or from the terminal:<br><code>./itsgoin_0.3.5_amd64.AppImage</code></li>
|
||||
</ol>
|
||||
<div class="note">
|
||||
<strong>Note:</strong> If it doesn't launch, you may need to install FUSE:<br><code>sudo apt install libfuse2</code> (Debian/Ubuntu) or <code>sudo dnf install fuse</code> (Fedora).
|
||||
|
|
@ -70,6 +71,16 @@
|
|||
<section>
|
||||
<h2>Changelog</h2>
|
||||
<div class="changelog">
|
||||
<div class="changelog-date">v0.3.5 — March 20, 2026</div>
|
||||
<ul>
|
||||
<li><strong>Private blob encryption</strong> — Attachments on encrypted posts (Friends, Circle, Direct) are now encrypted with the same CEK as the post text. Public blobs remain plaintext. CID computed on ciphertext preserves content addressing.</li>
|
||||
<li><strong>Blob prefetch on sync</strong> — When posts are pulled from peers, their attachments are eagerly fetched for offline availability. Previously blobs were only fetched on view.</li>
|
||||
<li><strong>Crypto refactoring</strong> — Extracted reusable primitives: <code>encrypt_bytes_with_cek</code>, <code>decrypt_bytes_with_cek</code>, <code>unwrap_cek_for_recipient</code>, <code>unwrap_group_cek</code>. Foundation for encrypted blob storage and future chunk-level encryption.</li>
|
||||
<li><strong>Intent-based post filtering</strong> — Feed, My Posts, and Messages now filter on the author's original visibility intent (<code>intentKind</code>) rather than encryption state. Direct messages are identified by intent, not by being “encrypted-for-me.” Backward-compatible with pre-intent posts.</li>
|
||||
<li><strong>Blob decryption on retrieval</strong> — New <code>get_blob_for_post</code> API decrypts private blobs in context of their post’s visibility. Public blobs pass through unchanged.</li>
|
||||
<li><strong>Download filename sanitization</strong> — Prevents path traversal in downloaded file names.</li>
|
||||
</ul>
|
||||
|
||||
<div class="changelog-date">v0.3.4 — March 18, 2026</div>
|
||||
<ul>
|
||||
<li><strong>Comment edit & delete</strong> — Edit or delete your own comments. Trust-based: post authors can also delete comments on their posts. Propagates via BlobHeaderDiff to all holders.</li>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<body>
|
||||
<nav>
|
||||
<a href="index.html" class="logo">ItsGoin</a>
|
||||
<button class="menu-toggle" onclick="this.parentElement.querySelector('.links').classList.toggle('open')" aria-label="Menu">☰</button>
|
||||
<div class="links">
|
||||
<a href="index.html" class="active">About</a>
|
||||
<a href="tech.html">How It Works</a>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,15 @@ nav .links a {
|
|||
transition: color 0.15s;
|
||||
}
|
||||
nav .links a:hover, nav .links a.active { color: var(--text); text-decoration: none; }
|
||||
nav .menu-toggle { display: none; background: none; border: none; color: var(--text); font-size: 1.5rem; cursor: pointer; padding: 0.2rem; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
nav { padding: 0.75rem 1rem; gap: 0; justify-content: space-between; flex-wrap: wrap; }
|
||||
nav .menu-toggle { display: block; }
|
||||
nav .links { display: none; width: 100%; flex-direction: column; gap: 0; padding-top: 0.5rem; }
|
||||
nav .links.open { display: flex; }
|
||||
nav .links a { padding: 0.5rem 0; border-top: 1px solid var(--border); font-size: 0.95rem; }
|
||||
}
|
||||
|
||||
/* --- Layout --- */
|
||||
.container { max-width: 800px; margin: 0 auto; padding: 3rem 2rem; }
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<body>
|
||||
<nav>
|
||||
<a href="index.html" class="logo">ItsGoin</a>
|
||||
<button class="menu-toggle" onclick="this.parentElement.querySelector('.links').classList.toggle('open')" aria-label="Menu">☰</button>
|
||||
<div class="links">
|
||||
<a href="index.html">About</a>
|
||||
<a href="tech.html" class="active">How It Works</a>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue