use super::assets::{relative_asset_path, resolve_asset, write_named_asset}; use super::settings::{map_settings, SETTINGS_COLS}; use crate::logo::{process_logo, sniff_extension, LogoMetrics, MAX_INPUT_BYTES}; use crate::models::Settings; use crate::AppState; use base64::{engine::general_purpose::STANDARD, Engine}; use rusqlite::{params, Connection}; use serde::Serialize; use sha2::{Digest, Sha256}; use std::path::Path; use tauri::State; /// The stored files and measurements of an imported logo. Paths are relative to the data dir. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LogoAsset { pub original_path: String, pub print_path: String, pub knockout_path: Option, pub metrics: LogoMetrics, } fn sha8(bytes: &[u8]) -> String { Sha256::digest(bytes) .iter() .take(4) .map(|b| format!("{b:02x}")) .collect() } /// Derive the print and knockout images from `bytes` and store them content-addressed in the asset /// store. `existing_original` is the already stored file the bytes came from (legacy logos); without /// it the bytes are stored as the original. Nothing is ever deleted: issued invoices may still point at /// older logo files. fn store_logo( data_dir: &Path, bytes: &[u8], existing_original: Option, ) -> Result { let derived = process_logo(bytes).map_err(|e| e.to_string())?; let id = sha8(bytes); let original_path = match existing_original { Some(path) => path, None => { let ext = sniff_extension(bytes).map_err(|e| e.to_string())?; write_named_asset(data_dir, &format!("logo-{id}-original.{ext}"), bytes)? } }; let print_path = write_named_asset( data_dir, &format!("logo-{id}-print.png"), &derived.print_png, )?; let knockout_path = derived .knockout_png .as_deref() .map(|png| write_named_asset(data_dir, &format!("logo-{id}-knockout.png"), png)) .transpose()?; Ok(LogoAsset { original_path, print_path, knockout_path, metrics: derived.metrics, }) } pub fn import_logo_from_path(data_dir: &Path, source_path: &str) -> Result { let len = std::fs::metadata(source_path) .map_err(|e| e.to_string())? .len(); if len > MAX_INPUT_BYTES as u64 { return Err(crate::logo::LogoError::TooLarge.to_string()); } let bytes = std::fs::read(source_path).map_err(|e| e.to_string())?; store_logo(data_dir, &bytes, None) } pub fn import_logo_from_base64(data_dir: &Path, data_base64: &str) -> Result { // Base64 is 4/3 of the payload; refuse before decoding something huge. if data_base64.len() > MAX_INPUT_BYTES / 3 * 4 + 8 { return Err(crate::logo::LogoError::TooLarge.to_string()); } let bytes = STANDARD .decode(data_base64.as_bytes()) .map_err(|e| e.to_string())?; store_logo(data_dir, &bytes, None) } fn metrics_json(metrics: &LogoMetrics) -> Result { serde_json::to_string(metrics).map_err(|e| e.to_string()) } /// Derive trimmed print and knockout images for a logo saved before the pipeline existed /// (`logo_path` set, `logo_meta` empty) and point the settings at them. The old file is kept as the /// original. Returns whether anything changed. pub fn ensure_logo_derived_impl(conn: &Connection, data_dir: &Path) -> Result { let (logo_path, meta): (Option, String) = conn .query_row( "SELECT logo_path, logo_meta FROM app_settings WHERE id = 1", [], |r| Ok((r.get(0)?, r.get(1)?)), ) .map_err(|e| e.to_string())?; let Some(logo_path) = logo_path.filter(|p| !p.trim().is_empty()) else { return Ok(false); }; if !meta.is_empty() { return Ok(false); } let file = resolve_asset(data_dir, &logo_path)?; let bytes = std::fs::read(&file).map_err(|e| e.to_string())?; let asset = store_logo( data_dir, &bytes, Some(relative_asset_path(data_dir, &logo_path)?), )?; conn.execute( "UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3, logo_meta = ?4 WHERE id = 1", params![ asset.print_path, asset.original_path, asset.knockout_path, metrics_json(&asset.metrics)? ], ) .map_err(|e| e.to_string())?; Ok(true) } /// Detach the logo from the settings. The files stay: issued invoices may reference them. pub fn remove_logo_impl(conn: &Connection) -> Result<(), String> { conn.execute( "UPDATE app_settings SET logo_path = NULL, logo_original_path = NULL, logo_knockout_path = NULL, logo_meta = '', logo_includes_name = 'auto' WHERE id = 1", [], ) .map_err(|e| e.to_string())?; Ok(()) } fn read_settings(conn: &Connection) -> Result { conn.query_row( &format!("SELECT {} FROM app_settings WHERE id = 1", SETTINGS_COLS), [], map_settings, ) .map_err(|e| e.to_string()) } #[tauri::command] pub fn import_logo(state: State, source_path: String) -> Result { import_logo_from_path(&state.data_dir, &source_path) } /// For when the frontend already holds the bytes. The file name is ignored: the type comes from the bytes. #[tauri::command] pub fn save_logo_bytes( state: State, #[allow(unused_variables)] file_name: String, data_base64: String, ) -> Result { import_logo_from_base64(&state.data_dir, &data_base64) } #[tauri::command] pub fn ensure_logo_derived(state: State) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; ensure_logo_derived_impl(&conn, &state.data_dir)?; read_settings(&conn) } #[tauri::command] pub fn remove_logo(state: State) -> Result { let conn = state.db.lock().map_err(|e| e.to_string())?; remove_logo_impl(&conn)?; read_settings(&conn) } #[cfg(test)] mod tests { use super::*; use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; use std::io::Cursor; use tempfile::tempdir; /// A 300x100 transparent canvas with a 100x20 opaque bar off-centre. fn padded_png() -> Vec { let mut img = RgbaImage::new(300, 100); for y in 40..60 { for x in 150..250 { img.put_pixel(x, y, Rgba([10, 20, 30, 255])); } } let mut out = Vec::new(); DynamicImage::ImageRgba8(img) .write_to(&mut Cursor::new(&mut out), ImageFormat::Png) .unwrap(); out } fn logo_columns(conn: &Connection) -> (Option, Option, Option, String) { conn.query_row( "SELECT logo_path, logo_original_path, logo_knockout_path, logo_meta FROM app_settings WHERE id = 1", [], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), ) .unwrap() } #[test] fn base64_import_stores_content_addressed_files() { let dir = tempdir().unwrap(); let bytes = padded_png(); let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)).unwrap(); let id = sha8(&bytes); assert_eq!( asset.original_path, format!("assets/logo-{id}-original.png") ); assert_eq!(asset.print_path, format!("assets/logo-{id}-print.png")); assert_eq!( asset.knockout_path.as_deref(), Some(format!("assets/logo-{id}-knockout.png").as_str()) ); assert_eq!((asset.metrics.width, asset.metrics.height), (100, 20)); assert_eq!( std::fs::read(dir.path().join(&asset.original_path)).unwrap(), bytes ); for rel in [&asset.print_path, asset.knockout_path.as_ref().unwrap()] { assert!(resolve_asset(dir.path(), rel).is_ok()); } // The same bytes land on the same names. assert_eq!( import_logo_from_base64(dir.path(), &STANDARD.encode(&bytes)) .unwrap() .print_path, asset.print_path ); let json = serde_json::to_value(&asset).unwrap(); assert_eq!(json["metrics"]["kind"], "wordmark"); assert_eq!(json["metrics"]["opaqueBackground"], false); } #[test] fn path_import_reads_the_file_and_rejects_bad_input() { let dir = tempdir().unwrap(); let src = tempdir().unwrap(); let file = src.path().join("brand.png"); std::fs::write(&file, padded_png()).unwrap(); let asset = import_logo_from_path(dir.path(), file.to_str().unwrap()).unwrap(); assert_eq!(asset.metrics.width, 100); let gif = src.path().join("x.gif"); std::fs::write(&gif, b"GIF89a....").unwrap(); assert_eq!( import_logo_from_path(dir.path(), gif.to_str().unwrap()).unwrap_err(), "Use a PNG, JPEG or WebP image" ); assert!(import_logo_from_path(dir.path(), "/nonexistent/logo.png").is_err()); assert!(import_logo_from_base64(dir.path(), "!!not base64!!").is_err()); assert!(import_logo_from_base64(dir.path(), &STANDARD.encode(b"junk")).is_err()); } #[test] fn ensure_derives_a_legacy_untrimmed_logo_and_keeps_the_old_file() { let dir = tempdir().unwrap(); let conn = crate::db::open_in_memory().unwrap(); let legacy = write_named_asset(dir.path(), "logo-legacy.png", &padded_png()).unwrap(); conn.execute("UPDATE app_settings SET logo_path = ?1", [&legacy]) .unwrap(); assert!(ensure_logo_derived_impl(&conn, dir.path()).unwrap()); let (logo, original, knockout, meta) = logo_columns(&conn); let logo = logo.unwrap(); assert!(logo.ends_with("-print.png"), "{logo}"); assert_eq!(original.as_deref(), Some(legacy.as_str())); assert!(knockout.unwrap().ends_with("-knockout.png")); let metrics: LogoMetrics = serde_json::from_str(&meta).unwrap(); assert_eq!((metrics.width, metrics.height), (100, 20)); assert!(dir.path().join(&legacy).is_file()); assert!(dir.path().join(&logo).is_file()); // Already derived: nothing to do the second time. assert!(!ensure_logo_derived_impl(&conn, dir.path()).unwrap()); assert_eq!(logo_columns(&conn).0.as_deref(), Some(logo.as_str())); } #[test] fn ensure_does_nothing_without_a_logo_and_errors_on_a_missing_file() { let dir = tempdir().unwrap(); let conn = crate::db::open_in_memory().unwrap(); assert!(!ensure_logo_derived_impl(&conn, dir.path()).unwrap()); conn.execute("UPDATE app_settings SET logo_path = 'assets/gone.png'", []) .unwrap(); assert!(ensure_logo_derived_impl(&conn, dir.path()).is_err()); // Left untouched so a later start can retry. assert_eq!(logo_columns(&conn).0.as_deref(), Some("assets/gone.png")); } #[test] fn remove_clears_the_columns_and_keeps_the_files() { let dir = tempdir().unwrap(); let conn = crate::db::open_in_memory().unwrap(); let asset = import_logo_from_base64(dir.path(), &STANDARD.encode(padded_png())).unwrap(); conn.execute( "UPDATE app_settings SET logo_path = ?1, logo_original_path = ?2, logo_knockout_path = ?3, logo_meta = ?4, logo_includes_name = 'yes'", params![asset.print_path, asset.original_path, asset.knockout_path, metrics_json(&asset.metrics).unwrap()], ) .unwrap(); remove_logo_impl(&conn).unwrap(); assert_eq!(logo_columns(&conn), (None, None, None, String::new())); let settings = read_settings(&conn).unwrap(); assert_eq!(settings.logo_includes_name, "auto"); assert!(dir.path().join(&asset.print_path).is_file()); assert!(dir.path().join(&asset.original_path).is_file()); } }