diff --git a/Cargo.lock b/Cargo.lock index 8264c79..268eba6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" + [[package]] name = "arrayvec" version = "0.7.6" @@ -185,6 +191,24 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "383d29d513d8764dcdc42ea295d979eb99c3c9f00607b3692cf68a431f7dca72" +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.9.0", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.99", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -309,6 +333,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -356,6 +389,17 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -862,6 +906,28 @@ dependencies = [ "walkdir", ] +[[package]] +name = "graphicsmagick" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c4ba437067d42af001dcda9d7188f368539b0731c2f5f4a6ba3765f3b64518" +dependencies = [ + "graphicsmagick-sys", + "null-terminated-str", + "num_enum", + "thiserror 2.0.12", +] + +[[package]] +name = "graphicsmagick-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9004437800b450090fdd1045edc43647209601b68320a4be450de541d0be16f6" +dependencies = [ + "anyhow", + "bindgen", +] + [[package]] name = "h2" version = "0.4.8" @@ -1373,6 +1439,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -1424,6 +1499,16 @@ version = "0.2.170" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" +[[package]] +name = "libloading" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +dependencies = [ + "cfg-if", + "windows-targets 0.52.6", +] + [[package]] name = "libm" version = "0.2.11" @@ -1648,6 +1733,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "null-terminated-str" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "594ec098b589ef9bcf24ff9d2a1ed9295851ecb4009ce1df58557c239cf250bd" + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1711,6 +1802,27 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.99", +] + [[package]] name = "object" version = "0.36.7" @@ -2090,6 +2202,7 @@ name = "receipts" version = "0.1.0" dependencies = [ "chrono", + "graphicsmagick", "reqwest", "rocket", "rocket_db_pools", @@ -2430,6 +2543,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 2273fee..a293ce9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ keywords = ["personal-finance", "receipts"] [dependencies] chrono = { version = "0.4.40", default-features = false, features = ["serde"] } +graphicsmagick = { version = "0.6.1", features = ["v1_3_38"] } reqwest = { version = "0.12.12", features = ["json"] } rocket = { version = "0.5.1", default-features = false, features = ["json"] } rocket_db_pools = { version = "0.2.0", features = ["sqlx_macros", "sqlx_postgres"] } diff --git a/Containerfile b/Containerfile index 838340a..eb80518 100644 --- a/Containerfile +++ b/Containerfile @@ -4,7 +4,9 @@ RUN --mount=type=cache,target=/var/cache \ microdnf install -y \ --setopt persistdir=/var/cache/dnf \ --setopt install_weak_deps=0 \ + GraphicsMagick-devel \ cargo \ + clang-devel \ openssl-devel \ && : @@ -38,6 +40,15 @@ RUN --mount=type=cache,target=/root/.cargo \ FROM git.pyrocufflink.net/containerimages/dch-base +RUN --mount=type=cache,target=/var/cache \ + microdnf install -y \ + --setopt persistdir=/var/cache/dnf \ + --setopt install_weak_deps=0 \ + GraphicsMagick \ + clang-libs \ + ghostscript \ + && : + COPY --from=build /build/target/release/receipts /usr/local/bin COPY --from=esbuild /build/dist /usr/local/share/receipts/static diff --git a/src/imaging.rs b/src/imaging.rs new file mode 100644 index 0000000..b3f3457 --- /dev/null +++ b/src/imaging.rs @@ -0,0 +1,46 @@ +use graphicsmagick::types::FilterTypes; +use graphicsmagick::wand::MagickWand; + +pub fn thumbnail( + image: &[u8], +) -> Result>, graphicsmagick::Error> { + let mut wand = MagickWand::new(); + wand.read_image_blob(image)?; + + // Multi-page documents like PDFs become multiple images in the + // MagickWand. We want a thumbnail of the first page, so we have + // to reset the iterator back to the first image. + wand.reset_iterator(); + + let orig_height = wand.get_image_height() as f64; + let orig_width = wand.get_image_width() as f64; + let min_width = 300.0; + let min_height = 450.0; + let scale_w = min_width / orig_width; + let scale_h = min_height / orig_height; + let scale = scale_w.max(scale_h); + let new_width = (orig_width * scale).round() as u64; + let new_height = (orig_height * scale).round() as u64; + + wand.resize_image( + new_width, + new_height, + FilterTypes::UndefinedFilter, + 1.0, + )?; + Ok(wand + .set_image_format("WEBP")? + .write_image_blob() + .map(|a| a.to_vec())) +} + +pub fn get_type(image: &[u8]) -> Option { + Some( + MagickWand::new() + .read_image_blob(image) + .ok()? + .get_image_format() + .to_str_lossy() + .to_string(), + ) +} diff --git a/src/main.rs b/src/main.rs index ae56083..331880f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod config; mod firefly; +mod imaging; mod receipts; mod routes; mod transactions; @@ -89,6 +90,8 @@ async fn rocket() -> _ { .with_writer(std::io::stderr) .init(); + graphicsmagick::initialize(); + let rocket = rocket::build(); let figment = rocket.figment(); diff --git a/src/receipts.rs b/src/receipts.rs index 102f20c..732202b 100644 --- a/src/receipts.rs +++ b/src/receipts.rs @@ -6,6 +6,7 @@ use serde::Serialize; use sqlx::types::Decimal; use tracing::error; +use crate::imaging; use crate::Database; #[derive(Debug, Serialize)] @@ -56,6 +57,8 @@ pub enum ReceiptPostFormError { Amount(#[from] rust_decimal::Error), #[error("Error reading photo: {0}")] Photo(#[from] std::io::Error), + #[error("Unsupported image type")] + UnsupportedImageFormat, } #[derive(Serialize)] @@ -84,16 +87,31 @@ impl ReceiptPostData { use rust_decimal::prelude::FromStr; let amount = Decimal::from_str(&form.amount)?; let notes = form.notes.clone(); - let filename = form - .photo - .raw_name() - .map(|n| n.dangerous_unsafe_unsanitized_raw().as_str()) - .unwrap_or("photo.jpg") - .into(); let stream = form.photo.open().await?; let mut reader = BufReader::new(stream); let mut photo = Vec::new(); reader.read_to_end(&mut photo).await?; + let extension = match imaging::get_type(&photo).as_deref() { + Some("BMP") => "bmp", + Some("JPEG") => "jpg", + Some("PDF") => "pdf", + Some("PNG") => "png", + Some("PPM") => "ppm", + Some("TIFF") => "tiff", + Some("WEBP") => "webp", + Some(f) => { + error!("Unsupported image format: {}", f); + return Err(ReceiptPostFormError::UnsupportedImageFormat); + }, + None => { + return Err(ReceiptPostFormError::UnsupportedImageFormat); + }, + }; + let filename = form + .photo + .name() + .map(|n| format!("{}.{}", n, extension)) + .unwrap_or("photo.jpg".into()); Ok(Self { date, vendor, @@ -108,6 +126,7 @@ impl ReceiptPostData { pub struct ReceiptsRepository { conn: Connection, } + impl ReceiptsRepository { pub fn new(conn: Connection) -> Self { Self { conn } diff --git a/src/routes/receipts.rs b/src/routes/receipts.rs index 8282699..bb8ecd7 100644 --- a/src/routes/receipts.rs +++ b/src/routes/receipts.rs @@ -8,6 +8,7 @@ use rust_decimal::prelude::ToPrimitive; use tracing::{debug, error, info}; use crate::firefly::{TransactionStore, TransactionUpdate}; +use crate::imaging; use crate::receipts::*; use crate::{Context, Database}; @@ -227,6 +228,31 @@ impl PhotoResponse { } } +#[rocket::get("//thumbnail/<_>")] +pub async fn view_receipt_thumbnail( + id: i32, + db: DatabaseConnection, +) -> Option { + let mut repo = ReceiptsRepository::new(db); + match repo.get_receipt_photo(id).await { + Ok((_, image)) => { + let thumbnail = match imaging::thumbnail(&image) { + Ok(Some(t)) => t, + Ok(None) => return None, + Err(e) => { + error!("Failed to create receipt photo thumbnail: {}", e); + return None; + }, + }; + Some(PhotoResponse::new(thumbnail, ContentType(MediaType::WEBP))) + }, + Err(e) => { + error!("Error fetching receipt image: {}", e); + None + }, + } +} + #[rocket::get("//view/<_>")] pub async fn view_receipt_photo( id: i32, @@ -273,6 +299,7 @@ pub fn routes() -> Vec { get_receipt, list_receipts, receipt_form, + view_receipt_thumbnail, view_receipt_photo, ] } diff --git a/templates/receipt-form.html.tera b/templates/receipt-form.html.tera index c4b8007..98ef8f5 100644 --- a/templates/receipt-form.html.tera +++ b/templates/receipt-form.html.tera @@ -54,7 +54,12 @@

- + Choose File diff --git a/templates/receipt-list.html.tera b/templates/receipt-list.html.tera index 4b9f275..1f61e7f 100644 --- a/templates/receipt-list.html.tera +++ b/templates/receipt-list.html.tera @@ -94,7 +94,7 @@ Receipt {{ receipt.id }}