diff --git a/quotesdb/src/bin/api/db/d1.rs b/quotesdb/src/bin/api/db/d1.rs index 65dc6f3..16fb233 100644 --- a/quotesdb/src/bin/api/db/d1.rs +++ b/quotesdb/src/bin/api/db/d1.rs @@ -569,32 +569,45 @@ impl QuoteRepository for D1Repository { /// Replace the admin auth code if `current` matches the stored value. /// /// Generates a fresh 4-word passphrase when `new_code` is `None`. - /// Returns `Err(DbError::Forbidden)` if `current` does not match the stored code. + /// + /// The check and update are performed atomically via a single + /// `UPDATE … WHERE key = 'admin_auth_code' AND value = ?current` statement. + /// The result metadata's `changes` count is inspected: if zero rows were + /// affected the stored code either does not exist or did not match `current`, + /// and `Err(DbError::Forbidden)` is returned. If one row was affected the new + /// code is returned. async fn update_admin_auth_code( &self, current: &str, new_code: Option<&str>, ) -> Result { - let stored = self.get_admin_auth_code().await?; - if stored.as_deref() != Some(current) { - return Err(DbError::Forbidden); - } - let replacement = new_code .map(|s| s.to_owned()) .unwrap_or_else(generate_auth_code); - self.db + let result = self + .db .prepare( - "INSERT INTO admin_config (key, value) VALUES ('admin_auth_code', ?1) \ - ON CONFLICT(key) DO UPDATE SET value = excluded.value", + "UPDATE admin_config \ + SET value = ?1 \ + WHERE key = 'admin_auth_code' AND value = ?2", ) - .bind(&[JsValue::from_str(&replacement)]) + .bind(&[JsValue::from_str(&replacement), JsValue::from_str(current)]) .map_err(|e| DbError::Internal(e.to_string()))? .run() .await .map_err(|e| DbError::Internal(e.to_string()))?; + let changes = result + .meta() + .map_err(|e| DbError::Internal(e.to_string()))? + .and_then(|m| m.changes) + .unwrap_or(0); + + if changes == 0 { + return Err(DbError::Forbidden); + } + Ok(replacement) } diff --git a/quotesdb/src/bin/api/db/native.rs b/quotesdb/src/bin/api/db/native.rs index 14b0008..573d6f0 100644 --- a/quotesdb/src/bin/api/db/native.rs +++ b/quotesdb/src/bin/api/db/native.rs @@ -513,34 +513,40 @@ impl QuoteRepository for NativeRepository { /// Replace the admin auth code if `current` matches the stored value. /// /// Generates a fresh 4-word passphrase when `new_code` is `None`. - /// Returns `Err(DbError::Forbidden)` if `current` does not match the stored code. + /// + /// The check and update are performed atomically via a single + /// `UPDATE … WHERE key = 'admin_auth_code' AND value = ?current` statement. + /// If zero rows are affected the stored code either does not exist or did not + /// match `current`, and `Err(DbError::Forbidden)` is returned. If one row is + /// affected the new code is returned. async fn update_admin_auth_code( &self, current: &str, new_code: Option<&str>, ) -> Result { - let stored = self.get_admin_auth_code().await?; - if stored.as_deref() != Some(current) { - return Err(DbError::Forbidden); - } - let replacement = new_code .map(|s| s.to_owned()) .unwrap_or_else(generate_auth_code); - let replacement2 = replacement.clone(); + let current = current.to_owned(); + let replacement_inner = replacement.clone(); - self.conn + let changed = self + .conn .call(move |conn| { - conn.execute( - "INSERT INTO admin_config (key, value) VALUES ('admin_auth_code', ?1) \ - ON CONFLICT(key) DO UPDATE SET value = excluded.value", - rusqlite::params![replacement2], - )?; - Ok(()) + Ok(conn.execute( + "UPDATE admin_config \ + SET value = ?1 \ + WHERE key = 'admin_auth_code' AND value = ?2", + rusqlite::params![replacement_inner, current], + )?) }) .await .map_err(|e| DbError::Internal(e.to_string()))?; + if changed == 0 { + return Err(DbError::Forbidden); + } + Ok(replacement) } diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index 91088f5..33f37e8 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -510,9 +510,12 @@ struct ResetAuthCodeResponse { /// { "auth_code": "word-word-word-word" } /// ``` /// -/// Returns `403 Forbidden` if the header is absent or the code is incorrect. -/// The DB layer (`update_admin_auth_code`) performs the auth check internally -/// and returns `DbError::Forbidden` on mismatch. +/// Returns `403 Forbidden` in two cases: +/// - Missing `X-Admin-Code` header — the handler returns `403` immediately, +/// before any database call. +/// - Wrong code — the DB layer (`update_admin_auth_code`) returns +/// `DbError::Forbidden` when the supplied code does not match the stored +/// value, which the handler maps to `403`. #[cfg_attr(target_arch = "wasm32", worker::send)] async fn reset_auth_code( State(repo): State,