Files
secrets/crates/secrets-mcp/src/web/entries.rs
voson cf93488c6a
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
release(secrets-mcp): 0.5.18 — Web 条目密文值编辑,PATCH /api/secrets/:id 支持 value
2026-04-11 15:14:04 +08:00

1258 lines
40 KiB
Rust

use axum::{
Json,
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::Response,
};
use chrono::SecondsFormat;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tower_sessions::Session;
use uuid::Uuid;
use secrets_core::error::AppError;
use secrets_core::service::{
delete::{
count_deleted_entries, delete_by_id, list_deleted_entries, purge_deleted_by_id,
restore_deleted_by_id,
},
get_secret::get_all_secrets_by_id,
relations::{RelationEntrySummary, get_relations_for_entries, set_parent_relations},
search::{SearchParams, count_entries, fetch_secrets_for_entries, ilike_pattern, list_entries},
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
};
use crate::AppState;
use super::{
ENTRIES_PAGE_LIMIT, UiLang, current_user_id, paginate, render_template, request_ui_lang,
require_valid_user, tr,
};
// ── Template types ────────────────────────────────────────────────────────────
use askama::Template;
#[derive(Template)]
#[template(path = "entries.html")]
struct EntriesPageTemplate {
user_name: String,
user_email: String,
entries: Vec<EntryListItemView>,
folder_tabs: Vec<FolderTabView>,
type_options: Vec<String>,
secret_type_options_json: String,
filter_folder: String,
filter_name: String,
filter_metadata_query: String,
filter_type: String,
current_page: u32,
total_pages: u32,
total_count: i64,
version: &'static str,
}
#[derive(Template)]
#[template(path = "trash.html")]
struct TrashPageTemplate {
user_name: String,
user_email: String,
entries: Vec<TrashEntryView>,
current_page: u32,
total_pages: u32,
total_count: i64,
version: &'static str,
}
/// Non-sensitive entry fields; `secrets` lists field names/types only (no ciphertext).
struct EntryListItemView {
id: String,
folder: String,
entry_type: String,
name: String,
notes: String,
tags: String,
/// Compact JSON for `data-entry-metadata` (dialog editor).
metadata_json: String,
/// Secret field summaries for table + dialog chips.
secrets: Vec<SecretSummaryView>,
/// JSON array of `{ id, name, secret_type }` for dialog secret chips.
secrets_json: String,
parents: Vec<RelationSummaryView>,
children: Vec<RelationSummaryView>,
parents_json: String,
/// RFC3339 UTC; shown in edit dialog.
updated_at_iso: String,
}
#[derive(Serialize)]
struct SecretSummaryView {
id: String,
name: String,
secret_type: String,
}
#[derive(Clone, Serialize)]
struct RelationSummaryView {
id: String,
name: String,
folder: String,
entry_type: String,
href: String,
}
struct FolderTabView {
name: String,
count: i64,
href: String,
active: bool,
}
struct TrashEntryView {
id: String,
name: String,
folder: String,
entry_type: String,
deleted_at_iso: String,
deleted_at_label: String,
}
#[derive(Deserialize)]
pub(super) struct EntriesQuery {
folder: Option<String>,
name: Option<String>,
metadata_query: Option<String>,
/// URL query key is `type` (maps to DB column `entries.type`).
#[serde(rename = "type")]
entry_type: Option<String>,
page: Option<u32>,
}
#[derive(Deserialize)]
pub(super) struct EntryOptionQuery {
q: Option<String>,
exclude_id: Option<Uuid>,
}
// ── Entry mutation error helpers ──────────────────────────────────────────────
type EntryApiError = (StatusCode, Json<serde_json::Value>);
fn require_encryption_key(headers: &HeaderMap, lang: UiLang) -> Result<[u8; 32], EntryApiError> {
let enc_key_hex = headers
.get("x-encryption-key")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "缺少 X-Encryption-Key 请求头", "缺少 X-Encryption-Key 請求標頭", "Missing X-Encryption-Key header") })),
)
})?;
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "X-Encryption-Key 格式无效", "X-Encryption-Key 格式無效", "Invalid X-Encryption-Key format") })),
)
})
}
fn map_entry_mutation_err(e: anyhow::Error, lang: UiLang) -> EntryApiError {
if let Some(app_err) = e.downcast_ref::<AppError>() {
return map_app_error(app_err, lang);
}
// Fallback for legacy string-based errors and raw sqlx errors
let msg = e.to_string();
if msg.contains("already exists") {
return (
StatusCode::CONFLICT,
Json(
json!({ "error": tr(lang, "该账号下已存在相同 folder + name 的条目", "此帳號下已存在相同 folder + name 的條目", "An entry with the same folder + name already exists for this account") }),
),
);
}
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": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") }),
),
)
}
fn map_app_error(err: &AppError, lang: UiLang) -> EntryApiError {
match err {
AppError::ConflictEntryName { .. } | AppError::ConflictSecretName { .. } => (
StatusCode::CONFLICT,
Json(json!({ "error": err.to_string() })),
),
AppError::NotFoundEntry | AppError::NotFoundUser | AppError::NotFoundSecret => (
StatusCode::NOT_FOUND,
Json(
json!({ "error": tr(lang, "资源不存在或无权访问", "資源不存在或無權存取", "Resource not found or no access") }),
),
),
AppError::AuthenticationFailed | AppError::Unauthorized => (
StatusCode::UNAUTHORIZED,
Json(
json!({ "error": tr(lang, "认证失败或无权访问", "認證失敗或無權存取", "Authentication failed or unauthorized") }),
),
),
AppError::Validation { message } => {
(StatusCode::BAD_REQUEST, Json(json!({ "error": message })))
}
AppError::ConcurrentModification => (
StatusCode::CONFLICT,
Json(
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::EncryptionKeyNotSet => (
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "请先设置密码短语后再使用此功能", "請先設定密碼短語再使用此功能", "Please set a passphrase before using this feature") }),
),
),
AppError::Internal(_) => {
tracing::error!(error = %err, "internal error in entry mutation");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") }),
),
)
}
}
}
fn relation_views(items: &[RelationEntrySummary]) -> Vec<RelationSummaryView> {
items
.iter()
.map(|item| RelationSummaryView {
id: item.id.to_string(),
name: item.name.clone(),
folder: item.folder.clone(),
entry_type: item.entry_type.clone(),
href: format!(
"/entries?folder={}&name={}",
urlencoding::encode(&item.folder),
urlencoding::encode(&item.name)
),
})
.collect()
}
// ── Handlers ──────────────────────────────────────────────────────────────────
pub(super) async fn entries_page(
State(state): State<AppState>,
session: Session,
Query(q): Query<EntriesQuery>,
) -> Result<Response, StatusCode> {
let user = match require_valid_user(&state.pool, &session, "entries_page").await {
Ok(u) => u,
Err(r) => return Ok(r),
};
let user_id = user.id;
let folder_filter = q
.folder
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let type_filter = q
.entry_type
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let name_filter = q
.name
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let metadata_query_filter = q
.metadata_query
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let page = q.page.unwrap_or(1).max(1);
let count_params = SearchParams {
folder: folder_filter.as_deref(),
entry_type: type_filter.as_deref(),
name: None,
name_query: name_filter.as_deref(),
tags: &[],
query: None,
metadata_query: metadata_query_filter.as_deref(),
sort: "updated",
limit: ENTRIES_PAGE_LIMIT,
offset: 0,
user_id: Some(user_id),
};
// Build folder query before joining so the SQL string lives long enough.
#[derive(sqlx::FromRow)]
struct FolderCountRow {
folder: String,
count: i64,
}
let mut folder_sql =
"SELECT folder, COUNT(*)::bigint AS count FROM entries WHERE user_id = $1 AND deleted_at IS NULL".to_string();
let mut bind_idx = 2;
if type_filter.is_some() {
folder_sql.push_str(&format!(" AND type = ${bind_idx}"));
bind_idx += 1;
}
if name_filter.is_some() {
folder_sql.push_str(&format!(" AND name ILIKE ${bind_idx} ESCAPE '\\'"));
bind_idx += 1;
}
if metadata_query_filter.is_some() {
folder_sql.push_str(&format!(
" AND EXISTS (SELECT 1 FROM jsonb_path_query(metadata, 'strict $.** ? (@.type() != \"object\" && @.type() != \"array\")') AS val \
WHERE (val #>> '{{}}') ILIKE ${bind_idx} ESCAPE '\\')"
));
bind_idx += 1;
}
let _ = bind_idx;
folder_sql.push_str(" GROUP BY folder ORDER BY folder");
let mut folder_query = sqlx::query_as::<_, FolderCountRow>(&folder_sql).bind(user_id);
if let Some(t) = type_filter.as_deref() {
folder_query = folder_query.bind(t);
}
if let Some(n) = name_filter.as_deref() {
folder_query = folder_query.bind(ilike_pattern(n));
}
if let Some(v) = metadata_query_filter.as_deref() {
folder_query = folder_query.bind(ilike_pattern(v));
}
#[derive(sqlx::FromRow)]
struct TypeOptionRow {
#[sqlx(rename = "type")]
entry_type: String,
}
// Phase 1: count, folder tabs, and type options are mutually independent — run in parallel.
let (total_count, folder_rows_res, type_options_res) = tokio::join!(
async {
count_entries(&state.pool, &count_params)
.await
.inspect_err(
|e| tracing::warn!(error = %e, "count_entries failed for web entries page"),
)
.unwrap_or(0)
},
folder_query.fetch_all(&state.pool),
sqlx::query_as::<_, TypeOptionRow>(
"SELECT DISTINCT type FROM entries WHERE user_id = $1 AND deleted_at IS NULL ORDER BY type",
)
.bind(user_id)
.fetch_all(&state.pool),
);
let folder_rows = folder_rows_res.map_err(|e| {
tracing::error!(error = %e, "failed to load folder tabs for web");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let mut type_options: Vec<String> = type_options_res
.map_err(|e| {
tracing::error!(error = %e, "failed to load type options for web");
StatusCode::INTERNAL_SERVER_ERROR
})?
.into_iter()
.map(|r| r.entry_type)
.filter(|t| !t.is_empty())
.collect();
// Phase 2: paginate using count, then fetch entries for the page.
let (current_page, total_pages, offset) = paginate(page, total_count, ENTRIES_PAGE_LIMIT);
let list_params = SearchParams {
offset,
..count_params
};
let rows = list_entries(&state.pool, list_params).await.map_err(|e| {
tracing::error!(error = %e, "failed to load entries list for web");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let entry_ids: Vec<Uuid> = rows.iter().map(|e| e.id).collect();
let secret_schemas = fetch_secrets_for_entries(&state.pool, &entry_ids)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to load secret schema list for web");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id))
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to load relation list for web");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if let Some(current) = type_filter.as_ref()
&& !current.is_empty()
&& !type_options.iter().any(|t| t == current)
{
type_options.push(current.clone());
type_options.sort_unstable();
}
fn entries_href(
folder: Option<&str>,
entry_type: Option<&str>,
name: Option<&str>,
metadata_query: Option<&str>,
page: Option<u32>,
) -> String {
let mut pairs: Vec<String> = Vec::new();
if let Some(f) = folder
&& !f.is_empty()
{
pairs.push(format!("folder={}", urlencoding::encode(f)));
}
if let Some(t) = entry_type
&& !t.is_empty()
{
pairs.push(format!("type={}", urlencoding::encode(t)));
}
if let Some(n) = name
&& !n.is_empty()
{
pairs.push(format!("name={}", urlencoding::encode(n)));
}
if let Some(v) = metadata_query
&& !v.is_empty()
{
pairs.push(format!("metadata_query={}", urlencoding::encode(v)));
}
if let Some(p) = page {
pairs.push(format!("page={}", p));
}
if pairs.is_empty() {
"/entries".to_string()
} else {
format!("/entries?{}", pairs.join("&"))
}
}
let all_count: i64 = folder_rows.iter().map(|r| r.count).sum();
let mut folder_tabs: Vec<FolderTabView> = Vec::with_capacity(folder_rows.len() + 1);
folder_tabs.push(FolderTabView {
name: "全部".to_string(),
count: all_count,
href: entries_href(
None,
type_filter.as_deref(),
name_filter.as_deref(),
metadata_query_filter.as_deref(),
Some(1),
),
active: folder_filter.is_none(),
});
for r in folder_rows {
let name = r.folder;
folder_tabs.push(FolderTabView {
href: entries_href(
Some(&name),
type_filter.as_deref(),
name_filter.as_deref(),
metadata_query_filter.as_deref(),
Some(1),
),
active: folder_filter.as_deref() == Some(name.as_str()),
name,
count: r.count,
});
}
let entries = rows
.into_iter()
.map(|e| {
let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
let secrets: Vec<SecretSummaryView> = secret_schemas
.get(&e.id)
.map(|fields| {
fields
.iter()
.map(|f| SecretSummaryView {
id: f.id.to_string(),
name: f.name.clone(),
secret_type: f.secret_type.clone(),
})
.collect()
})
.unwrap_or_default();
let secrets_json = serde_json::to_string(&secrets).unwrap_or_else(|_| "[]".to_string());
let metadata_json =
serde_json::to_string(&e.metadata).unwrap_or_else(|_| "{}".to_string());
let parents = relation_views(&relations.parents);
let children = relation_views(&relations.children);
let parents_json = serde_json::to_string(&parents).unwrap_or_else(|_| "[]".to_string());
EntryListItemView {
id: e.id.to_string(),
folder: e.folder,
entry_type: e.entry_type,
name: e.name,
notes: e.notes,
tags: e.tags.join(", "),
metadata_json,
secrets,
secrets_json,
parents,
children,
parents_json,
updated_at_iso: e.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true),
}
})
.collect();
let tmpl = EntriesPageTemplate {
user_name: user.name.clone(),
user_email: user.email.clone().unwrap_or_default(),
entries,
folder_tabs,
type_options,
secret_type_options_json: serde_json::to_string(
&secrets_core::taxonomy::SECRET_TYPE_OPTIONS
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
)
.unwrap_or_default(),
filter_folder: folder_filter.unwrap_or_default(),
filter_name: name_filter.unwrap_or_default(),
filter_metadata_query: metadata_query_filter.unwrap_or_default(),
filter_type: type_filter.unwrap_or_default(),
current_page,
total_pages,
total_count,
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
pub(super) async fn trash_page(
State(state): State<AppState>,
session: Session,
Query(q): Query<EntriesQuery>,
) -> Result<Response, StatusCode> {
let user = match require_valid_user(&state.pool, &session, "trash_page").await {
Ok(u) => u,
Err(r) => return Ok(r),
};
let page = q.page.unwrap_or(1).max(1);
let total_count = count_deleted_entries(&state.pool, user.id)
.await
.map_err(|e| {
tracing::error!(error = %e, user_id = %user.id, "failed to count trash entries");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let (current_page, total_pages, offset) = paginate(page, total_count, ENTRIES_PAGE_LIMIT);
let rows = list_deleted_entries(&state.pool, user.id, ENTRIES_PAGE_LIMIT, offset)
.await
.map_err(|e| {
tracing::error!(error = %e, user_id = %user.id, "failed to load trash entries");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let entries = rows
.into_iter()
.map(|entry| TrashEntryView {
id: entry.id.to_string(),
name: entry.name,
folder: entry.folder,
entry_type: entry.entry_type,
deleted_at_iso: entry.deleted_at.to_rfc3339_opts(SecondsFormat::Secs, true),
deleted_at_label: entry.deleted_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
})
.collect();
let tmpl = TrashPageTemplate {
user_name: user.name.clone(),
user_email: user.email.clone().unwrap_or_default(),
entries,
current_page,
total_pages,
total_count,
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
// ── Entry management (Web UI, non-sensitive fields only) ───────────────────────
#[derive(Deserialize)]
pub(super) struct EntryPatchBody {
folder: String,
#[serde(rename = "type")]
entry_type: String,
name: String,
notes: String,
tags: Vec<String>,
metadata: serde_json::Value,
parent_ids: Option<Vec<Uuid>>,
}
pub(super) async fn api_entry_patch(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Path(entry_id): Path<Uuid>,
Json(body): Json<EntryPatchBody>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
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": tr(lang, "name 不能为空", "name 不能為空", "name cannot be empty") }),
),
));
}
let tags: Vec<String> = 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": tr(lang, "metadata 必须是 JSON 对象", "metadata 必須是 JSON 物件", "metadata must be a JSON object") }),
),
));
}
update_fields_by_id(
&state.pool,
entry_id,
user_id,
UpdateEntryFieldsByIdParams {
folder,
entry_type,
name,
notes,
tags: &tags,
metadata: &body.metadata,
},
)
.await
.map_err(|e| map_entry_mutation_err(e, lang))?;
if let Some(parent_ids) = body.parent_ids.as_deref() {
set_parent_relations(&state.pool, entry_id, parent_ids, Some(user_id))
.await
.map_err(|e| map_entry_mutation_err(e, lang))?;
}
Ok(Json(json!({ "ok": true })))
}
pub(super) async fn api_entry_options(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Query(q): Query<EntryOptionQuery>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let query =
q.q.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("");
if query.is_empty() {
return Ok(Json(json!([])));
}
let rows = list_entries(
&state.pool,
SearchParams {
folder: None,
entry_type: None,
name: None,
name_query: Some(query),
tags: &[],
query: None,
metadata_query: None,
sort: "name",
limit: 10,
offset: 0,
user_id: Some(user_id),
},
)
.await
.map_err(|e| map_entry_mutation_err(e, lang))?;
let options: Vec<_> = rows
.into_iter()
.filter(|entry| Some(entry.id) != q.exclude_id)
.map(|entry| {
json!({
"id": entry.id,
"name": entry.name,
"folder": entry.folder,
"type": entry.entry_type,
})
})
.collect();
Ok(Json(serde_json::Value::Array(options)))
}
pub(super) async fn api_entry_delete(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
delete_by_id(&state.pool, entry_id, user_id)
.await
.map_err(|e| map_entry_mutation_err(e, lang))?;
Ok(Json(json!({
"ok": true,
"deleted": true,
})))
}
pub(super) async fn api_trash_restore(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
restore_deleted_by_id(&state.pool, entry_id, user_id)
.await
.map_err(|e| map_entry_mutation_err(e, lang))?;
Ok(Json(json!({
"ok": true,
"restored": true,
})))
}
pub(super) async fn api_trash_purge(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
purge_deleted_by_id(&state.pool, entry_id, user_id)
.await
.map_err(|e| map_entry_mutation_err(e, lang))?;
Ok(Json(json!({
"ok": true,
"purged": true,
})))
}
#[derive(Deserialize)]
pub(super) struct SecretCheckNameQuery {
name: String,
exclude_secret_id: Option<Uuid>,
}
#[derive(Serialize)]
pub(super) struct SecretCheckNameResponse {
ok: bool,
available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
pub(super) async fn api_secret_check_name(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Query(params): Query<SecretCheckNameQuery>,
) -> Result<Json<SecretCheckNameResponse>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let name = params.name.trim();
if name.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "secret name 不能为空", "secret name 不能為空", "secret name cannot be empty") }),
),
));
}
if name.chars().count() > 256 {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "secret name 长度不能超过 256 个字符", "secret name 長度不能超過 256 個字元", "secret name must be at most 256 characters") }),
),
));
}
let count: i64 = if let Some(exclude_id) = params.exclude_secret_id {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM secrets WHERE user_id = $1 AND name = $2 AND id != $3",
)
.bind(user_id)
.bind(name)
.bind(exclude_id)
.fetch_one(&state.pool)
.await
} else {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM secrets WHERE user_id = $1 AND name = $2",
)
.bind(user_id)
.bind(name)
.fetch_one(&state.pool)
.await
}.map_err(|e| {
tracing::error!(error = %e, "failed to check secret name availability");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") }),
),
)
})?;
let available = count == 0;
let error = if available {
None
} else {
Some(
tr(
lang,
"该用户下已存在相同 name 的密文",
"該用戶下已存在相同 name 的密文",
"A secret with the same name already exists for this user",
)
.to_string(),
)
};
Ok(Json(SecretCheckNameResponse {
ok: true,
available,
error,
}))
}
#[derive(Deserialize)]
pub(super) struct SecretPatchBody {
name: Option<String>,
#[serde(rename = "type")]
secret_type: Option<String>,
value: Option<serde_json::Value>,
}
pub(super) async fn api_secret_patch(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Path(secret_id): Path<Uuid>,
Json(body): Json<SecretPatchBody>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
#[derive(Serialize, sqlx::FromRow)]
struct LinkedEntryAuditRow {
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
}
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let name = body.name.as_ref().map(|s| s.trim());
let secret_type = body.secret_type.as_ref().map(|s| s.trim());
let secret_value = body.value.as_ref();
if let Some(n) = name {
if n.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "secret name 不能为空", "secret name 不能為空", "secret name cannot be empty") }),
),
));
}
if n.chars().count() > 256 {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "secret name 长度不能超过 256 个字符", "secret name 長度不能超過 256 個字元", "secret name must be at most 256 characters") }),
),
));
}
}
if let Some(t) = secret_type {
if t.is_empty() {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "secret type 不能为空", "secret type 不能為空", "secret type cannot be empty") }),
),
));
}
if t.chars().count() > 64 {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "secret type 长度不能超过 64 个字符", "secret type 長度不能超過 64 個字元", "secret type must be at most 64 characters") }),
),
));
}
}
if name.is_none() && secret_type.is_none() && secret_value.is_none() {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "至少需要提供 name、type 或 value 之一", "至少需要提供 name、type 或 value 之一", "At least one of name, type, or value is required") }),
),
));
}
let master_key = if secret_value.is_some() {
Some(require_encryption_key(&headers, lang)?)
} else {
None
};
let mut tx = state
.pool
.begin()
.await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
let secret_row: Option<(String, String, Vec<u8>)> = sqlx::query_as(
"SELECT name, type, encrypted FROM secrets WHERE id = $1 AND user_id = $2 FOR UPDATE",
)
.bind(secret_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
let Some((old_name, old_type, old_encrypted)) = secret_row else {
let _ = tx.rollback().await;
return Err((
StatusCode::NOT_FOUND,
Json(
json!({ "error": tr(lang, "密文不存在或无权访问", "密文不存在或無權存取", "Secret not found or no access") }),
),
));
};
let linked_entries: Vec<LinkedEntryAuditRow> = sqlx::query_as(
"SELECT e.folder, e.type, e.name \
FROM entry_secrets es \
JOIN entries e ON e.id = es.entry_id \
WHERE es.secret_id = $1 AND e.user_id = $2 \
ORDER BY e.folder, e.type, e.name",
)
.bind(secret_id)
.bind(user_id)
.fetch_all(&mut *tx)
.await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
let new_name = name.unwrap_or(&old_name).to_string();
let new_type = secret_type.unwrap_or(&old_type).to_string();
let new_encrypted = if let Some(value) = secret_value {
let encrypted = secrets_core::crypto::encrypt_json(
master_key
.as_ref()
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "请先设置密码短语后再编辑密文值", "請先設定密碼短語後再編輯密文值", "Unlock your passphrase before editing secret values") })),
)
})?,
value,
)
.map_err(|e| map_entry_mutation_err(e, lang))?;
Some(encrypted)
} else {
None
};
let value_changed = new_encrypted.is_some();
if let Err(e) = secrets_core::db::snapshot_secret_history(
&mut tx,
secrets_core::db::SecretSnapshotParams {
secret_id,
name: &old_name,
encrypted: &old_encrypted,
action: if value_changed { "update" } else { "rename" },
},
)
.await
{
tracing::warn!(error = %e, %secret_id, "failed to snapshot secret history before patch");
}
let result = sqlx::query(
"UPDATE secrets SET name = $1, type = $2, encrypted = $3, version = version + 1, updated_at = NOW() \
WHERE id = $4",
)
.bind(&new_name)
.bind(&new_type)
.bind(new_encrypted.as_deref().unwrap_or(&old_encrypted))
.bind(secret_id)
.execute(&mut *tx)
.await;
if let Err(e) = result {
if let Some(db_err) = e.as_database_error()
&& db_err.code() == Some("23505".into())
{
let _ = tx.rollback().await;
return Err(map_app_error(
&AppError::ConflictSecretName {
secret_name: new_name.clone(),
},
lang,
));
}
let _ = tx.rollback().await;
return Err(map_entry_mutation_err(e.into(), lang));
}
secrets_core::audit::log_tx(
&mut tx,
Some(user_id),
if value_changed {
"update_secret"
} else {
"rename_secret"
},
"",
"",
&old_name,
json!({
"source": "web",
"secret_id": secret_id,
"old_name": old_name,
"new_name": new_name,
"old_type": old_type,
"new_type": new_type,
"value_updated": value_changed,
"linked_entries": linked_entries,
}),
)
.await;
tx.commit()
.await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
Ok(Json(json!({ "ok": true })))
}
pub(super) async fn api_entry_secret_unlink(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Path((entry_id, secret_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
#[derive(sqlx::FromRow)]
struct EntryAuditRow {
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
}
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let mut tx = state
.pool
.begin()
.await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
let entry_row: Option<EntryAuditRow> =
sqlx::query_as("SELECT folder, type, name FROM entries WHERE id = $1 AND user_id = $2")
.bind(entry_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
let Some(entry_row) = entry_row else {
let _ = tx.rollback().await;
return Err((
StatusCode::NOT_FOUND,
Json(
json!({ "error": tr(lang, "条目不存在或无权访问", "條目不存在或無權存取", "Entry not found or no access") }),
),
));
};
let deleted = sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
.bind(entry_id)
.bind(secret_id)
.execute(&mut *tx)
.await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?
.rows_affected();
if deleted == 0 {
let _ = tx.rollback().await;
return Err((
StatusCode::NOT_FOUND,
Json(json!({ "error": tr(lang, "关联不存在", "關聯不存在", "Relation not found") })),
));
}
let secret_deleted = 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(secret_id)
.execute(&mut *tx)
.await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?
.rows_affected()
> 0;
secrets_core::audit::log_tx(
&mut tx,
Some(user_id),
"unlink_secret",
&entry_row.folder,
&entry_row.entry_type,
&entry_row.name,
json!({
"source": "web",
"entry_id": entry_id,
"secret_id": secret_id,
"deleted_secret": secret_deleted,
}),
)
.await;
tx.commit()
.await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
Ok(Json(json!({
"ok": true,
"deleted_relation": true,
"deleted_secret": secret_deleted,
})))
}
// ── Decrypt entry secrets (Web UI) ───────────────────────────────────────────
pub(super) async fn api_entry_secrets_decrypt(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let master_key = require_encryption_key(&headers, lang)?;
let secrets =
get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id))
.await
.map_err(|e| {
if let Some(app_err) = e.downcast_ref::<AppError>() {
return match app_err {
AppError::DecryptionFailed => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": tr(lang, "解密失败,请确认密码短语正确", "解密失敗,請確認密碼短語正確", "Decryption failed, please verify your passphrase") })),
),
AppError::NotFoundEntry | AppError::NotFoundUser | AppError::NotFoundSecret => (
StatusCode::NOT_FOUND,
Json(json!({ "error": tr(lang, "条目不存在或无权访问", "條目不存在或無權存取", "Entry not found or no access") })),
),
_ => {
tracing::error!(error = %e, %entry_id, "decrypt entry secrets failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") })),
)
}
};
}
tracing::error!(error = %e, %entry_id, "decrypt entry secrets failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") })),
)
})?;
Ok(Json(json!({ "ok": true, "secrets": secrets })))
}