Skip to content

Commit 522868c

Browse files
authored
feat: store uploaded attachments (#375)
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
1 parent f52b0a4 commit 522868c

File tree

7 files changed

+155
-108
lines changed

7 files changed

+155
-108
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ strum_macros = "0.26.4"
9494
strum = { version = "0.26.3", features = ["derive"] }
9595
tokio-util = { version = "0.7.13", features = ["rt"] }
9696
qrcode = { version = "0.14.1", default-features = false, features = ["image"] }
97+
sha2 = "0.10.8"
9798

9899
[package.metadata.cargo-machete]
99100
# not used directly; brings sqlcipher capabilities to sqlite

src/app.rs

Lines changed: 98 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ use std::io::Cursor;
77
use std::path::Path;
88

99
use anyhow::{Context as _, anyhow};
10-
use arboard::Clipboard;
10+
use arboard::{Clipboard, ImageData};
11+
use chrono::{DateTime, Local, TimeZone};
1112
use crokey::Combiner;
1213
use crossterm::event::{KeyCode, KeyEvent};
1314
use image::codecs::png::PngEncoder;
@@ -42,7 +43,7 @@ use crate::signal::{
4243
SignalManager,
4344
};
4445
use crate::storage::{MessageId, Storage};
45-
use crate::util::{self, ATTACHMENT_REGEX, LazyRegex, StatefulList, URL_REGEX};
46+
use crate::util::{self, ATTACHMENT_REGEX, StatefulList, URL_REGEX};
4647

4748
pub struct App {
4849
pub config: Config,
@@ -53,8 +54,6 @@ pub struct App {
5354
pub help_scroll: (u16, u16),
5455
pub user_id: Uuid,
5556
pub should_quit: bool,
56-
url_regex: LazyRegex,
57-
attachment_regex: LazyRegex,
5857
display_help: bool,
5958
receipt_handler: ReceiptHandler,
6059
pub input: Input,
@@ -116,8 +115,6 @@ impl App {
116115
messages,
117116
help_scroll: (0, 0),
118117
should_quit: false,
119-
url_regex: LazyRegex::new(URL_REGEX),
120-
attachment_regex: LazyRegex::new(ATTACHMENT_REGEX),
121118
display_help: false,
122119
receipt_handler: ReceiptHandler::new(),
123120
input: Default::default(),
@@ -406,8 +403,7 @@ impl App {
406403
let message = self
407404
.storage
408405
.message(MessageId::new(*channel_id, *arrived_at))?;
409-
let re = self.url_regex.compiled();
410-
open_url(&message, re)?;
406+
open_url(&message, &URL_REGEX)?;
411407
self.reset_message_selection();
412408
Some(())
413409
}
@@ -517,7 +513,9 @@ impl App {
517513

518514
fn send_input(&mut self, channel_idx: usize) {
519515
let input = self.take_input();
520-
let (input, attachments) = self.extract_attachments(&input);
516+
let (input, attachments) = Self::extract_attachments(&input, Local::now(), || {
517+
self.clipboard.as_mut().map(|c| c.get_image())
518+
});
521519
let channel_id = self.channels.items[channel_idx];
522520
let channel = self
523521
.storage
@@ -1410,24 +1408,37 @@ impl App {
14101408
}
14111409
}
14121410

1413-
fn extract_attachments(&mut self, input: &str) -> (String, Vec<(AttachmentSpec, Vec<u8>)>) {
1411+
fn extract_attachments<Tz: TimeZone>(
1412+
input: &str,
1413+
at: DateTime<Tz>,
1414+
mut get_clipboard_img: impl FnMut() -> Option<Result<ImageData<'static>, arboard::Error>>,
1415+
) -> (String, Vec<(AttachmentSpec, Vec<u8>)>)
1416+
where
1417+
Tz::Offset: std::fmt::Display,
1418+
{
14141419
let mut offset = 0;
14151420
let mut clean_input = String::new();
14161421

1417-
let re = self.attachment_regex.compiled();
1418-
let attachments = re.find_iter(input).filter_map(|m| {
1422+
let attachments = ATTACHMENT_REGEX.find_iter(input).filter_map(|m| {
14191423
let path_str = m.as_str().strip_prefix("file://")?;
14201424

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

1424-
let png: ImageBuffer<Rgba<_>, _> =
1425-
ImageBuffer::from_raw(img.width as _, img.height as _, img.bytes)?;
1428+
Some(if path_str.starts_with("clip") {
1429+
// clipboard
1430+
let img = get_clipboard_img()?
1431+
.inspect_err(|error| error!(%error, "failed to get clipboard image"))
1432+
.ok()?;
1433+
1434+
let width: u32 = img.width.try_into().ok()?;
1435+
let height: u32 = img.height.try_into().ok()?;
14261436

14271437
let mut bytes = Vec::new();
14281438
let mut cursor = Cursor::new(&mut bytes);
14291439
let encoder = PngEncoder::new(&mut cursor);
14301440

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

1441-
(
1442-
bytes,
1443-
"image/png".to_string(),
1444-
Some("clipboard.png".to_string()),
1445-
)
1453+
let file_name = format!("screenshot-{}.png", at.format("%Y-%m-%dT%H:%M:%S%z"));
1454+
1455+
let spec = AttachmentSpec {
1456+
content_type: "image/png".to_owned(),
1457+
length: bytes.len(),
1458+
file_name: Some(file_name),
1459+
width: Some(width),
1460+
height: Some(height),
1461+
..Default::default()
1462+
};
1463+
(spec, bytes)
14461464
} else {
1465+
// path
1466+
1467+
// TODO: Show error to user if the file does not exist. This would prevent not
1468+
// sending the attachment in the end.
1469+
14471470
let path = Path::new(path_str);
1448-
let contents = std::fs::read(path).ok()?;
1471+
let bytes = std::fs::read(path).ok()?;
14491472
let content_type = mime_guess::from_path(path)
14501473
.first()
14511474
.map(|mime| mime.essence_str().to_string())
14521475
.unwrap_or_default();
14531476
let file_name = path.file_name().map(|f| f.to_string_lossy().into());
1477+
let spec = AttachmentSpec {
1478+
content_type,
1479+
length: bytes.len(),
1480+
file_name,
1481+
..Default::default()
1482+
};
14541483

1455-
(contents, content_type, file_name)
1456-
};
1457-
1458-
clean_input.push_str(input[offset..m.start()].trim_end());
1459-
offset = m.end();
1460-
1461-
let spec = AttachmentSpec {
1462-
content_type,
1463-
length: contents.len(),
1464-
file_name,
1465-
preview: None,
1466-
voice_note: None,
1467-
borderless: None,
1468-
width: None,
1469-
height: None,
1470-
caption: None,
1471-
blur_hash: None,
1472-
};
1473-
Some((spec, contents))
1484+
(spec, bytes)
1485+
})
14741486
});
14751487

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

17011713
#[cfg(test)]
17021714
pub(crate) mod tests {
1703-
use super::*;
1715+
use chrono::FixedOffset;
1716+
use std::cell::RefCell;
1717+
use std::rc::Rc;
17041718

17051719
use crate::config::User;
17061720
use crate::data::GroupData;
17071721
use crate::signal::test::SignalManagerMock;
17081722
use crate::storage::{ForgetfulStorage, MemCache};
17091723

1710-
use std::cell::RefCell;
1711-
use std::rc::Rc;
1724+
use super::*;
17121725

17131726
pub(crate) fn test_app() -> (
17141727
App,
@@ -1950,4 +1963,45 @@ pub(crate) mod tests {
19501963
assert_eq!(to_emoji("☝🏿"), Some("☝🏿"));
19511964
assert_eq!(to_emoji("a"), None);
19521965
}
1966+
1967+
#[test]
1968+
fn test_extract_attachments() {
1969+
let tempdir = tempfile::tempdir().unwrap();
1970+
let image_png = tempdir.path().join("image.png");
1971+
let image_jpg = tempdir.path().join("image.jpg");
1972+
1973+
std::fs::write(&image_png, b"some png data").unwrap();
1974+
std::fs::write(&image_jpg, b"some jpg data").unwrap();
1975+
1976+
let clipboard_image = ImageData {
1977+
width: 1,
1978+
height: 1,
1979+
bytes: vec![0, 0, 0, 0].into(), // RGBA single pixel
1980+
};
1981+
1982+
let message = format!(
1983+
"Hello, file://{} file://{} World! file://clip",
1984+
image_png.display(),
1985+
image_jpg.display(),
1986+
);
1987+
1988+
let at_str = "2023-01-01T00:00:00+0200";
1989+
let at: DateTime<FixedOffset> = at_str.parse().unwrap();
1990+
1991+
let (cleaned_message, specs) =
1992+
App::extract_attachments(&message, at, || Some(Ok(clipboard_image.clone())));
1993+
assert_eq!(cleaned_message, "Hello, World!");
1994+
dbg!(&specs);
1995+
1996+
assert_eq!(specs.len(), 3);
1997+
assert_eq!(specs[0].0.content_type, "image/png");
1998+
assert_eq!(specs[0].0.file_name, Some("image.png".into()));
1999+
assert_eq!(specs[1].0.content_type, "image/jpeg");
2000+
assert_eq!(specs[1].0.file_name, Some("image.jpg".into()));
2001+
assert_eq!(specs[2].0.content_type, "image/png");
2002+
assert_eq!(
2003+
specs[2].0.file_name,
2004+
Some(format!("screenshot-{at_str}.png"))
2005+
);
2006+
}
19532007
}

src/config.rs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ impl Config {
134134
}
135135

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

140140
self.save(path)
@@ -204,7 +204,7 @@ impl Default for SqliteConfig {
204204

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

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

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

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

262261
fn default_data_json_path() -> PathBuf {
263-
default_data_dir().join("gurk.data.json")
262+
data_dir().join("gurk.data.json")
264263
}
265264

266265
fn default_true() -> bool {

src/signal/attachment.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const DIGEST_BYTES_LEN: usize = 4;
2020
pub(super) fn save(
2121
data_dir: impl AsRef<Path>,
2222
pointer: AttachmentPointer,
23-
data: Vec<u8>,
23+
data: &[u8],
2424
) -> anyhow::Result<Attachment> {
2525
let base_dir = data_dir.as_ref().join("files");
2626

@@ -154,7 +154,7 @@ mod tests {
154154
let attachment = save(
155155
tempdir.path(),
156156
attachment_pointer("image/jpeg", &digest, Some("image.jpeg"), upload_timestamp),
157-
vec![42],
157+
&[42],
158158
)
159159
.unwrap();
160160

@@ -172,7 +172,7 @@ mod tests {
172172
let attachment = save(
173173
tempdir.path(),
174174
attachment_pointer("image/jpeg", &digest, Some("image.jpeg"), upload_timestamp),
175-
vec![42],
175+
&[42],
176176
)
177177
.unwrap();
178178
assert_eq!(
@@ -184,7 +184,7 @@ mod tests {
184184
let attachment = save(
185185
tempdir.path(),
186186
attachment_pointer("image/jpeg", &digest, None, upload_timestamp),
187-
vec![42],
187+
&[42],
188188
)
189189
.unwrap();
190190
assert_eq!(
@@ -196,7 +196,7 @@ mod tests {
196196
let attachment = save(
197197
tempdir.path(),
198198
attachment_pointer("application/octet-stream", &digest, None, upload_timestamp),
199-
vec![42],
199+
&[42],
200200
)
201201
.unwrap();
202202
assert_eq!(
@@ -208,7 +208,7 @@ mod tests {
208208
let attachment = save(
209209
tempdir.path(),
210210
attachment_pointer("application/pdf", &digest, None, upload_timestamp),
211-
vec![42],
211+
&[42],
212212
)
213213
.unwrap();
214214
assert_eq!(

0 commit comments

Comments
 (0)