diff --git a/Cargo.lock b/Cargo.lock index b272177..c73b588 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1968,7 +1968,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.3.5" +version = "0.3.6" dependencies = [ "anyhow", "askama", diff --git a/crates/secrets-core/src/models.rs b/crates/secrets-core/src/models.rs index 055bad9..da31efa 100644 --- a/crates/secrets-core/src/models.rs +++ b/crates/secrets-core/src/models.rs @@ -51,6 +51,34 @@ pub struct EntryRow { pub notes: String, } +/// Entry row including `name` (used for id-scoped web / service updates). +#[derive(Debug, sqlx::FromRow)] +pub struct EntryWriteRow { + pub id: Uuid, + pub version: i64, + pub folder: String, + #[sqlx(rename = "type")] + pub entry_type: String, + pub name: String, + pub tags: Vec, + pub metadata: Value, + pub notes: String, +} + +impl From<&EntryWriteRow> for EntryRow { + fn from(r: &EntryWriteRow) -> Self { + EntryRow { + id: r.id, + version: r.version, + folder: r.folder.clone(), + entry_type: r.entry_type.clone(), + tags: r.tags.clone(), + metadata: r.metadata.clone(), + notes: r.notes.clone(), + } + } +} + /// Minimal secret field row fetched before snapshots or cascade deletes. #[derive(Debug, sqlx::FromRow)] pub struct SecretFieldRow { diff --git a/crates/secrets-core/src/service/delete.rs b/crates/secrets-core/src/service/delete.rs index 170524e..91f9bb6 100644 --- a/crates/secrets-core/src/service/delete.rs +++ b/crates/secrets-core/src/service/delete.rs @@ -4,7 +4,7 @@ use sqlx::PgPool; use uuid::Uuid; use crate::db; -use crate::models::{EntryRow, SecretFieldRow}; +use crate::models::{EntryRow, EntryWriteRow, SecretFieldRow}; #[derive(Debug, serde::Serialize)] pub struct DeletedEntry { @@ -31,6 +31,62 @@ pub struct DeleteParams<'a> { pub user_id: Option, } +/// Delete a single entry by id (multi-tenant: `user_id` must match). Cascades `secrets` via FK. +pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result { + let mut tx = pool.begin().await?; + let row: Option = sqlx::query_as( + "SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \ + WHERE id = $1 AND user_id = $2 FOR UPDATE", + ) + .bind(entry_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + let row = match row { + Some(r) => r, + None => { + tx.rollback().await?; + anyhow::bail!("Entry not found"); + } + }; + + let folder = row.folder.clone(); + let entry_type = row.entry_type.clone(); + let name = row.name.clone(); + let entry_row: EntryRow = (&row).into(); + + snapshot_and_delete( + &mut tx, + &folder, + &entry_type, + &name, + &entry_row, + Some(user_id), + ) + .await?; + crate::audit::log_tx( + &mut tx, + Some(user_id), + "delete", + &folder, + &entry_type, + &name, + json!({ "source": "web", "entry_id": entry_id }), + ) + .await; + tx.commit().await?; + + Ok(DeleteResult { + deleted: vec![DeletedEntry { + name, + folder, + entry_type, + }], + dry_run: false, + }) +} + pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result { match params.name { Some(name) => delete_one(pool, name, params.folder, params.dry_run, params.user_id).await, diff --git a/crates/secrets-core/src/service/update.rs b/crates/secrets-core/src/service/update.rs index 914465e..1426983 100644 --- a/crates/secrets-core/src/service/update.rs +++ b/crates/secrets-core/src/service/update.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::crypto; use crate::db; -use crate::models::EntryRow; +use crate::models::{EntryRow, EntryWriteRow}; use crate::service::add::{ collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path, parse_kv, remove_path, @@ -306,3 +306,118 @@ pub async fn run( remove_secrets: remove_secret_keys, }) } + +/// Update non-sensitive entry columns by primary key (multi-tenant: `user_id` must match). +/// Does not read or modify `secrets` rows. +pub struct UpdateEntryFieldsByIdParams<'a> { + pub folder: &'a str, + pub entry_type: &'a str, + pub name: &'a str, + pub notes: &'a str, + pub tags: &'a [String], + pub metadata: &'a serde_json::Value, +} + +pub async fn update_fields_by_id( + pool: &PgPool, + entry_id: Uuid, + user_id: Uuid, + params: UpdateEntryFieldsByIdParams<'_>, +) -> Result<()> { + if params.folder.len() > 128 { + anyhow::bail!("folder must be at most 128 characters"); + } + if params.entry_type.len() > 64 { + anyhow::bail!("type must be at most 64 characters"); + } + if params.name.len() > 256 { + anyhow::bail!("name must be at most 256 characters"); + } + + let mut tx = pool.begin().await?; + + let row: Option = sqlx::query_as( + "SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \ + WHERE id = $1 AND user_id = $2 FOR UPDATE", + ) + .bind(entry_id) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + let row = match row { + Some(r) => r, + None => { + tx.rollback().await?; + anyhow::bail!("Entry not found"); + } + }; + + if let Err(e) = db::snapshot_entry_history( + &mut tx, + db::EntrySnapshotParams { + entry_id: row.id, + user_id: Some(user_id), + folder: &row.folder, + entry_type: &row.entry_type, + name: &row.name, + version: row.version, + action: "update", + tags: &row.tags, + metadata: &row.metadata, + }, + ) + .await + { + tracing::warn!(error = %e, "failed to snapshot entry history before web update"); + } + + let res = sqlx::query( + "UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \ + version = version + 1, updated_at = NOW() \ + WHERE id = $7 AND version = $8", + ) + .bind(params.folder) + .bind(params.entry_type) + .bind(params.name) + .bind(params.notes) + .bind(params.tags) + .bind(params.metadata) + .bind(row.id) + .bind(row.version) + .execute(&mut *tx) + .await + .map_err(|e| { + if let sqlx::Error::Database(ref d) = e + && d.code().as_deref() == Some("23505") + { + return anyhow::anyhow!( + "An entry with this folder and name already exists for your account." + ); + } + e.into() + })?; + + if res.rows_affected() == 0 { + tx.rollback().await?; + anyhow::bail!("Concurrent modification detected. Please refresh and try again."); + } + + crate::audit::log_tx( + &mut tx, + Some(user_id), + "update", + params.folder, + params.entry_type, + params.name, + serde_json::json!({ + "source": "web", + "entry_id": entry_id, + "fields": ["folder", "type", "name", "notes", "tags", "metadata"], + }), + ) + .await; + + tx.commit().await?; + Ok(()) +} diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index 76e00b6..362f7e6 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.3.5" +version = "0.3.6" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index 9ce6c48..bdd4ef8 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -8,9 +8,10 @@ use axum::{ extract::{ConnectInfo, Path, Query, State}, http::{HeaderMap, StatusCode, header}, response::{Html, IntoResponse, Redirect, Response}, - routing::{get, post}, + routing::{get, patch, post}, }; use serde::{Deserialize, Serialize}; +use serde_json::json; use tower_sessions::Session; use uuid::Uuid; @@ -19,7 +20,9 @@ use secrets_core::crypto::hex; use secrets_core::service::{ api_key::{ensure_api_key, regenerate_api_key}, audit_log::list_for_user, + delete::delete_by_id, search::{SearchParams, count_entries, list_entries}, + update::{UpdateEntryFieldsByIdParams, update_fields_by_id}, user::{ OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id, unbind_oauth_account, update_user_key_setup, @@ -95,6 +98,7 @@ struct EntriesPageTemplate { /// Non-sensitive fields only (no `secrets` / ciphertext). struct EntryListItemView { + id: String, folder: String, entry_type: String, name: String, @@ -199,6 +203,10 @@ pub fn web_router() -> Router { .route("/api/key-setup", post(api_key_setup)) .route("/api/apikey", get(api_apikey_get)) .route("/api/apikey/regenerate", post(api_apikey_regenerate)) + .route( + "/api/entries/{id}", + patch(api_entry_patch).delete(api_entry_delete), + ) } fn text_asset_response(content: &'static str, content_type: &'static str) -> Response { @@ -573,6 +581,7 @@ async fn entries_page( let entries = rows .into_iter() .map(|e| EntryListItemView { + id: e.id.to_string(), folder: e.folder, entry_type: e.entry_type, name: e.name, @@ -872,6 +881,122 @@ async fn api_apikey_regenerate( Ok(Json(ApiKeyResponse { api_key })) } +// ── Entry management (Web UI, non-sensitive fields only) ─────────────────────── + +#[derive(Deserialize)] +struct EntryPatchBody { + folder: String, + #[serde(rename = "type")] + entry_type: String, + name: String, + notes: String, + tags: Vec, + metadata: serde_json::Value, +} + +type EntryApiError = (StatusCode, Json); + +fn map_entry_mutation_err(e: anyhow::Error) -> EntryApiError { + let msg = e.to_string(); + if msg.contains("Entry not found") { + return ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "条目不存在或无权访问" })), + ); + } + if msg.contains("already exists") { + return ( + StatusCode::CONFLICT, + Json(json!({ "error": "该账号下已存在相同 folder + name 的条目" })), + ); + } + if msg.contains("Concurrent modification") { + return ( + StatusCode::CONFLICT, + Json(json!({ "error": "条目已被修改,请刷新后重试" })), + ); + } + if msg.contains("must be at most") { + return (StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))); + } + tracing::error!(error = %e, "entry mutation failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "操作失败,请稍后重试" })), + ) +} + +async fn api_entry_patch( + State(state): State, + session: Session, + Path(entry_id): Path, + Json(body): Json, +) -> Result, EntryApiError> { + let user_id = current_user_id(&session) + .await + .ok_or((StatusCode::UNAUTHORIZED, Json(json!({ "error": "未登录" }))))?; + + let folder = body.folder.trim(); + let entry_type = body.entry_type.trim(); + let name = body.name.trim(); + let notes = body.notes.trim(); + + if name.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "name 不能为空" })), + )); + } + + let tags: Vec = body + .tags + .into_iter() + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + if !body.metadata.is_object() { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "metadata 必须是 JSON 对象" })), + )); + } + + update_fields_by_id( + &state.pool, + entry_id, + user_id, + UpdateEntryFieldsByIdParams { + folder, + entry_type, + name, + notes, + tags: &tags, + metadata: &body.metadata, + }, + ) + .await + .map_err(map_entry_mutation_err)?; + + Ok(Json(json!({ "ok": true }))) +} + +async fn api_entry_delete( + State(state): State, + session: Session, + Path(entry_id): Path, +) -> Result, EntryApiError> { + let user_id = current_user_id(&session) + .await + .ok_or((StatusCode::UNAUTHORIZED, Json(json!({ "error": "未登录" }))))?; + + delete_by_id(&state.pool, entry_id, user_id) + .await + .map_err(map_entry_mutation_err)?; + + Ok(Json(json!({ "ok": true }))) +} + // ── OAuth / Well-known ──────────────────────────────────────────────────────── /// RFC 9728 — OAuth 2.0 Protected Resource Metadata. diff --git a/crates/secrets-mcp/templates/entries.html b/crates/secrets-mcp/templates/entries.html index fa3a7ec..72bc31f 100644 --- a/crates/secrets-mcp/templates/entries.html +++ b/crates/secrets-mcp/templates/entries.html @@ -82,11 +82,58 @@ .cell-notes, .cell-meta { max-width: 280px; word-break: break-word; } + .notes-scroll { + max-height: 160px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + padding: 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 12px; + } .detail { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px; max-width: 320px; max-height: 160px; overflow: auto; } + .col-actions { white-space: nowrap; } + .row-actions { display: flex; flex-wrap: wrap; gap: 6px; } + .btn-row { + padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; + border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted); + font-family: inherit; + } + .btn-row:hover { color: var(--text); border-color: var(--text-muted); } + .btn-row.danger:hover { border-color: #f85149; color: #f85149; } + .modal-overlay { + position: fixed; inset: 0; background: rgba(1, 4, 9, 0.65); z-index: 200; + display: flex; align-items: center; justify-content: center; padding: 16px; + } + .modal-overlay[hidden] { display: none !important; } + .modal { + background: var(--surface); border: 1px solid var(--border); border-radius: 12px; + padding: 22px; width: 100%; max-width: 520px; max-height: 90vh; overflow: auto; + box-shadow: 0 16px 48px rgba(0,0,0,0.45); + } + .modal-title { font-size: 16px; font-weight: 600; margin-bottom: 14px; } + .modal-field { margin-bottom: 12px; } + .modal-field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; } + .modal-field input, .modal-field textarea { + width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; + color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace; + outline: none; + } + .modal-field textarea { min-height: 72px; resize: vertical; } + .modal-field textarea.metadata-edit { min-height: 140px; } + .modal-error { color: #f85149; font-size: 12px; margin-bottom: 10px; display: none; } + .modal-error.visible { display: block; } + .modal-footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 16px; } + .btn-modal { padding: 8px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; font-family: inherit; border: 1px solid var(--border); background: transparent; color: var(--text); } + .btn-modal.primary { background: var(--accent); color: #0d1117; border-color: transparent; font-weight: 600; } + .btn-modal.primary:hover { background: var(--accent-hover); } + .btn-modal.danger { border-color: #f85149; color: #f85149; } @media (max-width: 900px) { .layout { flex-direction: column; } .sidebar { @@ -113,7 +160,9 @@ td.col-notes::before { content: "Notes"; } td.col-tags::before { content: "Tags"; } td.col-meta::before { content: "Metadata"; } + td.col-actions::before { content: "操作"; } .detail { max-width: none; } + .notes-scroll { max-width: none; } } @@ -171,18 +220,25 @@ Notes Tags Metadata + 操作 {% for entry in entries %} - + - {{ entry.folder }} - {{ entry.entry_type }} - {{ entry.name }} - {{ entry.notes }} - {{ entry.tags }} -
{{ entry.metadata }}
+ {{ entry.folder }} + {{ entry.entry_type }} + {{ entry.name }} + {% if !entry.notes.is_empty() %}
{{ entry.notes }}
{% endif %} + {{ entry.tags }} +
{{ entry.metadata }}
+ +
+ + +
+ {% endfor %} @@ -193,6 +249,23 @@ + +