style(dashboard): move version footer out of card
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m30s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m37s

This commit is contained in:
agent
2026-04-09 15:23:16 +08:00
parent 10da51c203
commit 089d0b4b58
23 changed files with 2114 additions and 525 deletions

View File

@@ -26,6 +26,7 @@ use tracing_subscriber::fmt::time::FormatTime;
use secrets_core::config::resolve_db_config;
use secrets_core::db::{create_pool, migrate};
use secrets_core::service::delete::purge_expired_deleted_entries;
use crate::oauth::OAuthConfig;
use crate::tools::SecretsService;
@@ -169,6 +170,7 @@ async fn main() -> Result<()> {
// Rate limiting
let rate_limit_state = rate_limit::RateLimitState::new();
let rate_limit_cleanup = rate_limit::spawn_cleanup_task(rate_limit_state.ip_limiter.clone());
let recycle_bin_cleanup = tokio::spawn(start_recycle_bin_cleanup_task(pool.clone()));
let router = Router::new()
.merge(web::web_router())
@@ -212,9 +214,28 @@ async fn main() -> Result<()> {
session_cleanup.abort();
rate_limit_cleanup.abort();
recycle_bin_cleanup.abort();
Ok(())
}
async fn start_recycle_bin_cleanup_task(pool: PgPool) {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(24 * 60 * 60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
match purge_expired_deleted_entries(&pool).await {
Ok(count) if count > 0 => {
tracing::info!(purged_count = count, "purged expired recycle bin entries");
}
Ok(_) => {}
Err(error) => {
tracing::warn!(error = %error, "failed to purge expired recycle bin entries");
}
}
}
}
async fn shutdown_signal() {
let ctrl_c = tokio::signal::ctrl_c();

View File

@@ -168,8 +168,9 @@ use secrets_core::service::{
export::{ExportParams, export as svc_export},
get_secret::{get_all_secrets_by_id, get_secret_field_by_id},
history::run as svc_history,
relations::{add_parent_relation, get_relations_for_entries, remove_parent_relation},
rollback::run as svc_rollback,
search::{SearchParams, resolve_entry_by_id, run as svc_search},
search::{SearchParams, resolve_entry, resolve_entry_by_id, run as svc_search},
update::{UpdateParams, run as svc_update},
};
@@ -373,6 +374,8 @@ struct FindInput {
description = "Fuzzy search across name, folder, type, notes, tags, and metadata values"
)]
query: Option<String>,
#[schemars(description = "Fuzzy search across metadata values only (keys excluded)")]
metadata_query: Option<String>,
#[schemars(description = "Exact folder filter (e.g. 'refining', 'ricnsmart')")]
folder: Option<String>,
#[schemars(
@@ -401,6 +404,8 @@ struct FindInput {
struct SearchInput {
#[schemars(description = "Fuzzy search across name, folder, type, notes, tags, metadata")]
query: Option<String>,
#[schemars(description = "Fuzzy search across metadata values only (keys excluded)")]
metadata_query: Option<String>,
#[schemars(description = "Folder filter (e.g. 'refining', 'personal', 'family')")]
folder: Option<String>,
#[schemars(
@@ -486,6 +491,9 @@ struct AddInput {
)]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
link_secret_names: Option<Vec<String>>,
#[schemars(description = "UUIDs of parent entries to link to this entry")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
parent_ids: Option<Vec<String>>,
#[schemars(description = "Encryption key as a 64-char hex string. \
If provided, takes priority over the X-Encryption-Key HTTP header. \
Use this when the MCP client cannot reliably forward custom headers.")]
@@ -551,6 +559,12 @@ struct UpdateInput {
)]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
unlink_secret_names: Option<Vec<String>>,
#[schemars(description = "UUIDs of parent entries to link")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
add_parent_ids: Option<Vec<String>>,
#[schemars(description = "UUIDs of parent entries to unlink")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
remove_parent_ids: Option<Vec<String>>,
#[schemars(description = "Encryption key as a 64-char hex string. \
If provided, takes priority over the X-Encryption-Key HTTP header. \
Use this when the MCP client cannot reliably forward custom headers.")]
@@ -596,16 +610,8 @@ struct HistoryInput {
#[derive(Debug, Deserialize, JsonSchema)]
struct RollbackInput {
#[schemars(description = "Name of the entry")]
name: String,
#[schemars(
description = "Folder for disambiguation when multiple entries share the same name (optional)"
)]
folder: Option<String>,
#[schemars(
description = "Entry UUID (from secrets_find). If provided, name/folder are ignored."
)]
id: Option<String>,
#[schemars(description = "Entry UUID (from secrets_find) for an existing, non-deleted entry")]
id: String,
#[schemars(description = "Target version number. Omit to restore the most recent snapshot.")]
#[serde(default, deserialize_with = "deser::option_i64_from_string")]
to_version: Option<i64>,
@@ -725,6 +731,10 @@ fn parse_uuid(s: &str) -> Result<Uuid, rmcp::ErrorData> {
.map_err(|_| rmcp::ErrorData::invalid_request(format!("Invalid UUID: '{}'", s), None))
}
fn parse_uuid_list(values: &[String]) -> Result<Vec<Uuid>, rmcp::ErrorData> {
values.iter().map(|value| parse_uuid(value)).collect()
}
// ── Tool implementations ──────────────────────────────────────────────────────
#[tool_router]
@@ -752,6 +762,7 @@ impl SecretsService {
name = input.name.as_deref(),
name_query = input.name_query.as_deref(),
query = input.query.as_deref(),
metadata_query = input.metadata_query.as_deref(),
"tool call start",
);
let tags = input.tags.unwrap_or_default();
@@ -764,6 +775,7 @@ impl SecretsService {
name_query: input.name_query.as_deref(),
tags: &tags,
query: input.query.as_deref(),
metadata_query: input.metadata_query.as_deref(),
sort: "name",
limit: input.limit.unwrap_or(20),
offset: input.offset.unwrap_or(0),
@@ -780,6 +792,7 @@ impl SecretsService {
name_query: input.name_query.as_deref(),
tags: &tags,
query: input.query.as_deref(),
metadata_query: input.metadata_query.as_deref(),
sort: "name",
limit: 0,
offset: 0,
@@ -792,11 +805,23 @@ impl SecretsService {
|e| tracing::warn!(tool = "secrets_find", error = %e, "count_entries failed"),
)
.unwrap_or(0);
let relation_map = get_relations_for_entries(
&self.pool,
&result
.entries
.iter()
.map(|entry| entry.id)
.collect::<Vec<_>>(),
Some(user_id),
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
let entries: Vec<serde_json::Value> = result
.entries
.iter()
.map(|e| {
let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
let schema: Vec<serde_json::Value> = result
.secret_schemas
.get(&e.id)
@@ -819,6 +844,8 @@ impl SecretsService {
"type": e.entry_type,
"tags": e.tags,
"metadata": e.metadata,
"parents": relations.parents,
"children": relations.children,
"secret_fields": schema,
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
@@ -867,6 +894,7 @@ impl SecretsService {
name = input.name.as_deref(),
name_query = input.name_query.as_deref(),
query = input.query.as_deref(),
metadata_query = input.metadata_query.as_deref(),
"tool call start",
);
let tags = input.tags.unwrap_or_default();
@@ -879,6 +907,7 @@ impl SecretsService {
name_query: input.name_query.as_deref(),
tags: &tags,
query: input.query.as_deref(),
metadata_query: input.metadata_query.as_deref(),
sort: input.sort.as_deref().unwrap_or("name"),
limit: input.limit.unwrap_or(20),
offset: input.offset.unwrap_or(0),
@@ -887,12 +916,24 @@ impl SecretsService {
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_search", Some(user_id), e))?;
let relation_map = get_relations_for_entries(
&self.pool,
&result
.entries
.iter()
.map(|entry| entry.id)
.collect::<Vec<_>>(),
Some(user_id),
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_search", Some(user_id), e))?;
let summary = input.summary.unwrap_or(false);
let entries: Vec<serde_json::Value> = result
.entries
.iter()
.map(|e| {
let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
if summary {
serde_json::json!({
"name": e.name,
@@ -900,6 +941,8 @@ impl SecretsService {
"type": e.entry_type,
"tags": e.tags,
"notes": e.notes,
"parents": relations.parents,
"children": relations.children,
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
} else {
@@ -926,6 +969,8 @@ impl SecretsService {
"notes": e.notes,
"tags": e.tags,
"metadata": e.metadata,
"parents": relations.parents,
"children": relations.children,
"secret_fields": schema,
"version": e.version,
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
@@ -1066,6 +1111,7 @@ impl SecretsService {
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
.collect();
let link_secret_names = input.link_secret_names.unwrap_or_default();
let parent_ids = parse_uuid_list(&input.parent_ids.unwrap_or_default())?;
let folder = input.folder.as_deref().unwrap_or("");
let entry_type = input.entry_type.as_deref().unwrap_or("");
let notes = input.notes.as_deref().unwrap_or("");
@@ -1089,6 +1135,15 @@ impl SecretsService {
.await
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
let created_entry = resolve_entry(&self.pool, &input.name, Some(folder), Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_add", Some(user_id), e))?;
for parent_id in parent_ids {
add_parent_relation(&self.pool, parent_id, created_entry.id, Some(user_id))
.await
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
}
tracing::info!(
tool = "secrets_add",
?user_id,
@@ -1176,6 +1231,8 @@ impl SecretsService {
let remove_secrets = input.remove_secrets.unwrap_or_default();
let link_secret_names = input.link_secret_names.unwrap_or_default();
let unlink_secret_names = input.unlink_secret_names.unwrap_or_default();
let add_parent_ids = parse_uuid_list(&input.add_parent_ids.unwrap_or_default())?;
let remove_parent_ids = parse_uuid_list(&input.remove_parent_ids.unwrap_or_default())?;
let result = svc_update(
&self.pool,
@@ -1199,6 +1256,30 @@ impl SecretsService {
.await
.map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
let entry_id = if let Some(id_str) = input.id.as_deref() {
parse_uuid(id_str)?
} else {
resolve_entry(
&self.pool,
&resolved_name,
resolved_folder.as_deref(),
Some(user_id),
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_update", Some(user_id), e))?
.id
};
for parent_id in add_parent_ids {
add_parent_relation(&self.pool, parent_id, entry_id, Some(user_id))
.await
.map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
}
for parent_id in remove_parent_ids {
remove_parent_relation(&self.pool, parent_id, entry_id, Some(user_id))
.await
.map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
}
tracing::info!(
tool = "secrets_update",
?user_id,
@@ -1354,32 +1435,15 @@ impl SecretsService {
tracing::info!(
tool = "secrets_rollback",
?user_id,
name = %input.name,
id = ?input.id,
id = %input.id,
to_version = input.to_version,
"tool call start",
);
let entry_id = parse_uuid(&input.id)?;
let (resolved_name, resolved_folder): (String, Option<String>) =
if let Some(ref id_str) = input.id {
let eid = parse_uuid(id_str)?;
let entry = resolve_entry_by_id(&self.pool, eid, Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
(entry.name, Some(entry.folder))
} else {
(input.name.clone(), input.folder.clone())
};
let result = svc_rollback(
&self.pool,
&resolved_name,
resolved_folder.as_deref(),
input.to_version,
Some(user_id),
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
let result = svc_rollback(&self.pool, entry_id, input.to_version, Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
tracing::info!(
tool = "secrets_rollback",

View File

@@ -12,8 +12,12 @@ use uuid::Uuid;
use secrets_core::error::AppError;
use secrets_core::service::{
delete::delete_by_id,
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},
};
@@ -40,6 +44,7 @@ struct EntriesPageTemplate {
secret_type_options_json: String,
filter_folder: String,
filter_name: String,
filter_metadata_query: String,
filter_type: String,
current_page: u32,
total_pages: u32,
@@ -47,6 +52,18 @@ struct EntriesPageTemplate {
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,
@@ -61,6 +78,9 @@ struct EntryListItemView {
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,
}
@@ -72,6 +92,15 @@ struct SecretSummaryView {
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,
@@ -79,16 +108,32 @@ struct FolderTabView {
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>);
@@ -171,6 +216,23 @@ fn map_app_error(err: &AppError, lang: UiLang) -> EntryApiError {
}
}
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(
@@ -202,6 +264,12 @@ pub(super) async fn entries_page(
.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(),
@@ -210,6 +278,7 @@ pub(super) async fn entries_page(
name_query: name_filter.as_deref(),
tags: &[],
query: None,
metadata_query: metadata_query_filter.as_deref(),
sort: "updated",
limit: ENTRIES_PAGE_LIMIT,
offset: 0,
@@ -223,7 +292,7 @@ pub(super) async fn entries_page(
count: i64,
}
let mut folder_sql =
"SELECT folder, COUNT(*)::bigint AS count FROM entries WHERE user_id = $1".to_string();
"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}"));
@@ -233,6 +302,13 @@ pub(super) async fn entries_page(
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);
@@ -242,6 +318,9 @@ pub(super) async fn entries_page(
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 {
@@ -261,7 +340,7 @@ pub(super) async fn entries_page(
},
folder_query.fetch_all(&state.pool),
sqlx::query_as::<_, TypeOptionRow>(
"SELECT DISTINCT type FROM entries WHERE user_id = $1 ORDER BY type",
"SELECT DISTINCT type FROM entries WHERE user_id = $1 AND deleted_at IS NULL ORDER BY type",
)
.bind(user_id)
.fetch_all(&state.pool),
@@ -297,6 +376,12 @@ pub(super) async fn entries_page(
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)
@@ -309,6 +394,7 @@ pub(super) async fn entries_page(
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();
@@ -327,6 +413,11 @@ pub(super) async fn entries_page(
{
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));
}
@@ -346,6 +437,7 @@ pub(super) async fn entries_page(
None,
type_filter.as_deref(),
name_filter.as_deref(),
metadata_query_filter.as_deref(),
Some(1),
),
active: folder_filter.is_none(),
@@ -357,6 +449,7 @@ pub(super) async fn entries_page(
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()),
@@ -368,6 +461,7 @@ pub(super) async fn entries_page(
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| {
@@ -384,6 +478,9 @@ pub(super) async fn entries_page(
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,
@@ -394,6 +491,9 @@ pub(super) async fn entries_page(
metadata_json,
secrets,
secrets_json,
parents,
children,
parents_json,
updated_at_iso: e.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true),
}
})
@@ -414,6 +514,7 @@ pub(super) async fn entries_page(
.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,
@@ -424,6 +525,56 @@ pub(super) async fn entries_page(
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)]
@@ -435,6 +586,7 @@ pub(super) struct EntryPatchBody {
notes: String,
tags: Vec<String>,
metadata: serde_json::Value,
parent_ids: Option<Vec<Uuid>>,
}
pub(super) async fn api_entry_patch(
@@ -496,9 +648,70 @@ pub(super) async fn api_entry_patch(
.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,
@@ -517,6 +730,51 @@ pub(super) async fn api_entry_delete(
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,
})))
}

View File

@@ -193,6 +193,7 @@ pub fn web_router() -> Router<AppState> {
.route("/auth/logout", post(auth::auth_logout))
.route("/dashboard", get(account::dashboard))
.route("/entries", get(entries::entries_page))
.route("/trash", get(entries::trash_page))
.route("/audit", get(audit::audit_page))
.route("/account/bind/google", get(auth::account_bind_google))
.route("/account/unbind/{provider}", post(auth::account_unbind))
@@ -200,6 +201,7 @@ pub fn web_router() -> Router<AppState> {
.route("/api/key-setup", post(account::api_key_setup))
.route("/api/key-change", post(account::api_key_change))
.route("/api/apikey", get(account::api_apikey_get))
.route("/api/entries/options", get(entries::api_entry_options))
.route(
"/api/apikey/regenerate",
post(account::api_apikey_regenerate),
@@ -208,6 +210,11 @@ pub fn web_router() -> Router<AppState> {
"/api/entries/{id}",
patch(entries::api_entry_patch).delete(entries::api_entry_delete),
)
.route("/api/trash/{id}/restore", post(entries::api_trash_restore))
.route(
"/api/trash/{id}",
axum::routing::delete(entries::api_trash_purge),
)
.route(
"/api/entries/{entry_id}/secrets/{secret_id}",
axum::routing::delete(entries::api_entry_secret_unlink),