@ -56,16 +56,18 @@ fn row_to_quote(row: &rusqlite::Row<'_>, tags: Vec<String>) -> Result<Quote, rus
#[ async_trait::async_trait ]
#[ async_trait::async_trait ]
impl QuoteRepository for NativeRepository {
impl QuoteRepository for NativeRepository {
/// Run the f our DDL migration statements from [`super::migrations`].
/// Run the f ive DDL migration statements from [`super::migrations`].
///
///
/// Safe to call multiple times — all statements use `IF NOT EXISTS`.
/// Covers `quotes`, `quote_tags`, tag index, author index, and
/// `admin_config`. Safe to call multiple times — all statements use
/// `IF NOT EXISTS`.
async fn run_migrations ( & self ) -> Result < ( ) , DbError > {
async fn run_migrations ( & self ) -> Result < ( ) , DbError > {
self . conn
self . conn
. call ( | conn | {
. call ( | conn | {
use super ::migrations ::* ;
use super ::migrations ::* ;
conn . execute_batch ( & format! (
conn . execute_batch ( & format! (
" { CREATE_QUOTES } ; { CREATE_QUOTE_TAGS } ; \
" { CREATE_QUOTES } ; { CREATE_QUOTE_TAGS } ; \
{ CREATE_TAG_INDEX } ; { CREATE_AUTHOR_INDEX } ; "
{ CREATE_TAG_INDEX } ; { CREATE_AUTHOR_INDEX } ; { CREATE_ADMIN_CONFIG } ; "
) ) ? ;
) ) ? ;
Ok ( ( ) )
Ok ( ( ) )
} )
} )
@ -324,8 +326,14 @@ impl QuoteRepository for NativeRepository {
match stored {
match stored {
None = > return Err ( DbError ::NotFound ) ,
None = > return Err ( DbError ::NotFound ) ,
Some ( ref s ) if s . as_str ( ) ! = auth_code . as_str ( ) = > return Err ( DbError ::Forbidden ) ,
Some ( ref s ) if s . as_str ( ) = = auth_code . as_str ( ) = > { } // exact match, proceed
Some ( _ ) = > { }
Some ( _ ) = > {
// Check admin code fallback
let admin = self . get_admin_auth_code ( ) . await ? ;
if admin . as_deref ( ) ! = Some ( auth_code . as_str ( ) ) {
return Err ( DbError ::Forbidden ) ;
}
}
}
}
// Phase 2: apply the update
// Phase 2: apply the update
@ -390,9 +398,10 @@ impl QuoteRepository for NativeRepository {
/// Delete a quote by ID after verifying the auth code.
/// Delete a quote by ID after verifying the auth code.
///
///
/// Returns [`DeleteResult::NotFound`] if no quote has that ID,
/// Returns [`DeleteResult::NotFound`] if no quote has that ID,
/// [`DeleteResult::Forbidden`] if the auth code does not match,
/// [`DeleteResult::Forbidden`] if neither the per-quote auth code nor the
/// or [`DeleteResult::Deleted`] on success. Tags are removed automatically
/// admin super auth code matches, or [`DeleteResult::Deleted`] on success.
/// by the `ON DELETE CASCADE` constraint on `quote_tags`.
/// Tags are removed automatically by the `ON DELETE CASCADE` constraint on
/// `quote_tags`.
async fn delete_quote ( & self , id : & str , auth_code : & str ) -> Result < DeleteResult , DbError > {
async fn delete_quote ( & self , id : & str , auth_code : & str ) -> Result < DeleteResult , DbError > {
let id = id . to_owned ( ) ;
let id = id . to_owned ( ) ;
let auth_code = auth_code . to_owned ( ) ;
let auth_code = auth_code . to_owned ( ) ;
@ -408,13 +417,65 @@ impl QuoteRepository for NativeRepository {
. optional ( ) ? ;
. optional ( ) ? ;
match stored {
match stored {
None = > Ok ( DeleteResult ::NotFound ) ,
None = > return Ok ( DeleteResult ::NotFound ) ,
Some ( s ) if s ! = auth_code = > Ok ( DeleteResult ::Forbidden ) ,
Some ( ref s ) if s = = & auth_code = > {
Some ( _ ) = > {
conn . execute ( "DELETE FROM quotes WHERE id = ?" , [ & id as & str ] ) ? ;
return Ok ( DeleteResult ::Deleted ) ;
}
Some ( _ ) = > { }
}
// Check admin code as fallback
let admin : Option < String > = conn
. query_row (
"SELECT value FROM admin_config WHERE key = 'admin_auth_code'" ,
[ ] ,
| row | row . get ( 0 ) ,
)
. optional ( ) ? ;
if admin . as_deref ( ) = = Some ( auth_code . as_str ( ) ) {
conn . execute ( "DELETE FROM quotes WHERE id = ?" , [ & id as & str ] ) ? ;
conn . execute ( "DELETE FROM quotes WHERE id = ?" , [ & id as & str ] ) ? ;
Ok ( DeleteResult ::Deleted )
Ok ( DeleteResult ::Deleted )
} else {
Ok ( DeleteResult ::Forbidden )
}
} )
. await
. map_err ( | e | DbError ::Internal ( e . to_string ( ) ) )
}
}
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the admin code has not been seeded yet.
async fn get_admin_auth_code ( & self ) -> Result < Option < String > , DbError > {
self . conn
. call ( | conn | {
let result : Option < String > = conn
. query_row (
"SELECT value FROM admin_config WHERE key = 'admin_auth_code'" ,
[ ] ,
| row | row . get ( 0 ) ,
)
. optional ( ) ? ;
Ok ( result )
} )
. await
. map_err ( | e | DbError ::Internal ( e . to_string ( ) ) )
}
}
/// Insert the admin auth code if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_admin_auth_code ( & self , code : & str ) -> Result < ( ) , DbError > {
let code = code . to_owned ( ) ;
self . conn
. call ( move | conn | {
conn . execute (
"INSERT OR IGNORE INTO admin_config (key, value) VALUES ('admin_auth_code', ?1)" ,
rusqlite ::params ! [ code ] ,
) ? ;
Ok ( ( ) )
} )
} )
. await
. await
. map_err ( | e | DbError ::Internal ( e . to_string ( ) ) )
. map_err ( | e | DbError ::Internal ( e . to_string ( ) ) )