diff --git a/Cargo.lock b/Cargo.lock index 86ffd54..95a43bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1968,7 +1968,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.3.3" +version = "0.3.4" dependencies = [ "anyhow", "askama", diff --git a/crates/secrets-core/src/service/search.rs b/crates/secrets-core/src/service/search.rs index b372194..2a8e06f 100644 --- a/crates/secrets-core/src/service/search.rs +++ b/crates/secrets-core/src/service/search.rs @@ -27,49 +27,46 @@ pub struct SearchResult { pub secret_schemas: HashMap>, } -pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result { - let entries = fetch_entries_paged(pool, ¶ms).await?; - let entry_ids: Vec = entries.iter().map(|e| e.id).collect(); - let secret_schemas = if !entry_ids.is_empty() { - fetch_secret_schemas(pool, &entry_ids).await? - } else { - HashMap::new() - }; - Ok(SearchResult { - entries, - secret_schemas, - }) -} - -/// Fetch entries matching the given filters — returns all matching entries up to FETCH_ALL_LIMIT. -pub async fn fetch_entries( - pool: &PgPool, - folder: Option<&str>, - entry_type: Option<&str>, - name: Option<&str>, - tags: &[String], - query: Option<&str>, - user_id: Option, -) -> Result> { - let params = SearchParams { - folder, - entry_type, - name, - tags, - query, - sort: "name", - limit: FETCH_ALL_LIMIT, - offset: 0, - user_id, - }; +/// List `entries` rows matching params (paged, ordered per `params.sort`). +/// Does not read the `secrets` table. +pub async fn list_entries(pool: &PgPool, params: SearchParams<'_>) -> Result> { fetch_entries_paged(pool, ¶ms).await } -async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result> { +/// Count `entries` rows matching the same filters as [`list_entries`] (ignores `sort` / `limit` / `offset`). +/// Does not read the `secrets` table. +pub async fn count_entries(pool: &PgPool, a: &SearchParams<'_>) -> Result { + let (where_clause, _) = entry_where_clause_and_next_idx(a); + let sql = format!("SELECT COUNT(*)::bigint FROM entries {where_clause}"); + let mut q = sqlx::query_scalar::<_, i64>(&sql); + if let Some(uid) = a.user_id { + q = q.bind(uid); + } + if let Some(v) = a.folder { + q = q.bind(v); + } + if let Some(v) = a.entry_type { + q = q.bind(v); + } + if let Some(v) = a.name { + q = q.bind(v); + } + for tag in a.tags { + q = q.bind(tag); + } + if let Some(v) = a.query { + let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_")); + q = q.bind(pattern); + } + let n = q.fetch_one(pool).await?; + Ok(n) +} + +/// Shared WHERE clause and the next `$n` index (for LIMIT/OFFSET in paged queries). +fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) { let mut conditions: Vec = Vec::new(); let mut idx: i32 = 1; - // user_id filtering — always comes first when present if a.user_id.is_some() { conditions.push(format!("user_id = ${}", idx)); idx += 1; @@ -115,6 +112,55 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result) -> Result { + let entries = fetch_entries_paged(pool, ¶ms).await?; + let entry_ids: Vec = entries.iter().map(|e| e.id).collect(); + let secret_schemas = if !entry_ids.is_empty() { + fetch_secret_schemas(pool, &entry_ids).await? + } else { + HashMap::new() + }; + Ok(SearchResult { + entries, + secret_schemas, + }) +} + +/// Fetch entries matching the given filters — returns all matching entries up to FETCH_ALL_LIMIT. +pub async fn fetch_entries( + pool: &PgPool, + folder: Option<&str>, + entry_type: Option<&str>, + name: Option<&str>, + tags: &[String], + query: Option<&str>, + user_id: Option, +) -> Result> { + let params = SearchParams { + folder, + entry_type, + name, + tags, + query, + sort: "name", + limit: FETCH_ALL_LIMIT, + offset: 0, + user_id, + }; + list_entries(pool, params).await +} + +async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result> { + let (where_clause, idx) = entry_where_clause_and_next_idx(a); + let order = match a.sort { "updated" => "updated_at DESC", "created" => "created_at DESC", @@ -122,14 +168,7 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result) -> Result(&sql); - if let Some(uid) = a.user_id { q = q.bind(uid); } diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index 10f444e..78968a4 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.3.3" +version = "0.3.4" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index 6bd99d2..dca409b 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -19,6 +19,7 @@ use secrets_core::crypto::hex; use secrets_core::service::{ api_key::{ensure_api_key, regenerate_api_key}, audit_log::list_for_user, + search::{SearchParams, count_entries, list_entries}, user::{ OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id, unbind_oauth_account, update_user_key_setup, @@ -78,6 +79,33 @@ struct AuditEntryView { detail: String, } +#[derive(Template)] +#[template(path = "entries.html")] +struct EntriesPageTemplate { + user_name: String, + user_email: String, + entries: Vec, + total_count: i64, + shown_count: usize, + limit: u32, + version: &'static str, +} + +/// Non-sensitive fields only (no `secrets` / ciphertext). +struct EntryListItemView { + folder: String, + entry_type: String, + name: String, + notes: String, + tags: String, + metadata: String, + /// RFC3339 UTC for `