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:
@@ -61,7 +61,7 @@ pub fn update_act_summary(
|
||||
) -> AppResult<bool> {
|
||||
let update_result = conn
|
||||
.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],
|
||||
)
|
||||
.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.
|
||||
pub fn fetch_book(conn: &Connection, book_id: &str, user_id: &str, lang: Lang) -> AppResult<BookQuery> {
|
||||
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() }))?;
|
||||
|
||||
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)?,
|
||||
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)?,
|
||||
book_type: String::new(),
|
||||
book_type: query_row.get(10)?,
|
||||
})
|
||||
})
|
||||
.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
|
||||
/// 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> {
|
||||
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();
|
||||
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.
|
||||
|
||||
@@ -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::chapter_content::repo as chapter_content_repo;
|
||||
use crate::domains::world::repo as world_repo;
|
||||
use crate::error::AppResult;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::shared::types::Lang;
|
||||
|
||||
/// 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> {
|
||||
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_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 };
|
||||
|
||||
@@ -127,19 +127,21 @@ pub fn transform_to_pdf(book_data: &CompleteBookData) -> AppResult<ExportResult>
|
||||
continue;
|
||||
}
|
||||
|
||||
let (new_page_index, new_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 (first_page_index, first_layer_index) = pdf_document.add_page(Mm(210.0), Mm(297.0), &chapter.title);
|
||||
let mut current_layer = pdf_document.get_page(first_page_index).get_layer(first_layer_index);
|
||||
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;
|
||||
|
||||
let lines: Vec<&str> = chapter.content.split('\n').collect();
|
||||
for line in lines {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ pub fn offline_pin_set(data: PinData, session: State<SessionState>) -> Result<Of
|
||||
|
||||
#[tauri::command]
|
||||
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: String = match last_user_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)))?;
|
||||
|
||||
if !is_valid {
|
||||
key_manager::record_pin_failure()?;
|
||||
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 {
|
||||
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();
|
||||
|
||||
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(),
|
||||
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_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() {
|
||||
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_world::service as series_world_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::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> {
|
||||
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();
|
||||
@@ -68,22 +79,24 @@ pub fn apply_book_tombstones(tombstones: Vec<TombstoneInput>, db: State<DbManage
|
||||
|
||||
for tombstone in &tombstones {
|
||||
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() {
|
||||
"erit_books" => { let _ = book_service::remove_book(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
||||
"book_chapters" => { let _ = chapter_service::remove_chapter(conn, &user_id, book_id, &tombstone.entity_id, tombstone.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_characters" => { let _ = character_service::delete_character(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
||||
"book_characters_attributes" => { let _ = character_service::delete_attribute(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
||||
"book_location" => { let _ = location_service::delete_location_section(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
||||
"location_element" => { let _ = location_service::delete_location_element(conn, &user_id, book_id, &tombstone.entity_id, tombstone.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); }
|
||||
"book_world_elements" => { let _ = world_service::remove_element_from_world(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
||||
"book_incidents" => { let _ = incident_service::remove_incident(conn, &user_id, book_id, &tombstone.entity_id, tombstone.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_issues" => { let _ = issue_service::remove_issue(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
||||
"book_spells" => { let _ = spell_service::delete_spell(conn, &user_id, book_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
||||
"book_spell_tags" => { let _ = spell_service::delete_spell_tag(conn, &user_id, book_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" => apply_tombstone(|| chapter_service::remove_chapter(conn, &user_id, book_id, entity_id, 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" => apply_tombstone(|| character_service::delete_character(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||
"book_characters_attributes" => apply_tombstone(|| character_service::delete_attribute(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||
"book_location" => apply_tombstone(|| location_service::delete_location_section(conn, &user_id, book_id, entity_id, 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" => apply_tombstone(|| location_service::delete_location_sub_element(conn, &user_id, book_id, entity_id, 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" => apply_tombstone(|| incident_service::remove_incident(conn, &user_id, book_id, entity_id, 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" => apply_tombstone(|| issue_service::remove_issue(conn, &user_id, book_id, entity_id, deleted_at, lang))?,
|
||||
"book_spells" => apply_tombstone(|| spell_service::delete_spell(conn, &user_id, book_id, entity_id, 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)?;
|
||||
|
||||
for tombstone in &tombstones {
|
||||
let entity_id = tombstone.entity_id.as_str();
|
||||
let deleted_at = tombstone.deleted_at;
|
||||
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" => {
|
||||
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_attributes" => { let _ = series_character_service::delete_attribute(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
||||
"series_locations" => { let _ = series_location_service::delete_location(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
||||
"series_location_elements" => { let _ = series_location_service::delete_element(conn, &user_id, &tombstone.entity_id, tombstone.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_world_elements" => { let _ = series_world_service::delete_element(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
||||
"series_spells" => { let _ = series_spell_service::delete_spell(conn, &user_id, &tombstone.entity_id, tombstone.deleted_at, lang); }
|
||||
"series_spell_tags" => { let _ = series_spell_service::delete_tag(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" => apply_tombstone(|| series_character_service::delete_attribute(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||
"series_locations" => apply_tombstone(|| series_location_service::delete_location(conn, &user_id, entity_id, 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" => apply_tombstone(|| series_location_service::delete_sub_element(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||
"series_world_elements" => apply_tombstone(|| series_world_service::delete_element(conn, &user_id, entity_id, deleted_at, lang))?,
|
||||
"series_spells" => apply_tombstone(|| series_spell_service::delete_spell(conn, &user_id, entity_id, 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]
|
||||
pub fn remove_token(db: State<DbManager>, session: State<SessionState>) -> Result<(), AppError> {
|
||||
if let Ok(session_guard) = session.lock() {
|
||||
if let Ok(user_id) = session_guard.get_user_id() {
|
||||
if let Ok(mut db_manager) = db.lock() {
|
||||
db_manager.close(user_id);
|
||||
}
|
||||
}
|
||||
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() {
|
||||
let mut db_manager = db.lock().map_err(|e| AppError::Internal(format!("DB lock failed: {}", e)))?;
|
||||
db_manager.close(user_id);
|
||||
}
|
||||
drop(session_guard);
|
||||
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> {
|
||||
match key_manager::get_user_encryption_key(&user_id) {
|
||||
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