5 Commits
Author SHA1 Message Date
reya 024dc90d07 update ui 2026-09-16 08:23:42 +07:00
reya 89396b8f07 fix 2026-09-15 21:03:24 +07:00
reya ac70121bc9 update 2026-09-15 20:56:16 +07:00
reya ee3547fc86 feat: offer retrying a failed encrypted upload without encryption
Many servers reject opaque encrypted files, so report the failure with an action button that re-uploads the file as a plain link, like Amethyst does.
2026-09-15 20:45:32 +07:00
reya c8ee4f63bb fix: encode NIP-17 file key and nonce as hex
Amethyst parses decryption-key and decryption-nonce as hex, so base64 keys were unreadable by the only implementation of kind 15 file messages. Also accept the 16 bytes nonce Amethyst sends, while still emitting the standard 12 bytes. Replaces the now unused base64 dependency with data-encoding.
2026-09-15 20:45:32 +07:00
7 changed files with 234 additions and 142 deletions
Generated
+1 -1
View File
@@ -6679,9 +6679,9 @@ version = "1.0.1"
dependencies = [
"aes-gcm",
"anyhow",
"base64 0.22.1",
"browser-signer-proxy",
"common",
"data-encoding",
"flume 0.11.1",
"futures",
"gpui",
+1 -1
View File
@@ -30,7 +30,7 @@ nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "ni
# Crypto (NIP-17 encrypted file messages)
aes-gcm = "0.10"
sha2 = "0.10"
base64 = "0.22"
data-encoding = "2"
# Others
anyhow = "1.0.44"
+127 -72
View File
@@ -33,6 +33,7 @@ use ui::input::{Input, InputEvent, InputState};
use ui::menu::DropdownMenu;
use ui::notification::Notification;
use ui::scroll::Scrollbar;
use ui::tooltip::Tooltip;
use ui::{
Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt, WindowExtension,
h_flex, v_flex,
@@ -714,17 +715,34 @@ impl ChatPanel {
return Ok(());
};
this.update(cx, |this, cx| {
this.set_uploading(true, cx);
this.update_in(cx, |this, window, cx| {
this.upload_file(server, path, encrypted, window, cx);
})?;
// Upload the file, encrypted when it is the whole message
Ok(())
}));
}
/// Upload a file, encrypted when the attachment is the whole message
fn upload_file(
&mut self,
server: Url,
path: PathBuf,
encrypted: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.set_uploading(true, cx);
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = if encrypted {
upload_encrypted(server, path.clone(), cx)
upload_encrypted(server.clone(), path.clone(), cx)
.await
.map(|file| Uploaded::File(file, path))
.map(|file| Uploaded::File(file, path.clone()))
} else {
upload(server, path, cx).await.map(Uploaded::Url)
upload(server.clone(), path.clone(), cx)
.await
.map(Uploaded::Url)
};
this.update_in(cx, |this, window, cx| {
@@ -733,6 +751,9 @@ impl ChatPanel {
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) if encrypted => {
this.report_encrypted_upload_error(server, path, e, window, cx)
}
Err(e) => {
window.push_notification(
Notification::error(e.to_string()).autohide(false),
@@ -746,6 +767,38 @@ impl ChatPanel {
}));
}
/// Report a failed encrypted upload, offering to retry it without encryption
fn report_encrypted_upload_error(
&mut self,
server: Url,
path: PathBuf,
error: Error,
window: &mut Window,
cx: &mut Context<Self>,
) {
let view = cx.entity().downgrade();
window.push_notification(
Notification::error(error.to_string())
.title("Encrypted upload failed")
.action(move |_this, _window, _cx| {
let view = view.clone();
let server = server.clone();
let path = path.clone();
Button::new("retry-without-encryption")
.label("Upload without encryption")
.on_click(move |_ev, window, cx| {
view.update(cx, |this, cx| {
this.upload_file(server.clone(), path.clone(), false, window, cx);
})
.ok();
})
}),
cx,
);
}
fn set_uploading(&mut self, uploading: bool, cx: &mut Context<Self>) {
self.uploading = uploading;
cx.notify();
@@ -1599,7 +1652,7 @@ impl ChatPanel {
.size_16()
.when(cx.theme().shadow, |this| this.shadow_lg())
.rounded(cx.theme().radius)
.object_fit(ObjectFit::ScaleDown),
.object_fit(ObjectFit::Cover),
)
.child(
div()
@@ -1682,33 +1735,23 @@ impl ChatPanel {
label: SharedString,
cx: &Context<Self>,
) -> AnyElement {
div()
h_flex()
.id(SharedString::from(format!("file-{id}")))
.flex()
.items_center()
.self_start()
.items_start()
.min_w_0()
.gap_2()
.py_1()
.px_2()
.p_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(Icon::new(IconName::Lock).text_color(cx.theme().icon_accent))
.child(
v_flex()
.flex_1()
.min_w_0()
.overflow_hidden()
.text_sm()
.child(
div()
.text_ellipsis()
.line_clamp(1)
.child(file.display_name()),
)
.child(div().line_height(relative(1.2)).child(file.display_name()))
.child(
div()
.text_xs()
@@ -1730,58 +1773,59 @@ impl ChatPanel {
/// 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;
let label = file.display_name();
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),
)
.w_16()
.tooltip(move |window, cx| Tooltip::new(label.clone(), window, cx).into())
.map(|this| {
if file.is_image() {
this.child(
img(pending.path.clone())
.size_16()
.when(cx.theme().shadow, |this| this.shadow_sm())
.rounded(cx.theme().radius)
.object_fit(ObjectFit::Cover),
)
} else {
this.child(
div()
.size_16()
.flex()
.items_center()
.justify_center()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border_variant)
.bg(cx.theme().surface_background)
.text_xs()
.text_center()
.child("Preview not available"),
)
}
})
.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);
})
}),
.absolute()
.top_neg_1()
.right_neg_1()
.size_4()
.items_center()
.justify_center()
.rounded_full()
.border_1()
.border_color(cx.theme().border_variant)
.bg(gpui::green())
.child(Icon::new(IconName::Lock).size_2().text_color(gpui::white())),
)
.on_click({
let url = file.url.clone();
cx.listener(move |this, _, _, cx| {
this.remove_pending_file(&url, cx);
})
})
}
fn render_pending_file_list(
@@ -1974,6 +2018,10 @@ impl Focusable for ChatPanel {
impl Render for ChatPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const WARNING: &str = "Attachments added while typing are uploaded without encryption";
let is_typing = !self.input.read(cx).value().trim().is_empty();
v_flex()
.image_cache(coop_cache(self.id.clone(), 100))
.on_action(cx.listener(Self::on_command))
@@ -2007,10 +2055,8 @@ impl Render for ChatPanel {
.map(|this| {
if self.messages.is_empty() {
this.child(
div()
h_flex()
.size_full()
.flex()
.items_center()
.justify_end()
.child(self.render_announcement(cx)),
)
@@ -2037,6 +2083,15 @@ impl Render for ChatPanel {
.children(self.render_attachment_list(window, cx))
.children(self.render_pending_file_list(window, cx))
.children(self.render_reply_list(window, cx))
.when(is_typing, |this| {
this.child(
div()
.px_1()
.text_xs()
.text_color(cx.theme().text_warning)
.child(WARNING),
)
})
.child(
h_flex()
.items_end()
+10 -2
View File
@@ -12,6 +12,9 @@ pub fn init(window: &mut Window, cx: &mut App) {
AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx)
}
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band";
macro_rules! setting_accessors {
($(pub $field:ident: $type:ty),* $(,)?) => {
impl AppSettings {
@@ -138,7 +141,7 @@ impl Default for Settings {
screening: true,
nip4e: false,
trusted_relays: vec![],
file_server: Url::parse("https://blossom.band/").unwrap(),
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
}
}
}
@@ -217,7 +220,12 @@ impl AppSettings {
});
cx.spawn_in(window, async move |this, cx| {
let settings = task.await.unwrap_or(Settings::default());
let mut settings = task.await.unwrap_or(Settings::default());
// Move settings still pointed at the old default file server over to the new one
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
settings.file_server = Url::parse(DEFAULT_FILE_SERVER).unwrap();
}
// Update settings
this.update_in(cx, |this, window, cx| {
+1 -1
View File
@@ -26,7 +26,7 @@ mime_guess = "2.0.4"
aes-gcm.workspace = true
sha2.workspace = true
base64.workspace = true
data-encoding.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
nostr-memory.workspace = true
+32 -10
View File
@@ -4,30 +4,52 @@ use anyhow::{Error, anyhow};
use gpui::AsyncApp;
#[cfg(not(target_arch = "wasm32"))]
use gpui_tokio::Tokio;
#[cfg(not(target_arch = "wasm32"))]
use mime_guess::from_path;
use nostr_blossom::prelude::*;
use nostr_sdk::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
let content_type = from_path(&path).first_or_octet_stream().to_string();
let data = smol::fs::read(path).await?;
let keys = Keys::generate();
use crate::file::sha256_hex;
// Construct the blossom client
let client = BlossomClient::new(server);
/// Upload a blob to a blossom server and return its URL
#[cfg(not(target_arch = "wasm32"))]
pub(crate) async fn upload_blob(
server: &Url,
data: Vec<u8>,
content_type: &str,
sha256: &str,
cx: &AsyncApp,
) -> Result<Url, Error> {
let client = BlossomClient::new(server.clone());
let keys = Keys::generate();
let content_type = content_type.to_string();
let base = server.clone();
let hash = sha256.to_string();
Tokio::spawn(cx, async move {
let blob = client
match client
.upload_blob(data, Some(content_type), None, Some(&keys))
.await?;
Ok(blob.url)
.await
{
Ok(blob) => Ok(blob.url),
Err(e) if e.to_string().contains("201 Created") => Ok::<Url, Error>(base.join(&hash)?),
Err(e) => Err(anyhow!(e.to_string())),
}
})
.await
.map_err(|e| anyhow!("Upload error: {e}"))?
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn upload(server: Url, path: PathBuf, cx: &AsyncApp) -> Result<Url, Error> {
let content_type = from_path(&path).first_or_octet_stream().to_string();
let data = smol::fs::read(&path).await?;
let sha256 = sha256_hex(&data);
upload_blob(&server, data, &content_type, &sha256, cx).await
}
#[cfg(target_arch = "wasm32")]
pub async fn upload(_server: Url, _path: PathBuf, _cx: &AsyncApp) -> Result<Url, Error> {
Err(anyhow!("File upload not supported on web"))
+62 -55
View File
@@ -1,21 +1,17 @@
use std::path::PathBuf;
use aes_gcm::aead::consts::U12;
use aes_gcm::aead::consts::{U12, U16, U32};
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Nonce};
use aes_gcm::aes::Aes256;
use aes_gcm::{Aes256Gcm, AesGcm, 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 data_encoding::HEXLOWER;
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};
@@ -121,8 +117,8 @@ impl FileAttachment {
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 nonce = AesGcm::<Aes256, U16>::generate_nonce(OsRng);
let cipher = AesGcm::<Aes256, U16>::new(&key);
let data = cipher
.encrypt(&nonce, data)
@@ -130,20 +126,37 @@ pub fn encrypt(data: &[u8]) -> Result<EncryptedFile, Error> {
Ok(EncryptedFile {
data,
key: STANDARD.encode(key.as_slice()),
nonce: STANDARD.encode(nonce.as_slice()),
key: HEXLOWER.encode(key.as_slice()),
nonce: HEXLOWER.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 key = decode(key, "decryption key")?;
let nonce = decode(nonce, "decryption nonce")?;
let cipher = Aes256Gcm::new_from_slice(&key).map_err(|_| anyhow!("Invalid decryption key"))?;
if key.len() != 32 {
bail!(
"Invalid decryption key length: expected 32 bytes, got {}",
key.len()
);
}
cipher
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file"))
match nonce.len() {
12 => Aes256Gcm::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid decryption key"))?
.decrypt(Nonce::<U12>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file")),
16 => AesGcm::<Aes256, U16>::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid decryption key"))?
.decrypt(Nonce::<U16>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file")),
32 => AesGcm::<Aes256, U32>::new_from_slice(&key)
.map_err(|_| anyhow!("Invalid decryption key"))?
.decrypt(Nonce::<U32>::from_slice(&nonce), data)
.map_err(|_| anyhow!("Failed to decrypt file")),
len => bail!("Unsupported decryption nonce length: {len} bytes"),
}
}
pub fn sha256_hex(data: &[u8]) -> String {
@@ -165,35 +178,41 @@ pub async fn upload_encrypted(
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 sha256 = sha256_hex(&encrypted.data);
let original_sha256 = sha256_hex(&data);
let size = encrypted.data.len() as u64;
let base_url = server.to_string();
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)
})
let url = crate::blossom::upload_blob(
&server,
encrypted.data,
"application/octet-stream",
&sha256,
cx,
)
.await
.map_err(|e| anyhow!("Upload error: {e}"))??;
.map_err(|e| {
let message = e.to_string();
if !message.contains("415") {
return anyhow!(message);
}
anyhow!(
"{base_url} rejected the encrypted file. Encrypted attachments are uploaded as
opaque data, which this file server does not accept. Choose a different file
server in the settings."
)
})?;
Ok(FileAttachment {
url,
mime,
key: encrypted.key,
nonce: encrypted.nonce,
sha256,
original_sha256,
size,
sha256: Some(sha256),
original_sha256: Some(original_sha256),
size: Some(size),
dim: None,
name,
})
@@ -311,20 +330,8 @@ fn parse_dim(value: &str) -> Option<(u32, u32)> {
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)
fn decode(value: &str, label: &str) -> Result<Vec<u8>, Error> {
HEXLOWER
.decode(value.to_ascii_lowercase().as_bytes())
.map_err(|_| anyhow!("Invalid {label} encoding"))
}