Add issue/cancel lifecycle, GST derivation and integer-paise totals
issue_invoice allocates the number, freezes vendor and bank details and stores server-computed totals in one transaction. Issued invoices are cancelled, not deleted. Tax heads derive from supplier state and place of supply (a disagreeing choice is rejected); unregistered suppliers issue a plain Invoice. Adds GSTIN checksum validation, canonical India Compliance state list, server-side drafts, series validation, relative and magic-byte-checked asset paths, and GST settings. PDF re-exports use the frozen vendor snapshot.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
use crate::AppState;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use std::path::{Path, PathBuf};
|
||||
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 == '_')
|
||||
@@ -10,47 +12,88 @@ fn safe_kind(kind: &str) -> String {
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
fn sanitize_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn extension_of(path: &str) -> String {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.unwrap_or_else(|| "png".to_string())
|
||||
}
|
||||
|
||||
fn mime_for(ext: &str) -> &'static str {
|
||||
match ext {
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"svg" => "image/svg+xml",
|
||||
"webp" => "image/webp",
|
||||
"gif" => "image/gif",
|
||||
_ => "image/png",
|
||||
/// 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(state: &State<AppState>) -> Result<PathBuf, String> {
|
||||
let dir = state.data_dir.join("assets");
|
||||
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)
|
||||
}
|
||||
|
||||
fn write_asset(state: &State<AppState>, kind: &str, ext: &str, bytes: &[u8]) -> Result<String, String> {
|
||||
let dir = assets_dir(state)?;
|
||||
let file = format!(
|
||||
"{}-{}.{}",
|
||||
safe_kind(kind),
|
||||
uuid::Uuid::new_v4().simple(),
|
||||
ext
|
||||
);
|
||||
let path = dir.join(file);
|
||||
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
/// 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.
|
||||
@@ -61,56 +104,153 @@ pub fn import_asset(
|
||||
kind: String,
|
||||
) -> Result<String, String> {
|
||||
let bytes = std::fs::read(&source_path).map_err(|e| e.to_string())?;
|
||||
let ext = extension_of(&source_path);
|
||||
write_asset(&state, &kind, &ext, &bytes)
|
||||
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,
|
||||
file_name: 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())?;
|
||||
let ext = extension_of(&sanitize_name(&file_name));
|
||||
write_asset(&state, &kind, &ext, &bytes)
|
||||
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(path: String) -> Result<String, String> {
|
||||
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
|
||||
let ext = extension_of(&path);
|
||||
Ok(format!(
|
||||
"data:{};base64,{}",
|
||||
mime_for(&ext),
|
||||
STANDARD.encode(bytes)
|
||||
))
|
||||
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> {
|
||||
let dir = assets_dir(&state)?;
|
||||
let target = PathBuf::from(&path);
|
||||
// Only allow deleting files inside the managed assets directory.
|
||||
if target.starts_with(&dir) && target.exists() {
|
||||
std::fs::remove_file(&target).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
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::*;
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_binary_file_rejects_empty_path() {
|
||||
assert!(save_binary_file(" ".into(), "AAAA".into()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user