1258 lines
40 KiB
Rust
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 })))
|
|
}
|