release(secrets-mcp): 0.6.0 - local gateway onboarding and target_exec
This commit is contained in:
894
crates/secrets-mcp/src/web/local_mcp.rs
Normal file
894
crates/secrets-mcp/src/web/local_mcp.rs
Normal file
@@ -0,0 +1,894 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
use tower_sessions::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
use secrets_core::crypto::hex;
|
||||
use secrets_core::service::api_key::validate_api_key;
|
||||
use secrets_core::service::delete::{DeleteParams, run as svc_delete};
|
||||
use secrets_core::service::get_secret::get_all_secrets_by_id;
|
||||
use secrets_core::service::history::run as svc_history;
|
||||
use secrets_core::service::relations::get_relations_for_entries;
|
||||
use secrets_core::service::search::{
|
||||
SearchParams, count_entries, resolve_entry_by_id, run as svc_search,
|
||||
};
|
||||
use secrets_core::service::user::get_user_by_id;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{
|
||||
UiLang, render_template, request_ui_lang, require_valid_user, require_valid_user_json,
|
||||
};
|
||||
|
||||
const BIND_TTL_SECS: u64 = 600;
|
||||
|
||||
#[derive(Clone, sqlx::FromRow)]
|
||||
struct BindRow {
|
||||
device_code: String,
|
||||
user_id: Option<Uuid>,
|
||||
approved: bool,
|
||||
}
|
||||
|
||||
enum ConsumeBindOutcome {
|
||||
Pending,
|
||||
Ready(BindRow),
|
||||
NotFound,
|
||||
DeviceMismatch,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct BindStartOutput {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
approve_url: String,
|
||||
expires_in_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct BindApproveInput {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct BindExchangeInput {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(
|
||||
source = r#"<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="utf-8"><title>Local MCP 绑定确认</title></head>
|
||||
<body>
|
||||
<h1>确认绑定本地 MCP</h1>
|
||||
{% if error.is_some() %}
|
||||
<p style="color:#c00">{{ error.as_ref().unwrap() }}</p>
|
||||
{% endif %}
|
||||
{% if approved %}
|
||||
<p>绑定已确认。你可以返回本地页面继续下一步。</p>
|
||||
{% else %}
|
||||
<p>Bind ID: <code>{{ bind_id }}</code></p>
|
||||
<form method="post" action="/api/local-mcp/bind/approve">
|
||||
<input type="hidden" name="bind_id" value="{{ bind_id }}"/>
|
||||
<input type="hidden" name="device_code" value="{{ device_code }}"/>
|
||||
<button type="submit">确认绑定</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>"#,
|
||||
ext = "html"
|
||||
)]
|
||||
struct ApproveTemplate {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
approved: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
async fn cleanup_expired(pool: &PgPool) {
|
||||
let _ = sqlx::query("DELETE FROM local_mcp_bind_sessions WHERE expires_at <= NOW()")
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn fetch_bind(pool: &PgPool, bind_id: &str) -> Result<Option<BindRow>, StatusCode> {
|
||||
sqlx::query_as::<_, BindRow>(
|
||||
"SELECT device_code, user_id, approved
|
||||
FROM local_mcp_bind_sessions
|
||||
WHERE bind_id = $1 AND expires_at > NOW()",
|
||||
)
|
||||
.bind(bind_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to fetch local MCP bind");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})
|
||||
}
|
||||
|
||||
async fn require_user_from_bearer(pool: &PgPool, headers: &HeaderMap) -> Result<Uuid, StatusCode> {
|
||||
let auth_header = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
let raw_key = auth_header
|
||||
.strip_prefix("Bearer ")
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
validate_api_key(pool, raw_key)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to validate api key for local MCP refresh");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.ok_or(StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
|
||||
async fn consume_bind_session(
|
||||
pool: &PgPool,
|
||||
bind_id: &str,
|
||||
device_code: &str,
|
||||
) -> Result<ConsumeBindOutcome, (StatusCode, Json<serde_json::Value>)> {
|
||||
let mut tx = pool.begin().await.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to start tx for bind exchange");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to start bind exchange" })),
|
||||
)
|
||||
})?;
|
||||
let stored = sqlx::query_as::<_, BindRow>(
|
||||
"SELECT device_code, user_id, approved
|
||||
FROM local_mcp_bind_sessions
|
||||
WHERE bind_id = $1 AND expires_at > NOW()
|
||||
FOR UPDATE",
|
||||
)
|
||||
.bind(bind_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to lock bind session");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to load bind session" })),
|
||||
)
|
||||
})?;
|
||||
let Some(bind) = stored else {
|
||||
tx.rollback().await.ok();
|
||||
return Ok(ConsumeBindOutcome::NotFound);
|
||||
};
|
||||
if bind.device_code != device_code {
|
||||
tx.rollback().await.ok();
|
||||
return Ok(ConsumeBindOutcome::DeviceMismatch);
|
||||
}
|
||||
if !bind.approved {
|
||||
tx.rollback().await.ok();
|
||||
return Ok(ConsumeBindOutcome::Pending);
|
||||
}
|
||||
sqlx::query("DELETE FROM local_mcp_bind_sessions WHERE bind_id = $1")
|
||||
.bind(bind_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to consume bind session");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to consume bind session" })),
|
||||
)
|
||||
})?;
|
||||
tx.commit().await.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to commit bind exchange");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to commit bind exchange" })),
|
||||
)
|
||||
})?;
|
||||
Ok(ConsumeBindOutcome::Ready(bind))
|
||||
}
|
||||
|
||||
pub(super) async fn api_bind_start(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<BindStartOutput>, (StatusCode, Json<serde_json::Value>)> {
|
||||
cleanup_expired(&state.pool).await;
|
||||
let bind_id = Uuid::new_v4().to_string();
|
||||
let device_code = Uuid::new_v4().simple().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO local_mcp_bind_sessions (bind_id, device_code, expires_at)
|
||||
VALUES ($1, $2, NOW() + ($3 * INTERVAL '1 second'))",
|
||||
)
|
||||
.bind(&bind_id)
|
||||
.bind(&device_code)
|
||||
.bind(BIND_TTL_SECS as i64)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id, "failed to insert local MCP bind session");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to create bind session" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let approve_url = format!(
|
||||
"{}/local-mcp/approve?bind_id={}&device_code={}",
|
||||
state.base_url, bind_id, device_code
|
||||
);
|
||||
|
||||
Ok(Json(BindStartOutput {
|
||||
bind_id,
|
||||
device_code,
|
||||
approve_url,
|
||||
expires_in_secs: BIND_TTL_SECS,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ApproveQuery {
|
||||
bind_id: String,
|
||||
device_code: String,
|
||||
}
|
||||
|
||||
pub(super) async fn approve_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Query(query): Query<ApproveQuery>,
|
||||
) -> Result<Response, Response> {
|
||||
let _user = require_valid_user(&state.pool, &session, "local_mcp.approve_page").await?;
|
||||
|
||||
cleanup_expired(&state.pool).await;
|
||||
let mut approved = false;
|
||||
let mut error = None;
|
||||
|
||||
match fetch_bind(&state.pool, &query.bind_id).await {
|
||||
Ok(Some(bind)) if bind.device_code == query.device_code => approved = bind.approved,
|
||||
Ok(Some(_)) => error = Some("device_code 不匹配".to_string()),
|
||||
Ok(None) => error = Some("绑定已过期或不存在".to_string()),
|
||||
Err(status) => return Err(status.into_response()),
|
||||
}
|
||||
|
||||
render_template(ApproveTemplate {
|
||||
bind_id: query.bind_id,
|
||||
device_code: query.device_code,
|
||||
approved,
|
||||
error,
|
||||
})
|
||||
.map_err(|status| status.into_response())
|
||||
}
|
||||
|
||||
pub(super) async fn api_bind_approve(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: axum::http::HeaderMap,
|
||||
axum::Form(input): axum::Form<BindApproveInput>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
let lang: UiLang = request_ui_lang(&headers);
|
||||
let user = require_valid_user_json(&state.pool, &session, lang).await?;
|
||||
cleanup_expired(&state.pool).await;
|
||||
|
||||
match fetch_bind(&state.pool, &input.bind_id).await {
|
||||
Ok(Some(bind)) if bind.device_code == input.device_code => {
|
||||
sqlx::query(
|
||||
"UPDATE local_mcp_bind_sessions
|
||||
SET user_id = $1, approved = TRUE
|
||||
WHERE bind_id = $2 AND expires_at > NOW()",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(&input.bind_id)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, bind_id = %input.bind_id, "failed to approve bind session");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "failed to approve bind session" })),
|
||||
)
|
||||
})?;
|
||||
Ok(axum::response::Redirect::to(&format!(
|
||||
"/local-mcp/approve?bind_id={}&device_code={}&approved=1",
|
||||
input.bind_id, input.device_code
|
||||
))
|
||||
.into_response())
|
||||
}
|
||||
Ok(Some(_)) => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "device_code mismatch" })),
|
||||
)),
|
||||
Ok(None) => Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "bind session not found or expired" })),
|
||||
)),
|
||||
Err(status) => Err((
|
||||
status,
|
||||
Json(json!({ "error": "failed to load bind session" })),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn api_bind_exchange(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<BindExchangeInput>,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
cleanup_expired(&state.pool).await;
|
||||
|
||||
let bind = match consume_bind_session(&state.pool, &input.bind_id, &input.device_code).await? {
|
||||
ConsumeBindOutcome::Pending => {
|
||||
return Ok((StatusCode::ACCEPTED, Json(json!({ "status": "pending" }))).into_response());
|
||||
}
|
||||
ConsumeBindOutcome::NotFound => {
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "bind session not found or expired" })),
|
||||
));
|
||||
}
|
||||
ConsumeBindOutcome::DeviceMismatch => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "device_code mismatch" })),
|
||||
));
|
||||
}
|
||||
ConsumeBindOutcome::Ready(bind) => bind,
|
||||
};
|
||||
let user_id = bind.user_id.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "approved bind missing user_id" })),
|
||||
)
|
||||
})?;
|
||||
let user = get_user_by_id(&state.pool, user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("failed to load user: {e}") })),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "user not found" })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let key_salt_hex = user.key_salt.as_ref().map(|bytes| {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect::<String>()
|
||||
});
|
||||
let key_check_hex = user.key_check.as_deref().map(hex::encode_hex);
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"status": "ok",
|
||||
"user_id": user.id,
|
||||
"api_key": user.api_key,
|
||||
"key_salt_hex": key_salt_hex,
|
||||
"key_check_hex": key_check_hex,
|
||||
"key_params": user.key_params,
|
||||
"key_version": user.key_version,
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use secrets_core::{
|
||||
config::resolve_db_config,
|
||||
db::{create_pool, migrate},
|
||||
};
|
||||
|
||||
async fn test_pool() -> Option<PgPool> {
|
||||
let config = resolve_db_config("").ok()?;
|
||||
let pool = create_pool(&config).await.ok()?;
|
||||
migrate(&pool).await.ok()?;
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consume_bind_session_is_single_use() {
|
||||
let Some(pool) = test_pool().await else {
|
||||
return;
|
||||
};
|
||||
let bind_id = format!("test-{}", Uuid::new_v4());
|
||||
let device_code = Uuid::new_v4().simple().to_string();
|
||||
let user_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO local_mcp_bind_sessions (bind_id, device_code, user_id, approved, expires_at)
|
||||
VALUES ($1, $2, $3, TRUE, NOW() + INTERVAL '10 minutes')",
|
||||
)
|
||||
.bind(&bind_id)
|
||||
.bind(&device_code)
|
||||
.bind(user_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let first = consume_bind_session(&pool, &bind_id, &device_code)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(first, ConsumeBindOutcome::Ready(_)));
|
||||
let second = consume_bind_session(&pool, &bind_id, &device_code)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(second, ConsumeBindOutcome::NotFound));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn api_bind_refresh(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let user = get_user_by_id(&state.pool, user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": format!("failed to load user: {e}") })),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "user not found" })),
|
||||
)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"user_id": user.id,
|
||||
"key_salt_hex": user.key_salt.as_deref().map(hex::encode_hex),
|
||||
"key_check_hex": user.key_check.as_deref().map(hex::encode_hex),
|
||||
"key_params": user.key_params,
|
||||
"key_version": user.key_version,
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LocalSearchInput {
|
||||
query: Option<String>,
|
||||
metadata_query: Option<String>,
|
||||
folder: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
name: Option<String>,
|
||||
name_query: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
summary: Option<bool>,
|
||||
sort: Option<String>,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LocalHistoryInput {
|
||||
name: Option<String>,
|
||||
folder: Option<String>,
|
||||
id: Option<Uuid>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct LocalDeleteInput {
|
||||
id: Option<Uuid>,
|
||||
name: Option<String>,
|
||||
folder: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
dry_run: Option<bool>,
|
||||
}
|
||||
|
||||
fn require_encryption_key_local(
|
||||
headers: &HeaderMap,
|
||||
) -> Result<[u8; 32], (StatusCode, Json<serde_json::Value>)> {
|
||||
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": "Missing X-Encryption-Key header" })),
|
||||
)
|
||||
})?;
|
||||
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "Invalid X-Encryption-Key format" })),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_entry_json(
|
||||
entry: &secrets_core::models::Entry,
|
||||
relations: secrets_core::service::relations::EntryRelations,
|
||||
secret_fields: &[secrets_core::models::SecretField],
|
||||
summary: bool,
|
||||
) -> Value {
|
||||
if summary {
|
||||
json!({
|
||||
"name": entry.name,
|
||||
"folder": entry.folder,
|
||||
"type": entry.entry_type,
|
||||
"tags": entry.tags,
|
||||
"notes": entry.notes,
|
||||
"parents": relations.parents,
|
||||
"children": relations.children,
|
||||
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
})
|
||||
} else {
|
||||
let schema: Vec<_> = secret_fields
|
||||
.iter()
|
||||
.map(|field| {
|
||||
json!({
|
||||
"id": field.id,
|
||||
"name": field.name,
|
||||
"type": field.secret_type,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"id": entry.id,
|
||||
"name": entry.name,
|
||||
"folder": entry.folder,
|
||||
"type": entry.entry_type,
|
||||
"notes": entry.notes,
|
||||
"tags": entry.tags,
|
||||
"metadata": entry.metadata,
|
||||
"parents": relations.parents,
|
||||
"children": relations.children,
|
||||
"secret_fields": schema,
|
||||
"version": entry.version,
|
||||
"updated_at": entry.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn api_entries_find(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LocalSearchInput>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let tags = input.tags.unwrap_or_default();
|
||||
let result = svc_search(
|
||||
&state.pool,
|
||||
SearchParams {
|
||||
folder: input.folder.as_deref(),
|
||||
entry_type: input.entry_type.as_deref(),
|
||||
name: input.name.as_deref(),
|
||||
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),
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp find failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "find failed" })),
|
||||
)
|
||||
})?;
|
||||
let total_count = count_entries(
|
||||
&state.pool,
|
||||
&SearchParams {
|
||||
folder: input.folder.as_deref(),
|
||||
entry_type: input.entry_type.as_deref(),
|
||||
name: input.name.as_deref(),
|
||||
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,
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp find count failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "find count failed" })),
|
||||
)
|
||||
})?;
|
||||
let entry_ids: Vec<_> = result.entries.iter().map(|entry| entry.id).collect();
|
||||
let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp find relations failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "find relations failed" })),
|
||||
)
|
||||
})?;
|
||||
let entries = result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let relations = relation_map.get(&entry.id).cloned().unwrap_or_default();
|
||||
let secret_fields = result
|
||||
.secret_schemas
|
||||
.get(&entry.id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
render_entry_json(entry, relations, secret_fields, false)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({
|
||||
"total_count": total_count,
|
||||
"entries": entries,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entries_search(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LocalSearchInput>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let tags = input.tags.unwrap_or_default();
|
||||
let result = svc_search(
|
||||
&state.pool,
|
||||
SearchParams {
|
||||
folder: input.folder.as_deref(),
|
||||
entry_type: input.entry_type.as_deref(),
|
||||
name: input.name.as_deref(),
|
||||
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),
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp search failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "search failed" })),
|
||||
)
|
||||
})?;
|
||||
let entry_ids: Vec<_> = result.entries.iter().map(|entry| entry.id).collect();
|
||||
let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp search relations failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "search relations failed" })),
|
||||
)
|
||||
})?;
|
||||
let summary = input.summary.unwrap_or(false);
|
||||
let entries = result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let relations = relation_map.get(&entry.id).cloned().unwrap_or_default();
|
||||
let secret_fields = result
|
||||
.secret_schemas
|
||||
.get(&entry.id)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
render_entry_json(entry, relations, secret_fields, summary)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(Value::Array(entries)))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entry_history(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LocalHistoryInput>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let (name, folder) = if let Some(id) = input.id {
|
||||
let entry = resolve_entry_by_id(&state.pool, id, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, %id, "local mcp history missing entry");
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "entry not found" })),
|
||||
)
|
||||
})?;
|
||||
(entry.name, Some(entry.folder))
|
||||
} else {
|
||||
let name = input.name.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "name or id is required" })),
|
||||
)
|
||||
})?;
|
||||
(name, input.folder)
|
||||
};
|
||||
let result = svc_history(
|
||||
&state.pool,
|
||||
&name,
|
||||
folder.as_deref(),
|
||||
input.limit.unwrap_or(20),
|
||||
Some(user_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, name = %name, "local mcp history failed");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(
|
||||
serde_json::to_value(result).unwrap_or_else(|_| json!([])),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entries_overview(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CountRow {
|
||||
name: String,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let folder_rows: Vec<CountRow> = sqlx::query_as::<_, CountRow>(
|
||||
"SELECT folder AS name, COUNT(*) AS count FROM entries \
|
||||
WHERE user_id = $1 GROUP BY folder ORDER BY folder",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp overview folders failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "overview failed" })),
|
||||
)
|
||||
})?;
|
||||
let type_rows: Vec<CountRow> = sqlx::query_as::<_, CountRow>(
|
||||
"SELECT type AS name, COUNT(*) AS count FROM entries \
|
||||
WHERE user_id = $1 GROUP BY type ORDER BY type",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "local mcp overview types failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "overview failed" })),
|
||||
)
|
||||
})?;
|
||||
let total: i64 = folder_rows.iter().map(|row| row.count).sum();
|
||||
Ok(Json(json!({
|
||||
"total": total,
|
||||
"folders": folder_rows.iter().map(|row| json!({"name": row.name, "count": row.count})).collect::<Vec<_>>(),
|
||||
"types": type_rows.iter().map(|row| json!({"name": row.name, "count": row.count})).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entries_delete_preview(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<LocalDeleteInput>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
if !input.dry_run.unwrap_or(false) {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": "dry_run=true is required" })),
|
||||
));
|
||||
}
|
||||
let (effective_name, effective_folder) =
|
||||
if let Some(id) = input.id {
|
||||
let entry = resolve_entry_by_id(&state.pool, id, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, %id, "local mcp delete preview missing entry");
|
||||
(StatusCode::NOT_FOUND, Json(json!({ "error": "entry not found" })))
|
||||
})?;
|
||||
(Some(entry.name), Some(entry.folder))
|
||||
} else {
|
||||
(input.name, input.folder)
|
||||
};
|
||||
let result = svc_delete(
|
||||
&state.pool,
|
||||
DeleteParams {
|
||||
name: effective_name.as_deref(),
|
||||
folder: effective_folder.as_deref(),
|
||||
entry_type: input.entry_type.as_deref(),
|
||||
dry_run: true,
|
||||
user_id: Some(user_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, "local mcp delete preview failed");
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(
|
||||
serde_json::to_value(result).unwrap_or_else(|_| json!({})),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entry_secrets_decrypt_bearer(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(entry_id): Path<Uuid>,
|
||||
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
|
||||
let user_id = require_user_from_bearer(&state.pool, &headers)
|
||||
.await
|
||||
.map_err(|status| (status, Json(json!({ "error": "unauthorized" }))))?;
|
||||
let master_key = require_encryption_key_local(&headers)?;
|
||||
let secrets = get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, %user_id, %entry_id, "local mcp decrypt failed");
|
||||
if let Some(app_err) = e.downcast_ref::<secrets_core::error::AppError>() {
|
||||
return match app_err {
|
||||
secrets_core::error::AppError::DecryptionFailed => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(json!({ "error": "Decryption failed, verify passphrase" })),
|
||||
),
|
||||
secrets_core::error::AppError::NotFoundEntry
|
||||
| secrets_core::error::AppError::NotFoundUser
|
||||
| secrets_core::error::AppError::NotFoundSecret => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": "entry not found" })),
|
||||
),
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "decrypt failed" })),
|
||||
),
|
||||
};
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": "decrypt failed" })),
|
||||
)
|
||||
})?;
|
||||
Ok(Json(
|
||||
serde_json::to_value(secrets).unwrap_or_else(|_| json!({})),
|
||||
))
|
||||
}
|
||||
@@ -18,6 +18,7 @@ mod audit;
|
||||
mod auth;
|
||||
mod changelog;
|
||||
mod entries;
|
||||
mod local_mcp;
|
||||
|
||||
// ── Session keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -259,6 +260,7 @@ pub fn web_router() -> Router<AppState> {
|
||||
.route("/auth/google", get(auth::auth_google))
|
||||
.route("/auth/google/callback", get(auth::auth_google_callback))
|
||||
.route("/auth/logout", post(auth::auth_logout))
|
||||
.route("/local-mcp/approve", get(local_mcp::approve_page))
|
||||
.route("/dashboard", get(account::dashboard))
|
||||
.route("/entries", get(entries::entries_page))
|
||||
.route("/trash", get(entries::trash_page))
|
||||
@@ -266,6 +268,43 @@ pub fn web_router() -> Router<AppState> {
|
||||
.route("/account/bind/google", get(auth::account_bind_google))
|
||||
.route("/account/unbind/{provider}", post(auth::account_unbind))
|
||||
.route("/api/key-salt", get(account::api_key_salt))
|
||||
.route("/api/local-mcp/bind/start", post(local_mcp::api_bind_start))
|
||||
.route(
|
||||
"/api/local-mcp/bind/approve",
|
||||
post(local_mcp::api_bind_approve),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/bind/exchange",
|
||||
post(local_mcp::api_bind_exchange),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/bind/refresh",
|
||||
post(local_mcp::api_bind_refresh),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/find",
|
||||
post(local_mcp::api_entries_find),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/search",
|
||||
post(local_mcp::api_entries_search),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/history",
|
||||
post(local_mcp::api_entry_history),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/overview",
|
||||
get(local_mcp::api_entries_overview),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/delete-preview",
|
||||
post(local_mcp::api_entries_delete_preview),
|
||||
)
|
||||
.route(
|
||||
"/api/local-mcp/entries/{id}/secrets",
|
||||
get(local_mcp::api_entry_secrets_decrypt_bearer),
|
||||
)
|
||||
.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))
|
||||
|
||||
Reference in New Issue
Block a user