7 Commits
Author SHA1 Message Date
reya 06449d226f fix upload button 2026-09-15 20:17:36 +07:00
reya 8303b054f9 update chat ui 2026-09-15 20:05:27 +07:00
reya 2d7881e574 update api 2026-09-15 19:33:30 +07:00
reya 37d3380c55 update room 2026-09-15 19:21:01 +07:00
reya 9fcfb9f0bf update message 2026-09-15 19:16:10 +07:00
reya 1877e0082c add encrypted file construction 2026-09-15 19:11:54 +07:00
reya 68d3d77ae3 prepare 2026-09-15 19:03:20 +07:00
14 changed files with 1396 additions and 105 deletions
Generated
+49
View File
@@ -139,6 +139,20 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "ahash" name = "ahash"
version = "0.8.12" version = "0.8.12"
@@ -1686,6 +1700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core 0.6.4",
"typenum", "typenum",
] ]
@@ -1708,6 +1723,15 @@ dependencies = [
"linktime-proc-macro", "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]] [[package]]
name = "ctutils" name = "ctutils"
version = "0.4.2" version = "0.4.2"
@@ -2542,6 +2566,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "gif" name = "gif"
version = "0.14.2" version = "0.14.2"
@@ -5191,6 +5225,18 @@ dependencies = [
"arrayvec", "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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.15.0" version = "1.15.0"
@@ -6631,7 +6677,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
name = "state" name = "state"
version = "1.0.1" version = "1.0.1"
dependencies = [ dependencies = [
"aes-gcm",
"anyhow", "anyhow",
"base64 0.22.1",
"browser-signer-proxy", "browser-signer-proxy",
"common", "common",
"flume 0.11.1", "flume 0.11.1",
@@ -6650,6 +6698,7 @@ dependencies = [
"nostr-sdk", "nostr-sdk",
"rustls", "rustls",
"serde_json", "serde_json",
"sha2 0.10.9",
"smol", "smol",
"webbrowser", "webbrowser",
] ]
+5
View File
@@ -27,6 +27,11 @@ nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
nostr-sdk = { 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" ] } 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 # Others
anyhow = "1.0.44" anyhow = "1.0.44"
chrono = { version = "0.4.38", features = ["wasmbind"] } chrono = { version = "0.4.38", features = ["wasmbind"] }
+452
View File
@@ -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 24 are
independent of any UI change, so the branch stays green without touching `chat_ui`.
+3
View File
@@ -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

+8 -7
View File
@@ -21,6 +21,7 @@ mod room;
pub use message::*; pub use message::*;
pub use room::*; pub use room::*;
pub use state::FileAttachment;
/// A static keypair used only for signing locally-cached rumor events. /// A static keypair used only for signing locally-cached rumor events.
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate); static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
@@ -629,7 +630,7 @@ impl ChatRegistry {
/// Load all rooms from the database. /// Load all rooms from the database.
pub fn get_rooms(&mut self, cx: &mut Context<Self>) { 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| { self.tasks.push(cx.spawn(async move |this, cx| {
match task.await { match task.await {
@@ -650,8 +651,8 @@ impl ChatRegistry {
})); }));
} }
/// Create a task to load rooms from the database /// Query the chat rooms from the database
fn get_rooms_task(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> { fn query_chat_rooms(&self, cx: &App) -> Task<Result<HashSet<Room>, Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
@@ -677,7 +678,7 @@ impl ChatRegistry {
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::ApplicationSpecificData) .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 events = client.database().query(filter).await?;
let mut grouped: HashMap<u64, Vec<UnsignedEvent>> = HashMap::new(); 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 /// Parse a nostr event into a message and push it to the belonging room
/// ///
/// If the room doesn't exist, it will be created. /// - If the room doesn't exist, it will be created.
/// Updates room ordering based on the most recent messages. /// - Updates room ordering based on the most recent messages.
pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) { pub fn new_message(&mut self, message: NewMessage, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -823,7 +824,7 @@ async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Resul
Tag::identifier(id), Tag::identifier(id),
Tag::public_key(rumor.pubkey), Tag::public_key(rumor.pubkey),
Tag::custom("r", [room_id]), 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()) let event = EventBuilder::new(Kind::ApplicationSpecificData, rumor.as_json())
+82 -39
View File
@@ -4,6 +4,9 @@ use std::ops::Range;
use common::{EventExt, NostrParser, extract_and_remove_media_urls}; use common::{EventExt, NostrParser, extract_and_remove_media_urls};
use gpui::{SharedString, SharedUri}; use gpui::{SharedString, SharedUri};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::FileAttachment;
pub const KIND_FILE_MESSAGE: Kind = Kind::Custom(15);
/// Rendered message. /// Rendered message.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -21,61 +24,90 @@ pub struct Message {
pub mentions: Vec<Mention>, pub mentions: Vec<Mention>,
/// List of event of the message this message is a reply to /// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>, pub replies_to: Vec<EventId>,
/// Encrypted file attachment
pub file: Option<FileAttachment>,
} }
impl From<&Event> for Message { impl From<&Event> for Message {
fn from(val: &Event) -> Self { fn from(val: &Event) -> Self {
let mentions = extract_mentions(&val.content); from_parts(
let replies_to = extract_reply_ids(&val.tags); val.id,
let (media, string) = extract_and_remove_media_urls(&val.content); val.pubkey,
val.created_at,
Self { val.kind,
id: val.id, &val.content,
author: val.pubkey, &val.tags,
content: string, )
media,
created_at: val.created_at,
mentions,
replies_to,
}
} }
} }
impl From<&UnsignedEvent> for Message { impl From<&UnsignedEvent> for Message {
fn from(val: &UnsignedEvent) -> Self { fn from(val: &UnsignedEvent) -> Self {
let mentions = extract_mentions(&val.content); from_parts(
let replies_to = extract_reply_ids(&val.tags);
let (media, string) = extract_and_remove_media_urls(&val.content);
Self {
// Event ID must be known // Event ID must be known
id: val.id.unwrap(), val.id.unwrap(),
author: val.pubkey, val.pubkey,
content: string, val.created_at,
media, val.kind,
created_at: val.created_at, &val.content,
mentions, &val.tags,
replies_to, )
}
} }
} }
impl From<&NewMessage> for Message { impl From<&NewMessage> for Message {
fn from(val: &NewMessage) -> Self { fn from(val: &NewMessage) -> Self {
let mentions = extract_mentions(&val.rumor.content); from_parts(
let replies_to = extract_reply_ids(&val.rumor.tags);
let (media, string) = extract_and_remove_media_urls(&val.rumor.content);
Self {
// Event ID must be known // Event ID must be known
id: val.rumor.id.unwrap(), val.rumor.id.unwrap(),
author: val.rumor.pubkey, val.rumor.pubkey,
content: string, val.rumor.created_at,
media, val.rumor.kind,
created_at: val.rumor.created_at, &val.rumor.content,
mentions, &val.rumor.tags,
replies_to, )
} }
}
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. /// New message.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NewMessage { pub struct NewMessage {
+46 -15
View File
@@ -12,7 +12,7 @@ use person::{Person, PersonRegistry};
use settings::{RoomConfig, SignerKind}; use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT, UniversalSigner}; 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 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."; 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 content: String = content.into();
let replies: Vec<EventId> = replies.into_iter().collect(); 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 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 // Get current user's public key
let nostr = NostrRegistry::global(cx);
let sender = nostr.read(cx).current_user()?; 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 // Construct event's tags
let mut tags = vec![]; let mut tags = vec![];
@@ -454,8 +494,8 @@ impl Room {
} }
// Add all reply tags // Add all reply tags
for id in replies.into_iter() { for id in replies {
tags.push(Tag::event(id)) tags.push(Tag::event(*id))
} }
// Add all receiver tags (no intermediate allocation) // Add all receiver tags (no intermediate allocation)
@@ -467,16 +507,7 @@ impl Room {
})); }));
} }
// Construct a direct message rumor event tags
// 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)
} }
/// Select the appropriate signer based on signer kind and available keys. /// Select the appropriate signer based on signer kind and available keys.
@@ -609,7 +640,7 @@ async fn send_gift_wrap(
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
config: &SignerKind, config: &SignerKind,
) -> Result<SendReport, Error> { ) -> 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]; let mut extra_tags = vec![k_tag];
// Determine the receiver public key based on the config // Determine the receiver public key based on the config
+32
View File
@@ -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
View File
@@ -1,8 +1,9 @@
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::sync::{Arc, LazyLock, RwLock}; use std::sync::{Arc, LazyLock, RwLock};
pub use actions::*; pub use actions::*;
use anyhow::{Context as AnyhowContext, Error}; use anyhow::Error;
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus}; use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport, SendStatus};
use common::{TimestampExt, coop_cache}; use common::{TimestampExt, coop_cache};
use futures::lock::Mutex; use futures::lock::Mutex;
@@ -21,7 +22,9 @@ use person::{Person, PersonRegistry};
use regex::Regex; use regex::Regex;
use settings::{AppSettings, SignerKind}; use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{NostrRegistry, upload}; use state::{
FileAttachment, NostrRegistry, download_and_decrypt_to_file, upload, upload_encrypted,
};
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar; use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
@@ -35,6 +38,7 @@ use ui::{
h_flex, v_flex, h_flex, v_flex,
}; };
use crate::file::*;
use crate::text::RenderedText; use crate::text::RenderedText;
const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"]; 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()); LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
mod actions; mod actions;
mod file;
mod text; mod text;
pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> { pub fn init(room: WeakEntity<Room>, window: &mut Window, cx: &mut App) -> Entity<ChatPanel> {
@@ -96,6 +101,12 @@ pub struct ChatPanel {
/// Media Attachment /// Media Attachment
attachments: Entity<Vec<Url>>, 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 /// Upload state
uploading: bool, uploading: bool,
@@ -110,6 +121,7 @@ impl ChatPanel {
pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(room: WeakEntity<Room>, window: &mut Window, cx: &mut Context<Self>) -> Self {
// Define attachments and replies_to entities // Define attachments and replies_to entities
let attachments = cx.new(|_| vec![]); let attachments = cx.new(|_| vec![]);
let encrypted_attachments = cx.new(|_| vec![]);
let replies_to = cx.new(|_| HashSet::new()); let replies_to = cx.new(|_| HashSet::new());
let reports_by_id = Arc::new(RwLock::new(BTreeMap::new())); let reports_by_id = Arc::new(RwLock::new(BTreeMap::new()));
@@ -185,6 +197,8 @@ impl ChatPanel {
subject_bar, subject_bar,
replies_to, replies_to,
attachments, attachments,
encrypted_attachments,
decrypted_files: HashMap::new(),
rendered_texts_by_id: BTreeMap::new(), rendered_texts_by_id: BTreeMap::new(),
reports_by_id, reports_by_id,
sent_ids: Arc::new(Mutex::new(Vec::new())), 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>) { 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); let content = self.get_input_value(cx);
// Get the replies to this message // Get the replies to this message
let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect(); let replies: Vec<EventId> = self.replies_to.read(cx).iter().copied().collect();
// Return if message is empty // Uploaded files are sent as encrypted file messages
if content.trim().is_empty() { 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); window.push_notification("Cannot send an empty message", cx);
return; return;
} }
// If replying to exactly one message with only a valid emoji, // If replying to exactly one message with only a valid emoji,
// send as a reaction instead of a text message // 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 { for reply in &replies {
self.send_reaction(&content, reply, window, cx); self.send_reaction(&content, reply, window, cx);
@@ -393,7 +418,15 @@ impl ChatPanel {
return; 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( fn send_reaction(
@@ -426,29 +459,59 @@ impl ChatPanel {
return; 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 // 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; return;
}; };
// Create rumor and send task let outcome = room.read_with(cx, |room, cx| {
let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| { let rumor = room.rumor(value, replies, reaction, cx)?;
let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?;
let send_task = room.send(rumor.clone(), cx)?; let send_task = room.send(rumor.clone(), cx)?;
Some((rumor, send_task)) Some((rumor, send_task))
}) { });
Some(pair) => pair,
None => { match outcome {
window.push_notification("Failed to create message", cx); Some((rumor, send_task)) => self.dispatch(rumor, send_task, window, cx),
return; 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 id = rumor.id.expect("rumor must have an id");
let sent_ids = self.sent_ids.clone();
// Insert optimistic message and clear input // Insert optimistic message and clear input
if rumor.kind != Kind::Reaction { if rumor.kind != Kind::Reaction {
@@ -487,6 +550,10 @@ impl ChatPanel {
this.clear(); this.clear();
cx.notify(); cx.notify();
}); });
self.encrypted_attachments.update(cx, |this, cx| {
this.clear();
cx.notify();
});
self.replies_to.update(cx, |this, cx| { self.replies_to.update(cx, |this, cx| {
this.clear(); this.clear();
cx.notify(); cx.notify();
@@ -604,7 +671,7 @@ impl ChatPanel {
let Some(message) = self.message(id) else { let Some(message) = self.message(id) else {
return; return;
}; };
let content = message.content.to_string(); let content = message.preview().to_string();
let item = ClipboardItem::new_string(content); let item = ClipboardItem::new_string(content);
cx.write_to_clipboard(item); cx.write_to_clipboard(item);
@@ -630,6 +697,9 @@ impl ChatPanel {
// Get the user's configured blossom server // Get the user's configured blossom server
let server = AppSettings::get_file_server(cx); 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 // Ask user for file upload
let path = cx.prompt_for_paths(PathPromptOptions { let path = cx.prompt_for_paths(PathPromptOptions {
files: true, files: true,
@@ -639,31 +709,38 @@ impl ChatPanel {
}); });
self.tasks.push(cx.spawn_in(window, async move |this, cx| { 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.update(cx, |this, cx| {
this.set_uploading(true, cx); this.set_uploading(true, cx);
})?; })?;
let mut paths = path.await??.context("Not found")?; // Upload the file, encrypted when it is the whole message
let path = paths.pop().context("No path")?; 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 this.update_in(cx, |this, window, cx| {
match upload(server, path, cx).await { this.set_uploading(false, cx);
Ok(url) => {
this.update_in(cx, |this, _window, cx| { match result {
this.add_attachment(url, cx); Ok(Uploaded::Url(url)) => this.add_attachment(url, cx),
this.set_uploading(false, cx); Ok(Uploaded::File(file, path)) => this.add_pending_file(file, path, cx),
})?; Err(e) => {
}
Err(e) => {
this.update_in(cx, |this, window, cx| {
this.set_uploading(false, cx);
window.push_notification( window.push_notification(
Notification::error(e.to_string()).autohide(false), Notification::error(e.to_string()).autohide(false),
cx, cx,
); );
})?; }
} }
} })?;
Ok(()) 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 { fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
persons.read(cx).get(public_key, cx) persons.read(cx).get(public_key, cx)
@@ -929,6 +1088,16 @@ impl ChatPanel {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> 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) { if let Some(message) = self.messages.get(ix) {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
let show_author = self.is_group_start(ix); let show_author = self.is_group_start(ix);
@@ -1016,8 +1185,11 @@ impl ChatPanel {
.when(has_replies, |this| { .when(has_replies, |this| {
this.children(self.render_message_replies(replies, cx)) 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)) .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| { .when(has_reactions, |this| {
this.child(self.render_reactions(&id, cx)) this.child(self.render_reactions(&id, cx))
}), }),
@@ -1123,7 +1295,7 @@ impl ChatPanel {
.w_full() .w_full()
.text_ellipsis() .text_ellipsis()
.line_clamp(1) .line_clamp(1)
.child(SharedString::from(&message.content)), .child(message.preview()),
) )
.hover(|this| this.bg(cx.theme().elevated_surface_background)) .hover(|this| this.bg(cx.theme().elevated_surface_background))
.on_click({ .on_click({
@@ -1464,6 +1636,168 @@ impl ChatPanel {
items 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 { fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
if let Some(text) = self.message(id) { if let Some(text) = self.message(id) {
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
@@ -1512,7 +1846,7 @@ impl ChatPanel {
.text_sm() .text_sm()
.text_ellipsis() .text_ellipsis()
.line_clamp(1) .line_clamp(1)
.child(SharedString::from(&text.content)), .child(text.preview()),
) )
} else { } else {
div() div()
@@ -1701,6 +2035,7 @@ impl Render for ChatPanel {
.w_full() .w_full()
.gap_1p5() .gap_1p5()
.children(self.render_attachment_list(window, cx)) .children(self.render_attachment_list(window, cx))
.children(self.render_pending_file_list(window, cx))
.children(self.render_reply_list(window, cx)) .children(self.render_reply_list(window, cx))
.child( .child(
h_flex() h_flex()
+4
View File
@@ -24,6 +24,10 @@ serde_json.workspace = true
mime_guess = "2.0.4" mime_guess = "2.0.4"
aes-gcm.workspace = true
sha2.workspace = true
base64.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
nostr-memory.workspace = true nostr-memory.workspace = true
+330
View File
@@ -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)
}
+2
View File
@@ -17,12 +17,14 @@ use nostr_sdk::prelude::*;
mod blossom; mod blossom;
mod constants; mod constants;
mod file;
mod nip05; mod nip05;
mod nip4e; mod nip4e;
mod signer; mod signer;
pub use blossom::*; pub use blossom::*;
pub use constants::*; pub use constants::*;
pub use file::*;
pub use nip4e::*; pub use nip4e::*;
pub use nip05::*; pub use nip05::*;
pub use signer::{CoopAuthUrlHandler, UniversalSigner}; pub use signer::{CoopAuthUrlHandler, UniversalSigner};
+2
View File
@@ -46,6 +46,7 @@ pub enum IconName {
InboxFill, InboxFill,
Link, Link,
Loader, Loader,
Lock,
Moon, Moon,
Plus, Plus,
PlusCircle, PlusCircle,
@@ -118,6 +119,7 @@ impl IconNamed for IconName {
Self::InboxFill => "icons/inbox-fill.svg", Self::InboxFill => "icons/inbox-fill.svg",
Self::Link => "icons/link.svg", Self::Link => "icons/link.svg",
Self::Loader => "icons/loader.svg", Self::Loader => "icons/loader.svg",
Self::Lock => "icons/lock.svg",
Self::Moon => "icons/moon.svg", Self::Moon => "icons/moon.svg",
Self::Plus => "icons/plus.svg", Self::Plus => "icons/plus.svg",
Self::PlusCircle => "icons/plus-circle.svg", Self::PlusCircle => "icons/plus-circle.svg",
+6 -4
View File
@@ -1,6 +1,6 @@
use std::str::FromStr; use std::str::FromStr;
use anyhow::{Context as AnyhowContext, Error}; use anyhow::Error;
use gpui::{ use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task, 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| { 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.update(cx, |this, cx| {
this.set_uploading(true, 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 // Upload via blossom client
match upload(server, path, cx).await { match upload(server, path, cx).await {
Ok(url) => { Ok(url) => {