From 0ffb81e57f2f37eecaf5e8d865df638d4fb1bc2f Mon Sep 17 00:00:00 2001 From: voson Date: Sat, 4 Apr 2026 20:30:32 +0800 Subject: [PATCH] feat: entry update links existing secrets (link_secret_names) - secrets-core: update flow validates and applies secret links - secrets-mcp: MCP tool params and UI for managing links on edit - Align errors and templates; minor crypto/.gitignore tweaks Made-with: Cursor --- .gitignore | 3 +- Cargo.lock | 2 +- crates/secrets-core/src/crypto.rs | 4 +- crates/secrets-core/src/error.rs | 3 + crates/secrets-core/src/service/update.rs | 103 +++++++++++++++++ crates/secrets-mcp/Cargo.toml | 2 +- crates/secrets-mcp/src/error.rs | 4 + crates/secrets-mcp/src/tools.rs | 83 +++++++++++++- crates/secrets-mcp/src/web.rs | 6 + crates/secrets-mcp/templates/entries.html | 130 ++++++++++++++++++++-- 10 files changed, 321 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index ab4bfc1..0c995d8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ .cursor/ *.pem tmp/ -client_secret_*.apps.googleusercontent.com.json \ No newline at end of file +client_secret_*.apps.googleusercontent.com.json +node_modules/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index c00190c..9bc70d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1969,7 +1969,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "askama", diff --git a/crates/secrets-core/src/crypto.rs b/crates/secrets-core/src/crypto.rs index 48d0473..91cb72d 100644 --- a/crates/secrets-core/src/crypto.rs +++ b/crates/secrets-core/src/crypto.rs @@ -5,6 +5,8 @@ use aes_gcm::{ use anyhow::{Context, Result, bail}; use serde_json::Value; +use crate::error::AppError; + const NONCE_LEN: usize = 12; // ─── AES-256-GCM encrypt / decrypt ─────────────────────────────────────────── @@ -38,7 +40,7 @@ pub fn decrypt(master_key: &[u8; 32], data: &[u8]) -> Result> { let nonce = Nonce::from_slice(nonce_bytes); cipher .decrypt(nonce, ciphertext) - .map_err(|_| anyhow::anyhow!("decryption failed — wrong master key or corrupted data")) + .map_err(|_| AppError::DecryptionFailed.into()) } // ─── JSON helpers ───────────────────────────────────────────────────────────── diff --git a/crates/secrets-core/src/error.rs b/crates/secrets-core/src/error.rs index 767ea1f..2c3d604 100644 --- a/crates/secrets-core/src/error.rs +++ b/crates/secrets-core/src/error.rs @@ -21,6 +21,9 @@ pub enum AppError { #[error("Concurrent modification detected")] ConcurrentModification, + #[error("Decryption failed — the encryption key may be incorrect")] + DecryptionFailed, + #[error(transparent)] Internal(#[from] anyhow::Error), } diff --git a/crates/secrets-core/src/service/update.rs b/crates/secrets-core/src/service/update.rs index cc34554..1997a23 100644 --- a/crates/secrets-core/src/service/update.rs +++ b/crates/secrets-core/src/service/update.rs @@ -25,6 +25,8 @@ pub struct UpdateResult { pub remove_meta: Vec, pub secret_keys: Vec, pub remove_secrets: Vec, + pub linked_secrets: Vec, + pub unlinked_secrets: Vec, } pub struct UpdateParams<'a> { @@ -39,6 +41,8 @@ pub struct UpdateParams<'a> { pub secret_entries: &'a [String], pub secret_types: &'a std::collections::HashMap, pub remove_secrets: &'a [String], + pub link_secret_names: &'a [String], + pub unlink_secret_names: &'a [String], pub user_id: Option, } @@ -295,6 +299,101 @@ pub async fn run( } } + // Link existing secrets by name + let mut linked_secrets = Vec::new(); + for link_name in params.link_secret_names { + let link_name = link_name.trim(); + if link_name.is_empty() { + anyhow::bail!("link_secret_names contains an empty name"); + } + let secret_ids: Vec = if let Some(uid) = params.user_id { + sqlx::query_scalar("SELECT id FROM secrets WHERE user_id = $1 AND name = $2") + .bind(uid) + .bind(link_name) + .fetch_all(&mut *tx) + .await? + } else { + sqlx::query_scalar("SELECT id FROM secrets WHERE user_id IS NULL AND name = $1") + .bind(link_name) + .fetch_all(&mut *tx) + .await? + }; + + match secret_ids.len() { + 0 => anyhow::bail!("Not found: secret named '{}'", link_name), + 1 => { + sqlx::query( + "INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(row.id) + .bind(secret_ids[0]) + .execute(&mut *tx) + .await?; + linked_secrets.push(link_name.to_string()); + } + n => anyhow::bail!( + "Ambiguous: {} secrets named '{}' found. Please deduplicate names first.", + n, + link_name + ), + } + } + + // Unlink secrets by name + let mut unlinked_secrets = Vec::new(); + for unlink_name in params.unlink_secret_names { + let unlink_name = unlink_name.trim(); + if unlink_name.is_empty() { + continue; + } + + #[derive(sqlx::FromRow)] + struct SecretToUnlink { + id: Uuid, + encrypted: Vec, + } + let secret: Option = sqlx::query_as( + "SELECT s.id, s.encrypted \ + FROM entry_secrets es \ + JOIN secrets s ON s.id = es.secret_id \ + WHERE es.entry_id = $1 AND s.name = $2", + ) + .bind(row.id) + .bind(unlink_name) + .fetch_optional(&mut *tx) + .await?; + + if let Some(s) = secret { + if let Err(e) = db::snapshot_secret_history( + &mut tx, + db::SecretSnapshotParams { + secret_id: s.id, + name: unlink_name, + encrypted: &s.encrypted, + action: "delete", + }, + ) + .await + { + tracing::warn!(error = %e, "failed to snapshot secret field history before unlink"); + } + sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2") + .bind(row.id) + .bind(s.id) + .execute(&mut *tx) + .await?; + sqlx::query( + "DELETE FROM secrets s \ + WHERE s.id = $1 \ + AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)", + ) + .bind(s.id) + .execute(&mut *tx) + .await?; + unlinked_secrets.push(unlink_name.to_string()); + } + } + let meta_keys = collect_key_paths(params.meta_entries)?; let remove_meta_keys = collect_field_paths(params.remove_meta)?; let secret_keys = collect_key_paths(params.secret_entries)?; @@ -314,6 +413,8 @@ pub async fn run( "remove_meta": remove_meta_keys, "secret_keys": secret_keys, "remove_secrets": remove_secret_keys, + "linked_secrets": linked_secrets, + "unlinked_secrets": unlinked_secrets, }), ) .await; @@ -330,6 +431,8 @@ pub async fn run( remove_meta: remove_meta_keys, secret_keys, remove_secrets: remove_secret_keys, + linked_secrets, + unlinked_secrets, }) } diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index f21a50e..4605af0 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.4.0" +version = "0.5.0" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/error.rs b/crates/secrets-mcp/src/error.rs index 1d90647..09adda3 100644 --- a/crates/secrets-mcp/src/error.rs +++ b/crates/secrets-mcp/src/error.rs @@ -28,6 +28,10 @@ pub fn app_error_to_mcp(err: &AppError) -> rmcp::ErrorData { "The entry was modified by another request. Please refresh and try again.", None, ), + AppError::DecryptionFailed => rmcp::ErrorData::invalid_request( + "Decryption failed — the encryption key may be incorrect or does not match the data.", + None, + ), AppError::Internal(_) => rmcp::ErrorData::internal_error( "Request failed due to a server error. Check service logs if you need details.", None, diff --git a/crates/secrets-mcp/src/tools.rs b/crates/secrets-mcp/src/tools.rs index 9c18afc..36b8e11 100644 --- a/crates/secrets-mcp/src/tools.rs +++ b/crates/secrets-mcp/src/tools.rs @@ -13,11 +13,65 @@ use rmcp::{ tool, tool_handler, tool_router, }; use schemars::JsonSchema; -use serde::Deserialize; +use serde::{Deserialize, Deserializer, de}; use serde_json::{Map, Value}; use sqlx::PgPool; use uuid::Uuid; +// ── Serde helpers for numeric parameters that may arrive as strings ────────── + +mod deser { + use super::*; + + /// Deserialize a value that may come as a JSON number or a JSON string. + pub fn option_u32_from_string<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum NumOrStr { + Num(u32), + Str(String), + } + + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(NumOrStr::Num(n)) => Ok(Some(n)), + Some(NumOrStr::Str(s)) => { + if s.is_empty() { + return Ok(None); + } + s.parse::().map(Some).map_err(de::Error::custom) + } + } + } + + /// Deserialize an i64 that may come as a JSON number or a JSON string. + pub fn option_i64_from_string<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum NumOrStr { + Num(i64), + Str(String), + } + + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(NumOrStr::Num(n)) => Ok(Some(n)), + Some(NumOrStr::Str(s)) => { + if s.is_empty() { + return Ok(None); + } + s.parse::().map(Some).map_err(de::Error::custom) + } + } + } +} + use secrets_core::models::ExportFormat; use secrets_core::service::{ add::{AddParams, run as svc_add}, @@ -188,6 +242,7 @@ struct FindInput { #[schemars(description = "Tag filters (all must match)")] tags: Option>, #[schemars(description = "Max results (default 20)")] + #[serde(default, deserialize_with = "deser::option_u32_from_string")] limit: Option, } @@ -215,8 +270,10 @@ struct SearchInput { #[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")] sort: Option, #[schemars(description = "Max results (default 20)")] + #[serde(default, deserialize_with = "deser::option_u32_from_string")] limit: Option, #[schemars(description = "Pagination offset (default 0)")] + #[serde(default, deserialize_with = "deser::option_u32_from_string")] offset: Option, } @@ -307,6 +364,14 @@ struct UpdateInput { secret_types: Option>, #[schemars(description = "Secret field keys to remove")] remove_secrets: Option>, + #[schemars( + description = "Link existing secrets by name to this entry. Names must resolve uniquely under current user." + )] + link_secret_names: Option>, + #[schemars( + description = "Unlink secrets by name from this entry. Orphaned secrets are auto-deleted." + )] + unlink_secret_names: Option>, } #[derive(Debug, Deserialize, JsonSchema)] @@ -341,6 +406,7 @@ struct HistoryInput { )] id: Option, #[schemars(description = "Max history entries to return (default 20)")] + #[serde(default, deserialize_with = "deser::option_u32_from_string")] limit: Option, } @@ -357,6 +423,7 @@ struct RollbackInput { )] id: Option, #[schemars(description = "Target version number. Omit to restore the most recent snapshot.")] + #[serde(default, deserialize_with = "deser::option_i64_from_string")] to_version: Option, } @@ -643,7 +710,7 @@ impl SecretsService { let value = get_secret_field_by_id(&self.pool, entry_id, field_name, &user_key, Some(user_id)) .await - .map_err(|e| mcp_err_internal_logged("secrets_get", None, e))?; + .map_err(|e| mcp_err_from_anyhow("secrets_get", Some(user_id), e))?; tracing::info!( tool = "secrets_get", @@ -657,7 +724,7 @@ impl SecretsService { } else { let secrets = get_all_secrets_by_id(&self.pool, entry_id, &user_key, Some(user_id)) .await - .map_err(|e| mcp_err_internal_logged("secrets_get", None, e))?; + .map_err(|e| mcp_err_from_anyhow("secrets_get", Some(user_id), e))?; tracing::info!( tool = "secrets_get", @@ -793,6 +860,8 @@ impl SecretsService { .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string()))) .collect(); let remove_secrets = input.remove_secrets.unwrap_or_default(); + let link_secret_names = input.link_secret_names.unwrap_or_default(); + let unlink_secret_names = input.unlink_secret_names.unwrap_or_default(); let result = svc_update( &self.pool, @@ -807,6 +876,8 @@ impl SecretsService { secret_entries: &secrets, secret_types: &secret_types_map, remove_secrets: &remove_secrets, + link_secret_names: &link_secret_names, + unlink_secret_names: &unlink_secret_names, user_id: Some(user_id), }, &user_key, @@ -1048,7 +1119,7 @@ impl SecretsService { Some(&user_key), ) .await - .map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?; + .map_err(|e| mcp_err_from_anyhow("secrets_export", Some(user_id), e))?; let fmt = format.parse::().map_err(|e| { tracing::warn!( @@ -1064,7 +1135,7 @@ impl SecretsService { })?; let serialized = fmt .serialize(&data) - .map_err(|e| mcp_err_internal_logged("secrets_export", Some(user_id), e))?; + .map_err(|e| mcp_err_from_anyhow("secrets_export", Some(user_id), e))?; tracing::info!( tool = "secrets_export", @@ -1115,7 +1186,7 @@ impl SecretsService { Some(user_id), ) .await - .map_err(|e| mcp_err_internal_logged("secrets_env_map", Some(user_id), e))?; + .map_err(|e| mcp_err_from_anyhow("secrets_env_map", Some(user_id), e))?; let entry_count = env_map.len(); tracing::info!( diff --git a/crates/secrets-mcp/src/web.rs b/crates/secrets-mcp/src/web.rs index 732f221..1249917 100644 --- a/crates/secrets-mcp/src/web.rs +++ b/crates/secrets-mcp/src/web.rs @@ -1149,6 +1149,12 @@ fn map_app_error(err: &AppError, lang: UiLang) -> EntryApiError { json!({ "error": tr(lang, "条目已被修改,请刷新后重试", "條目已被修改,請重新整理後重試", "Entry was modified, please refresh and try again") }), ), ), + AppError::DecryptionFailed => ( + StatusCode::BAD_REQUEST, + Json( + json!({ "error": tr(lang, "解密失败,请检查密码短语", "解密失敗,請檢查密碼短語", "Decryption failed — please check your passphrase") }), + ), + ), AppError::Internal(_) => { tracing::error!(error = %err, "internal error in entry mutation"); ( diff --git a/crates/secrets-mcp/templates/entries.html b/crates/secrets-mcp/templates/entries.html index df90aa7..761924f 100644 --- a/crates/secrets-mcp/templates/entries.html +++ b/crates/secrets-mcp/templates/entries.html @@ -123,7 +123,7 @@ background: var(--bg); } table { - width: max-content; + width: 100%; min-width: 960px; border-collapse: separate; border-spacing: 0; @@ -142,12 +142,12 @@ td { font-size: 13px; line-height: 1.45; } tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); } .mono { font-family: 'JetBrains Mono', monospace; } - .col-type { min-width: 108px; } + .col-type { min-width: 108px; width: 1%; } .col-name { min-width: 180px; max-width: 260px; } .col-tags { min-width: 160px; max-width: 220px; } .col-secrets { min-width: 220px; max-width: 420px; vertical-align: top; } .col-secrets .secret-list { max-height: 120px; overflow: auto; } - .col-actions { min-width: 132px; } + .col-actions { min-width: 132px; width: 1%; } .cell-name, .cell-tags-val { overflow-wrap: anywhere; word-break: break-word; @@ -961,6 +961,114 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option showDeleteErr(''); } + function refreshListAfterSave(entryId, body, secretRows) { + var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]'); + if (!tr) { window.location.reload(); return; } + var nameCell = tr.querySelector('.cell-name'); + if (nameCell) nameCell.textContent = body.name; + var typeCell = tr.querySelector('.cell-type'); + if (typeCell) typeCell.textContent = body.type; + var notesCell = tr.querySelector('.cell-notes-val'); + if (notesCell) { + if (body.notes) { notesCell.textContent = body.notes; } + else { var notesWrap = tr.querySelector('.cell-notes'); if (notesWrap) notesWrap.innerHTML = ''; } + } + var tagsCell = tr.querySelector('.cell-tags-val'); + if (tagsCell) tagsCell.textContent = body.tags.join(', '); + var secretsList = tr.querySelector('.secret-list'); + if (secretsList) { + secretsList.innerHTML = ''; + secretRows.forEach(function (info) { + var chip = document.createElement('span'); + chip.className = 'secret-chip'; + var nameSpan = document.createElement('span'); + nameSpan.className = 'secret-name'; + nameSpan.textContent = info.newName; + nameSpan.title = info.newName; + var typeSpan = document.createElement('span'); + typeSpan.className = 'secret-type'; + typeSpan.textContent = info.newType || 'text'; + var unlinkBtn = document.createElement('button'); + unlinkBtn.type = 'button'; + unlinkBtn.className = 'btn-unlink-secret'; + unlinkBtn.setAttribute('data-secret-id', info.secretId); + unlinkBtn.setAttribute('data-secret-name', info.newName); + unlinkBtn.title = t('unlinkTitle'); + unlinkBtn.textContent = '\u00d7'; + chip.appendChild(nameSpan); + chip.appendChild(typeSpan); + chip.appendChild(unlinkBtn); + secretsList.appendChild(chip); + }); + } + tr.setAttribute('data-entry-folder', body.folder); + tr.setAttribute('data-entry-metadata', JSON.stringify(body.metadata)); + var updatedSecrets = secretRows.map(function (info) { + return { id: info.secretId, name: info.newName, secret_type: info.newType || 'text' }; + }); + tr.setAttribute('data-entry-secrets', JSON.stringify(updatedSecrets)); + } + + function refreshListAfterDelete(entryId) { + var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]'); + var folder = tr ? tr.getAttribute('data-entry-folder') : null; + if (tr) tr.remove(); + var tbody = document.querySelector('table tbody'); + if (tbody && !tbody.querySelector('tr[data-entry-id]')) { + var card = document.querySelector('.card'); + if (card) { + var tableWrap = card.querySelector('.table-wrap'); + if (tableWrap) tableWrap.remove(); + var existingEmpty = card.querySelector('.empty'); + if (!existingEmpty) { + var emptyDiv = document.createElement('div'); + emptyDiv.className = 'empty'; + emptyDiv.setAttribute('data-i18n', 'emptyEntries'); + emptyDiv.textContent = t('emptyEntries'); + var filterBar = card.querySelector('.filter-bar'); + if (filterBar) { card.insertBefore(emptyDiv, filterBar.nextSibling); } + else { card.appendChild(emptyDiv); } + } + } + } + var allTab = document.querySelector('.folder-tab[data-all-tab="1"]'); + if (allTab) { + var count = parseInt(allTab.getAttribute('data-count') || '0', 10); + if (count > 0) { + count -= 1; + allTab.setAttribute('data-count', String(count)); + allTab.textContent = t('allTab') + ' (' + count + ')'; + } + } + if (folder) { + document.querySelectorAll('.folder-tab:not([data-all-tab])').forEach(function (tab) { + if (tab.textContent.trim().indexOf(folder) === 0) { + var m = tab.textContent.match(/\((\d+)\)/); + if (m) { + var c = parseInt(m[1], 10); + if (c > 0) { + c -= 1; + tab.textContent = folder + ' (' + c + ')'; + } + } + } + }); + } + } + + function refreshListAfterUnlink(entryId, secretId) { + var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]'); + if (!tr) return; + var chip = tr.querySelector('.btn-unlink-secret[data-secret-id="' + secretId + '"]'); + if (chip && chip.parentElement) chip.parentElement.remove(); + var secrets = tr.getAttribute('data-entry-secrets'); + try { + var arr = JSON.parse(secrets); + arr = arr.filter(function (s) { return s.id !== secretId; }); + tr.setAttribute('data-entry-secrets', JSON.stringify(arr)); + } catch (e) {} + } + document.getElementById('delete-cancel').addEventListener('click', closeDelete); deleteOverlay.addEventListener('click', function (e) { if (e.target === deleteOverlay) closeDelete(); @@ -975,8 +1083,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option }); }) .then(function () { + var deletedId = pendingDeleteId; closeDelete(); - window.location.reload(); + refreshListAfterDelete(deletedId); }) .catch(function (e) { showDeleteErr(e.message || String(e)); }); }); @@ -1086,7 +1195,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option })); }).then(function () { closeEdit(); - window.location.reload(); + refreshListAfterSave(currentEntryId, body, secretRows); }).catch(function (e) { showEditErr(e.message || String(e)); }); @@ -1102,7 +1211,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option var secretId = btn.getAttribute('data-secret-id'); var secretName = btn.getAttribute('data-secret-name') || ''; if (!entryId || !secretId) return; - if (!confirm(tf('confirmUnlinkSecret', { name: secretName }))) return; fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/' + encodeURIComponent(secretId), { method: 'DELETE', credentials: 'same-origin' @@ -1112,7 +1220,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option return data; }); }).then(function () { - window.location.reload(); + refreshListAfterUnlink(entryId, secretId); }).catch(function (err) { alert(err.message || String(err)); }); @@ -1126,7 +1234,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option var secretId = btn.getAttribute('data-secret-id'); var secretName = btn.getAttribute('data-secret-name') || ''; if (!entryId || !secretId) return; - if (!confirm(tf('confirmUnlinkSecret', { name: secretName }))) return; fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/' + encodeURIComponent(secretId), { method: 'DELETE', credentials: 'same-origin' @@ -1136,7 +1243,12 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option return data; }); }).then(function () { - window.location.reload(); + btn.closest('.secret-edit-row').remove(); + var tableRow = document.querySelector('tr[data-entry-id="' + entryId + '"]'); + if (tableRow) { + var chip = tableRow.querySelector('.btn-unlink-secret[data-secret-id="' + secretId + '"]'); + if (chip && chip.parentElement) chip.parentElement.remove(); + } }).catch(function (err) { alert(err.message || String(err)); });