The searchable PDF can be archived content-addressed under the local data dir with its sha256 and layout fingerprint; a different PDF for an archived invoice is refused and reads verify the hash. Export writes go through a raw-body command that remembers the last folder; reveal-in-folder and open fall back to xdg-open / explorer. Adds a Windows-safe export file name builder. Migration M4 adds the archive columns and last export dir.
236 lines
9.4 KiB
Rust
236 lines
9.4 KiB
Rust
use crate::AppState;
|
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
|
use std::path::{Component, Path, PathBuf};
|
|
use tauri::State;
|
|
|
|
const FORMAT_ERROR: &str = "Use a PNG or JPEG image";
|
|
|
|
fn safe_kind(kind: &str) -> String {
|
|
kind.chars()
|
|
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
|
|
.collect::<String>()
|
|
.to_lowercase()
|
|
}
|
|
|
|
/// Identify an image by its magic bytes. react-pdf only renders PNG and JPEG and
|
|
/// silently drops everything else, so nothing else is accepted.
|
|
/// Returns (extension, MIME type).
|
|
pub fn sniff_image(bytes: &[u8]) -> Result<(&'static str, &'static str), String> {
|
|
if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
|
|
Ok(("png", "image/png"))
|
|
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
|
|
Ok(("jpg", "image/jpeg"))
|
|
} else {
|
|
Err(FORMAT_ERROR.to_string())
|
|
}
|
|
}
|
|
|
|
fn assets_dir(data_dir: &Path) -> Result<PathBuf, String> {
|
|
let dir = data_dir.join("assets");
|
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
Ok(dir)
|
|
}
|
|
|
|
/// Resolve a stored asset path (relative to the data dir, or a legacy absolute one)
|
|
/// to a real file inside `<data_dir>/assets`. `..`, symlink escapes and anything
|
|
/// outside the assets directory are rejected.
|
|
pub fn resolve_asset(data_dir: &Path, path: &str) -> Result<PathBuf, String> {
|
|
let raw = Path::new(path);
|
|
if path.trim().is_empty() {
|
|
return Err("Asset path is empty".into());
|
|
}
|
|
if raw.components().any(|c| matches!(c, Component::ParentDir)) {
|
|
return Err("Asset path must not contain '..'".into());
|
|
}
|
|
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
|
|
let root = data_dir
|
|
.join("assets")
|
|
.canonicalize()
|
|
.map_err(|e| format!("Asset store is unavailable: {e}"))?;
|
|
let canonical = full
|
|
.canonicalize()
|
|
.map_err(|e| format!("Could not open asset {path}: {e}"))?;
|
|
if canonical.starts_with(&root) && canonical != root {
|
|
Ok(canonical)
|
|
} else {
|
|
Err("Asset path is outside the asset store".into())
|
|
}
|
|
}
|
|
|
|
/// The form stored in the database: `assets/<file>` relative to the data dir, with
|
|
/// forward slashes on every platform.
|
|
pub fn relative_asset_path(data_dir: &Path, path: &str) -> Result<String, String> {
|
|
let canonical = resolve_asset(data_dir, path)?;
|
|
let base = data_dir.canonicalize().map_err(|e| e.to_string())?;
|
|
let rel = canonical.strip_prefix(&base).map_err(|e| e.to_string())?;
|
|
Ok(rel
|
|
.components()
|
|
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
|
.collect::<Vec<_>>()
|
|
.join("/"))
|
|
}
|
|
|
|
fn write_asset(data_dir: &Path, kind: &str, bytes: &[u8]) -> Result<String, String> {
|
|
let (ext, _) = sniff_image(bytes)?;
|
|
let dir = assets_dir(data_dir)?;
|
|
let file = format!("{}-{}.{}", safe_kind(kind), uuid::Uuid::new_v4().simple(), ext);
|
|
std::fs::write(dir.join(&file), bytes).map_err(|e| e.to_string())?;
|
|
Ok(format!("assets/{file}"))
|
|
}
|
|
|
|
fn asset_data_uri(data_dir: &Path, path: &str) -> Result<String, String> {
|
|
let file = resolve_asset(data_dir, path)?;
|
|
let bytes = std::fs::read(&file).map_err(|e| e.to_string())?;
|
|
let (_, mime) = sniff_image(&bytes)?;
|
|
Ok(format!("data:{};base64,{}", mime, STANDARD.encode(bytes)))
|
|
}
|
|
|
|
fn remove_asset_file(data_dir: &Path, path: &str) -> Result<(), String> {
|
|
let raw = Path::new(path);
|
|
let full = if raw.is_absolute() { raw.to_path_buf() } else { data_dir.join(raw) };
|
|
// Removing something that is already gone is fine, but an escape attempt is not.
|
|
if std::fs::symlink_metadata(&full).is_err() {
|
|
return Ok(());
|
|
}
|
|
let target = resolve_asset(data_dir, path)?;
|
|
std::fs::remove_file(target).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Copy a user-picked file into the app's asset store and return the stored path.
|
|
#[tauri::command]
|
|
pub fn import_asset(
|
|
state: State<AppState>,
|
|
source_path: String,
|
|
kind: String,
|
|
) -> Result<String, String> {
|
|
let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?;
|
|
write_asset(&state.data_dir, &kind, &bytes)
|
|
}
|
|
|
|
/// Used when the frontend already holds the bytes (base64) instead of a path.
|
|
/// The file name is ignored: the type comes from the bytes.
|
|
#[tauri::command]
|
|
pub fn save_asset_bytes(
|
|
state: State<AppState>,
|
|
kind: String,
|
|
#[allow(unused_variables)] file_name: String,
|
|
data_base64: String,
|
|
) -> Result<String, String> {
|
|
let bytes = STANDARD
|
|
.decode(data_base64.as_bytes())
|
|
.map_err(|e| e.to_string())?;
|
|
write_asset(&state.data_dir, &kind, &bytes)
|
|
}
|
|
|
|
/// Read a stored asset back as a data URI so it can be embedded in the PDF.
|
|
#[tauri::command]
|
|
pub fn read_asset_data_uri(state: State<AppState>, path: String) -> Result<String, String> {
|
|
asset_data_uri(&state.data_dir, &path)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub fn remove_asset(state: State<AppState>, path: String) -> Result<(), String> {
|
|
remove_asset_file(&state.data_dir, &path)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
const PNG: &[u8] = &[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0];
|
|
const JPEG: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10, b'J', b'F', b'I', b'F'];
|
|
|
|
#[test]
|
|
fn sniffing_accepts_only_png_and_jpeg() {
|
|
assert_eq!(sniff_image(PNG).unwrap(), ("png", "image/png"));
|
|
assert_eq!(sniff_image(JPEG).unwrap(), ("jpg", "image/jpeg"));
|
|
assert_eq!(sniff_image(b"GIF89a....").unwrap_err(), "Use a PNG or JPEG image");
|
|
assert!(sniff_image(b"RIFF\x00\x00\x00\x00WEBPVP8 ").is_err());
|
|
assert!(sniff_image(b"<svg xmlns='http://www.w3.org/2000/svg'/>").is_err());
|
|
assert!(sniff_image(b"").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn write_ignores_the_name_and_stores_a_relative_path() {
|
|
let dir = tempdir().unwrap();
|
|
let stored = write_asset(dir.path(), "Logo", JPEG).unwrap();
|
|
assert!(stored.starts_with("assets/logo-") && stored.ends_with(".jpg"), "{stored}");
|
|
assert!(dir.path().join(&stored).is_file());
|
|
assert!(write_asset(dir.path(), "logo", b"GIF89a").is_err());
|
|
let uri = asset_data_uri(dir.path(), &stored).unwrap();
|
|
assert!(uri.starts_with("data:image/jpeg;base64,"));
|
|
}
|
|
|
|
#[test]
|
|
fn data_uri_mime_comes_from_the_bytes_not_the_extension() {
|
|
let dir = tempdir().unwrap();
|
|
let assets = dir.path().join("assets");
|
|
std::fs::create_dir_all(&assets).unwrap();
|
|
std::fs::write(assets.join("liar.jpg"), PNG).unwrap();
|
|
let uri = asset_data_uri(dir.path(), "assets/liar.jpg").unwrap();
|
|
assert!(uri.starts_with("data:image/png;base64,"));
|
|
}
|
|
|
|
#[test]
|
|
fn resolver_accepts_relative_and_inside_absolute_paths() {
|
|
let dir = tempdir().unwrap();
|
|
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
|
|
let by_relative = resolve_asset(dir.path(), &stored).unwrap();
|
|
let absolute = dir.path().join(&stored);
|
|
let by_absolute = resolve_asset(dir.path(), absolute.to_str().unwrap()).unwrap();
|
|
assert_eq!(by_relative, by_absolute);
|
|
assert_eq!(relative_asset_path(dir.path(), absolute.to_str().unwrap()).unwrap(), stored);
|
|
}
|
|
|
|
#[test]
|
|
fn resolver_rejects_escapes() {
|
|
let dir = tempdir().unwrap();
|
|
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
|
|
std::fs::write(dir.path().join("voiced.db"), b"secret").unwrap();
|
|
let outside = tempdir().unwrap();
|
|
std::fs::write(outside.path().join("x.png"), PNG).unwrap();
|
|
|
|
assert!(resolve_asset(dir.path(), "../voiced.db").is_err());
|
|
assert!(resolve_asset(dir.path(), "assets/../voiced.db").is_err());
|
|
assert!(resolve_asset(dir.path(), "voiced.db").is_err());
|
|
assert!(resolve_asset(dir.path(), "assets").is_err());
|
|
assert!(resolve_asset(dir.path(), "").is_err());
|
|
let abs_outside = outside.path().join("x.png");
|
|
assert!(resolve_asset(dir.path(), abs_outside.to_str().unwrap()).is_err());
|
|
let abs_db = dir.path().join("voiced.db");
|
|
assert!(resolve_asset(dir.path(), abs_db.to_str().unwrap()).is_err());
|
|
assert!(resolve_asset(dir.path(), "assets/missing.png").is_err());
|
|
assert!(resolve_asset(dir.path(), &stored).is_ok());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn resolver_rejects_symlink_escapes() {
|
|
let dir = tempdir().unwrap();
|
|
let _ = write_asset(dir.path(), "logo", PNG).unwrap();
|
|
let outside = tempdir().unwrap();
|
|
let target = outside.path().join("x.png");
|
|
std::fs::write(&target, PNG).unwrap();
|
|
std::os::unix::fs::symlink(&target, dir.path().join("assets/link.png")).unwrap();
|
|
std::os::unix::fs::symlink(outside.path(), dir.path().join("assets/dir")).unwrap();
|
|
|
|
assert!(resolve_asset(dir.path(), "assets/link.png").is_err());
|
|
assert!(resolve_asset(dir.path(), "assets/dir/x.png").is_err());
|
|
assert!(remove_asset_file(dir.path(), "assets/link.png").is_err());
|
|
assert!(target.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn remove_deletes_inside_and_tolerates_missing() {
|
|
let dir = tempdir().unwrap();
|
|
let stored = write_asset(dir.path(), "logo", PNG).unwrap();
|
|
remove_asset_file(dir.path(), &stored).unwrap();
|
|
assert!(!dir.path().join(&stored).exists());
|
|
remove_asset_file(dir.path(), &stored).unwrap();
|
|
std::fs::write(dir.path().join("voiced.db"), b"x").unwrap();
|
|
assert!(remove_asset_file(dir.path(), "voiced.db").is_err());
|
|
assert!(dir.path().join("voiced.db").exists());
|
|
}
|
|
}
|