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, folder_tabs: Vec, type_options: Vec, 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, 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, /// JSON array of `{ id, name, secret_type }` for dialog secret chips. secrets_json: String, parents: Vec, children: Vec, 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, name: Option, metadata_query: Option, /// URL query key is `type` (maps to DB column `entries.type`). #[serde(rename = "type")] entry_type: Option, page: Option, } #[derive(Deserialize)] pub(super) struct EntryOptionQuery { q: Option, exclude_id: Option, } // ── Entry mutation error helpers ────────────────────────────────────────────── type EntryApiError = (StatusCode, Json); 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::() { 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 { 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, session: Session, Query(q): Query, ) -> Result { 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 = 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 = 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, ) -> String { let mut pairs: Vec = 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 = 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 = 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::>(), ) .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, session: Session, Query(q): Query, ) -> Result { 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, metadata: serde_json::Value, parent_ids: Option>, } pub(super) async fn api_entry_patch( State(state): State, session: Session, headers: HeaderMap, Path(entry_id): Path, Json(body): Json, ) -> Result, 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 = 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, session: Session, headers: HeaderMap, Query(q): Query, ) -> Result, 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, session: Session, headers: HeaderMap, Path(entry_id): Path, ) -> Result, 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, session: Session, headers: HeaderMap, Path(entry_id): Path, ) -> Result, 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, session: Session, headers: HeaderMap, Path(entry_id): Path, ) -> Result, 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, } #[derive(Serialize)] pub(super) struct SecretCheckNameResponse { ok: bool, available: bool, #[serde(skip_serializing_if = "Option::is_none")] error: Option, } pub(super) async fn api_secret_check_name( State(state): State, session: Session, headers: HeaderMap, Query(params): Query, ) -> Result, 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, #[serde(rename = "type")] secret_type: Option, value: Option, } pub(super) async fn api_secret_patch( State(state): State, session: Session, headers: HeaderMap, Path(secret_id): Path, Json(body): Json, ) -> Result, 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)> = 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 = 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, session: Session, headers: HeaderMap, Path((entry_id, secret_id)): Path<(Uuid, Uuid)>, ) -> Result, 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 = 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, session: Session, headers: HeaderMap, Path(entry_id): Path, ) -> Result, 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::() { 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 }))) }