diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 39287d6..4dc1269 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4236,10 +4236,12 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "chrono", + "percent-encoding", "rusqlite", "rusqlite_migration", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8ead673..17a2e79 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,8 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] } thiserror = "2" base64 = "0.22" +sha2 = "0.10" +percent-encoding = "2" # Desktop-only plugins (not available on mobile targets). [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] diff --git a/src-tauri/src/commands/archive.rs b/src-tauri/src/commands/archive.rs new file mode 100644 index 0000000..5ef4e75 --- /dev/null +++ b/src-tauri/src/commands/archive.rs @@ -0,0 +1,289 @@ +//! Content-addressed archive of the searchable PDF produced when an invoice is issued. +//! Files live at `/archive/.pdf`; the invoice row keeps the hash. +use super::raw::{header_map, raw_body, required_header, write_atomic, Headers}; +use crate::AppState; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use tauri::ipc::{Request, Response}; +use tauri::State; + +pub const MAX_ARCHIVE_BYTES: usize = 64 * 1024 * 1024; +const MAX_FINGERPRINT_LEN: usize = 256; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveStatus { + pub archived: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fingerprint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub archived_at: Option, +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +/// The hash comes from the database, but it ends up in a file path, so check its shape. +fn archive_path(local_dir: &Path, sha256: &str) -> Result { + let valid = sha256.len() == 64 && sha256.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')); + if !valid { + return Err("The stored archive hash is malformed".to_string()); + } + Ok(local_dir.join("archive").join(format!("{sha256}.pdf"))) +} + +fn parse_invoice_id(headers: &Headers) -> Result { + required_header(headers, "x-invoice-id")? + .trim() + .parse::() + .map_err(|_| "x-invoice-id must be a number".to_string()) +} + +pub fn archive_pdf_impl( + conn: &mut Connection, + local_dir: &Path, + bytes: &[u8], + headers: &Headers, +) -> Result { + let id = parse_invoice_id(headers)?; + let fingerprint = required_header(headers, "x-fingerprint")?.trim(); + if fingerprint.is_empty() || fingerprint.len() > MAX_FINGERPRINT_LEN { + return Err("x-fingerprint must be 1-256 characters".to_string()); + } + if !bytes.starts_with(b"%PDF-") { + return Err("The data is not a PDF".to_string()); + } + if bytes.len() > MAX_ARCHIVE_BYTES { + return Err("The PDF is larger than the 64 MB archive limit".to_string()); + } + + let row: Option<(String, Option)> = conn + .query_row( + "SELECT status, archived_pdf_sha256 FROM invoices WHERE id = ?1", + params![id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + let (status, existing) = row.ok_or_else(|| "Invoice not found".to_string())?; + if status != "issued" && status != "cancelled" { + return Err(format!("Only an issued invoice can be archived (this one is {status})")); + } + + let sha = sha256_hex(bytes); + if let Some(existing) = &existing { + if *existing != sha { + return Err( + "This invoice already has an archived original; the issued PDF cannot be replaced" + .to_string(), + ); + } + } + + let path = archive_path(local_dir, &sha)?; + std::fs::create_dir_all(path.parent().expect("archive path has a parent")) + .map_err(|e| format!("Could not create the archive folder: {e}"))?; + // Same content, same name; a matching length means the file is already in place. + let present = std::fs::metadata(&path).map(|m| m.len() == bytes.len() as u64).unwrap_or(false); + if !present { + write_atomic(&path, bytes)?; + } + + if existing.is_none() { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE invoices SET archived_pdf_sha256 = ?1, archived_fingerprint = ?2, archived_at = ?3 + WHERE id = ?4", + params![sha, fingerprint, now, id], + ) + .map_err(|e| e.to_string())?; + } + Ok(sha) +} + +pub fn read_archive_impl(conn: &Connection, local_dir: &Path, id: i64) -> Result, String> { + let sha = stored_sha(conn, id)?.ok_or_else(|| "This invoice has no archived PDF".to_string())?; + let path = archive_path(local_dir, &sha)?; + let bytes = std::fs::read(&path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + "The archived PDF file is missing".to_string() + } else { + format!("Could not read the archived PDF: {e}") + } + })?; + if sha256_hex(&bytes) != sha { + return Err("The archived PDF is corrupted (its checksum no longer matches)".to_string()); + } + Ok(bytes) +} + +fn stored_sha(conn: &Connection, id: i64) -> Result, String> { + let row: Option> = conn + .query_row("SELECT archived_pdf_sha256 FROM invoices WHERE id = ?1", params![id], |r| r.get(0)) + .optional() + .map_err(|e| e.to_string())?; + row.ok_or_else(|| "Invoice not found".to_string()) +} + +pub fn archive_status_impl(conn: &Connection, id: i64) -> Result { + let row: Option<(Option, Option, Option)> = conn + .query_row( + "SELECT archived_pdf_sha256, archived_fingerprint, archived_at FROM invoices WHERE id = ?1", + params![id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional() + .map_err(|e| e.to_string())?; + let (sha256, fingerprint, archived_at) = row.ok_or_else(|| "Invoice not found".to_string())?; + Ok(ArchiveStatus { archived: sha256.is_some(), sha256, fingerprint, archived_at }) +} + +// The commands are async so hashing and file I/O of a multi-MB PDF stay off the main thread. +#[tauri::command] +pub async fn archive_pdf(request: Request<'_>, state: State<'_, AppState>) -> Result { + let bytes = raw_body(&request)?; + let headers = header_map(&request); + let mut conn = state.db.lock().map_err(|e| e.to_string())?; + archive_pdf_impl(&mut conn, &state.local_data_dir, bytes, &headers) +} + +#[tauri::command] +pub async fn read_archive(invoice_id: i64, state: State<'_, AppState>) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + read_archive_impl(&conn, &state.local_data_dir, invoice_id).map(Response::new) +} + +#[tauri::command] +pub async fn archive_status(invoice_id: i64, state: State<'_, AppState>) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + archive_status_impl(&conn, invoice_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn conn_with_invoice(status: &str) -> Connection { + let conn = crate::db::open_in_memory().unwrap(); + conn.execute( + "INSERT INTO invoices (number, invoice_date, client_name, status, created_at, updated_at) + VALUES ('AP/2026-001', '2026-04-01', 'Client', ?1, 'now', 'now')", + params![status], + ) + .unwrap(); + conn + } + + fn headers(id: &str) -> Headers { + Headers::from([ + ("x-invoice-id".to_string(), id.to_string()), + ("x-fingerprint".to_string(), "fp-1".to_string()), + ]) + } + + const PDF: &[u8] = b"%PDF-1.7\nbody"; + + #[test] + fn archives_once_and_is_idempotent() { + let dir = tempdir().unwrap(); + let mut conn = conn_with_invoice("issued"); + let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); + assert_eq!(sha.len(), 64); + assert_eq!(std::fs::read(dir.path().join("archive").join(format!("{sha}.pdf"))).unwrap(), PDF); + + let status = archive_status_impl(&conn, 1).unwrap(); + assert!(status.archived); + assert_eq!(status.sha256.as_deref(), Some(sha.as_str())); + assert_eq!(status.fingerprint.as_deref(), Some("fp-1")); + let first_at = status.archived_at.clone().unwrap(); + + let again = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); + assert_eq!(again, sha); + assert_eq!(archive_status_impl(&conn, 1).unwrap().archived_at.unwrap(), first_at); + assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF); + } + + #[test] + fn cancelled_invoices_can_be_archived() { + let dir = tempdir().unwrap(); + let mut conn = conn_with_invoice("cancelled"); + assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).is_ok()); + } + + #[test] + fn different_bytes_for_an_archived_invoice_are_refused() { + let dir = tempdir().unwrap(); + let mut conn = conn_with_invoice("issued"); + let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); + let err = archive_pdf_impl(&mut conn, dir.path(), b"%PDF-1.7\nother", &headers("1")).unwrap_err(); + assert!(err.contains("already has an archived original"), "{err}"); + assert_eq!(archive_status_impl(&conn, 1).unwrap().sha256.unwrap(), sha); + assert_eq!(std::fs::read_dir(dir.path().join("archive")).unwrap().count(), 1); + } + + #[test] + fn non_pdf_and_oversize_bodies_are_refused() { + let dir = tempdir().unwrap(); + let mut conn = conn_with_invoice("issued"); + assert!(archive_pdf_impl(&mut conn, dir.path(), b"", &headers("1")) + .unwrap_err() + .contains("not a PDF")); + let mut big = b"%PDF-".to_vec(); + big.resize(MAX_ARCHIVE_BYTES + 1, 0); + assert!(archive_pdf_impl(&mut conn, dir.path(), &big, &headers("1")) + .unwrap_err() + .contains("64 MB")); + assert!(!archive_status_impl(&conn, 1).unwrap().archived); + } + + #[test] + fn drafts_unknown_invoices_and_bad_headers_are_refused() { + let dir = tempdir().unwrap(); + let mut conn = conn_with_invoice("draft"); + assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")) + .unwrap_err() + .contains("Only an issued invoice")); + assert_eq!( + archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("99")).unwrap_err(), + "Invoice not found" + ); + assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("abc")).is_err()); + let mut no_fp = headers("1"); + no_fp.remove("x-fingerprint"); + assert!(archive_pdf_impl(&mut conn, dir.path(), PDF, &no_fp).is_err()); + assert!(!dir.path().join("archive").exists()); + } + + #[test] + fn read_archive_detects_corruption_and_missing_files() { + let dir = tempdir().unwrap(); + let mut conn = conn_with_invoice("issued"); + assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("no archived PDF")); + let sha = archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); + let file = dir.path().join("archive").join(format!("{sha}.pdf")); + + std::fs::write(&file, b"%PDF-1.7\ntampered").unwrap(); + assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("corrupted")); + + std::fs::remove_file(&file).unwrap(); + assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("missing")); + // Archiving the same bytes again restores the file. + archive_pdf_impl(&mut conn, dir.path(), PDF, &headers("1")).unwrap(); + assert_eq!(read_archive_impl(&conn, dir.path(), 1).unwrap(), PDF); + } + + #[test] + fn a_malformed_stored_hash_never_becomes_a_path() { + let dir = tempdir().unwrap(); + let conn = conn_with_invoice("issued"); + conn.execute("UPDATE invoices SET archived_pdf_sha256 = '../../etc/passwd' WHERE id = 1", []) + .unwrap(); + assert!(read_archive_impl(&conn, dir.path(), 1).unwrap_err().contains("malformed")); + } +} diff --git a/src-tauri/src/commands/assets.rs b/src-tauri/src/commands/assets.rs index 95290f1..009fb99 100644 --- a/src-tauri/src/commands/assets.rs +++ b/src-tauri/src/commands/assets.rs @@ -133,22 +133,6 @@ pub fn remove_asset(state: State, path: String) -> Result<(), String> remove_asset_file(&state.data_dir, &path) } -/// Write a base64 payload (e.g. a generated PDF) to a user-chosen path. -#[tauri::command] -pub fn save_binary_file(path: String, data_base64: String) -> Result<(), String> { - if path.trim().is_empty() { - return Err("No file path given".into()); - } - let bytes = STANDARD - .decode(data_base64.as_bytes()) - .map_err(|e| e.to_string())?; - if let Some(parent) = Path::new(&path).parent() { - // A missing parent is created; if that fails the write below reports the real error. - std::fs::create_dir_all(parent).ok(); - } - std::fs::write(&path, bytes).map_err(|e| e.to_string()) -} - #[cfg(test)] mod tests { use super::*; @@ -248,9 +232,4 @@ mod tests { assert!(remove_asset_file(dir.path(), "voiced.db").is_err()); assert!(dir.path().join("voiced.db").exists()); } - - #[test] - fn save_binary_file_rejects_empty_path() { - assert!(save_binary_file(" ".into(), "AAAA".into()).is_err()); - } } diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs new file mode 100644 index 0000000..39bb9e6 --- /dev/null +++ b/src-tauri/src/commands/files.rs @@ -0,0 +1,211 @@ +//! User-visible files: exports written to a path from the save dialog, and the +//! reveal/open helpers used afterwards. +use super::raw::{decode_header_path, header_map, raw_body, required_header, write_atomic, Headers}; +use crate::AppState; +use rusqlite::{params, Connection}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tauri::ipc::Request; +use tauri::State; +use tauri_plugin_opener::OpenerExt; + +pub fn write_export_file_impl( + conn: &Connection, + bytes: &[u8], + headers: &Headers, +) -> Result { + let raw = decode_header_path(required_header(headers, "x-path")?)?; + if raw.trim().is_empty() { + return Err("No file path given".to_string()); + } + let path = PathBuf::from(&raw); + if !path.is_absolute() { + return Err("The export path must be absolute".to_string()); + } + if bytes.is_empty() { + return Err("Nothing to write: the file is empty".to_string()); + } + let parent = path.parent().ok_or_else(|| "The export path has no folder".to_string())?; + std::fs::create_dir_all(parent) + .map_err(|e| format!("Could not create {}: {e}", parent.display()))?; + write_atomic(&path, bytes)?; + conn.execute( + "UPDATE app_settings SET last_export_dir = ?1 WHERE id = 1", + params![parent.to_string_lossy()], + ) + .map_err(|e| e.to_string())?; + Ok(raw) +} + +pub fn get_last_export_dir_impl(conn: &Connection) -> Result { + conn.query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0)) + .map_err(|e| e.to_string()) +} + +fn require_existing(path: &str) -> Result { + if path.trim().is_empty() { + return Err("No file path given".to_string()); + } + let path = PathBuf::from(path); + if !path.exists() { + return Err(format!("{} does not exist", path.display())); + } + Ok(path) +} + +/// Last resort when the opener plugin cannot reach a file manager (a minimal Linux +/// install has no D-Bus file-manager service or portal). Arguments are passed +/// individually; nothing goes through a shell. +fn reveal_fallback_command(path: &Path) -> Command { + #[cfg(target_os = "windows")] + { + let mut cmd = Command::new("explorer"); + cmd.arg(format!("/select,{}", path.display())); + cmd + } + #[cfg(target_os = "macos")] + { + let mut cmd = Command::new("open"); + cmd.arg("-R").arg(path); + cmd + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let folder = if path.is_dir() { path } else { path.parent().unwrap_or(path) }; + let mut cmd = Command::new("xdg-open"); + cmd.arg(folder); + cmd + } +} + +fn open_fallback_command(path: &Path) -> Command { + #[cfg(target_os = "windows")] + let mut cmd = Command::new("explorer"); + #[cfg(target_os = "macos")] + let mut cmd = Command::new("open"); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + let mut cmd = Command::new("xdg-open"); + cmd.arg(path); + cmd +} + +fn spawn_detached(mut cmd: Command) -> Result<(), String> { + let mut child = cmd.spawn().map_err(|e| format!("Could not start the file manager: {e}"))?; + // Reap the child so it does not linger as a zombie; the result is irrelevant + // (explorer.exe, for one, exits non-zero even on success). + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) +} + +#[tauri::command] +pub async fn write_export_file(request: Request<'_>, state: State<'_, AppState>) -> Result { + let bytes = raw_body(&request)?; + let headers = header_map(&request); + let conn = state.db.lock().map_err(|e| e.to_string())?; + write_export_file_impl(&conn, bytes, &headers) +} + +#[tauri::command] +pub fn get_last_export_dir(state: State) -> Result { + let conn = state.db.lock().map_err(|e| e.to_string())?; + get_last_export_dir_impl(&conn) +} + +#[tauri::command] +pub fn reveal_in_folder(app: tauri::AppHandle, path: String) -> Result<(), String> { + let path = require_existing(&path)?; + if app.opener().reveal_item_in_dir(&path).is_ok() { + return Ok(()); + } + spawn_detached(reveal_fallback_command(&path)) +} + +#[tauri::command] +pub fn open_file(app: tauri::AppHandle, path: String) -> Result<(), String> { + let path = require_existing(&path)?; + if app.opener().open_path(path.to_string_lossy(), None::<&str>).is_ok() { + return Ok(()); + } + spawn_detached(open_fallback_command(&path)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn headers(path: &str) -> Headers { + Headers::from([("x-path".to_string(), path.to_string())]) + } + + fn encode(path: &Path) -> String { + percent_encoding::utf8_percent_encode(&path.to_string_lossy(), percent_encoding::NON_ALPHANUMERIC) + .to_string() + } + + #[test] + fn writes_the_file_and_remembers_the_folder() { + let dir = tempdir().unwrap(); + let conn = crate::db::open_in_memory().unwrap(); + assert_eq!(get_last_export_dir_impl(&conn).unwrap(), ""); + + // Non-ASCII, spaces and a folder that does not exist yet. + let target = dir.path().join("New folder").join("\u{9ac}\u{9be}\u{982}\u{9b2}\u{9be} 1.pdf"); + let written = write_export_file_impl(&conn, b"%PDF-1.7", &headers(&encode(&target))).unwrap(); + assert_eq!(PathBuf::from(&written), target); + assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-1.7"); + assert_eq!( + get_last_export_dir_impl(&conn).unwrap(), + target.parent().unwrap().to_string_lossy() + ); + + // Overwrites in place. + write_export_file_impl(&conn, b"%PDF-2", &headers(&encode(&target))).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"%PDF-2"); + } + + #[test] + fn rejects_empty_relative_and_missing_paths() { + let conn = crate::db::open_in_memory().unwrap(); + for bad in ["", " ", "out.pdf", "sub%2Fout.pdf", "..%2Fout.pdf"] { + assert!(write_export_file_impl(&conn, b"x", &headers(bad)).is_err(), "{bad}"); + } + assert!(write_export_file_impl(&conn, b"x", &Headers::new()).is_err()); + assert_eq!(get_last_export_dir_impl(&conn).unwrap(), ""); + } + + #[test] + fn rejects_an_empty_body_without_touching_the_folder_setting() { + let dir = tempdir().unwrap(); + let conn = crate::db::open_in_memory().unwrap(); + let target = dir.path().join("a.pdf"); + assert!(write_export_file_impl(&conn, b"", &headers(&encode(&target))).is_err()); + assert!(!target.exists()); + assert_eq!(get_last_export_dir_impl(&conn).unwrap(), ""); + } + + #[test] + fn reveal_and_open_refuse_missing_paths() { + let dir = tempdir().unwrap(); + assert!(require_existing("").is_err()); + let missing = dir.path().join("nope.pdf"); + assert!(require_existing(&missing.to_string_lossy()).unwrap_err().contains("does not exist")); + assert!(require_existing(&dir.path().to_string_lossy()).is_ok()); + } + + #[cfg(target_os = "linux")] + #[test] + fn linux_fallback_opens_the_parent_folder_with_separate_args() { + let dir = tempdir().unwrap(); + let file = dir.path().join("a b; rm -rf.pdf"); + std::fs::write(&file, b"x").unwrap(); + let cmd = reveal_fallback_command(&file); + assert_eq!(cmd.get_program(), "xdg-open"); + let args: Vec<_> = cmd.get_args().collect(); + assert_eq!(args, [dir.path().as_os_str()]); + let open = open_fallback_command(&file); + assert_eq!(open.get_args().collect::>(), [file.as_os_str()]); + } +} diff --git a/src-tauri/src/commands/invoice.rs b/src-tauri/src/commands/invoice.rs index 7bfb3c2..7361c4a 100644 --- a/src-tauri/src/commands/invoice.rs +++ b/src-tauri/src/commands/invoice.rs @@ -17,7 +17,7 @@ const INVOICE_COLS: &str = "id, number, series_id, invoice_date, due_date, clien tax_type, tax_rate, cgst_amount, sgst_amount, igst_amount, total, amount_in_words, bank_account_id, bank_snapshot, signature_path, notes, status, created_at, updated_at, doc_type, reverse_charge, COALESCE(vendor_snapshot, ''), snapshot_origin, cancelled_at, - cancel_reason, archived_pdf_sha256, COALESCE(render_prefs, '')"; + cancel_reason, archived_pdf_sha256, COALESCE(render_prefs, ''), archived_fingerprint, archived_at"; fn map_invoice(row: &Row) -> rusqlite::Result { Ok(Invoice { @@ -56,6 +56,8 @@ fn map_invoice(row: &Row) -> rusqlite::Result { cancel_reason: row.get(32)?, archived_pdf_sha256: row.get(33)?, render_prefs: row.get(34)?, + archived_fingerprint: row.get(35)?, + archived_at: row.get(36)?, items: Vec::new(), }) } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ecd2d7c..e0316c5 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,8 @@ +pub mod archive; pub mod assets; pub mod clients; +pub mod files; pub mod invoice; +pub mod raw; pub mod series; pub mod settings; diff --git a/src-tauri/src/commands/raw.rs b/src-tauri/src/commands/raw.rs new file mode 100644 index 0000000..d64d495 --- /dev/null +++ b/src-tauri/src/commands/raw.rs @@ -0,0 +1,92 @@ +//! Helpers for commands that take their payload as a raw IPC body (no base64). +//! +//! The webview sends `invoke(cmd, Uint8Array, { headers })`; Tauri hands the bytes over as +//! `InvokeBody::Raw` and the headers as an `http::HeaderMap`. Header values must be visible +//! ASCII (`HeaderValue::from_str` rejects anything else), so non-ASCII data such as file +//! paths is percent-encoded by the caller. +use percent_encoding::percent_decode_str; +use std::collections::HashMap; +use tauri::ipc::{InvokeBody, Request}; + +/// Lower-cased header name to value; values that are not valid UTF-8 are dropped. +pub type Headers = HashMap; + +pub fn raw_body<'a>(request: &'a Request<'_>) -> Result<&'a [u8], String> { + match request.body() { + InvokeBody::Raw(bytes) => Ok(bytes), + InvokeBody::Json(_) => Err("Expected a raw binary request body".to_string()), + } +} + +pub fn header_map(request: &Request<'_>) -> Headers { + request + .headers() + .iter() + .filter_map(|(name, value)| { + let value = value.to_str().ok()?; + Some((name.as_str().to_ascii_lowercase(), value.to_string())) + }) + .collect() +} + +pub fn required_header<'a>(headers: &'a Headers, name: &str) -> Result<&'a str, String> { + headers + .get(name) + .map(String::as_str) + .ok_or_else(|| format!("Missing {name} header")) +} + +pub fn decode_header_path(value: &str) -> Result { + percent_decode_str(value) + .decode_utf8() + .map(|s| s.into_owned()) + .map_err(|_| "The path header is not valid UTF-8".to_string()) +} + +/// Write `bytes` to `path` via a sibling temp file and a rename, so a crash never leaves a +/// half-written file under the final name. +pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> { + use std::io::Write; + let mut tmp_name = path + .file_name() + .ok_or_else(|| "The path has no file name".to_string())? + .to_os_string(); + tmp_name.push(".tmp"); + let tmp = path.with_file_name(tmp_name); + let result = (|| { + let mut file = std::fs::File::create(&tmp)?; + file.write_all(bytes)?; + file.sync_all()?; + std::fs::rename(&tmp, path) + })(); + if let Err(e) = result { + let _ = std::fs::remove_file(&tmp); + return Err(format!("Could not write {}: {e}", path.display())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn decodes_percent_encoded_paths() { + assert_eq!( + decode_header_path("%2Ftmp%2Fa%20b%2F%E0%A6%AC.pdf").unwrap(), + "/tmp/a b/\u{9ac}.pdf" + ); + assert!(decode_header_path("%FF").is_err()); + } + + #[test] + fn atomic_write_replaces_and_leaves_no_temp_file() { + let dir = tempdir().unwrap(); + let target = dir.path().join("out.pdf"); + write_atomic(&target, b"one").unwrap(); + write_atomic(&target, b"two").unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"two"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } +} diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index bb8a006..10042a8 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -6,7 +6,7 @@ use tauri::State; pub(crate) const SETTINGS_COLS: &str = "vendor_name, vendor_address, vendor_email, vendor_phone, vendor_pan, vendor_gstin, vendor_state_code, logo_path, signature_path, default_bank_id, default_tax_rate, default_tax_type, payment_terms_days, currency, onboarded, theme, gst_registration, default_hsn_sac, signatory_name, - signatory_designation, render_prefs"; + signatory_designation, render_prefs, last_export_dir"; pub(crate) fn map_settings(row: &Row) -> rusqlite::Result { Ok(Settings { @@ -31,6 +31,7 @@ pub(crate) fn map_settings(row: &Row) -> rusqlite::Result { signatory_name: row.get(18)?, signatory_designation: row.get(19)?, render_prefs: row.get(20)?, + last_export_dir: row.get(21)?, }) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 005ab49..38483bc 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -10,7 +10,7 @@ const MAX_BACKUPS: usize = 10; const BACKUP_PREFIX: &str = "voiced-pre-v"; /// Highest schema version, i.e. the number of entries in `migrations()`. -const LATEST_VERSION: i64 = 3; +const LATEST_VERSION: i64 = 4; #[derive(Debug, thiserror::Error)] pub enum DbError { @@ -174,8 +174,15 @@ const M3: &str = r#" ALTER TABLE app_settings ADD COLUMN render_prefs TEXT NOT NULL DEFAULT ''; "#; +/// Version 4: the archived PDF's layout fingerprint and time, and the last export folder. +const M4: &str = r#" +ALTER TABLE invoices ADD COLUMN archived_fingerprint TEXT; +ALTER TABLE invoices ADD COLUMN archived_at TEXT; +ALTER TABLE app_settings ADD COLUMN last_export_dir TEXT NOT NULL DEFAULT ''; +"#; + fn migrations() -> Migrations<'static> { - Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3)]) + Migrations::new(vec![M::up(SCHEMA), M::up(M2), M::up(M3), M::up(M4)]) } /// Open (creating if needed) the database at `path` and bring it to the latest schema. @@ -541,6 +548,27 @@ CREATE INDEX IF NOT EXISTS idx_invoices_created ON invoices(created_at DESC); assert!(backups_in(&backups).is_empty()); } + #[test] + fn m4_adds_archive_and_export_dir_columns() { + let conn = open_in_memory().unwrap(); + let dir: String = conn + .query_row("SELECT last_export_dir FROM app_settings WHERE id = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(dir, ""); + // The columns exist and are nullable on invoices (no rows needed to prove it). + conn.prepare("SELECT archived_pdf_sha256, archived_fingerprint, archived_at FROM invoices") + .unwrap(); + let notnull: i64 = conn + .query_row( + "SELECT SUM(\"notnull\") FROM pragma_table_info('invoices') + WHERE name IN ('archived_fingerprint', 'archived_at')", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(notnull, 0); + } + #[test] fn render_prefs_column_defaults_to_empty_and_round_trips() { let conn = open_in_memory().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ea171d4..97c200b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,9 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; pub struct AppState { pub db: Mutex, pub data_dir: PathBuf, + /// Machine-local, non-roaming data (the PDF archive). On Windows this is + /// %LOCALAPPDATA%, unlike `data_dir`, which is the roaming location. + pub local_data_dir: PathBuf, } /// Show a native error dialog. Release builds have no console on Windows, so @@ -40,6 +43,17 @@ fn init_state(app: &tauri::App) -> Result> show_startup_error(app, &format!("Could not create the application data directory: {e}"), Some(&data_dir)); return Err(e.into()); } + let local_data_dir = match app.path().app_local_data_dir() { + Ok(dir) => dir, + Err(e) => { + show_startup_error(app, &format!("Could not resolve the local data directory: {e}"), Some(&data_dir)); + return Err(e.into()); + } + }; + if let Err(e) = std::fs::create_dir_all(&local_data_dir) { + show_startup_error(app, &format!("Could not create the local data directory: {e}"), Some(&local_data_dir)); + return Err(e.into()); + } let conn = match db::open(&data_dir.join("voiced.db"), &data_dir.join("backups")) { Ok(conn) => conn, Err(e) => { @@ -54,6 +68,7 @@ fn init_state(app: &tauri::App) -> Result> Ok(AppState { db: Mutex::new(conn), data_dir, + local_data_dir, }) } @@ -113,7 +128,13 @@ pub fn run() { commands::assets::save_asset_bytes, commands::assets::read_asset_data_uri, commands::assets::remove_asset, - commands::assets::save_binary_file, + commands::archive::archive_pdf, + commands::archive::read_archive, + commands::archive::archive_status, + commands::files::write_export_file, + commands::files::get_last_export_dir, + commands::files::reveal_in_folder, + commands::files::open_file, ]) .run(tauri::generate_context!()); diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index eec9bc3..7ec9abe 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -26,6 +26,9 @@ pub struct Settings { /// The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults. #[serde(default)] pub render_prefs: String, + /// Folder of the last export; owned by the backend, so saving settings never changes it. + #[serde(default)] + pub last_export_dir: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -199,6 +202,8 @@ pub struct Invoice { pub archived_pdf_sha256: Option, /// RenderPrefsV1 JSON frozen at issue; empty when none was stored. pub render_prefs: String, + pub archived_fingerprint: Option, + pub archived_at: Option, pub items: Vec, } diff --git a/src/lib/api.ts b/src/lib/api.ts index c9f4c0b..eec34fb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -10,6 +10,13 @@ import type { Settings, } from "./types"; +export interface ArchiveStatus { + archived: boolean; + sha256?: string; + fingerprint?: string; + archivedAt?: string; +} + export const api = { getSettings: () => invoke("get_settings"), saveSettings: (settings: Settings) => invoke("save_settings", { settings }), @@ -47,6 +54,19 @@ export const api = { invoke("save_asset_bytes", { kind, fileName, dataBase64 }), readAssetDataUri: (path: string) => invoke("read_asset_data_uri", { path }), removeAsset: (path: string) => invoke("remove_asset", { path }), - saveBinaryFile: (path: string, dataBase64: string) => - invoke("save_binary_file", { path, dataBase64 }), + + // Raw-body commands: the payload is the bytes themselves (no base64); metadata goes in headers. + // Header values must be visible ASCII, hence the encodeURIComponent on paths. + archivePdf: (invoiceId: number, bytes: Uint8Array, fingerprint: string) => + invoke("archive_pdf", bytes, { + headers: { "x-invoice-id": String(invoiceId), "x-fingerprint": fingerprint }, + }), + readArchive: async (invoiceId: number): Promise => + new Uint8Array(await invoke("read_archive", { invoiceId })), + archiveStatus: (invoiceId: number) => invoke("archive_status", { invoiceId }), + writeExportFile: (path: string, bytes: Uint8Array) => + invoke("write_export_file", bytes, { headers: { "x-path": encodeURIComponent(path) } }), + getLastExportDir: () => invoke("get_last_export_dir"), + revealInFolder: (path: string) => invoke("reveal_in_folder", { path }), + openFile: (path: string) => invoke("open_file", { path }), }; diff --git a/src/lib/filename.test.ts b/src/lib/filename.test.ts new file mode 100644 index 0000000..07180d1 --- /dev/null +++ b/src/lib/filename.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { exportFileName, type ExportFileNameInput } from "./filename"; + +const base: ExportFileNameInput = { + number: "AP-2026-001", + client: "Acme Ltd", + date: "2026-04-01", + mode: "searchable", +}; +const name = (over: Partial = {}) => exportFileName({ ...base, ...over }); + +describe("exportFileName", () => { + it("uses number_client_date by default", () => { + expect(name()).toBe("AP-2026-001_Acme Ltd_2026-04-01.pdf"); + }); + + it("turns a slash in the invoice number into a dash", () => { + expect(name({ number: "AP/2026-001" })).toBe("AP-2026-001_Acme Ltd_2026-04-01.pdf"); + }); + + it("adds -flat for the flattened export", () => { + expect(name({ mode: "flattened" })).toBe("AP-2026-001_Acme Ltd_2026-04-01-flat.pdf"); + }); + + it("honours a custom pattern", () => { + expect(name({ pattern: "{date} {client} {number}" })).toBe("2026-04-01 Acme Ltd AP-2026-001.pdf"); + expect(name({ pattern: "{number}" })).toBe("AP-2026-001.pdf"); + }); + + it("falls back to the default pattern for a blank pattern", () => { + expect(name({ pattern: " " })).toBe(name()); + }); + + it("replaces every Windows-forbidden character", () => { + expect(name({ client: 'AC:D"E/F\\G|H?I*J' })).toBe("AP-2026-001_A-B-C-D-E-F-G-H-I-J_2026-04-01.pdf"); + }); + + it("replaces control characters", () => { + expect(name({ client: "A\u0000B\tC\nD\u007fE" })).toBe("AP-2026-001_A-B-C-D-E_2026-04-01.pdf"); + }); + + it("collapses runs of separators and spaces", () => { + expect(name({ client: "A///B C___D" })).toBe("AP-2026-001_A-B C_D_2026-04-01.pdf"); + }); + + it("trims dots and spaces from the ends", () => { + expect(name({ pattern: " {client}. . " })).toBe("Acme Ltd.pdf"); + expect(name({ pattern: "{client}", client: "Acme Ltd. " })).toBe("Acme Ltd.pdf"); + expect(name({ pattern: "{client}", client: "...hidden" })).toBe("hidden.pdf"); + }); + + it("drops the separators left behind by empty parts", () => { + expect(name({ client: "" })).toBe("AP-2026-001_2026-04-01.pdf"); + expect(name({ client: "", date: "" })).toBe("AP-2026-001.pdf"); + }); + + it("prefixes reserved device names", () => { + expect(name({ pattern: "{client}", client: "CON" })).toBe("_CON.pdf"); + expect(name({ pattern: "{client}", client: "nul" })).toBe("_nul.pdf"); + expect(name({ pattern: "{client}", client: "Aux" })).toBe("_Aux.pdf"); + expect(name({ pattern: "{client}", client: "PRN" })).toBe("_PRN.pdf"); + }); + + it("prefixes COM1-9 and LPT1-9 but not COM10 or lookalikes", () => { + expect(name({ pattern: "{client}", client: "COM1" })).toBe("_COM1.pdf"); + expect(name({ pattern: "{client}", client: "com9" })).toBe("_com9.pdf"); + expect(name({ pattern: "{client}", client: "LPT3" })).toBe("_LPT3.pdf"); + expect(name({ pattern: "{client}", client: "COM10" })).toBe("COM10.pdf"); + expect(name({ pattern: "{client}", client: "CONSOLE" })).toBe("CONSOLE.pdf"); + }); + + it("catches a reserved name that has an extension-like tail", () => { + expect(name({ pattern: "{client}", client: "CON.txt" })).toBe("_CON.txt.pdf"); + }); + + it("keeps Indic client names intact", () => { + expect(name({ client: "சென்னை ஸ்டூடியோ" })).toBe( + "AP-2026-001_சென்னை ஸ்டூடியோ_2026-04-01.pdf", + ); + expect(name({ client: "ਭਾਰਤ/ਕੰਪਨੀ" })).toContain("ਭਾਰਤ-ਕੰਪਨੀ"); + }); + + it("normalises Unicode to NFC", () => { + const decomposed = "Café"; + expect(name({ pattern: "{client}", client: decomposed })).toBe("Café.pdf"); + }); + + it("caps the stem at 120 characters and cuts the client first", () => { + const out = name({ client: "x".repeat(300) }); + const stem = out.slice(0, -".pdf".length); + expect(stem.length).toBeLessThanOrEqual(120); + expect(out.startsWith("AP-2026-001_x")).toBe(true); + expect(out.endsWith("_2026-04-01.pdf")).toBe(true); + }); + + it("keeps the number whole even when it alone is long", () => { + const number = "N".repeat(110); + const out = name({ number, client: "y".repeat(200) }); + expect(out.startsWith(number)).toBe(true); + expect(out.length).toBeLessThanOrEqual(120 + ".pdf".length); + }); + + it("hard-caps an absurdly long number", () => { + const out = name({ number: "9".repeat(500) }); + expect(out.length).toBeLessThanOrEqual(120 + ".pdf".length); + }); + + it("does not split a surrogate pair when truncating", () => { + const out = name({ pattern: "{client}", client: "\u{1F600}".repeat(100) }); + const stem = out.slice(0, -".pdf".length); + expect(stem.length).toBeLessThanOrEqual(120); + expect([...stem].every((ch) => ch === "\u{1F600}")).toBe(true); + }); + + it("adds -flat after truncation", () => { + const out = name({ client: "z".repeat(300), mode: "flattened" }); + expect(out.endsWith("_2026-04-01-flat.pdf")).toBe(true); + }); + + it("uses a non-empty fallback", () => { + expect(name({ number: "", client: "", date: "" })).toBe("invoice.pdf"); + expect(name({ pattern: "...", number: "x" })).toBe("invoice.pdf"); + expect(name({ number: "///", client: "***", date: "|" })).toBe("invoice.pdf"); + expect(name({ number: "", client: "", date: "", mode: "flattened" })).toBe("invoice-flat.pdf"); + }); + + it("the result never ends in a dot or space", () => { + const out = name({ client: "Trailing dot.", date: "" }); + expect(out).not.toMatch(/[. ]\.pdf$/); + }); +}); diff --git a/src/lib/filename.ts b/src/lib/filename.ts new file mode 100644 index 0000000..e0173e2 --- /dev/null +++ b/src/lib/filename.ts @@ -0,0 +1,71 @@ +export type ExportMode = "searchable" | "flattened"; + +export interface ExportFileNameInput { + /** Tokens: {number}, {client}, {date}. */ + pattern?: string; + number: string; + client: string; + date: string; + mode: ExportMode; +} + +const DEFAULT_PATTERN = "{number}_{client}_{date}"; +const MAX_STEM = 120; +const FALLBACK = "invoice"; + +// Characters Windows forbids in file names, plus control characters. +// eslint-disable-next-line no-control-regex +const FORBIDDEN = /[<>:"/\\|?*\u0000-\u001f\u007f]/g; +// The device name matters even with an extension ("CON.txt" is still the console). +const RESERVED = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])$/i; +const EDGE_JUNK = /^[.\s_-]+|[.\s_-]+$/g; + +function collapse(value: string): string { + return value + .replace(FORBIDDEN, "-") + .replace(/\s+/g, " ") + .replace(/-{2,}/g, "-") + .replace(/_{2,}/g, "_") + .replace(/ ?([-_]) ?/g, "$1"); +} + +function cleanPart(value: string): string { + return collapse(value.normalize("NFC")).replace(EDGE_JUNK, ""); +} + +/** Cut to at most `max` UTF-16 units without splitting a surrogate pair. */ +function truncate(value: string, max: number): string { + let out = ""; + for (const ch of value) { + if (out.length + ch.length > max) break; + out += ch; + } + return out; +} + +function fill(pattern: string, parts: { number: string; client: string; date: string }): string { + return pattern.replace(/\{(number|client|date)\}/g, (_, key: keyof typeof parts) => parts[key]); +} + +/** A file name that is valid on Windows, macOS and Linux for an exported invoice PDF. */ +export function exportFileName(input: ExportFileNameInput): string { + const pattern = input.pattern?.trim() ? input.pattern : DEFAULT_PATTERN; + const parts = { + number: cleanPart(input.number), + client: cleanPart(input.client), + date: cleanPart(input.date), + }; + + let stem = fill(pattern, parts); + if (stem.length > MAX_STEM) { + // The invoice number is what identifies the file, so the client name gives way first. + const overflow = stem.length - MAX_STEM; + const client = truncate(parts.client, Math.max(0, parts.client.length - overflow)); + stem = fill(pattern, { ...parts, client }); + } + stem = cleanPart(truncate(cleanPart(stem), MAX_STEM)); + if (!stem) stem = FALLBACK; + if (RESERVED.test(stem.split(".")[0])) stem = `_${stem}`; + + return `${stem}${input.mode === "flattened" ? "-flat" : ""}.pdf`; +} diff --git a/src/lib/pdf.tsx b/src/lib/pdf.tsx index ee76b95..9dfba74 100644 --- a/src/lib/pdf.tsx +++ b/src/lib/pdf.tsx @@ -10,13 +10,6 @@ import { imageSizeFromDataUri } from "./imageSize"; import { getRenderClient, nextRenderJobId } from "./renderClient"; import type { BankAccount, Invoice, Settings } from "./types"; -function bytesToBase64(bytes: Uint8Array): string { - let bin = ""; - const step = 0x8000; - for (let i = 0; i < bytes.length; i += step) bin += String.fromCharCode(...bytes.subarray(i, i + step)); - return btoa(bin); -} - /** A logo with no readable size is fitted as if it filled Classic's 170 x 54 box. */ const UNKNOWN_LOGO_SIZE = { width: 170, height: 54 }; @@ -82,7 +75,7 @@ export async function savePdfBytes(bytes: Uint8Array, fileName: string): Promise filters: [{ name: "PDF document", extensions: ["pdf"] }], }); if (!path) return null; - await api.saveBinaryFile(path, bytesToBase64(bytes)); + await api.writeExportFile(path, bytes); return path; } diff --git a/src/lib/types.ts b/src/lib/types.ts index cc01864..fd4eb8e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -25,6 +25,8 @@ export interface Settings { signatoryDesignation: string; /** The user's default page setup (RenderPrefsV1 JSON); empty means the built-in defaults. */ renderPrefs: string; + /** Folder of the last export; kept by the backend (saving settings does not change it). */ + lastExportDir?: string; } export interface BankAccount { @@ -136,6 +138,9 @@ export interface Invoice { archivedPdfSha256: string | null; /** RenderPrefsV1 JSON frozen at issue; empty when none was stored. */ renderPrefs: string; + /** Layout fingerprint of the archived PDF; null when nothing is archived. */ + archivedFingerprint?: string | null; + archivedAt?: string | null; items: InvoiceItem[]; }