Skip to content

Commit

Permalink
feat: store uploaded attachments (#375)
Browse files Browse the repository at this point in the history
Uploaded attachments via file://<local-path> or file://clip are now
stored in the data directory of gurk. The file name of the stored file
is added to the local message.

Closes #235
  • Loading branch information
boxdot authored Feb 27, 2025
1 parent f52b0a4 commit 522868c
Show file tree
Hide file tree
Showing 7 changed files with 155 additions and 108 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ strum_macros = "0.26.4"
strum = { version = "0.26.3", features = ["derive"] }
tokio-util = { version = "0.7.13", features = ["rt"] }
qrcode = { version = "0.14.1", default-features = false, features = ["image"] }
sha2 = "0.10.8"

[package.metadata.cargo-machete]
# not used directly; brings sqlcipher capabilities to sqlite
Expand Down
142 changes: 98 additions & 44 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use std::io::Cursor;
use std::path::Path;

use anyhow::{Context as _, anyhow};
use arboard::Clipboard;
use arboard::{Clipboard, ImageData};
use chrono::{DateTime, Local, TimeZone};
use crokey::Combiner;
use crossterm::event::{KeyCode, KeyEvent};
use image::codecs::png::PngEncoder;
Expand Down Expand Up @@ -42,7 +43,7 @@ use crate::signal::{
SignalManager,
};
use crate::storage::{MessageId, Storage};
use crate::util::{self, ATTACHMENT_REGEX, LazyRegex, StatefulList, URL_REGEX};
use crate::util::{self, ATTACHMENT_REGEX, StatefulList, URL_REGEX};

pub struct App {
pub config: Config,
Expand All @@ -53,8 +54,6 @@ pub struct App {
pub help_scroll: (u16, u16),
pub user_id: Uuid,
pub should_quit: bool,
url_regex: LazyRegex,
attachment_regex: LazyRegex,
display_help: bool,
receipt_handler: ReceiptHandler,
pub input: Input,
Expand Down Expand Up @@ -116,8 +115,6 @@ impl App {
messages,
help_scroll: (0, 0),
should_quit: false,
url_regex: LazyRegex::new(URL_REGEX),
attachment_regex: LazyRegex::new(ATTACHMENT_REGEX),
display_help: false,
receipt_handler: ReceiptHandler::new(),
input: Default::default(),
Expand Down Expand Up @@ -406,8 +403,7 @@ impl App {
let message = self
.storage
.message(MessageId::new(*channel_id, *arrived_at))?;
let re = self.url_regex.compiled();
open_url(&message, re)?;
open_url(&message, &URL_REGEX)?;
self.reset_message_selection();
Some(())
}
Expand Down Expand Up @@ -517,7 +513,9 @@ impl App {

fn send_input(&mut self, channel_idx: usize) {
let input = self.take_input();
let (input, attachments) = self.extract_attachments(&input);
let (input, attachments) = Self::extract_attachments(&input, Local::now(), || {
self.clipboard.as_mut().map(|c| c.get_image())
});
let channel_id = self.channels.items[channel_idx];
let channel = self
.storage
Expand Down Expand Up @@ -1410,24 +1408,37 @@ impl App {
}
}

fn extract_attachments(&mut self, input: &str) -> (String, Vec<(AttachmentSpec, Vec<u8>)>) {
fn extract_attachments<Tz: TimeZone>(
input: &str,
at: DateTime<Tz>,
mut get_clipboard_img: impl FnMut() -> Option<Result<ImageData<'static>, arboard::Error>>,
) -> (String, Vec<(AttachmentSpec, Vec<u8>)>)
where
Tz::Offset: std::fmt::Display,
{
let mut offset = 0;
let mut clean_input = String::new();

let re = self.attachment_regex.compiled();
let attachments = re.find_iter(input).filter_map(|m| {
let attachments = ATTACHMENT_REGEX.find_iter(input).filter_map(|m| {
let path_str = m.as_str().strip_prefix("file://")?;

let (contents, content_type, file_name) = if path_str.starts_with("clip") {
let img = self.clipboard.as_mut()?.get_image().ok()?;
clean_input.push_str(input[offset..m.start()].trim_end());
offset = m.end();

let png: ImageBuffer<Rgba<_>, _> =
ImageBuffer::from_raw(img.width as _, img.height as _, img.bytes)?;
Some(if path_str.starts_with("clip") {
// clipboard
let img = get_clipboard_img()?
.inspect_err(|error| error!(%error, "failed to get clipboard image"))
.ok()?;

let width: u32 = img.width.try_into().ok()?;
let height: u32 = img.height.try_into().ok()?;

let mut bytes = Vec::new();
let mut cursor = Cursor::new(&mut bytes);
let encoder = PngEncoder::new(&mut cursor);

let png: ImageBuffer<Rgba<_>, _> = ImageBuffer::from_raw(width, height, img.bytes)?;
let data: Vec<_> = png.into_raw().iter().map(|b| b.swap_bytes()).collect();
encoder
.write_image(
Expand All @@ -1436,41 +1447,42 @@ impl App {
img.height as _,
image::ExtendedColorType::Rgba8,
)
.inspect_err(|error| error!(%error, "failed to encode image"))
.ok()?;

(
bytes,
"image/png".to_string(),
Some("clipboard.png".to_string()),
)
let file_name = format!("screenshot-{}.png", at.format("%Y-%m-%dT%H:%M:%S%z"));

let spec = AttachmentSpec {
content_type: "image/png".to_owned(),
length: bytes.len(),
file_name: Some(file_name),
width: Some(width),
height: Some(height),
..Default::default()
};
(spec, bytes)
} else {
// path

// TODO: Show error to user if the file does not exist. This would prevent not
// sending the attachment in the end.

let path = Path::new(path_str);
let contents = std::fs::read(path).ok()?;
let bytes = std::fs::read(path).ok()?;
let content_type = mime_guess::from_path(path)
.first()
.map(|mime| mime.essence_str().to_string())
.unwrap_or_default();
let file_name = path.file_name().map(|f| f.to_string_lossy().into());
let spec = AttachmentSpec {
content_type,
length: bytes.len(),
file_name,
..Default::default()
};

(contents, content_type, file_name)
};

clean_input.push_str(input[offset..m.start()].trim_end());
offset = m.end();

let spec = AttachmentSpec {
content_type,
length: contents.len(),
file_name,
preview: None,
voice_note: None,
borderless: None,
width: None,
height: None,
caption: None,
blur_hash: None,
};
Some((spec, contents))
(spec, bytes)
})
});

let attachments = attachments.collect();
Expand Down Expand Up @@ -1700,15 +1712,16 @@ fn add_emoji_from_sticker(body: &mut Option<String>, sticker: Option<Sticker>) {

#[cfg(test)]
pub(crate) mod tests {
use super::*;
use chrono::FixedOffset;
use std::cell::RefCell;
use std::rc::Rc;

use crate::config::User;
use crate::data::GroupData;
use crate::signal::test::SignalManagerMock;
use crate::storage::{ForgetfulStorage, MemCache};

use std::cell::RefCell;
use std::rc::Rc;
use super::*;

pub(crate) fn test_app() -> (
App,
Expand Down Expand Up @@ -1950,4 +1963,45 @@ pub(crate) mod tests {
assert_eq!(to_emoji("☝🏿"), Some("☝🏿"));
assert_eq!(to_emoji("a"), None);
}

#[test]
fn test_extract_attachments() {
let tempdir = tempfile::tempdir().unwrap();
let image_png = tempdir.path().join("image.png");
let image_jpg = tempdir.path().join("image.jpg");

std::fs::write(&image_png, b"some png data").unwrap();
std::fs::write(&image_jpg, b"some jpg data").unwrap();

let clipboard_image = ImageData {
width: 1,
height: 1,
bytes: vec![0, 0, 0, 0].into(), // RGBA single pixel
};

let message = format!(
"Hello, file://{} file://{} World! file://clip",
image_png.display(),
image_jpg.display(),
);

let at_str = "2023-01-01T00:00:00+0200";
let at: DateTime<FixedOffset> = at_str.parse().unwrap();

let (cleaned_message, specs) =
App::extract_attachments(&message, at, || Some(Ok(clipboard_image.clone())));
assert_eq!(cleaned_message, "Hello, World!");
dbg!(&specs);

assert_eq!(specs.len(), 3);
assert_eq!(specs[0].0.content_type, "image/png");
assert_eq!(specs[0].0.file_name, Some("image.png".into()));
assert_eq!(specs[1].0.content_type, "image/jpeg");
assert_eq!(specs[1].0.file_name, Some("image.jpg".into()));
assert_eq!(specs[2].0.content_type, "image/png");
assert_eq!(
specs[2].0.file_name,
Some(format!("screenshot-{at_str}.png"))
);
}
}
17 changes: 8 additions & 9 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ impl Config {
}

// make sure data_path exists
let data_path = default_data_dir();
let data_path = data_dir();
fs::create_dir_all(data_path).context("could not create data dir")?;

self.save(path)
Expand Down Expand Up @@ -204,7 +204,7 @@ impl Default for SqliteConfig {

impl SqliteConfig {
fn default_db_url() -> Url {
let path = default_data_dir().join("gurk.sqlite");
let path = data_dir().join("gurk.sqlite");
format!("sqlite://{}", path.display())
.parse()
.expect("invalid default sqlite path")
Expand Down Expand Up @@ -244,23 +244,22 @@ fn installed_config() -> Option<PathBuf> {

/// Path to store the signal database containing the data for the linked device.
pub fn default_signal_db_path() -> PathBuf {
default_data_dir().join("signal-db")
data_dir().join("signal-db")
}

/// Fallback to legacy data path location
pub fn fallback_data_path() -> Option<PathBuf> {
dirs::home_dir().map(|p| p.join(".gurk.data.json"))
}

fn default_data_dir() -> PathBuf {
match dirs::data_dir() {
Some(dir) => dir.join("gurk"),
None => panic!("default data directory not found, $XDG_DATA_HOME and $HOME are unset"),
}
pub(crate) fn data_dir() -> PathBuf {
let data_dir =
dirs::data_dir().expect("data directory not found, $XDG_DATA_HOME and $HOME are unset?");
data_dir.join("gurk")
}

fn default_data_json_path() -> PathBuf {
default_data_dir().join("gurk.data.json")
data_dir().join("gurk.data.json")
}

fn default_true() -> bool {
Expand Down
12 changes: 6 additions & 6 deletions src/signal/attachment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const DIGEST_BYTES_LEN: usize = 4;
pub(super) fn save(
data_dir: impl AsRef<Path>,
pointer: AttachmentPointer,
data: Vec<u8>,
data: &[u8],
) -> anyhow::Result<Attachment> {
let base_dir = data_dir.as_ref().join("files");

Expand Down Expand Up @@ -154,7 +154,7 @@ mod tests {
let attachment = save(
tempdir.path(),
attachment_pointer("image/jpeg", &digest, Some("image.jpeg"), upload_timestamp),
vec![42],
&[42],
)
.unwrap();

Expand All @@ -172,7 +172,7 @@ mod tests {
let attachment = save(
tempdir.path(),
attachment_pointer("image/jpeg", &digest, Some("image.jpeg"), upload_timestamp),
vec![42],
&[42],
)
.unwrap();
assert_eq!(
Expand All @@ -184,7 +184,7 @@ mod tests {
let attachment = save(
tempdir.path(),
attachment_pointer("image/jpeg", &digest, None, upload_timestamp),
vec![42],
&[42],
)
.unwrap();
assert_eq!(
Expand All @@ -196,7 +196,7 @@ mod tests {
let attachment = save(
tempdir.path(),
attachment_pointer("application/octet-stream", &digest, None, upload_timestamp),
vec![42],
&[42],
)
.unwrap();
assert_eq!(
Expand All @@ -208,7 +208,7 @@ mod tests {
let attachment = save(
tempdir.path(),
attachment_pointer("application/pdf", &digest, None, upload_timestamp),
vec![42],
&[42],
)
.unwrap();
assert_eq!(
Expand Down
Loading

0 comments on commit 522868c

Please sign in to comment.