Simpl-Resultat/src-tauri/src/commands/profile_commands.rs
le king fu 08c54b1f75
Some checks are pending
Release / build-and-release (push) Waiting to run
Fix migration repair: update checksums instead of deleting records
The previous approach deleted migration records to force re-application,
but this is dangerous for migration 2 which DELETEs all categories and
keywords before re-inserting them, wiping user customizations.

Now computes the expected SHA-384 checksum (matching sqlx) and updates
the stored checksum in _sqlx_migrations, so the migration is recognized
as already applied without being re-run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:27:02 -05:00

219 lines
6.6 KiB
Rust

use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256, Sha384};
use std::fs;
use tauri::Manager;
use crate::database;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
pub id: String,
pub name: String,
pub color: String,
pub pin_hash: Option<String>,
pub db_filename: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfilesConfig {
pub active_profile_id: String,
pub profiles: Vec<Profile>,
}
fn get_profiles_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let app_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
Ok(app_dir.join("profiles.json"))
}
fn make_default_config() -> ProfilesConfig {
let now = chrono_now();
let default_id = "default".to_string();
ProfilesConfig {
active_profile_id: default_id.clone(),
profiles: vec![Profile {
id: default_id,
name: "Default".to_string(),
color: "#4A90A4".to_string(),
pin_hash: None,
db_filename: "simpl_resultat.db".to_string(),
created_at: now,
}],
}
}
fn chrono_now() -> String {
// Simple ISO-ish timestamp without pulling in chrono crate
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = dur.as_secs();
// Return as unix timestamp string — frontend can format it
secs.to_string()
}
#[tauri::command]
pub fn load_profiles(app: tauri::AppHandle) -> Result<ProfilesConfig, String> {
let path = get_profiles_path(&app)?;
if !path.exists() {
let config = make_default_config();
let json =
serde_json::to_string_pretty(&config).map_err(|e| format!("JSON error: {}", e))?;
// Ensure parent dir exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create app data dir: {}", e))?;
}
fs::write(&path, json).map_err(|e| format!("Cannot write profiles.json: {}", e))?;
return Ok(config);
}
let content =
fs::read_to_string(&path).map_err(|e| format!("Cannot read profiles.json: {}", e))?;
let config: ProfilesConfig =
serde_json::from_str(&content).map_err(|e| format!("Invalid profiles.json: {}", e))?;
Ok(config)
}
#[tauri::command]
pub fn save_profiles(app: tauri::AppHandle, config: ProfilesConfig) -> Result<(), String> {
let path = get_profiles_path(&app)?;
let json =
serde_json::to_string_pretty(&config).map_err(|e| format!("JSON error: {}", e))?;
fs::write(&path, json).map_err(|e| format!("Cannot write profiles.json: {}", e))
}
#[tauri::command]
pub fn delete_profile_db(app: tauri::AppHandle, db_filename: String) -> Result<(), String> {
if db_filename == "simpl_resultat.db" {
return Err("Cannot delete the default profile database".to_string());
}
let app_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
let db_path = app_dir.join(&db_filename);
if db_path.exists() {
fs::remove_file(&db_path)
.map_err(|e| format!("Cannot delete database file: {}", e))?;
}
Ok(())
}
#[tauri::command]
pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
Ok(vec![
database::CONSOLIDATED_SCHEMA.to_string(),
database::SEED_CATEGORIES.to_string(),
])
}
#[tauri::command]
pub fn hash_pin(pin: String) -> Result<String, String> {
let mut salt = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut salt);
let salt_hex = hex_encode(&salt);
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(pin.as_bytes());
let result = hasher.finalize();
let hash_hex = hex_encode(&result);
// Store as "salt:hash"
Ok(format!("{}:{}", salt_hex, hash_hex))
}
#[tauri::command]
pub fn verify_pin(pin: String, stored_hash: String) -> Result<bool, String> {
let parts: Vec<&str> = stored_hash.split(':').collect();
if parts.len() != 2 {
return Err("Invalid stored hash format".to_string());
}
let salt_hex = parts[0];
let expected_hash = parts[1];
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(pin.as_bytes());
let result = hasher.finalize();
let computed_hash = hex_encode(&result);
Ok(computed_hash == expected_hash)
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
/// Repair migration checksums for a profile database.
/// Updates stored checksums to match current migration SQL, avoiding re-application
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
#[tauri::command]
pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<bool, String> {
let app_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
let db_path = app_dir.join(&db_filename);
if !db_path.exists() {
return Ok(false);
}
let conn = rusqlite::Connection::open(&db_path)
.map_err(|e| format!("Cannot open database: {}", e))?;
let table_exists: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='_sqlx_migrations'",
[],
|row| row.get(0),
)
.unwrap_or(false);
if !table_exists {
return Ok(false);
}
// Current migration SQL — must match the vec in lib.rs
let migrations: &[(i64, &str)] = &[
(1, database::SCHEMA),
(2, database::SEED_CATEGORIES),
];
let mut repaired = false;
for (version, sql) in migrations {
let expected_checksum = Sha384::digest(sql.as_bytes()).to_vec();
// Check if this migration exists with a different checksum
let needs_repair: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM _sqlx_migrations WHERE version = ?1 AND checksum != ?2",
rusqlite::params![version, expected_checksum],
|row| row.get(0),
)
.unwrap_or(false);
if needs_repair {
conn.execute(
"UPDATE _sqlx_migrations SET checksum = ?1 WHERE version = ?2",
rusqlite::params![expected_checksum, version],
)
.map_err(|e| format!("Cannot repair migration {}: {}", version, e))?;
repaired = true;
}
}
Ok(repaired)
}