Compare commits
7
Commits
master
...
encrypt-media
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06449d226f | ||
|
|
8303b054f9 | ||
|
|
2d7881e574 | ||
|
|
37d3380c55 | ||
|
|
9fcfb9f0bf | ||
|
|
1877e0082c | ||
|
|
68d3d77ae3 |
Generated
+49
@@ -139,6 +139,20 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes-gcm"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||
dependencies = [
|
||||
"aead",
|
||||
"aes",
|
||||
"cipher 0.4.4",
|
||||
"ctr",
|
||||
"ghash",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -1686,6 +1700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"rand_core 0.6.4",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -1708,6 +1723,15 @@ dependencies = [
|
||||
"linktime-proc-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||
dependencies = [
|
||||
"cipher 0.4.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
@@ -2542,6 +2566,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghash"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||
dependencies = [
|
||||
"opaque-debug",
|
||||
"polyval",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
@@ -5191,6 +5225,18 @@ dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polyval"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
@@ -6631,7 +6677,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
name = "state"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"browser-signer-proxy",
|
||||
"common",
|
||||
"flume 0.11.1",
|
||||
@@ -6650,6 +6698,7 @@ dependencies = [
|
||||
"nostr-sdk",
|
||||
"rustls",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"smol",
|
||||
"webbrowser",
|
||||
]
|
||||
|
||||
@@ -27,6 +27,11 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr-sdk = { git = "https://github.com/rust-nostr/nostr" }
|
||||
nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "nip49", "nip44" ] }
|
||||
|
||||
# Crypto (NIP-17 encrypted file messages)
|
||||
aes-gcm = "0.10"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
||||
# Others
|
||||
anyhow = "1.0.44"
|
||||
chrono = { version = "0.4.38", features = ["wasmbind"] }
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
# NIP-17 Encrypted File Messages (kind 15) — Backend Plan
|
||||
|
||||
Implementation plan for sending and receiving NIP-17 **file messages** in coop.
|
||||
|
||||
**Scope: backend only.** This document covers crypto, blob upload/download, rumor
|
||||
construction, rumor parsing and local caching. No UI work (composer button, file
|
||||
rendering, image cache, decryption-on-render state) — that is a separate follow-up
|
||||
once these APIs exist.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the protocol requires
|
||||
|
||||
NIP-17 file messages are **not** a new transport. They reuse everything coop already
|
||||
has (NIP-44, NIP-59 seal + gift wrap, kind 10050 inbox relays) and only add:
|
||||
|
||||
1. A new inner **rumor kind: `15`** (`Kind::Custom(15)`), whose `.content` is the URL
|
||||
of an **encrypted** blob and whose tags carry the MIME type and the decryption
|
||||
material.
|
||||
2. **AES-256-GCM** encryption of the file bytes before upload.
|
||||
|
||||
Kind 15 tags (per NIP-17):
|
||||
|
||||
| Tag | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `p` | yes | receivers (as for kind 14) |
|
||||
| `e` | if reply | parent message id |
|
||||
| `subject` | optional | conversation title |
|
||||
| `file-type` | yes | MIME type of the **plaintext** file |
|
||||
| `encryption-algorithm` | yes | `aes-gcm` (only supported value) |
|
||||
| `decryption-key` | yes | key for the recipient |
|
||||
| `decryption-nonce` | yes | nonce for the recipient |
|
||||
| `x` | yes | SHA-256 hex of the **encrypted** file |
|
||||
| `ox` | expected | SHA-256 hex of the file **before** encryption |
|
||||
| `size` | optional | size of the **encrypted** file in bytes |
|
||||
| `dim` | optional | `<width>x<height>` in pixels |
|
||||
| `thumbhash` / `blurhash` | optional | placeholder previews |
|
||||
| `thumb` | optional | thumbnail URL (same key/nonce) |
|
||||
| `fallback` | optional | extra file sources (same key/nonce) |
|
||||
|
||||
Key material travels **inside** the gift wrap, so the public blob URL is useless
|
||||
without it. That property falls out of the existing seal/gift-wrap code for free.
|
||||
|
||||
`thumbhash`, `blurhash`, `thumb`, `fallback` are out of scope for v1 (all optional).
|
||||
|
||||
---
|
||||
|
||||
## 2. What already exists in coop (verified against the pinned deps)
|
||||
|
||||
| Requirement | Location | Status |
|
||||
|---|---|---|
|
||||
| NIP-59 seal + gift wrap | `crates/chat/src/room.rs::send_gift_wrap` (`nip59::GiftWrapBuilder`) | exists |
|
||||
| Per-recipient publish + self backup | `crates/chat/src/room.rs::send` | exists |
|
||||
| Inbox relays (kind 10050) | `crates/chat/src/lib.rs::handle_notifications` | exists |
|
||||
| Rumor unwrap + local cache | `crates/chat/src/lib.rs::{extract_rumor,try_unwrap_with,set_rumor,get_rumor}` | exists |
|
||||
| Kind 14 rumor construction | `crates/chat/src/room.rs::rumor` | exists |
|
||||
| Blossom upload | `crates/state/src/blossom.rs::upload` (plaintext, random signing key) | partial |
|
||||
| **Kind 15 rumor + parse** | — | **to add** |
|
||||
| **AES-256-GCM encrypt/decrypt** | — | **to add** |
|
||||
| **Encrypted blob upload/download** | — | **to add** |
|
||||
|
||||
Verified facts about the pinned SDK (`rust-nostr@b230cec`, `nostr 0.45.4`) and
|
||||
`gpui@69af529`:
|
||||
|
||||
- rust-nostr has **no** kind-15 helper and **no** AES-GCM/AES-GCM-tag support anywhere
|
||||
(`FileMetadata` in NIP-94 is kind 1063, unrelated). `nip17.rs` only covers kind 14
|
||||
and 10050.
|
||||
- `nip59::GiftWrapBuilder::new(receiver, rumor: UnsignedEvent)` accepts **any**
|
||||
`UnsignedEvent`, so kind 15 flows through the existing wrap/send path unchanged.
|
||||
- `Kind` has no named variant for 15; `Kind::Custom(15)` is required, and
|
||||
`Kind: Display` prints `as_u16()`, so `rumor.kind.to_string()` yields `"15"`.
|
||||
- `nostr::nips::nip94::Sha256Hash` is public (`from_byte_array`, `to_hex`, `Display`,
|
||||
`from_hex`) even though it lives in the nip94 module — usable for `x`/`ox` hex
|
||||
formatting without a new hashing crate.
|
||||
- `gpui::App::http_client()` returns `Arc<dyn HttpClient>` and
|
||||
`gpui_web/src/http_client.rs` implements it, so HTTP download is cross-platform.
|
||||
`AsyncApp` exposes `update(|app| ...)`, which is how backend async code reaches it.
|
||||
- `nostr-blossom` exposes `upload_blob`, `get_blob`, `has_blob`, `list_blobs`,
|
||||
`delete_blob`.
|
||||
- The `k` tag is currently hardcoded to `"14"` in two places
|
||||
(`room.rs::send_gift_wrap`, `lib.rs::set_rumor`) and used as a room-list filter
|
||||
(`lib.rs::get_rooms_task`, `custom_tag(LOWERCASE_K, "14")`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependencies
|
||||
|
||||
Add to `[workspace.dependencies]` in `Cargo.toml`, then reference from the crates below.
|
||||
|
||||
```toml
|
||||
aes-gcm = "0.10" # NEW - RustCrypto: Aes256Gcm, aead::{Aead, KeyInit, OsRng}
|
||||
sha2 = "0.10" # NEW (already in Cargo.lock, cached) - SHA-256 for x / ox
|
||||
base64 = "0.22" # NEW as a direct dep (already in the tree transitively)
|
||||
```
|
||||
|
||||
- No new RNG dependency: `aes_gcm::aead::OsRng` (the wasm getrandom backends are
|
||||
already configured in `web/Cargo.toml`).
|
||||
- No new hashing/hex dependency: `sha2` output → `nostr::nips::nip94::Sha256Hash::from_byte_array(...).to_hex()`.
|
||||
- `aes-gcm` is the only crate that needs a crates.io fetch (`aes 0.8` / `aead 0.5` are
|
||||
already in the lock file), so it is a small addition to the build graph.
|
||||
- Hand-rolling AES-GCM is explicitly **not** an option.
|
||||
|
||||
---
|
||||
|
||||
## 4. Layering and type ownership
|
||||
|
||||
`chat` depends on `state` (see `crates/chat/Cargo.toml`), never the reverse, so the
|
||||
shared types and tag names must live in `state`.
|
||||
|
||||
- `crates/state/src/file.rs` (new) owns:
|
||||
- `EncryptedFile`, `FileAttachment`, the tag-name constants, `ALGORITHM = "aes-gcm"`
|
||||
- `encrypt` / `decrypt` / `sha256_hex`
|
||||
- `FileAttachment::from_tags` / `FileAttachment::tags` (single source of truth for
|
||||
tag names, so build and parse can't drift)
|
||||
- `upload_encrypted`, `download_and_decrypt`
|
||||
- re-exported from `crates/state/src/lib.rs`: `mod file; pub use file::*;`
|
||||
- `crates/chat` consumes it: `message.rs` (parse into `Message`), `room.rs`
|
||||
(build kind-15 rumor), `lib.rs` (cache tag + room list query).
|
||||
- `crates/chat` should re-export the type for the future UI layer:
|
||||
`pub use state::FileAttachment;` in `crates/chat/src/lib.rs`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Data flow (backend)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[caller: path + blossom server] --> B[read bytes + guess mime]
|
||||
B --> C[encrypt: random 32B key, 12B nonce, AES-256-GCM]
|
||||
C --> D[sha256 ciphertext = x, sha256 plaintext = ox]
|
||||
D --> E[Blossom upload ciphertext]
|
||||
E --> F[FileAttachment]
|
||||
F --> G[Room::file_rumor -> kind 15 rumor]
|
||||
G --> H[Room::send -> existing NIP-59 wrap per member + self backup]
|
||||
H --> I[existing extract_rumor on receiver]
|
||||
I --> J[Message.file]
|
||||
J --> K[download_and_decrypt: GET url, verify x, decrypt]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation steps
|
||||
|
||||
### Step 1 — Dependencies
|
||||
|
||||
Add the three lines from section 3 and wire them into `crates/state/Cargo.toml`
|
||||
(`aes-gcm`, `sha2`, `base64`). Run `cargo check -p state` to confirm the fetch works.
|
||||
|
||||
### Step 2 — `crates/state/src/file.rs` (new, ~180 LOC)
|
||||
|
||||
```rust
|
||||
use aes_gcm::aead::{Aead, KeyInit, OsRng};
|
||||
use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce};
|
||||
use nostr::nips::nip94::Sha256Hash;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const ALGORITHM: &str = "aes-gcm";
|
||||
/// Ciphertext hash tag (NIP-17).
|
||||
const TAG_SHA256: &str = "x";
|
||||
const TAG_ORIGINAL_SHA256: &str = "ox";
|
||||
const TAG_FILE_TYPE: &str = "file-type";
|
||||
const TAG_ALGORITHM: &str = "encryption-algorithm";
|
||||
const TAG_KEY: &str = "decryption-key";
|
||||
const TAG_NONCE: &str = "decryption-nonce";
|
||||
const TAG_SIZE: &str = "size";
|
||||
const TAG_DIM: &str = "dim";
|
||||
const TAG_ALT: &str = "alt";
|
||||
|
||||
/// Maximum blob size accepted when downloading (bytes). See edge cases.
|
||||
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
||||
|
||||
/// Result of encrypting a file: ciphertext to upload plus NIP-17 key material.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EncryptedFile {
|
||||
pub data: Vec<u8>,
|
||||
/// base64-encoded 32-byte key
|
||||
pub key: String,
|
||||
/// base64-encoded 12-byte nonce
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
/// NIP-17 kind 15 attachment metadata (tags + `.content` URL).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FileAttachment {
|
||||
pub url: Url,
|
||||
pub mime: String,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
pub sha256: Option<String>,
|
||||
pub original_sha256: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
pub dim: Option<(u32, u32)>,
|
||||
/// Non-standard display name (see "Open decisions" #2).
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl FileAttachment {
|
||||
/// Build the NIP-17 tags for a kind 15 rumor.
|
||||
pub fn tags(&self) -> Vec<Tag>;
|
||||
/// Parse a kind 15 rumor's tags. Returns `None` if key material is missing
|
||||
/// or `encryption-algorithm` is not `aes-gcm`.
|
||||
pub fn from_tags(tags: &Tags) -> Option<Self>;
|
||||
pub fn is_image(&self) -> bool;
|
||||
pub fn display_name(&self) -> SharedString; // falls back to mime/size
|
||||
}
|
||||
|
||||
/// AES-256-GCM encrypt with a fresh random key and nonce.
|
||||
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile>;
|
||||
/// AES-256-GCM decrypt using the values from a kind 15 rumor.
|
||||
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>>;
|
||||
|
||||
/// Lowercase hex SHA-256, matching the NIP-94 `x`/`ox` convention.
|
||||
pub fn sha256_hex(data: &[u8]) -> String;
|
||||
|
||||
/// Read a file, encrypt it, upload the ciphertext to Blossom, return the attachment.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload_encrypted(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<FileAttachment>;
|
||||
|
||||
/// Fetch the blob, verify its SHA-256 against `expected_sha256`, then decrypt.
|
||||
pub async fn download_and_decrypt(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
nonce: &str,
|
||||
expected_sha256: Option<&str>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Vec<u8>>;
|
||||
```
|
||||
|
||||
Implementation notes, in priority order:
|
||||
|
||||
1. **Compute `x` and `ox` locally with `sha256_hex`.** Do **not** derive `x` from
|
||||
`BlobDescriptor::sha256` — that field is a `bitcoin_hashes::sha256::Hash` whose
|
||||
`Display` byte order could not be confirmed from the vendored sources, and a
|
||||
reversed digest would silently break integrity checks in other clients. Use the
|
||||
Blossom response only for `blob.url`. (`sha2` → `Sha256Hash::from_byte_array(...).to_hex()`
|
||||
gives the conventional digest order and is unambiguous.)
|
||||
2. **Key/nonce decoding must be tolerant.** NIP-17 does not specify an encoding:
|
||||
accept base64 standard (padded and unpadded), base64url, and hex on read; emit
|
||||
base64 standard on write. See "Open decisions" #1.
|
||||
3. Validate sizes after decoding: key must be exactly 32 bytes, nonce exactly 12.
|
||||
Return a descriptive error otherwise.
|
||||
4. `decrypt` must fail closed on a bad tag (GCM authentication failure) — never
|
||||
return partial plaintext.
|
||||
5. `upload_encrypted` mirrors the existing `state::blossom::upload` shape:
|
||||
`smol::fs::read` + `mime_guess::from_path`, `BlossomClient::new(server)`,
|
||||
`upload_blob(ciphertext, Some("application/octet-stream"), None, Some(&keys))`
|
||||
wrapped in `Tokio::spawn(cx, ...)`. Note the content type describes the
|
||||
**ciphertext**; the plaintext MIME goes in the `file-type` tag.
|
||||
On `wasm32` return `Err(anyhow!("File upload not supported on web"))`, matching
|
||||
the existing stub in `blossom.rs`.
|
||||
6. `download_and_decrypt` uses gpui's HTTP client
|
||||
(`cx.update(|app| app.http_client())`, then
|
||||
`client.get(url.as_str(), AsyncBody::default(), true)`), reads the body with
|
||||
`futures::AsyncReadExt`, caps the read at `MAX_FILE_SIZE + 1` and rejects anything
|
||||
larger, verifies `sha256_hex(&ciphertext)` against `expected_sha256`
|
||||
(case-insensitive) when present, then decrypts. This path works on desktop and web.
|
||||
7. `size` in `FileAttachment` is the **ciphertext** size (per the NIP wording), i.e.
|
||||
`encrypted.data.len()`. `dim` is optional; if wanted, decode dimensions with the
|
||||
`image` crate (`image::ImageReader`) — mark as a nice-to-have, not a blocker.
|
||||
8. `encrypt` should take `&[u8]` (not a path) so it stays pure and testable without I/O.
|
||||
|
||||
### Step 3 — `crates/chat/src/message.rs` (~80 LOC)
|
||||
|
||||
- Add the kind constant next to `Message`:
|
||||
|
||||
```rust
|
||||
/// NIP-17 file message (rust-nostr has no named `Kind` variant for 15).
|
||||
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||
```
|
||||
|
||||
- Add the field:
|
||||
|
||||
```rust
|
||||
pub struct Message {
|
||||
// ... existing fields ...
|
||||
/// Encrypted file attachment (NIP-17 kind 15)
|
||||
pub file: Option<FileAttachment>,
|
||||
}
|
||||
```
|
||||
|
||||
- In all three `From` impls (`&Event`, `&UnsignedEvent`, `&NewMessage`), parse from
|
||||
tags when `kind == KIND_FILE_MESSAGE`, and when a file is present **do not** run
|
||||
`extract_and_remove_media_urls` or `extract_mentions` on `.content` — for kind 15
|
||||
`.content` is a ciphertext URL that must never be rendered as text or as media:
|
||||
|
||||
```rust
|
||||
let file = (val.kind == KIND_FILE_MESSAGE)
|
||||
.then(|| FileAttachment::from_tags(&val.tags))
|
||||
.flatten();
|
||||
|
||||
let (media, content) = if file.is_some() {
|
||||
(vec![], String::new())
|
||||
} else {
|
||||
extract_and_remove_media_urls(&val.content)
|
||||
};
|
||||
```
|
||||
|
||||
- Add a helper for non-UI consumers that display a message one-line
|
||||
(reply previews, notifications, copy):
|
||||
|
||||
```rust
|
||||
/// Single-line representation: text for kind 14, `[File] <name>` for kind 15.
|
||||
pub fn preview(&self) -> SharedString;
|
||||
```
|
||||
|
||||
- `Ord`/`Eq`/`Hash` are keyed on `id`/`created_at`, so adding the field does not
|
||||
change `Message` ordering semantics.
|
||||
|
||||
### Step 4 — `crates/chat/src/room.rs` (~70 LOC)
|
||||
|
||||
- Extract the shared tag logic from `rumor()` into a private helper so kind 14 and
|
||||
kind 15 cannot drift:
|
||||
|
||||
```rust
|
||||
/// `subject` + reply `e` tags + receiver `p` tags (excluding `sender`).
|
||||
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag>;
|
||||
```
|
||||
|
||||
`rumor()` then becomes `EventBuilder::new(kind, content).tags(self.conversation_tags(...))`,
|
||||
and gains no behavior change.
|
||||
|
||||
- Add:
|
||||
|
||||
```rust
|
||||
/// Build a NIP-17 kind 15 (file message) rumor.
|
||||
///
|
||||
/// WARNING: never sign and send this event to relays; it is only ever wrapped.
|
||||
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
|
||||
where
|
||||
I: IntoIterator<Item = EventId>,
|
||||
{
|
||||
// tags = conversation_tags(...) ++ file.tags()
|
||||
// content = file.url.to_string()
|
||||
// EventBuilder::new(KIND_FILE_MESSAGE, content).tags(tags).finalize_unsigned(sender)
|
||||
// then event.ensure_id()
|
||||
}
|
||||
```
|
||||
|
||||
- Fix the gift wrap's `k` tag so relays see the real inner kind
|
||||
(`send_gift_wrap`, currently hardcoded):
|
||||
|
||||
```rust
|
||||
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
|
||||
```
|
||||
|
||||
- `Room::send` needs **no change**: it already accepts any `UnsignedEvent`, and the
|
||||
self-backup branch publishes the same rumor wrapped for the sender, so the sender's
|
||||
other devices receive the file message with the same key material.
|
||||
|
||||
### Step 5 — `crates/chat/src/lib.rs` (~5 LOC)
|
||||
|
||||
- `set_rumor`: store the real kind in the local cache tag:
|
||||
|
||||
```rust
|
||||
Tag::custom("k", [rumor.kind.to_string()]),
|
||||
```
|
||||
|
||||
- `get_rooms_task` currently filters `custom_tag(SingleLetterTag::LOWERCASE_K, "14")`,
|
||||
which would hide rooms whose only messages are file messages. Simplest fix: drop
|
||||
the `k` filter and keep `.kind(Kind::ApplicationSpecificData)`; the existing
|
||||
in-loop JSON parse plus `tags.public_keys().next().is_some()` check already
|
||||
discards junk, and kind 30078 rows in the local DB exist only for this rumor cache.
|
||||
Fallback if the extra rows are a concern: run two queries (`k = "14"` and
|
||||
`k = "15"`) and merge the results.
|
||||
|
||||
- No changes to `handle_notifications`, `extract_rumor`, `try_unwrap_with`, `new_message`,
|
||||
`Room::get_messages` — all are already kind-agnostic. (`Room::get_messages` filters
|
||||
only on the `r` room tag, so file messages load with the rest of the history.)
|
||||
|
||||
### Step 6 — (out of scope) UI wiring
|
||||
|
||||
Composer entry point, decryption cache, image/file rendering live in
|
||||
`crates/chat_ui`. Not part of this plan; the APIs above are what that layer needs:
|
||||
`state::upload_encrypted`, `FileAttachment::tags/from_tags`, `Room::file_rumor`,
|
||||
`Room::send`, `state::download_and_decrypt`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open decisions
|
||||
|
||||
1. **Key/nonce encoding (interop risk — decide/verify first).** NIP-17 does not specify
|
||||
how `decryption-key` / `decryption-nonce` are encoded, and no reference
|
||||
implementation could be reached from this environment to confirm. Plan: emit
|
||||
**base64 standard**, accept base64 std/unpadded/url-safe/hex on read. Before
|
||||
release, verify against another NIP-17 client (0xchat, Amethyst) and tighten if
|
||||
needed. Everything else in the plan is encoding-agnostic.
|
||||
2. **Display name.** The NIP defines no filename tag and Blossom URLs end in a content
|
||||
hash, so a name has to come from a non-standard tag. Proposal: emit
|
||||
`["alt", <file name>]` (NIP-94 tag; NIP-17 allows "rest of tags", and `alt` is a
|
||||
benign a11y field), tolerate its absence, fall back to mime + size. Alternative:
|
||||
ship strictly spec-shaped and show mime + size only.
|
||||
3. **`thumbhash`/`blurhash`/`thumb`/`fallback`/`dim`**: all optional; recommend skipping
|
||||
in v1 (and letting the UI show a placeholder until the blob is decrypted).
|
||||
4. **Web (wasm)**: encrypted upload stays unsupported on web, matching today's
|
||||
`state::blossom::upload` stub. Download/decrypt is cross-platform via gpui's client.
|
||||
5. **Ciphertext caching**: recommend *not* persisting decrypted plaintext in the local
|
||||
database in v1; decryption happens on demand from the blob URL.
|
||||
|
||||
---
|
||||
|
||||
## 8. Edge cases and failure handling
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| `encryption-algorithm` != `aes-gcm` | do not decrypt; report "unsupported encryption" (forward compatibility) |
|
||||
| missing `decryption-key` / `decryption-nonce` | `FileAttachment::from_tags` returns `None` → surfaced as a failed message, not a panic |
|
||||
| key != 32 bytes or nonce != 12 bytes | descriptive error |
|
||||
| GCM authentication failure | error; never emit partial plaintext |
|
||||
| `sha256_hex(ciphertext) != x` | error (tampered or corrupted blob), checked **before** decrypting |
|
||||
| blob larger than `MAX_FILE_SIZE` | rejected while reading the body |
|
||||
| HTTP error / non-200 | error message including status |
|
||||
| `content` not a valid URL | error; message still cached so it renders as a failed attachment |
|
||||
| sender's own copy | works automatically via the existing self-backup gift wrap |
|
||||
| multiple receivers | one blob, one key, key re-sent inside each recipient's gift wrap |
|
||||
|
||||
---
|
||||
|
||||
## 9. Verification
|
||||
|
||||
The feature cannot be exercised end-to-end without the UI step (out of scope), so
|
||||
backend verification is:
|
||||
|
||||
1. `cargo check -p state -p chat` after each step.
|
||||
2. `cargo check -p coop_web --target wasm32-unknown-unknown` to confirm the
|
||||
`cfg(target_arch = "wasm32")` stubs and the new module compile for web.
|
||||
3. Cheap unit checks (no network, ~15 LOC in `state/src/file.rs`), despite "no tests
|
||||
required" for the feature, because these are pure functions and catch the two
|
||||
riskiest bugs — encoding and tag ordering:
|
||||
- `encrypt` → `decrypt` round trip returns the original bytes.
|
||||
- flipping one ciphertext bit makes `decrypt` fail.
|
||||
- `sha256_hex(b"abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"`
|
||||
(confirms the digest order used for `x`/`ox`).
|
||||
- `FileAttachment` tags → `from_tags` round trip preserves key/nonce/mime/url.
|
||||
4. End-to-end (requires the UI step or a temporary call site): send to self with
|
||||
`RoomConfig::backup` enabled and confirm the rumor unwraps, downloads, verifies and
|
||||
decrypts; then a second account; then a cross-client check for decision #1.
|
||||
|
||||
---
|
||||
|
||||
## 10. Estimated size
|
||||
|
||||
| File | Change | ~LOC |
|
||||
|---|---|---|
|
||||
| `Cargo.toml`, `crates/state/Cargo.toml` | deps | 6 |
|
||||
| `crates/state/src/file.rs` | new | 180 |
|
||||
| `crates/state/src/lib.rs` | module + re-export | 2 |
|
||||
| `crates/chat/src/message.rs` | parse + preview | 80 |
|
||||
| `crates/chat/src/room.rs` | `file_rumor` + tag refactor + `k` tag | 70 |
|
||||
| `crates/chat/src/lib.rs` | cache tag + room list query | 5 |
|
||||
|
||||
Order of work: 1 → 2 → 3 → 4 → 5, each step compiling on its own. Steps 2–4 are
|
||||
independent of any UI change, so the branch stays green without touching `chat_ui`.
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 8.75003C11.1716 8.75003 10.5 9.4216 10.5 10.25C10.5 11.0785 11.1716 11.75 12 11.75C12.8284 11.75 13.5 11.0785 13.5 10.25C13.5 9.4216 12.8284 8.75003 12 8.75003ZM12 8.75003V14.75M20.25 11.9124V6.94155C20.25 6.08069 19.6991 5.31641 18.8825 5.04418L12.6325 2.96085C12.2219 2.824 11.7781 2.824 11.3675 2.96085L5.11754 5.04418C4.30086 5.31641 3.75 6.08069 3.75 6.94155V11.9124C3.75 16.8848 8 19.25 12 21.4079C16 19.25 20.25 16.8848 20.25 11.9124Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 626 B |
@@ -21,6 +21,7 @@ mod room;
|
||||
|
||||
pub use message::*;
|
||||
pub use room::*;
|
||||
pub use state::FileAttachment;
|
||||
|
||||
/// A static keypair used only for signing locally-cached rumor events.
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
@@ -629,7 +630,7 @@ impl ChatRegistry {
|
||||
|
||||
/// Load all rooms from the database.
|
||||
pub fn get_rooms(&mut self, cx: &mut Context<Self>) {
|
||||
let task = self.get_rooms_task(cx);
|
||||
let task = self.query_chat_rooms(cx);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
match task.await {
|
||||
@@ -650,8 +651,8 @@ impl ChatRegistry {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Create a task to load rooms from the database
|
||||
fn get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||
/// Query the chat rooms from the database
|
||||
fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let client = nostr.read(cx).client();
|
||||
let signer = nostr.read(cx).signer();
|
||||
@@ -677,7 +678,7 @@ impl ChatRegistry {
|
||||
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(SingleLetterTag::LOWERCASE_K, "14");
|
||||
.custom_tags(SingleLetterTag::LOWERCASE_K, ["7", "14", "15"]);
|
||||
|
||||
let events = client.database().query(filter).await?;
|
||||
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new();
|
||||
@@ -719,8 +720,8 @@ impl ChatRegistry {
|
||||
|
||||
/// Parse a nostr event into a message and push it to the belonging room
|
||||
///
|
||||
/// If the room doesn't exist, it will be created.
|
||||
/// Updates room ordering based on the most recent messages.
|
||||
/// - If the room doesn't exist, it will be created.
|
||||
/// - Updates room ordering based on the most recent messages.
|
||||
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
@@ -823,7 +824,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
|
||||
Tag::identifier(id),
|
||||
Tag::public_key(rumor.pubkey),
|
||||
Tag::custom("r", [room_id]),
|
||||
Tag::custom("k", ["14"]),
|
||||
Tag::custom("k", [rumor.kind.to_string()]),
|
||||
];
|
||||
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
|
||||
|
||||
+82
-39
@@ -4,6 +4,9 @@ use std::ops::Range;
|
||||
use common::{EventExt, NostrParser, extract_and_remove_media_urls};
|
||||
use gpui::{SharedString, SharedUri};
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::FileAttachment;
|
||||
|
||||
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
|
||||
|
||||
/// Rendered message.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -21,61 +24,90 @@ pub struct Message {
|
||||
pub mentions: Vec<Mention>,
|
||||
/// List of event of the message this message is a reply to
|
||||
pub replies_to: Vec<EventId>,
|
||||
/// Encrypted file attachment
|
||||
pub file: Option<FileAttachment>,
|
||||
}
|
||||
|
||||
impl From<&Event> for Message {
|
||||
fn from(val: &Event) -> Self {
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
||||
|
||||
Self {
|
||||
id: val.id,
|
||||
author: val.pubkey,
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
from_parts(
|
||||
val.id,
|
||||
val.pubkey,
|
||||
val.created_at,
|
||||
val.kind,
|
||||
&val.content,
|
||||
&val.tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&UnsignedEvent> for Message {
|
||||
fn from(val: &UnsignedEvent) -> Self {
|
||||
let mentions = extract_mentions(&val.content);
|
||||
let replies_to = extract_reply_ids(&val.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.content);
|
||||
|
||||
Self {
|
||||
from_parts(
|
||||
// Event ID must be known
|
||||
id: val.id.unwrap(),
|
||||
author: val.pubkey,
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
val.id.unwrap(),
|
||||
val.pubkey,
|
||||
val.created_at,
|
||||
val.kind,
|
||||
&val.content,
|
||||
&val.tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&NewMessage> for Message {
|
||||
fn from(val: &NewMessage) -> Self {
|
||||
let mentions = extract_mentions(&val.rumor.content);
|
||||
let replies_to = extract_reply_ids(&val.rumor.tags);
|
||||
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
|
||||
|
||||
Self {
|
||||
from_parts(
|
||||
// Event ID must be known
|
||||
id: val.rumor.id.unwrap(),
|
||||
author: val.rumor.pubkey,
|
||||
content: string,
|
||||
media,
|
||||
created_at: val.rumor.created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
}
|
||||
val.rumor.id.unwrap(),
|
||||
val.rumor.pubkey,
|
||||
val.rumor.created_at,
|
||||
val.rumor.kind,
|
||||
&val.rumor.content,
|
||||
&val.rumor.tags,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn from_parts(
|
||||
id: EventId,
|
||||
author: PublicKey,
|
||||
created_at: Timestamp,
|
||||
kind: Kind,
|
||||
content: &str,
|
||||
tags: &Tags,
|
||||
) -> Message {
|
||||
let file = if kind == KIND_FILE_MESSAGE {
|
||||
FileAttachment::from_tags(content, tags)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let has_file = file.is_some();
|
||||
|
||||
let replies_to = extract_reply_ids(tags);
|
||||
|
||||
// For file messages `.content` is the encrypted blob URL, not text or media
|
||||
let mentions = if has_file {
|
||||
Vec::new()
|
||||
} else {
|
||||
extract_mentions(content)
|
||||
};
|
||||
|
||||
let (media, content) = if has_file {
|
||||
(Vec::new(), String::new())
|
||||
} else {
|
||||
extract_and_remove_media_urls(content)
|
||||
};
|
||||
|
||||
Message {
|
||||
id,
|
||||
author,
|
||||
content,
|
||||
media,
|
||||
created_at,
|
||||
mentions,
|
||||
replies_to,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +137,17 @@ impl Hash for Message {
|
||||
}
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// Single-line representation for reply previews, notifications and copy.
|
||||
pub fn preview(&self) -> SharedString {
|
||||
if let Some(file) = &self.file {
|
||||
return format!("[File] {}", file.display_name()).into();
|
||||
}
|
||||
|
||||
self.content.clone().into()
|
||||
}
|
||||
}
|
||||
|
||||
/// New message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct NewMessage {
|
||||
|
||||
+46
-15
@@ -12,7 +12,7 @@ use person::{Person, PersonRegistry};
|
||||
use settings::{RoomConfig, SignerKind};
|
||||
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
|
||||
|
||||
use crate::NewMessage;
|
||||
use crate::{FileAttachment, KIND_FILE_MESSAGE, NewMessage};
|
||||
|
||||
const NO_DEKEY: &str = "User hasn't set up a decoupled encryption key yet.";
|
||||
const USER_NO_DEKEY: &str = "You haven't set up a decoupled encryption key or it's not available.";
|
||||
@@ -439,12 +439,52 @@ impl Room {
|
||||
let content: String = content.into();
|
||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
// Get current user's public key
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
// Construct a direct message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(kind, content)
|
||||
.tags(self.conversation_tags(&replies, sender, cx))
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
Some(event)
|
||||
}
|
||||
|
||||
// Construct a rumor event for an encrypted file message (NIP-17 kind 15)
|
||||
pub fn file_rumor<I>(&self, file: FileAttachment, replies: I, cx: &App) -> Option<UnsignedEvent>
|
||||
where
|
||||
I: IntoIterator<Item = EventId>,
|
||||
{
|
||||
let replies: Vec<EventId> = replies.into_iter().collect();
|
||||
|
||||
// Get current user's public key
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let sender = nostr.read(cx).current_user()?;
|
||||
|
||||
let mut tags = self.conversation_tags(&replies, sender, cx);
|
||||
tags.extend(file.tags());
|
||||
|
||||
// Construct a file message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(KIND_FILE_MESSAGE, file.url.to_string())
|
||||
.tags(tags)
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
Some(event)
|
||||
}
|
||||
|
||||
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
|
||||
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
|
||||
// Construct event's tags
|
||||
let mut tags = vec![];
|
||||
|
||||
@@ -454,8 +494,8 @@ impl Room {
|
||||
}
|
||||
|
||||
// Add all reply tags
|
||||
for id in replies.into_iter() {
|
||||
tags.push(Tag::event(id))
|
||||
for id in replies {
|
||||
tags.push(Tag::event(*id))
|
||||
}
|
||||
|
||||
// Add all receiver tags (no intermediate allocation)
|
||||
@@ -467,16 +507,7 @@ impl Room {
|
||||
}));
|
||||
}
|
||||
|
||||
// Construct a direct message rumor event
|
||||
// WARNING: never sign and send this event to relays
|
||||
let mut event = EventBuilder::new(kind, content)
|
||||
.tags(tags)
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
event.ensure_id();
|
||||
|
||||
Some(event)
|
||||
tags
|
||||
}
|
||||
|
||||
/// Select the appropriate signer based on signer kind and available keys.
|
||||
@@ -609,7 +640,7 @@ async fn send_gift_wrap(
|
||||
rumor: &UnsignedEvent,
|
||||
config: &SignerKind,
|
||||
) -> Result<SendReport, Error> {
|
||||
let k_tag = Tag::custom("k", vec!["14"]);
|
||||
let k_tag = Tag::custom("k", [rumor.kind.to_string()]);
|
||||
let mut extra_tags = vec![k_tag];
|
||||
|
||||
// Determine the receiver public key based on the config
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chat::FileAttachment;
|
||||
use gpui::SharedString;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
/// A file attachment that has been uploaded, but not sent yet.
|
||||
///
|
||||
/// The local `path` is kept around so the composer can preview
|
||||
/// the file without downloading and decrypting it again.
|
||||
pub(crate) struct PendingFile {
|
||||
pub file: FileAttachment,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// State of the encrypted file attachment of a message
|
||||
pub(crate) enum DecryptedFile {
|
||||
Loading,
|
||||
Ready(PathBuf),
|
||||
Failed(SharedString),
|
||||
}
|
||||
|
||||
/// Result of an upload, either plain or encrypted
|
||||
pub(crate) enum Uploaded {
|
||||
Url(Url),
|
||||
File(FileAttachment, PathBuf),
|
||||
}
|
||||
|
||||
/// A `file://` url for a decrypted file, so it can be opened by the OS
|
||||
pub(crate) fn file_url(path: &Path) -> String {
|
||||
format!("file://{}", path.display())
|
||||
}
|
||||
+375
-40
@@ -1,8 +1,9 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, LazyLock, RwLock};
|
||||
|
||||
pub use actions::*;
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use anyhow::Error;
|
||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
|
||||
use common::{TimestampExt, coop_cache};
|
||||
use futures::lock::Mutex;
|
||||
@@ -21,7 +22,9 @@ use person::{Person, PersonRegistry};
|
||||
use regex::Regex;
|
||||
use settings::{AppSettings, SignerKind};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{NostrRegistry, upload};
|
||||
use state::{
|
||||
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
|
||||
};
|
||||
use theme::ActiveTheme;
|
||||
use ui::avatar::Avatar;
|
||||
use ui::button::{Button, ButtonVariants};
|
||||
@@ -35,6 +38,7 @@ use ui::{
|
||||
h_flex, v_flex,
|
||||
};
|
||||
|
||||
use crate::file::*;
|
||||
use crate::text::RenderedText;
|
||||
|
||||
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"];
|
||||
@@ -46,6 +50,7 @@ static EMOJI_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
|
||||
|
||||
mod actions;
|
||||
mod file;
|
||||
mod text;
|
||||
|
||||
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
|
||||
@@ -96,6 +101,12 @@ pub struct ChatPanel {
|
||||
/// Media Attachment
|
||||
attachments: Entity<Vec<Url>>,
|
||||
|
||||
/// Uploaded, encrypted file attachments which are not sent yet
|
||||
encrypted_attachments: Entity<Vec<PendingFile>>,
|
||||
|
||||
/// Decrypted attachments of file messages, by message id
|
||||
decrypted_files: HashMap<EventId, DecryptedFile>,
|
||||
|
||||
/// Upload state
|
||||
uploading: bool,
|
||||
|
||||
@@ -110,6 +121,7 @@ impl ChatPanel {
|
||||
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
// Define attachments and replies_to entities
|
||||
let attachments = cx.new(|_| vec![]);
|
||||
let encrypted_attachments = cx.new(|_| vec![]);
|
||||
let replies_to = cx.new(|_| HashSet::new());
|
||||
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
|
||||
|
||||
@@ -185,6 +197,8 @@ impl ChatPanel {
|
||||
subject_bar,
|
||||
replies_to,
|
||||
attachments,
|
||||
encrypted_attachments,
|
||||
decrypted_files: HashMap::new(),
|
||||
rendered_texts_by_id: BTreeMap::new(),
|
||||
reports_by_id,
|
||||
sent_ids: Arc::new(Mutex::new(Vec::new())),
|
||||
@@ -370,21 +384,32 @@ impl ChatPanel {
|
||||
}
|
||||
|
||||
fn send_text_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
// Get the message which includes all attachments
|
||||
// Get the message which includes all plain attachments
|
||||
let content = self.get_input_value(cx);
|
||||
|
||||
// Get the replies to this message
|
||||
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
|
||||
|
||||
// Return if message is empty
|
||||
if content.trim().is_empty() {
|
||||
// Uploaded files are sent as encrypted file messages
|
||||
let files: Vec<FileAttachment> = self
|
||||
.encrypted_attachments
|
||||
.read(cx)
|
||||
.iter()
|
||||
.map(|pending| pending.file.clone())
|
||||
.collect();
|
||||
|
||||
// Return if there is nothing to send
|
||||
if content.trim().is_empty() && files.is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
return;
|
||||
}
|
||||
|
||||
// If replying to exactly one message with only a valid emoji,
|
||||
// send as a reaction instead of a text message
|
||||
if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty()
|
||||
if replies.len() == 1
|
||||
&& EMOJI_RE.is_match(&content)
|
||||
&& self.attachments.read(cx).is_empty()
|
||||
&& files.is_empty()
|
||||
{
|
||||
for reply in &replies {
|
||||
self.send_reaction(&content, reply, window, cx);
|
||||
@@ -393,7 +418,15 @@ impl ChatPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
self.send_message(&content, replies, false, window, cx);
|
||||
// Send the text part, including the plain attachment urls
|
||||
if !content.trim().is_empty() {
|
||||
self.send_message(&content, replies.clone(), false, window, cx);
|
||||
}
|
||||
|
||||
// Send every file as its own encrypted file message
|
||||
for file in files {
|
||||
self.send_file(file, replies.clone(), window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn send_reaction(
|
||||
@@ -426,29 +459,59 @@ impl ChatPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
let room = self.room.clone();
|
||||
let content = value.to_string();
|
||||
let sent_ids = self.sent_ids.clone();
|
||||
|
||||
// Upgrade room and create rumor + send task in a single read lock
|
||||
let Some(room_entity) = room.upgrade() else {
|
||||
let Some(room) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Create rumor and send task
|
||||
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| {
|
||||
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
|
||||
let outcome = room.read_with(cx, |room, cx| {
|
||||
let rumor = room.rumor(value, replies, reaction, cx)?;
|
||||
let send_task = room.send(rumor.clone(), cx)?;
|
||||
|
||||
Some((rumor, send_task))
|
||||
}) {
|
||||
Some(pair) => pair,
|
||||
None => {
|
||||
window.push_notification("Failed to create message", cx);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
match outcome {
|
||||
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
|
||||
None => window.push_notification("Failed to create message", cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an encrypted file message (NIP-17 kind 15) to all members of the chat
|
||||
fn send_file(
|
||||
&mut self,
|
||||
file: FileAttachment,
|
||||
replies: Vec<EventId>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(room) = self.room.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let outcome = room.read_with(cx, |room, cx| {
|
||||
let rumor = room.file_rumor(file, replies, cx)?;
|
||||
let send_task = room.send(rumor.clone(), cx)?;
|
||||
|
||||
Some((rumor, send_task))
|
||||
});
|
||||
|
||||
match outcome {
|
||||
Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
|
||||
None => window.push_notification("Failed to create message", cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a rumor optimistically and track the send reports of its gift wraps
|
||||
fn dispatch(
|
||||
&mut self,
|
||||
rumor: UnsignedEvent,
|
||||
send_task: Task<Vec<SendReport>>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let id = rumor.id.expect("rumor must have an id");
|
||||
let sent_ids = self.sent_ids.clone();
|
||||
|
||||
// Insert optimistic message and clear input
|
||||
if rumor.kind != Kind::Reaction {
|
||||
@@ -487,6 +550,10 @@ impl ChatPanel {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
self.encrypted_attachments.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
});
|
||||
self.replies_to.update(cx, |this, cx| {
|
||||
this.clear();
|
||||
cx.notify();
|
||||
@@ -604,7 +671,7 @@ impl ChatPanel {
|
||||
let Some(message) = self.message(id) else {
|
||||
return;
|
||||
};
|
||||
let content = message.content.to_string();
|
||||
let content = message.preview().to_string();
|
||||
let item = ClipboardItem::new_string(content);
|
||||
|
||||
cx.write_to_clipboard(item);
|
||||
@@ -630,6 +697,9 @@ impl ChatPanel {
|
||||
// Get the user's configured blossom server
|
||||
let server = AppSettings::get_file_server(cx);
|
||||
|
||||
// Encrypt attachments which are not part of a message being written
|
||||
let encrypted = self.input.read(cx).value().trim().is_empty();
|
||||
|
||||
// Ask user for file upload
|
||||
let path = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
@@ -639,31 +709,38 @@ impl ChatPanel {
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
// Selecting no file means the prompt was cancelled
|
||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_uploading(true, cx);
|
||||
})?;
|
||||
|
||||
let mut paths = path.await??.context("Not found")?;
|
||||
let path = paths.pop().context("No path")?;
|
||||
// Upload the file, encrypted when it is the whole message
|
||||
let result = if encrypted {
|
||||
upload_encrypted(server, path.clone(), cx)
|
||||
.await
|
||||
.map(|file| Uploaded::File(file, path))
|
||||
} else {
|
||||
upload(server, path, cx).await.map(Uploaded::Url)
|
||||
};
|
||||
|
||||
// Upload via blossom client
|
||||
match upload(server, path, cx).await {
|
||||
Ok(url) => {
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
this.add_attachment(url, cx);
|
||||
this.set_uploading(false, cx);
|
||||
})?;
|
||||
}
|
||||
Err(e) => {
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_uploading(false, cx);
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_uploading(false, cx);
|
||||
|
||||
match result {
|
||||
Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
|
||||
Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
|
||||
Err(e) => {
|
||||
window.push_notification(
|
||||
Notification::error(e.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
@@ -690,6 +767,88 @@ impl ChatPanel {
|
||||
});
|
||||
}
|
||||
|
||||
fn add_pending_file(&mut self, file: FileAttachment, path: PathBuf, cx: &mut Context<Self>) {
|
||||
self.encrypted_attachments.update(cx, |this, cx| {
|
||||
this.push(PendingFile { file, path });
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn remove_pending_file(&mut self, url: &Url, cx: &mut Context<Self>) {
|
||||
self.encrypted_attachments.update(cx, |this, cx| {
|
||||
if let Some(ix) = this.iter().position(|pending| &pending.file.url == url) {
|
||||
this.remove(ix);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Download and decrypt the attachment of a file message for preview
|
||||
fn load_file(&mut self, id: EventId, file: FileAttachment, cx: &mut Context<Self>) {
|
||||
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = download_and_decrypt_to_file(&file, cx).await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
match result {
|
||||
Ok(path) => {
|
||||
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
||||
}
|
||||
Err(e) => {
|
||||
this.decrypted_files
|
||||
.insert(id, DecryptedFile::Failed(e.to_string().into()));
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Decrypt the attachment of a file message and open it with the OS
|
||||
fn open_file(
|
||||
&mut self,
|
||||
id: EventId,
|
||||
file: FileAttachment,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
match self.decrypted_files.get(&id) {
|
||||
Some(DecryptedFile::Ready(path)) => {
|
||||
cx.open_url(&file_url(path));
|
||||
return;
|
||||
}
|
||||
Some(DecryptedFile::Loading) => return,
|
||||
_ => {}
|
||||
};
|
||||
|
||||
self.decrypted_files.insert(id, DecryptedFile::Loading);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = download_and_decrypt_to_file(&file, cx).await;
|
||||
|
||||
this.update_in(cx, |this, _window, cx| {
|
||||
match result {
|
||||
Ok(path) => {
|
||||
cx.open_url(&file_url(&path));
|
||||
this.decrypted_files.insert(id, DecryptedFile::Ready(path));
|
||||
}
|
||||
Err(e) => {
|
||||
this.decrypted_files
|
||||
.insert(id, DecryptedFile::Failed(e.to_string().into()));
|
||||
}
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
persons.read(cx).get(public_key, cx)
|
||||
@@ -929,6 +1088,16 @@ impl ChatPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let file = self.messages.get(ix).and_then(|message| {
|
||||
let file = message.file.clone()?;
|
||||
(!self.decrypted_files.contains_key(&message.id) && file.is_image())
|
||||
.then_some((message.id, file))
|
||||
});
|
||||
|
||||
if let Some((id, file)) = file {
|
||||
self.load_file(id, file, cx);
|
||||
}
|
||||
|
||||
if let Some(message) = self.messages.get(ix) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
let show_author = self.is_group_start(ix);
|
||||
@@ -1016,8 +1185,11 @@ impl ChatPanel {
|
||||
.when(has_replies, |this| {
|
||||
this.children(self.render_message_replies(replies, cx))
|
||||
})
|
||||
.child(rendered_text)
|
||||
.when(message.file.is_none(), |this| this.child(rendered_text))
|
||||
.child(self.render_media(&message.media, cx))
|
||||
.when_some(message.file.as_ref(), |this, file| {
|
||||
this.child(self.render_message_file(&id, file, cx))
|
||||
})
|
||||
.when(has_reactions, |this| {
|
||||
this.child(self.render_reactions(&id, cx))
|
||||
}),
|
||||
@@ -1123,7 +1295,7 @@ impl ChatPanel {
|
||||
.w_full()
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(SharedString::from(&message.content)),
|
||||
.child(message.preview()),
|
||||
)
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.on_click({
|
||||
@@ -1464,6 +1636,168 @@ impl ChatPanel {
|
||||
items
|
||||
}
|
||||
|
||||
/// Render the encrypted file attachment of a message
|
||||
fn render_message_file(
|
||||
&self,
|
||||
id: &EventId,
|
||||
file: &FileAttachment,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
let state = self.decrypted_files.get(id);
|
||||
|
||||
if let Some(path) = state
|
||||
.and_then(|state| match state {
|
||||
DecryptedFile::Ready(path) => Some(path),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|_| file.is_image())
|
||||
{
|
||||
return div()
|
||||
.child(
|
||||
img(path.clone())
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.h(px(250.))
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.rounded(cx.theme().radius),
|
||||
)
|
||||
.into_any_element();
|
||||
}
|
||||
|
||||
let label = match state {
|
||||
Some(DecryptedFile::Loading) => SharedString::from("Decrypting..."),
|
||||
Some(DecryptedFile::Failed(error)) => error.clone(),
|
||||
Some(DecryptedFile::Ready(_)) => SharedString::from("Click to open"),
|
||||
None => SharedString::from("Click to decrypt"),
|
||||
};
|
||||
|
||||
self.render_file_chip(id, file, label, cx)
|
||||
}
|
||||
|
||||
/// Render an encrypted file as a chip which decrypts and opens it on click
|
||||
fn render_file_chip(
|
||||
&self,
|
||||
id: &EventId,
|
||||
file: &FileAttachment,
|
||||
label: SharedString,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
div()
|
||||
.id(SharedString::from(format!("file-{id}")))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.py_1()
|
||||
.px_2()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.rounded(cx.theme().radius)
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.child(
|
||||
Icon::new(IconName::Lock)
|
||||
.small()
|
||||
.text_color(cx.theme().text_placeholder),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
.overflow_hidden()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(file.display_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(label),
|
||||
),
|
||||
)
|
||||
.on_click({
|
||||
let file = file.clone();
|
||||
let id = *id;
|
||||
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.open_file(id, file.clone(), window, cx);
|
||||
})
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Render an uploaded, encrypted file which is not sent yet
|
||||
fn render_pending_file(&self, pending: &PendingFile, cx: &Context<Self>) -> impl IntoElement {
|
||||
let file = &pending.file;
|
||||
|
||||
div()
|
||||
.id(SharedString::from(file.url.to_string()))
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.p_1()
|
||||
.pr_2()
|
||||
.border_1()
|
||||
.border_color(cx.theme().border_variant)
|
||||
.rounded(cx.theme().radius)
|
||||
.when(file.is_image(), |this| {
|
||||
this.child(
|
||||
img(pending.path.clone())
|
||||
.size_8()
|
||||
.rounded(cx.theme().radius)
|
||||
.object_fit(ObjectFit::Cover),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.max_w(px(160.))
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(file.display_name()),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(Icon::new(IconName::Lock).size_2())
|
||||
.child("End-to-end encrypted"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Button::new(SharedString::from(format!("remove-{}", file.url)))
|
||||
.icon(IconName::Close)
|
||||
.xsmall()
|
||||
.ghost()
|
||||
.on_click({
|
||||
let url = file.url.clone();
|
||||
cx.listener(move |this, _, _, cx| {
|
||||
this.remove_pending_file(&url, cx);
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_pending_file_list(
|
||||
&self,
|
||||
_window: &Window,
|
||||
cx: &Context<Self>,
|
||||
) -> impl IntoIterator<Item = impl IntoElement> {
|
||||
let mut items = vec![];
|
||||
|
||||
for pending in self.encrypted_attachments.read(cx).iter() {
|
||||
items.push(self.render_pending_file(pending, cx));
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
|
||||
if let Some(text) = self.message(id) {
|
||||
let persons = PersonRegistry::global(cx);
|
||||
@@ -1512,7 +1846,7 @@ impl ChatPanel {
|
||||
.text_sm()
|
||||
.text_ellipsis()
|
||||
.line_clamp(1)
|
||||
.child(SharedString::from(&text.content)),
|
||||
.child(text.preview()),
|
||||
)
|
||||
} else {
|
||||
div()
|
||||
@@ -1701,6 +2035,7 @@ impl Render for ChatPanel {
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.children(self.render_attachment_list(window, cx))
|
||||
.children(self.render_pending_file_list(window, cx))
|
||||
.children(self.render_reply_list(window, cx))
|
||||
.child(
|
||||
h_flex()
|
||||
|
||||
@@ -24,6 +24,10 @@ serde_json.workspace = true
|
||||
|
||||
mime_guess = "2.0.4"
|
||||
|
||||
aes-gcm.workspace = true
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
nostr-memory.workspace = true
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use aes_gcm::aead::consts::U12;
|
||||
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
|
||||
use aes_gcm::{Aes256Gcm, Nonce};
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
|
||||
use futures::AsyncReadExt;
|
||||
use gpui::http_client::AsyncBody;
|
||||
use gpui::{AsyncApp, SharedString};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_tokio::Tokio;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use mime_guess::from_path;
|
||||
use nostr::nips::nip94::Sha256Hash;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use nostr_blossom::prelude::*;
|
||||
use nostr_sdk::prelude::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub const ALGORITHM: &str = "aes-gcm";
|
||||
|
||||
pub const MAX_FILE_SIZE: usize = 25 * 1024 * 1024;
|
||||
|
||||
const TAG_SHA256: &str = "x";
|
||||
const TAG_ORIGINAL_SHA256: &str = "ox";
|
||||
const TAG_FILE_TYPE: &str = "file-type";
|
||||
const TAG_ALGORITHM: &str = "encryption-algorithm";
|
||||
const TAG_KEY: &str = "decryption-key";
|
||||
const TAG_NONCE: &str = "decryption-nonce";
|
||||
const TAG_SIZE: &str = "size";
|
||||
const TAG_DIM: &str = "dim";
|
||||
const TAG_ALT: &str = "alt";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EncryptedFile {
|
||||
pub data: Vec<u8>,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FileAttachment {
|
||||
pub url: Url,
|
||||
pub mime: String,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
pub sha256: Option<String>,
|
||||
pub original_sha256: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
pub dim: Option<(u32, u32)>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl FileAttachment {
|
||||
pub fn tags(&self) -> Vec<Tag> {
|
||||
let mut tags = vec![
|
||||
Tag::custom(TAG_FILE_TYPE, [self.mime.clone()]),
|
||||
Tag::custom(TAG_ALGORITHM, [ALGORITHM]),
|
||||
Tag::custom(TAG_KEY, [self.key.clone()]),
|
||||
Tag::custom(TAG_NONCE, [self.nonce.clone()]),
|
||||
];
|
||||
|
||||
if let Some(sha256) = &self.sha256 {
|
||||
tags.push(Tag::custom(TAG_SHA256, [sha256.clone()]));
|
||||
}
|
||||
|
||||
if let Some(original_sha256) = &self.original_sha256 {
|
||||
tags.push(Tag::custom(TAG_ORIGINAL_SHA256, [original_sha256.clone()]));
|
||||
}
|
||||
|
||||
if let Some(size) = self.size {
|
||||
tags.push(Tag::custom(TAG_SIZE, [size.to_string()]));
|
||||
}
|
||||
|
||||
if let Some((width, height)) = self.dim {
|
||||
tags.push(Tag::custom(TAG_DIM, [format!("{width}x{height}")]));
|
||||
}
|
||||
|
||||
if let Some(name) = &self.name {
|
||||
tags.push(Tag::custom(TAG_ALT, [name.clone()]));
|
||||
}
|
||||
|
||||
tags
|
||||
}
|
||||
|
||||
pub fn from_tags(content: &str, tags: &Tags) -> Option<Self> {
|
||||
if tag_value(tags, TAG_ALGORITHM)? != ALGORITHM {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
url: Url::parse(content).ok()?,
|
||||
mime: tag_value(tags, TAG_FILE_TYPE)?.to_string(),
|
||||
key: tag_value(tags, TAG_KEY)?.to_string(),
|
||||
nonce: tag_value(tags, TAG_NONCE)?.to_string(),
|
||||
sha256: tag_value(tags, TAG_SHA256).map(str::to_string),
|
||||
original_sha256: tag_value(tags, TAG_ORIGINAL_SHA256).map(str::to_string),
|
||||
size: tag_value(tags, TAG_SIZE).and_then(|size| size.parse().ok()),
|
||||
dim: tag_value(tags, TAG_DIM).and_then(parse_dim),
|
||||
name: tag_value(tags, TAG_ALT).map(str::to_string),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_image(&self) -> bool {
|
||||
self.mime.starts_with("image/")
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> SharedString {
|
||||
if let Some(name) = &self.name {
|
||||
return name.clone().into();
|
||||
}
|
||||
|
||||
match self.size {
|
||||
Some(size) => format!("{} ({size} bytes)", self.mime).into(),
|
||||
None => self.mime.clone().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
|
||||
let key = Aes256Gcm::generate_key(OsRng);
|
||||
let nonce = Aes256Gcm::generate_nonce(OsRng);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
|
||||
let data = cipher
|
||||
.encrypt(&nonce, data)
|
||||
.map_err(|_| anyhow!("Failed to encrypt file"))?;
|
||||
|
||||
Ok(EncryptedFile {
|
||||
data,
|
||||
key: STANDARD.encode(key.as_slice()),
|
||||
nonce: STANDARD.encode(nonce.as_slice()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decrypt(data: &[u8], key: &str, nonce: &str) -> Result<Vec<u8>, Error> {
|
||||
let key = decode(key, 32, "decryption key")?;
|
||||
let nonce = decode(nonce, 12, "decryption nonce")?;
|
||||
|
||||
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| anyhow!("Invalid decryption key"))?;
|
||||
|
||||
cipher
|
||||
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
|
||||
.map_err(|_| anyhow!("Failed to decrypt file"))
|
||||
}
|
||||
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
let hash: [u8; 32] = Sha256::digest(data).into();
|
||||
|
||||
Sha256Hash::from_byte_array(hash).to_hex()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn upload_encrypted(
|
||||
server: Url,
|
||||
path: PathBuf,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<FileAttachment, Error> {
|
||||
let mime = from_path(&path).first_or_octet_stream().to_string();
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned());
|
||||
let data = smol::fs::read(&path).await?;
|
||||
|
||||
let encrypted = encrypt(&data)?;
|
||||
let sha256 = Some(sha256_hex(&encrypted.data));
|
||||
let original_sha256 = Some(sha256_hex(&data));
|
||||
let size = Some(encrypted.data.len() as u64);
|
||||
let keys = Keys::generate();
|
||||
let client = BlossomClient::new(server);
|
||||
|
||||
let url = Tokio::spawn(cx, async move {
|
||||
let blob = client
|
||||
.upload_blob(
|
||||
encrypted.data,
|
||||
Some("application/octet-stream".to_string()),
|
||||
None,
|
||||
Some(&keys),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok::<Url, Error>(blob.url)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!("Upload error: {e}"))??;
|
||||
|
||||
Ok(FileAttachment {
|
||||
url,
|
||||
mime,
|
||||
key: encrypted.key,
|
||||
nonce: encrypted.nonce,
|
||||
sha256,
|
||||
original_sha256,
|
||||
size,
|
||||
dim: None,
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn upload_encrypted(
|
||||
_server: Url,
|
||||
_path: PathBuf,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<FileAttachment, Error> {
|
||||
Err(anyhow!("File upload not supported on web"))
|
||||
}
|
||||
|
||||
pub async fn download_and_decrypt(
|
||||
url: &Url,
|
||||
key: &str,
|
||||
nonce: &str,
|
||||
expected_sha256: Option<&str>,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<Vec<u8>, Error> {
|
||||
let client = cx.update(|app| app.http_client());
|
||||
let response = client.get(url.as_str(), AsyncBody::default(), true).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!("Failed to download file: HTTP {}", response.status());
|
||||
}
|
||||
|
||||
let mut data = Vec::new();
|
||||
response
|
||||
.into_body()
|
||||
.take(MAX_FILE_SIZE as u64 + 1)
|
||||
.read_to_end(&mut data)
|
||||
.await?;
|
||||
|
||||
if data.len() > MAX_FILE_SIZE {
|
||||
bail!("File is too large (max {MAX_FILE_SIZE} bytes)");
|
||||
}
|
||||
|
||||
if let Some(expected) = expected_sha256
|
||||
&& !sha256_hex(&data).eq_ignore_ascii_case(expected)
|
||||
{
|
||||
bail!("File hash mismatch");
|
||||
}
|
||||
|
||||
decrypt(&data, key, nonce)
|
||||
}
|
||||
|
||||
/// Download and decrypt a file attachment into a temporary file.
|
||||
///
|
||||
/// The same attachment always maps to the same path, so callers can render the
|
||||
/// result directly (e.g. with `img`) without downloading it more than once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn download_and_decrypt_to_file(
|
||||
file: &FileAttachment,
|
||||
cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let name = file
|
||||
.sha256
|
||||
.clone()
|
||||
.unwrap_or_else(|| sha256_hex(file.url.as_str().as_bytes()));
|
||||
|
||||
let extension = mime_guess::get_mime_extensions_str(&file.mime)
|
||||
.and_then(|extensions| extensions.first())
|
||||
.copied()
|
||||
.unwrap_or("bin");
|
||||
|
||||
let path = std::env::temp_dir()
|
||||
.join("coop-files")
|
||||
.join(format!("{name}.{extension}"));
|
||||
|
||||
if smol::fs::metadata(&path).await.is_ok() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let data = download_and_decrypt(
|
||||
&file.url,
|
||||
&file.key,
|
||||
&file.nonce,
|
||||
file.sha256.as_deref(),
|
||||
cx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(parent) = path.parent() else {
|
||||
bail!("Invalid file path");
|
||||
};
|
||||
smol::fs::create_dir_all(parent).await?;
|
||||
|
||||
// Write under a temporary name first, so an interrupted download is never reused
|
||||
let partial = path.with_extension("download");
|
||||
smol::fs::write(&partial, data).await?;
|
||||
smol::fs::rename(&partial, &path).await?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn download_and_decrypt_to_file(
|
||||
_file: &FileAttachment,
|
||||
_cx: &AsyncApp,
|
||||
) -> Result<PathBuf, Error> {
|
||||
Err(anyhow!("File download not supported on web"))
|
||||
}
|
||||
|
||||
fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
|
||||
tags.iter()
|
||||
.find(|tag| tag.kind() == name)
|
||||
.and_then(|tag| tag.content())
|
||||
}
|
||||
|
||||
fn parse_dim(value: &str) -> Option<(u32, u32)> {
|
||||
let (width, height) = value.split_once('x')?;
|
||||
|
||||
Some((width.parse().ok()?, height.parse().ok()?))
|
||||
}
|
||||
|
||||
fn decode(value: &str, expected_len: usize, label: &str) -> Result<Vec<u8>, Error> {
|
||||
let decoded = STANDARD
|
||||
.decode(value)
|
||||
.or_else(|_| STANDARD_NO_PAD.decode(value))
|
||||
.or_else(|_| URL_SAFE.decode(value))
|
||||
.or_else(|_| URL_SAFE_NO_PAD.decode(value))
|
||||
.map_err(|_| anyhow!("Invalid {label} encoding"))?;
|
||||
|
||||
if decoded.len() != expected_len {
|
||||
bail!(
|
||||
"Invalid {label} length: expected {expected_len} bytes, got {}",
|
||||
decoded.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(decoded)
|
||||
}
|
||||
@@ -17,12 +17,14 @@ use nostr_sdk::prelude::*;
|
||||
|
||||
mod blossom;
|
||||
mod constants;
|
||||
mod file;
|
||||
mod nip05;
|
||||
mod nip4e;
|
||||
mod signer;
|
||||
|
||||
pub use blossom::*;
|
||||
pub use constants::*;
|
||||
pub use file::*;
|
||||
pub use nip4e::*;
|
||||
pub use nip05::*;
|
||||
pub use signer::{CoopAuthUrlHandler, UniversalSigner};
|
||||
|
||||
@@ -46,6 +46,7 @@ pub enum IconName {
|
||||
InboxFill,
|
||||
Link,
|
||||
Loader,
|
||||
Lock,
|
||||
Moon,
|
||||
Plus,
|
||||
PlusCircle,
|
||||
@@ -118,6 +119,7 @@ impl IconNamed for IconName {
|
||||
Self::InboxFill => "icons/inbox-fill.svg",
|
||||
Self::Link => "icons/link.svg",
|
||||
Self::Loader => "icons/loader.svg",
|
||||
Self::Lock => "icons/lock.svg",
|
||||
Self::Moon => "icons/moon.svg",
|
||||
Self::Plus => "icons/plus.svg",
|
||||
Self::PlusCircle => "icons/plus-circle.svg",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Context as AnyhowContext, Error};
|
||||
use anyhow::Error;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
|
||||
@@ -167,13 +167,15 @@ impl ProfilePanel {
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
// Selecting no file means the prompt was cancelled
|
||||
let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_uploading(true, cx);
|
||||
})?;
|
||||
|
||||
let mut paths = path.await??.context("Not found")?;
|
||||
let path = paths.pop().context("No path")?;
|
||||
|
||||
// Upload via blossom client
|
||||
match upload(server, path, cx).await {
|
||||
Ok(url) => {
|
||||
|
||||
Reference in New Issue
Block a user