Logos (PNG, JPEG, WebP) are decoded, trimmed to their ink box, downscaled to 1200 px and re-encoded as a content-addressed print PNG plus a white knockout variant when the logo has alpha; aspect, ink density, mean colour and kind (wordmark, mark, tall) are stored with the settings and frozen in the issue snapshot. Legacy logos are derived at startup. The Test Vendor logo PDF shrinks from 334 KB to 92 KB. Settings gains a Branding section with white and dark previews and a business-name toggle (migration M5).
266 lines
11 KiB
Rust
266 lines
11 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}"))
|
|
}
|
|
|
|
/// Write `assets/<file_name>` unless it already exists. For content-addressed derivatives, where the
|
|
/// same name always means the same bytes. The name must be a plain `[a-z0-9._-]` file name.
|
|
pub fn write_named_asset(data_dir: &Path, file_name: &str, bytes: &[u8]) -> Result<String, String> {
|
|
let plain = !file_name.is_empty()
|
|
&& !file_name.starts_with('.')
|
|
&& file_name
|
|
.chars()
|
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | '_'));
|
|
if !plain {
|
|
return Err(format!("Invalid asset name: {file_name}"));
|
|
}
|
|
let target = assets_dir(data_dir)?.join(file_name);
|
|
if !target.exists() {
|
|
std::fs::write(&target, bytes).map_err(|e| e.to_string())?;
|
|
}
|
|
Ok(format!("assets/{file_name}"))
|
|
}
|
|
|
|
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 named_assets_are_plain_names_and_not_overwritten() {
|
|
let dir = tempdir().unwrap();
|
|
let stored = write_named_asset(dir.path(), "logo-ab12cd34-print.png", b"one").unwrap();
|
|
assert_eq!(stored, "assets/logo-ab12cd34-print.png");
|
|
write_named_asset(dir.path(), "logo-ab12cd34-print.png", b"two").unwrap();
|
|
assert_eq!(std::fs::read(dir.path().join(&stored)).unwrap(), b"one");
|
|
for bad in ["", ".hidden", "../x.png", "a/b.png", "A.png", "x y.png"] {
|
|
assert!(write_named_asset(dir.path(), bad, b"x").is_err(), "{bad}");
|
|
}
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|