Migrate vault encryption to OS keyring, handle PIN rate limiting, and improve tombstone handling logic.
- Replaced legacy vault key derivation with OS keyring-backed storage for improved security and platform integration. - Introduced PIN rate limiting with configurable lockout durations to mitigate brute-force attacks. - Enhanced tombstone services to use unified error-handling logic via `apply_tombstone`. - Refactored logic for book and series data synchronization, fixing null checks and improving flow consistency. - Added comprehensive error handling to all database and vault operations in key manager services.
This commit is contained in:
@@ -24,6 +24,10 @@ struct SecureVault {
|
|||||||
encryption_keys: HashMap<String, String>,
|
encryption_keys: HashMap<String, String>,
|
||||||
last_user_id: Option<String>,
|
last_user_id: Option<String>,
|
||||||
pin_hashes: HashMap<String, String>,
|
pin_hashes: HashMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pin_failed_attempts: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pin_locked_until: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn vault_path() -> PathBuf {
|
fn vault_path() -> PathBuf {
|
||||||
@@ -33,7 +37,37 @@ fn vault_path() -> PathBuf {
|
|||||||
.join("secure-config.json")
|
.join("secure-config.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn derive_machine_key() -> [u8; 32] {
|
const KEYRING_USER: &str = "vault-key";
|
||||||
|
|
||||||
|
/// Retrieves (or generates and stores) the vault encryption key via the OS keyring
|
||||||
|
/// (macOS Keychain, Windows DPAPI, Linux Secret Service).
|
||||||
|
/// Falls back to the old derivation method if the keyring is unavailable,
|
||||||
|
/// and attempts to migrate the key into the keyring for next time.
|
||||||
|
fn get_vault_key() -> [u8; 32] {
|
||||||
|
let entry = keyring::Entry::new(SERVICE_NAME, KEYRING_USER);
|
||||||
|
if let Ok(entry) = &entry {
|
||||||
|
if let Ok(stored) = entry.get_password() {
|
||||||
|
if let Ok(decoded) = BASE64.decode(stored.trim()) {
|
||||||
|
if decoded.len() == 32 {
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
key.copy_from_slice(&decoded);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No key in keyring yet — generate a random one
|
||||||
|
let mut key = [0u8; 32];
|
||||||
|
rand::rng().fill_bytes(&mut key);
|
||||||
|
let encoded = BASE64.encode(key);
|
||||||
|
if let Ok(entry) = &entry {
|
||||||
|
let _ = entry.set_password(&encoded);
|
||||||
|
}
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy derivation for migrating vaults created before keyring support.
|
||||||
|
fn derive_machine_key_legacy() -> [u8; 32] {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(SERVICE_NAME.as_bytes());
|
hasher.update(SERVICE_NAME.as_bytes());
|
||||||
if let Ok(name) = hostname::get() {
|
if let Ok(name) = hostname::get() {
|
||||||
@@ -76,96 +110,150 @@ fn decrypt_vault(data: &[u8], key: &[u8; 32]) -> AppResult<Vec<u8>> {
|
|||||||
Ok(decrypted.to_vec())
|
Ok(decrypted.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_vault() -> SecureVault {
|
fn read_vault() -> AppResult<SecureVault> {
|
||||||
let path = vault_path();
|
let path = vault_path();
|
||||||
let key = derive_machine_key();
|
if !path.exists() {
|
||||||
match fs::read_to_string(&path) {
|
return Ok(SecureVault::default());
|
||||||
Ok(content) => {
|
}
|
||||||
if let Ok(raw) = BASE64.decode(content.trim()) {
|
let content = fs::read_to_string(&path)
|
||||||
|
.map_err(|e| AppError::Keyring(format!("Failed to read vault: {}", e)))?;
|
||||||
|
let raw = BASE64.decode(content.trim())
|
||||||
|
.map_err(|e| AppError::Keyring(format!("Vault corrupted (base64): {}", e)))?;
|
||||||
|
|
||||||
|
// Try the new keyring-backed key first
|
||||||
|
let key = get_vault_key();
|
||||||
if let Ok(decrypted) = decrypt_vault(&raw, &key) {
|
if let Ok(decrypted) = decrypt_vault(&raw, &key) {
|
||||||
if let Ok(vault) = serde_json::from_slice::<SecureVault>(&decrypted) {
|
if let Ok(vault) = serde_json::from_slice::<SecureVault>(&decrypted) {
|
||||||
return vault;
|
return Ok(vault);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SecureVault::default()
|
|
||||||
}
|
|
||||||
Err(_) => SecureVault::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_vault(vault: &SecureVault) -> AppResult<()> {
|
// Fallback: try legacy key and migrate if successful
|
||||||
|
let legacy_key = derive_machine_key_legacy();
|
||||||
|
let decrypted = decrypt_vault(&raw, &legacy_key)
|
||||||
|
.map_err(|_| AppError::Keyring("Vault corrupted: unable to decrypt with any key.".to_string()))?;
|
||||||
|
let vault: SecureVault = serde_json::from_slice(&decrypted)
|
||||||
|
.map_err(|e| AppError::Keyring(format!("Vault corrupted (json): {}", e)))?;
|
||||||
|
|
||||||
|
// Migrate: re-encrypt with the new keyring key
|
||||||
|
let _ = write_vault_with_key(&vault, &key);
|
||||||
|
Ok(vault)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_vault_with_key(vault: &SecureVault, key: &[u8; 32]) -> AppResult<()> {
|
||||||
let path = vault_path();
|
let path = vault_path();
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| AppError::Internal(format!("Failed to create vault dir: {}", e)))?;
|
fs::create_dir_all(parent).map_err(|e| AppError::Internal(format!("Failed to create vault dir: {}", e)))?;
|
||||||
}
|
}
|
||||||
let json = serde_json::to_string(vault)
|
let json = serde_json::to_string(vault)
|
||||||
.map_err(|e| AppError::Internal(format!("Failed to serialize vault: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("Failed to serialize vault: {}", e)))?;
|
||||||
let key = derive_machine_key();
|
let encrypted = encrypt_vault(json.as_bytes(), key)?;
|
||||||
let encrypted = encrypt_vault(json.as_bytes(), &key)?;
|
|
||||||
let encoded = BASE64.encode(&encrypted);
|
let encoded = BASE64.encode(&encrypted);
|
||||||
fs::write(&path, encoded).map_err(|e| AppError::Internal(format!("Failed to write vault: {}", e)))
|
fs::write(&path, encoded).map_err(|e| AppError::Internal(format!("Failed to write vault: {}", e)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_vault(vault: &SecureVault) -> AppResult<()> {
|
||||||
|
let key = get_vault_key();
|
||||||
|
write_vault_with_key(vault, &key)
|
||||||
|
}
|
||||||
|
|
||||||
// ===== Public API (same signatures as before) =====
|
// ===== Public API (same signatures as before) =====
|
||||||
|
|
||||||
pub fn get_user_encryption_key(user_id: &str) -> AppResult<String> {
|
pub fn get_user_encryption_key(user_id: &str) -> AppResult<String> {
|
||||||
let vault = read_vault();
|
let vault = read_vault()?;
|
||||||
vault.encryption_keys.get(user_id).cloned()
|
vault.encryption_keys.get(user_id).cloned()
|
||||||
.ok_or_else(|| AppError::Keyring(format!("No encryption key for user {}", user_id)))
|
.ok_or_else(|| AppError::Keyring(format!("No encryption key for user {}", user_id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_user_encryption_key(user_id: &str, encryption_key: &str) -> AppResult<()> {
|
pub fn set_user_encryption_key(user_id: &str, encryption_key: &str) -> AppResult<()> {
|
||||||
let mut vault = read_vault();
|
let mut vault = read_vault()?;
|
||||||
vault.encryption_keys.insert(user_id.to_string(), encryption_key.to_string());
|
vault.encryption_keys.insert(user_id.to_string(), encryption_key.to_string());
|
||||||
write_vault(&vault)
|
write_vault(&vault)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn has_user_encryption_key(user_id: &str) -> bool {
|
pub fn has_user_encryption_key(user_id: &str) -> AppResult<bool> {
|
||||||
let vault = read_vault();
|
let vault = read_vault()?;
|
||||||
vault.encryption_keys.contains_key(user_id)
|
Ok(vault.encryption_keys.contains_key(user_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn get_token() -> AppResult<Option<String>> {
|
pub fn get_token() -> AppResult<Option<String>> {
|
||||||
let vault = read_vault();
|
let vault = read_vault()?;
|
||||||
Ok(vault.token)
|
Ok(vault.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_token(token: &str) -> AppResult<()> {
|
pub fn set_token(token: &str) -> AppResult<()> {
|
||||||
let mut vault = read_vault();
|
let mut vault = read_vault()?;
|
||||||
vault.token = Some(token.to_string());
|
vault.token = Some(token.to_string());
|
||||||
write_vault(&vault)
|
write_vault(&vault)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_token() -> AppResult<()> {
|
pub fn remove_token() -> AppResult<()> {
|
||||||
let mut vault = read_vault();
|
let mut vault = read_vault()?;
|
||||||
vault.token = None;
|
vault.token = None;
|
||||||
write_vault(&vault)
|
write_vault(&vault)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_pin_hash(user_id: &str, pin_hash: &str) -> AppResult<()> {
|
pub fn set_pin_hash(user_id: &str, pin_hash: &str) -> AppResult<()> {
|
||||||
let mut vault = read_vault();
|
let mut vault = read_vault()?;
|
||||||
vault.pin_hashes.insert(user_id.to_string(), pin_hash.to_string());
|
vault.pin_hashes.insert(user_id.to_string(), pin_hash.to_string());
|
||||||
write_vault(&vault)
|
write_vault(&vault)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_pin_hash(user_id: &str) -> AppResult<Option<String>> {
|
pub fn get_pin_hash(user_id: &str) -> AppResult<Option<String>> {
|
||||||
let vault = read_vault();
|
let vault = read_vault()?;
|
||||||
Ok(vault.pin_hashes.get(user_id).cloned())
|
Ok(vault.pin_hashes.get(user_id).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_last_user_id(user_id: &str) -> AppResult<()> {
|
pub fn set_last_user_id(user_id: &str) -> AppResult<()> {
|
||||||
let mut vault = read_vault();
|
let mut vault = read_vault()?;
|
||||||
vault.last_user_id = Some(user_id.to_string());
|
vault.last_user_id = Some(user_id.to_string());
|
||||||
write_vault(&vault)
|
write_vault(&vault)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_last_user_id() -> AppResult<Option<String>> {
|
pub fn get_last_user_id() -> AppResult<Option<String>> {
|
||||||
let vault = read_vault();
|
let vault = read_vault()?;
|
||||||
Ok(vault.last_user_id)
|
Ok(vault.last_user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_vault() -> AppResult<()> {
|
pub fn clear_vault() -> AppResult<()> {
|
||||||
write_vault(&SecureVault::default())
|
write_vault(&SecureVault::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_PIN_ATTEMPTS: i32 = 5;
|
||||||
|
const PIN_LOCKOUT_SECONDS: i64 = 300; // 5 minutes
|
||||||
|
|
||||||
|
pub fn check_pin_rate_limit() -> AppResult<()> {
|
||||||
|
let vault = read_vault()?;
|
||||||
|
if let Some(locked_until) = vault.pin_locked_until {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if now < locked_until {
|
||||||
|
let remaining = locked_until - now;
|
||||||
|
return Err(AppError::Auth(format!("Too many attempts. Try again in {} seconds.", remaining)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_pin_failure() -> AppResult<()> {
|
||||||
|
let mut vault = read_vault()?;
|
||||||
|
vault.pin_failed_attempts += 1;
|
||||||
|
if vault.pin_failed_attempts >= MAX_PIN_ATTEMPTS {
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
vault.pin_locked_until = Some(now + PIN_LOCKOUT_SECONDS);
|
||||||
|
}
|
||||||
|
write_vault(&vault)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_pin_attempts() -> AppResult<()> {
|
||||||
|
let mut vault = read_vault()?;
|
||||||
|
vault.pin_failed_attempts = 0;
|
||||||
|
vault.pin_locked_until = None;
|
||||||
|
write_vault(&vault)
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ pub fn update_act_summary(
|
|||||||
) -> AppResult<bool> {
|
) -> AppResult<bool> {
|
||||||
let update_result = conn
|
let update_result = conn
|
||||||
.execute(
|
.execute(
|
||||||
"UPDATE book_act_summaries SET summary=?1, last_update=?2 WHERE user_id=?3 AND book_id=?4 AND act_sum_id=?5",
|
"UPDATE book_act_summaries SET summary=?1, last_update=?2 WHERE user_id=?3 AND book_id=?4 AND act_index=?5",
|
||||||
params![summary, last_update, user_id, book_id, act_id],
|
params![summary, last_update, user_id, book_id, act_id],
|
||||||
)
|
)
|
||||||
.map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le résumé de l'acte.".to_string() } else { "Unable to update act summary.".to_string() }))?;
|
.map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de mettre à jour le résumé de l'acte.".to_string() } else { "Unable to update act summary.".to_string() }))?;
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ pub fn fetch_books(conn: &Connection, user_id: &str, lang: Lang) -> AppResult<Ve
|
|||||||
/// Returns the book information.
|
/// Returns the book information.
|
||||||
pub fn fetch_book(conn: &Connection, book_id: &str, user_id: &str, lang: Lang) -> AppResult<BookQuery> {
|
pub fn fetch_book(conn: &Connection, book_id: &str, user_id: &str, lang: Lang) -> AppResult<BookQuery> {
|
||||||
let mut statement = conn
|
let mut statement = conn
|
||||||
.prepare("SELECT book_id, author_id, title, summary, sub_title, cover_image, desired_release_date, desired_word_count, words_count, serie_id FROM erit_books WHERE book_id=?1 AND author_id=?2")
|
.prepare("SELECT book_id, author_id, title, summary, sub_title, cover_image, desired_release_date, desired_word_count, words_count, serie_id, type FROM erit_books WHERE book_id=?1 AND author_id=?2")
|
||||||
.map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations du livre.".to_string() } else { "Unable to retrieve book information.".to_string() }))?;
|
.map_err(|_| AppError::Internal(if lang == Lang::Fr { "Impossible de récupérer les informations du livre.".to_string() } else { "Unable to retrieve book information.".to_string() }))?;
|
||||||
|
|
||||||
let book = statement
|
let book = statement
|
||||||
@@ -121,7 +121,7 @@ pub fn fetch_book(conn: &Connection, book_id: &str, user_id: &str, lang: Lang) -
|
|||||||
sub_title: query_row.get(4)?, cover_image: query_row.get(5)?,
|
sub_title: query_row.get(4)?, cover_image: query_row.get(5)?,
|
||||||
desired_release_date: query_row.get(6)?, desired_word_count: query_row.get(7)?,
|
desired_release_date: query_row.get(6)?, desired_word_count: query_row.get(7)?,
|
||||||
words_count: query_row.get(8)?, serie_id: query_row.get(9)?,
|
words_count: query_row.get(8)?, serie_id: query_row.get(9)?,
|
||||||
book_type: String::new(),
|
book_type: query_row.get(10)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|error| match error {
|
.map_err(|error| match error {
|
||||||
|
|||||||
@@ -870,9 +870,19 @@ pub fn remove_book(conn: &Connection, user_id: &str, book_id: &str, deleted_at:
|
|||||||
/// * `lang` - The language for error messages
|
/// * `lang` - The language for error messages
|
||||||
/// Returns true if the update was successful.
|
/// Returns true if the update was successful.
|
||||||
pub fn update_book_tool_setting(conn: &Connection, user_id: &str, book_id: &str, tool_name: &str, enabled: bool, lang: Lang) -> AppResult<bool> {
|
pub fn update_book_tool_setting(conn: &Connection, user_id: &str, book_id: &str, tool_name: &str, enabled: bool, lang: Lang) -> AppResult<bool> {
|
||||||
let column_name: String = format!("{}_enabled", tool_name);
|
let column_name: &str = match tool_name {
|
||||||
|
"characters" => "characters_enabled",
|
||||||
|
"worlds" => "worlds_enabled",
|
||||||
|
"locations" => "locations_enabled",
|
||||||
|
"spells" => "spells_enabled",
|
||||||
|
_ => return Err(AppError::Validation(if lang == Lang::Fr {
|
||||||
|
format!("Outil inconnu: {}", tool_name)
|
||||||
|
} else {
|
||||||
|
format!("Unknown tool: {}", tool_name)
|
||||||
|
})),
|
||||||
|
};
|
||||||
let last_update: i64 = timestamp_in_seconds();
|
let last_update: i64 = timestamp_in_seconds();
|
||||||
repo::update_book_tool_setting(conn, user_id, book_id, &column_name, enabled, last_update, lang)
|
repo::update_book_tool_setting(conn, user_id, book_id, column_name, enabled, last_update, lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves complete book data including chapters and user information.
|
/// Retrieves complete book data including chapters and user information.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use crate::domains::spell::repo as spell_repo;
|
|||||||
use crate::domains::spell_tag::repo as spell_tag_repo;
|
use crate::domains::spell_tag::repo as spell_tag_repo;
|
||||||
use crate::domains::chapter_content::repo as chapter_content_repo;
|
use crate::domains::chapter_content::repo as chapter_content_repo;
|
||||||
use crate::domains::world::repo as world_repo;
|
use crate::domains::world::repo as world_repo;
|
||||||
use crate::error::AppResult;
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::shared::types::Lang;
|
use crate::shared::types::Lang;
|
||||||
|
|
||||||
/// Saves a complete book with all its associated data to the local database.
|
/// Saves a complete book with all its associated data to the local database.
|
||||||
@@ -33,7 +33,8 @@ use crate::shared::types::Lang;
|
|||||||
pub fn save_complete_book(conn: &Connection, user_id: &str, data: &CompleteBook, lang: Lang) -> AppResult<bool> {
|
pub fn save_complete_book(conn: &Connection, user_id: &str, data: &CompleteBook, lang: Lang) -> AppResult<bool> {
|
||||||
let user_encryption_key: String = get_user_encryption_key(user_id)?;
|
let user_encryption_key: String = get_user_encryption_key(user_id)?;
|
||||||
|
|
||||||
let book_data = &data.erit_books[0];
|
let book_data = data.erit_books.first()
|
||||||
|
.ok_or_else(|| AppError::Validation("No book data to download.".to_string()))?;
|
||||||
let encrypted_book_title: String = encrypt_data_with_user_key(&book_data.title, &user_encryption_key)?;
|
let encrypted_book_title: String = encrypt_data_with_user_key(&book_data.title, &user_encryption_key)?;
|
||||||
let encrypted_book_sub_title: Option<String> = if let Some(ref sub_title) = book_data.sub_title { Some(encrypt_data_with_user_key(sub_title, &user_encryption_key)?) } else { None };
|
let encrypted_book_sub_title: Option<String> = if let Some(ref sub_title) = book_data.sub_title { Some(encrypt_data_with_user_key(sub_title, &user_encryption_key)?) } else { None };
|
||||||
let encrypted_book_summary: Option<String> = if let Some(ref summary) = book_data.summary { Some(encrypt_data_with_user_key(summary, &user_encryption_key)?) } else { None };
|
let encrypted_book_summary: Option<String> = if let Some(ref summary) = book_data.summary { Some(encrypt_data_with_user_key(summary, &user_encryption_key)?) } else { None };
|
||||||
|
|||||||
@@ -127,19 +127,21 @@ pub fn transform_to_pdf(book_data: &CompleteBookData) -> AppResult<ExportResult>
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let (new_page_index, new_layer_index) = pdf_document.add_page(Mm(210.0), Mm(297.0), &chapter.title);
|
let (first_page_index, first_layer_index) = pdf_document.add_page(Mm(210.0), Mm(297.0), &chapter.title);
|
||||||
let chapter_layer = pdf_document.get_page(new_page_index).get_layer(new_layer_index);
|
let mut current_layer = pdf_document.get_page(first_page_index).get_layer(first_layer_index);
|
||||||
let mut chapter_y: f32 = 270.0;
|
let mut chapter_y: f32 = 270.0;
|
||||||
|
|
||||||
chapter_layer.use_text(&chapter.title, 16.0, Mm(20.0), Mm(chapter_y), &font);
|
current_layer.use_text(&chapter.title, 16.0, Mm(20.0), Mm(chapter_y), &font);
|
||||||
chapter_y -= 10.0;
|
chapter_y -= 10.0;
|
||||||
|
|
||||||
let lines: Vec<&str> = chapter.content.split('\n').collect();
|
let lines: Vec<&str> = chapter.content.split('\n').collect();
|
||||||
for line in lines {
|
for line in lines {
|
||||||
if chapter_y < 20.0 {
|
if chapter_y < 20.0 {
|
||||||
break;
|
let (next_page_index, next_layer_index) = pdf_document.add_page(Mm(210.0), Mm(297.0), &chapter.title);
|
||||||
|
current_layer = pdf_document.get_page(next_page_index).get_layer(next_layer_index);
|
||||||
|
chapter_y = 270.0;
|
||||||
}
|
}
|
||||||
chapter_layer.use_text(line, 12.0, Mm(20.0), Mm(chapter_y), &font);
|
current_layer.use_text(line, 12.0, Mm(20.0), Mm(chapter_y), &font);
|
||||||
chapter_y -= 6.0;
|
chapter_y -= 6.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ pub fn offline_pin_set(data: PinData, session: State<SessionState>) -> Result<Of
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn offline_pin_verify(data: PinData, db: State<DbManager>, session: State<SessionState>) -> Result<OfflineResult, AppError> {
|
pub fn offline_pin_verify(data: PinData, db: State<DbManager>, session: State<SessionState>) -> Result<OfflineResult, AppError> {
|
||||||
|
key_manager::check_pin_rate_limit()?;
|
||||||
|
|
||||||
let last_user_id: Option<String> = key_manager::get_last_user_id()?;
|
let last_user_id: Option<String> = key_manager::get_last_user_id()?;
|
||||||
let last_user_id: String = match last_user_id {
|
let last_user_id: String = match last_user_id {
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
@@ -73,10 +75,13 @@ pub fn offline_pin_verify(data: PinData, db: State<DbManager>, session: State<Se
|
|||||||
.map_err(|e| AppError::Encryption(format!("Failed to verify PIN: {}", e)))?;
|
.map_err(|e| AppError::Encryption(format!("Failed to verify PIN: {}", e)))?;
|
||||||
|
|
||||||
if !is_valid {
|
if !is_valid {
|
||||||
|
key_manager::record_pin_failure()?;
|
||||||
return Ok(OfflineResult { success: false, error: Some("Invalid PIN".to_string()), user_id: None });
|
return Ok(OfflineResult { success: false, error: Some("Invalid PIN".to_string()), user_id: None });
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_key: bool = key_manager::has_user_encryption_key(&last_user_id);
|
key_manager::reset_pin_attempts()?;
|
||||||
|
|
||||||
|
let has_key: bool = key_manager::has_user_encryption_key(&last_user_id)?;
|
||||||
if !has_key {
|
if !has_key {
|
||||||
return Ok(OfflineResult { success: false, error: Some("No encryption key found".to_string()), user_id: None });
|
return Ok(OfflineResult { success: false, error: Some("No encryption key found".to_string()), user_id: None });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ pub fn get_spell_list(conn: &Connection, user_id: &str, book_id: &str, lang: Lan
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let truncated_description: String = match decrypted_description {
|
let truncated_description: String = match decrypted_description {
|
||||||
Some(ref desc) if desc.len() > 150 => format!("{}...", &desc[..150]),
|
Some(ref desc) if desc.chars().count() > 150 => format!("{}...", desc.chars().take(150).collect::<String>()),
|
||||||
Some(ref desc) => desc.clone(),
|
Some(ref desc) => desc.clone(),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -626,7 +626,10 @@ pub fn sync_book_from_server_to_client(conn: &Connection, user_id: &str, complet
|
|||||||
let server_guide_lines: &Vec<BookGuideLineTable> = &complete_book.guide_line;
|
let server_guide_lines: &Vec<BookGuideLineTable> = &complete_book.guide_line;
|
||||||
let server_ai_guide_lines: &Vec<BookAIGuideLineTable> = &complete_book.ai_guide_line;
|
let server_ai_guide_lines: &Vec<BookAIGuideLineTable> = &complete_book.ai_guide_line;
|
||||||
|
|
||||||
let book_id: String = if !complete_book.erit_books.is_empty() { complete_book.erit_books[0].book_id.clone() } else { String::new() };
|
let book_id: String = match complete_book.erit_books.first() {
|
||||||
|
Some(book) => book.book_id.clone(),
|
||||||
|
None => return Ok(false),
|
||||||
|
};
|
||||||
|
|
||||||
if !server_chapters.is_empty() {
|
if !server_chapters.is_empty() {
|
||||||
for server_chapter in server_chapters {
|
for server_chapter in server_chapters {
|
||||||
|
|||||||
@@ -17,10 +17,21 @@ use crate::domains::series_character::service as series_character_service;
|
|||||||
use crate::domains::series_location::service as series_location_service;
|
use crate::domains::series_location::service as series_location_service;
|
||||||
use crate::domains::series_world::service as series_world_service;
|
use crate::domains::series_world::service as series_world_service;
|
||||||
use crate::domains::series_spell::service as series_spell_service;
|
use crate::domains::series_spell::service as series_spell_service;
|
||||||
use crate::error::AppError;
|
use crate::error::{AppError, AppResult};
|
||||||
use crate::shared::session::SessionState;
|
use crate::shared::session::SessionState;
|
||||||
use crate::shared::types::Lang;
|
use crate::shared::types::Lang;
|
||||||
|
|
||||||
|
/// Runs a tombstone deletion, ignoring NotFound errors (entity already deleted).
|
||||||
|
/// Propagates all other errors (DB corruption, lock failure, etc.).
|
||||||
|
fn apply_tombstone<F: FnOnce() -> AppResult<bool>>(action: F) -> AppResult<()> {
|
||||||
|
match action() {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(AppError::NotFound(_)) => Ok(()),
|
||||||
|
Err(AppError::Internal(msg)) if msg.contains("non trouvé") || msg.contains("not found") => Ok(()),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn get_session(session: &State<SessionState>) -> Result<(String, Lang), AppError> {
|
fn get_session(session: &State<SessionState>) -> Result<(String, Lang), AppError> {
|
||||||
let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?;
|
let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?;
|
||||||
let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string();
|
let user_id = session_guard.get_user_id().map_err(|e| AppError::Auth(e))?.to_string();
|
||||||
@@ -68,22 +79,24 @@ pub fn apply_book_tombstones(tombstones: Vec<TombstoneInput>, db: State<DbManage
|
|||||||
|
|
||||||
for tombstone in &tombstones {
|
for tombstone in &tombstones {
|
||||||
let book_id = tombstone.book_id.as_deref().unwrap_or("");
|
let book_id = tombstone.book_id.as_deref().unwrap_or("");
|
||||||
|
let entity_id = tombstone.entity_id.as_str();
|
||||||
|
let deleted_at = tombstone.deleted_at;
|
||||||
match tombstone.table_name.as_str() {
|
match tombstone.table_name.as_str() {
|
||||||
"erit_books" => { let _ = book_service::remove_book(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"erit_books" => apply_tombstone(|| book_service::remove_book(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||||
"book_chapters" => { let _ = chapter_service::remove_chapter(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"book_chapters" => apply_tombstone(|| chapter_service::remove_chapter(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
"book_chapter_infos" => { let _ = chapter_service::remove_chapter_information(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"book_chapter_infos" => apply_tombstone(|| chapter_service::remove_chapter_information(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
"book_characters" => { let _ = character_service::delete_character(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"book_characters" => apply_tombstone(|| character_service::delete_character(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
"book_characters_attributes" => { let _ = character_service::delete_attribute(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"book_characters_attributes" => apply_tombstone(|| character_service::delete_attribute(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
"book_location" => { let _ = location_service::delete_location_section(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"book_location" => apply_tombstone(|| location_service::delete_location_section(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
"location_element" => { let _ = location_service::delete_location_element(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"location_element" => apply_tombstone(|| location_service::delete_location_element(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
"location_sub_element" => { let _ = location_service::delete_location_sub_element(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"location_sub_element" => apply_tombstone(|| location_service::delete_location_sub_element(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
"book_world_elements" => { let _ = world_service::remove_element_from_world(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"book_world_elements" => apply_tombstone(|| world_service::remove_element_from_world(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
"book_incidents" => { let _ = incident_service::remove_incident(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"book_incidents" => apply_tombstone(|| incident_service::remove_incident(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
"book_plot_points" => { let _ = plotpoint_service::remove_plot_point(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"book_plot_points" => apply_tombstone(|| plotpoint_service::remove_plot_point(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
"book_issues" => { let _ = issue_service::remove_issue(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"book_issues" => apply_tombstone(|| issue_service::remove_issue(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
"book_spells" => { let _ = spell_service::delete_spell(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"book_spells" => apply_tombstone(|| spell_service::delete_spell(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
"book_spell_tags" => { let _ = spell_service::delete_spell_tag(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"book_spell_tags" => apply_tombstone(|| spell_service::delete_spell_tag(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||||
_ => {}
|
_ => return Err(AppError::Validation(format!("Unknown tombstone table: {}", tombstone.table_name))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,22 +110,24 @@ pub fn apply_series_tombstones(tombstones: Vec<TombstoneInput>, db: State<DbMana
|
|||||||
let conn = db_manager.get_connection(&user_id)?;
|
let conn = db_manager.get_connection(&user_id)?;
|
||||||
|
|
||||||
for tombstone in &tombstones {
|
for tombstone in &tombstones {
|
||||||
|
let entity_id = tombstone.entity_id.as_str();
|
||||||
|
let deleted_at = tombstone.deleted_at;
|
||||||
match tombstone.table_name.as_str() {
|
match tombstone.table_name.as_str() {
|
||||||
"erit_series" => { let _ = series_service::delete_series(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"erit_series" => apply_tombstone(|| series_service::delete_series(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||||
"series_books" => {
|
"series_books" => {
|
||||||
if let Some(ref book_id) = tombstone.book_id {
|
if let Some(ref book_id) = tombstone.book_id {
|
||||||
let _ = series_service::remove_book_from_series(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang);
|
apply_tombstone(|| series_service::remove_book_from_series(conn, &user_id, book_id, entity_id, deleted_at, lang))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"series_characters" => { let _ = series_character_service::delete_character(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"series_characters" => apply_tombstone(|| series_character_service::delete_character(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||||
"series_characters_attributes" => { let _ = series_character_service::delete_attribute(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"series_characters_attributes" => apply_tombstone(|| series_character_service::delete_attribute(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||||
"series_locations" => { let _ = series_location_service::delete_location(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"series_locations" => apply_tombstone(|| series_location_service::delete_location(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||||
"series_location_elements" => { let _ = series_location_service::delete_element(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"series_location_elements" => apply_tombstone(|| series_location_service::delete_element(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||||
"series_location_sub_elements" => { let _ = series_location_service::delete_sub_element(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"series_location_sub_elements" => apply_tombstone(|| series_location_service::delete_sub_element(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||||
"series_world_elements" => { let _ = series_world_service::delete_element(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"series_world_elements" => apply_tombstone(|| series_world_service::delete_element(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||||
"series_spells" => { let _ = series_spell_service::delete_spell(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"series_spells" => apply_tombstone(|| series_spell_service::delete_spell(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||||
"series_spell_tags" => { let _ = series_spell_service::delete_tag(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
"series_spell_tags" => apply_tombstone(|| series_spell_service::delete_tag(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||||
_ => {}
|
_ => return Err(AppError::Validation(format!("Unknown tombstone table: {}", tombstone.table_name))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,13 +111,12 @@ pub fn set_token(token: String) -> Result<(), AppError> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn remove_token(db: State<DbManager>, session: State<SessionState>) -> Result<(), AppError> {
|
pub fn remove_token(db: State<DbManager>, session: State<SessionState>) -> Result<(), AppError> {
|
||||||
if let Ok(session_guard) = session.lock() {
|
let session_guard = session.lock().map_err(|e| AppError::Internal(format!("Session lock failed: {}", e)))?;
|
||||||
if let Ok(user_id) = session_guard.get_user_id() {
|
if let Ok(user_id) = session_guard.get_user_id() {
|
||||||
if let Ok(mut db_manager) = db.lock() {
|
let mut db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?;
|
||||||
db_manager.close(user_id);
|
db_manager.close(user_id);
|
||||||
}
|
}
|
||||||
}
|
drop(session_guard);
|
||||||
}
|
|
||||||
key_manager::remove_token()
|
key_manager::remove_token()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +124,8 @@ pub fn remove_token(db: State<DbManager>, session: State<SessionState>) -> Resul
|
|||||||
pub fn get_user_encryption_key(user_id: String) -> Result<Option<String>, AppError> {
|
pub fn get_user_encryption_key(user_id: String) -> Result<Option<String>, AppError> {
|
||||||
match key_manager::get_user_encryption_key(&user_id) {
|
match key_manager::get_user_encryption_key(&user_id) {
|
||||||
Ok(key) => Ok(Some(key)),
|
Ok(key) => Ok(Some(key)),
|
||||||
Err(_) => Ok(None),
|
Err(AppError::Keyring(msg)) if msg.contains("No encryption key") => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user