Bump version: secrets-mcp-0.5.1 tag already existed while crates had further changes. Made-with: Cursor
1695 lines
53 KiB
Rust
1695 lines
53 KiB
Rust
use askama::Template;
|
|
use chrono::SecondsFormat;
|
|
use std::net::SocketAddr;
|
|
|
|
use axum::{
|
|
Json, Router,
|
|
body::Body,
|
|
extract::{ConnectInfo, Path, Query, State},
|
|
http::{HeaderMap, StatusCode, header},
|
|
response::{Html, IntoResponse, Redirect, Response},
|
|
routing::{get, patch, post},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::json;
|
|
use tower_sessions::Session;
|
|
use uuid::Uuid;
|
|
|
|
use secrets_core::audit::log_login;
|
|
use secrets_core::crypto::hex;
|
|
use secrets_core::error::AppError;
|
|
use secrets_core::service::{
|
|
api_key::{ensure_api_key, regenerate_api_key},
|
|
audit_log::list_for_user,
|
|
delete::delete_by_id,
|
|
search::{SearchParams, fetch_secret_schemas, ilike_pattern, list_entries},
|
|
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
|
|
user::{
|
|
OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id,
|
|
unbind_oauth_account, update_user_key_setup,
|
|
},
|
|
};
|
|
|
|
use crate::AppState;
|
|
use crate::oauth::{OAuthConfig, OAuthUserInfo, google_auth_url, random_state};
|
|
|
|
const SESSION_USER_ID: &str = "user_id";
|
|
const SESSION_OAUTH_STATE: &str = "oauth_state";
|
|
const SESSION_OAUTH_BIND_MODE: &str = "oauth_bind_mode";
|
|
const SESSION_LOGIN_PROVIDER: &str = "login_provider";
|
|
|
|
// ── Template types ────────────────────────────────────────────────────────────
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "login.html")]
|
|
struct LoginTemplate {
|
|
has_google: bool,
|
|
base_url: String,
|
|
version: &'static str,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "home.html")]
|
|
struct HomeTemplate {
|
|
is_logged_in: bool,
|
|
base_url: String,
|
|
version: &'static str,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "dashboard.html")]
|
|
struct DashboardTemplate {
|
|
user_name: String,
|
|
user_email: String,
|
|
has_passphrase: bool,
|
|
base_url: String,
|
|
version: &'static str,
|
|
}
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "audit.html")]
|
|
struct AuditPageTemplate {
|
|
user_name: String,
|
|
user_email: String,
|
|
entries: Vec<AuditEntryView>,
|
|
version: &'static str,
|
|
}
|
|
|
|
struct AuditEntryView {
|
|
/// RFC3339 UTC for `<time datetime>`; rendered as browser-local in audit.html.
|
|
created_at_iso: String,
|
|
action: String,
|
|
target: String,
|
|
detail: String,
|
|
}
|
|
|
|
#[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_type: String,
|
|
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,
|
|
/// RFC3339 UTC; shown in edit dialog.
|
|
updated_at_iso: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SecretSummaryView {
|
|
id: String,
|
|
name: String,
|
|
secret_type: String,
|
|
}
|
|
|
|
struct FolderTabView {
|
|
name: String,
|
|
count: i64,
|
|
href: String,
|
|
active: bool,
|
|
}
|
|
|
|
/// Cap for HTML list (avoids loading unbounded rows into memory).
|
|
const ENTRIES_PAGE_LIMIT: u32 = 5_000;
|
|
|
|
#[derive(Deserialize)]
|
|
struct EntriesQuery {
|
|
folder: Option<String>,
|
|
name: Option<String>,
|
|
/// URL query key is `type` (maps to DB column `entries.type`).
|
|
#[serde(rename = "type")]
|
|
entry_type: Option<String>,
|
|
}
|
|
|
|
// ── App state helpers ─────────────────────────────────────────────────────────
|
|
|
|
fn google_cfg(state: &AppState) -> Option<&OAuthConfig> {
|
|
state.google_config.as_ref()
|
|
}
|
|
|
|
async fn current_user_id(session: &Session) -> Option<Uuid> {
|
|
match session.get::<String>(SESSION_USER_ID).await {
|
|
Ok(opt) => match opt {
|
|
Some(s) => match Uuid::parse_str(&s) {
|
|
Ok(id) => Some(id),
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, user_id_str = %s, "invalid user_id UUID in session");
|
|
None
|
|
}
|
|
},
|
|
None => None,
|
|
},
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "failed to read user_id from session");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> {
|
|
if let Some(first) = headers
|
|
.get("x-forwarded-for")
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.split(',').next())
|
|
{
|
|
let value = first.trim();
|
|
if !value.is_empty() {
|
|
return Some(value.to_string());
|
|
}
|
|
}
|
|
|
|
Some(connect_info.ip().to_string())
|
|
}
|
|
|
|
fn request_user_agent(headers: &HeaderMap) -> Option<String> {
|
|
headers
|
|
.get(header::USER_AGENT)
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
}
|
|
|
|
// ── Routes ────────────────────────────────────────────────────────────────────
|
|
|
|
pub fn web_router() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/robots.txt", get(robots_txt))
|
|
.route("/llms.txt", get(llms_txt))
|
|
.route("/ai.txt", get(ai_txt))
|
|
.route("/static/i18n.js", get(i18n_js))
|
|
.route("/favicon.svg", get(favicon_svg))
|
|
.route(
|
|
"/favicon.ico",
|
|
get(|| async { Redirect::permanent("/favicon.svg") }),
|
|
)
|
|
.route(
|
|
"/.well-known/oauth-protected-resource",
|
|
get(oauth_protected_resource_metadata),
|
|
)
|
|
.route("/", get(home_page))
|
|
.route("/login", get(login_page))
|
|
.route("/auth/google", get(auth_google))
|
|
.route("/auth/google/callback", get(auth_google_callback))
|
|
.route("/auth/logout", post(auth_logout))
|
|
.route("/dashboard", get(dashboard))
|
|
.route("/entries", get(entries_page))
|
|
.route("/audit", get(audit_page))
|
|
.route("/account/bind/google", get(account_bind_google))
|
|
.route(
|
|
"/account/bind/google/callback",
|
|
get(account_bind_google_callback),
|
|
)
|
|
.route("/account/unbind/{provider}", post(account_unbind))
|
|
.route("/api/key-salt", get(api_key_salt))
|
|
.route("/api/key-setup", post(api_key_setup))
|
|
.route("/api/apikey", get(api_apikey_get))
|
|
.route("/api/apikey/regenerate", post(api_apikey_regenerate))
|
|
.route(
|
|
"/api/entries/{id}",
|
|
patch(api_entry_patch).delete(api_entry_delete),
|
|
)
|
|
.route(
|
|
"/api/entries/{entry_id}/secrets/{secret_id}",
|
|
axum::routing::delete(api_entry_secret_unlink),
|
|
)
|
|
.route("/api/secrets/{secret_id}", patch(api_secret_patch))
|
|
.route("/api/secrets/check-name", get(api_secret_check_name))
|
|
}
|
|
|
|
fn text_asset_response(content: &'static str, content_type: &'static str) -> Response {
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, content_type)
|
|
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
|
.body(Body::from(content))
|
|
.expect("text asset response")
|
|
}
|
|
|
|
async fn robots_txt() -> Response {
|
|
text_asset_response(
|
|
include_str!("../static/robots.txt"),
|
|
"text/plain; charset=utf-8",
|
|
)
|
|
}
|
|
|
|
async fn llms_txt() -> Response {
|
|
text_asset_response(
|
|
include_str!("../static/llms.txt"),
|
|
"text/markdown; charset=utf-8",
|
|
)
|
|
}
|
|
|
|
async fn ai_txt() -> Response {
|
|
llms_txt().await
|
|
}
|
|
|
|
async fn i18n_js() -> Response {
|
|
text_asset_response(
|
|
include_str!("../templates/i18n.js"),
|
|
"application/javascript; charset=utf-8",
|
|
)
|
|
}
|
|
|
|
async fn favicon_svg() -> Response {
|
|
Response::builder()
|
|
.status(StatusCode::OK)
|
|
.header(header::CONTENT_TYPE, "image/svg+xml")
|
|
.header(header::CACHE_CONTROL, "public, max-age=86400")
|
|
.body(Body::from(include_str!("../static/favicon.svg")))
|
|
.expect("favicon response")
|
|
}
|
|
|
|
// ── Home page (public) ───────────────────────────────────────────────────────
|
|
|
|
async fn home_page(
|
|
State(state): State<AppState>,
|
|
session: Session,
|
|
) -> Result<Response, StatusCode> {
|
|
let is_logged_in = current_user_id(&session).await.is_some();
|
|
let tmpl = HomeTemplate {
|
|
is_logged_in,
|
|
base_url: state.base_url.clone(),
|
|
version: env!("CARGO_PKG_VERSION"),
|
|
};
|
|
render_template(tmpl)
|
|
}
|
|
|
|
// ── Login page ────────────────────────────────────────────────────────────────
|
|
|
|
async fn login_page(
|
|
State(state): State<AppState>,
|
|
session: Session,
|
|
) -> Result<Response, StatusCode> {
|
|
if let Some(_uid) = current_user_id(&session).await {
|
|
return Ok(Redirect::to("/dashboard").into_response());
|
|
}
|
|
|
|
let tmpl = LoginTemplate {
|
|
has_google: state.google_config.is_some(),
|
|
base_url: state.base_url.clone(),
|
|
version: env!("CARGO_PKG_VERSION"),
|
|
};
|
|
render_template(tmpl)
|
|
}
|
|
|
|
// ── Google OAuth ──────────────────────────────────────────────────────────────
|
|
|
|
async fn auth_google(
|
|
State(state): State<AppState>,
|
|
session: Session,
|
|
) -> Result<Response, StatusCode> {
|
|
let config = google_cfg(&state).ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
|
|
|
let oauth_state = random_state();
|
|
session
|
|
.insert(SESSION_OAUTH_STATE, &oauth_state)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "failed to insert oauth_state into session");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
let url = google_auth_url(config, &oauth_state);
|
|
Ok(Redirect::to(&url).into_response())
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct OAuthCallbackQuery {
|
|
code: Option<String>,
|
|
state: Option<String>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
async fn auth_google_callback(
|
|
State(state): State<AppState>,
|
|
connect_info: ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
session: Session,
|
|
Query(params): Query<OAuthCallbackQuery>,
|
|
) -> Result<Response, StatusCode> {
|
|
let client_ip = request_client_ip(&headers, connect_info);
|
|
let user_agent = request_user_agent(&headers);
|
|
handle_oauth_callback(
|
|
&state,
|
|
&session,
|
|
params,
|
|
"google",
|
|
client_ip.as_deref(),
|
|
user_agent.as_deref(),
|
|
|s, cfg, code| {
|
|
Box::pin(crate::oauth::google::exchange_code(
|
|
&s.http_client,
|
|
cfg,
|
|
code,
|
|
))
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
// ── Shared OAuth callback handler ─────────────────────────────────────────────
|
|
|
|
async fn handle_oauth_callback<F>(
|
|
state: &AppState,
|
|
session: &Session,
|
|
params: OAuthCallbackQuery,
|
|
provider: &str,
|
|
client_ip: Option<&str>,
|
|
user_agent: Option<&str>,
|
|
exchange_fn: F,
|
|
) -> Result<Response, StatusCode>
|
|
where
|
|
F: for<'a> Fn(
|
|
&'a AppState,
|
|
&'a OAuthConfig,
|
|
&'a str,
|
|
) -> std::pin::Pin<
|
|
Box<dyn std::future::Future<Output = anyhow::Result<OAuthUserInfo>> + Send + 'a>,
|
|
>,
|
|
{
|
|
if let Some(err) = params.error {
|
|
tracing::warn!(provider, error = %err, "OAuth error");
|
|
return Ok(Redirect::to("/login?error=oauth_error").into_response());
|
|
}
|
|
|
|
let Some(code) = params.code else {
|
|
tracing::warn!(provider, "OAuth callback missing code");
|
|
return Ok(Redirect::to("/login?error=oauth_missing_code").into_response());
|
|
};
|
|
let Some(returned_state) = params.state.as_deref() else {
|
|
tracing::warn!(provider, "OAuth callback missing state");
|
|
return Ok(Redirect::to("/login?error=oauth_missing_state").into_response());
|
|
};
|
|
|
|
let expected_state: Option<String> = session.get(SESSION_OAUTH_STATE).await.map_err(|e| {
|
|
tracing::error!(provider, error = %e, "failed to read oauth_state from session");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
if expected_state.as_deref() != Some(returned_state) {
|
|
tracing::warn!(
|
|
provider,
|
|
expected_present = expected_state.is_some(),
|
|
"OAuth state mismatch (empty session often means SameSite=Strict or server restart)"
|
|
);
|
|
return Ok(Redirect::to("/login?error=oauth_state").into_response());
|
|
}
|
|
if let Err(e) = session.remove::<String>(SESSION_OAUTH_STATE).await {
|
|
tracing::warn!(provider, error = %e, "failed to remove oauth_state from session");
|
|
}
|
|
|
|
let config = match provider {
|
|
"google" => state
|
|
.google_config
|
|
.as_ref()
|
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?,
|
|
_ => return Err(StatusCode::BAD_REQUEST),
|
|
};
|
|
|
|
let user_info = exchange_fn(state, config, code.as_str())
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(provider, error = %e, "failed to exchange OAuth code");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
let bind_mode: bool = match session.get::<bool>(SESSION_OAUTH_BIND_MODE).await {
|
|
Ok(v) => v.unwrap_or(false),
|
|
Err(e) => {
|
|
tracing::error!(
|
|
provider,
|
|
error = %e,
|
|
"failed to read oauth_bind_mode from session"
|
|
);
|
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
|
}
|
|
};
|
|
|
|
if bind_mode {
|
|
let user_id = current_user_id(session)
|
|
.await
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
if let Err(e) = session.remove::<bool>(SESSION_OAUTH_BIND_MODE).await {
|
|
tracing::warn!(provider, error = %e, "failed to remove oauth_bind_mode from session after bind");
|
|
}
|
|
|
|
let profile = OAuthProfile {
|
|
provider: user_info.provider,
|
|
provider_id: user_info.provider_id,
|
|
email: user_info.email,
|
|
name: user_info.name,
|
|
avatar_url: user_info.avatar_url,
|
|
};
|
|
|
|
bind_oauth_account(&state.pool, user_id, profile)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "failed to bind OAuth account");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
return Ok(Redirect::to("/dashboard?bound=1").into_response());
|
|
}
|
|
|
|
let profile = OAuthProfile {
|
|
provider: user_info.provider,
|
|
provider_id: user_info.provider_id,
|
|
email: user_info.email,
|
|
name: user_info.name,
|
|
avatar_url: user_info.avatar_url,
|
|
};
|
|
|
|
let (user, _is_new) = find_or_create_user(&state.pool, profile)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "failed to find or create user");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
session
|
|
.insert(SESSION_USER_ID, user.id.to_string())
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(
|
|
error = %e,
|
|
user_id = %user.id,
|
|
"failed to insert user_id into session after OAuth"
|
|
);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
session
|
|
.insert(SESSION_LOGIN_PROVIDER, &provider)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(
|
|
provider,
|
|
error = %e,
|
|
"failed to insert login_provider into session after OAuth"
|
|
);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
log_login(
|
|
&state.pool,
|
|
"oauth",
|
|
provider,
|
|
user.id,
|
|
client_ip,
|
|
user_agent,
|
|
)
|
|
.await;
|
|
|
|
Ok(Redirect::to("/dashboard").into_response())
|
|
}
|
|
|
|
// ── Logout ────────────────────────────────────────────────────────────────────
|
|
|
|
async fn auth_logout(session: Session) -> impl IntoResponse {
|
|
if let Err(e) = session.flush().await {
|
|
tracing::warn!(error = %e, "failed to flush session on logout");
|
|
}
|
|
Redirect::to("/")
|
|
}
|
|
|
|
// ── Dashboard ─────────────────────────────────────────────────────────────────
|
|
|
|
async fn dashboard(
|
|
State(state): State<AppState>,
|
|
session: Session,
|
|
) -> Result<Response, StatusCode> {
|
|
let Some(user_id) = current_user_id(&session).await else {
|
|
return Ok(Redirect::to("/login").into_response());
|
|
};
|
|
|
|
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
|
|
tracing::error!(error = %e, %user_id, "failed to load user for dashboard");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})? {
|
|
Some(u) => u,
|
|
None => return Ok(Redirect::to("/login").into_response()),
|
|
};
|
|
|
|
let tmpl = DashboardTemplate {
|
|
user_name: user.name.clone(),
|
|
user_email: user.email.clone().unwrap_or_default(),
|
|
has_passphrase: user.key_salt.is_some(),
|
|
base_url: state.base_url.clone(),
|
|
version: env!("CARGO_PKG_VERSION"),
|
|
};
|
|
|
|
render_template(tmpl)
|
|
}
|
|
|
|
async fn entries_page(
|
|
State(state): State<AppState>,
|
|
session: Session,
|
|
Query(q): Query<EntriesQuery>,
|
|
) -> Result<Response, StatusCode> {
|
|
let Some(user_id) = current_user_id(&session).await else {
|
|
return Ok(Redirect::to("/login").into_response());
|
|
};
|
|
|
|
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
|
|
tracing::error!(error = %e, %user_id, "failed to load user for entries page");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})? {
|
|
Some(u) => u,
|
|
None => return Ok(Redirect::to("/login").into_response()),
|
|
};
|
|
|
|
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 params = SearchParams {
|
|
folder: folder_filter.as_deref(),
|
|
entry_type: type_filter.as_deref(),
|
|
name: None,
|
|
name_query: name_filter.as_deref(),
|
|
tags: &[],
|
|
query: None,
|
|
sort: "updated",
|
|
limit: ENTRIES_PAGE_LIMIT,
|
|
offset: 0,
|
|
user_id: Some(user_id),
|
|
};
|
|
|
|
let rows = list_entries(&state.pool, 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_secret_schemas(&state.pool, &entry_ids)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "failed to load secret schema list for web");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
#[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".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;
|
|
}
|
|
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));
|
|
}
|
|
let folder_rows: Vec<FolderCountRow> =
|
|
folder_query.fetch_all(&state.pool).await.map_err(|e| {
|
|
tracing::error!(error = %e, "failed to load folder tabs for web");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct TypeOptionRow {
|
|
#[sqlx(rename = "type")]
|
|
entry_type: String,
|
|
}
|
|
let mut type_options: Vec<String> = sqlx::query_as::<_, TypeOptionRow>(
|
|
"SELECT DISTINCT type FROM entries WHERE user_id = $1 ORDER BY type",
|
|
)
|
|
.bind(user_id)
|
|
.fetch_all(&state.pool)
|
|
.await
|
|
.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();
|
|
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>) -> 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 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()),
|
|
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()),
|
|
active: folder_filter.as_deref() == Some(name.as_str()),
|
|
name,
|
|
count: r.count,
|
|
});
|
|
}
|
|
|
|
let entries = rows
|
|
.into_iter()
|
|
.map(|e| {
|
|
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());
|
|
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,
|
|
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_type: type_filter.unwrap_or_default(),
|
|
version: env!("CARGO_PKG_VERSION"),
|
|
};
|
|
|
|
render_template(tmpl)
|
|
}
|
|
|
|
async fn audit_page(
|
|
State(state): State<AppState>,
|
|
session: Session,
|
|
) -> Result<Response, StatusCode> {
|
|
let Some(user_id) = current_user_id(&session).await else {
|
|
return Ok(Redirect::to("/login").into_response());
|
|
};
|
|
|
|
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
|
|
tracing::error!(error = %e, %user_id, "failed to load user for audit page");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})? {
|
|
Some(u) => u,
|
|
None => return Ok(Redirect::to("/login").into_response()),
|
|
};
|
|
|
|
let rows = list_for_user(&state.pool, user_id, 100)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "failed to load audit log for user");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
let entries = rows
|
|
.into_iter()
|
|
.map(|row| AuditEntryView {
|
|
created_at_iso: row.created_at.to_rfc3339_opts(SecondsFormat::Secs, true),
|
|
action: row.action,
|
|
target: format_audit_target(&row.folder, &row.entry_type, &row.name),
|
|
detail: serde_json::to_string_pretty(&row.detail).unwrap_or_else(|_| "{}".to_string()),
|
|
})
|
|
.collect();
|
|
|
|
let tmpl = AuditPageTemplate {
|
|
user_name: user.name.clone(),
|
|
user_email: user.email.clone().unwrap_or_default(),
|
|
entries,
|
|
version: env!("CARGO_PKG_VERSION"),
|
|
};
|
|
|
|
render_template(tmpl)
|
|
}
|
|
|
|
// ── Account bind/unbind ───────────────────────────────────────────────────────
|
|
|
|
async fn account_bind_google(
|
|
State(state): State<AppState>,
|
|
session: Session,
|
|
) -> Result<Response, StatusCode> {
|
|
let _ = current_user_id(&session)
|
|
.await
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
|
|
session
|
|
.insert(SESSION_OAUTH_BIND_MODE, true)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "failed to insert oauth_bind_mode into session");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
let redirect_uri = format!("{}/account/bind/google/callback", state.base_url);
|
|
let mut cfg = state
|
|
.google_config
|
|
.clone()
|
|
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
|
cfg.redirect_uri = redirect_uri;
|
|
let st = random_state();
|
|
if let Err(e) = session.insert(SESSION_OAUTH_STATE, &st).await {
|
|
tracing::error!(error = %e, "failed to insert oauth_state for account bind flow");
|
|
if let Err(rm) = session.remove::<bool>(SESSION_OAUTH_BIND_MODE).await {
|
|
tracing::warn!(error = %rm, "failed to roll back oauth_bind_mode after oauth_state insert failure");
|
|
}
|
|
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
|
}
|
|
|
|
Ok(Redirect::to(&google_auth_url(&cfg, &st)).into_response())
|
|
}
|
|
|
|
async fn account_bind_google_callback(
|
|
State(state): State<AppState>,
|
|
connect_info: ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
session: Session,
|
|
Query(params): Query<OAuthCallbackQuery>,
|
|
) -> Result<Response, StatusCode> {
|
|
let client_ip = request_client_ip(&headers, connect_info);
|
|
let user_agent = request_user_agent(&headers);
|
|
handle_oauth_callback(
|
|
&state,
|
|
&session,
|
|
params,
|
|
"google",
|
|
client_ip.as_deref(),
|
|
user_agent.as_deref(),
|
|
|s, cfg, code| {
|
|
Box::pin(crate::oauth::google::exchange_code(
|
|
&s.http_client,
|
|
cfg,
|
|
code,
|
|
))
|
|
},
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn account_unbind(
|
|
State(state): State<AppState>,
|
|
Path(provider): Path<String>,
|
|
session: Session,
|
|
) -> Result<Response, StatusCode> {
|
|
let user_id = current_user_id(&session)
|
|
.await
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
|
|
let current_login_provider = session
|
|
.get::<String>(SESSION_LOGIN_PROVIDER)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "failed to read login_provider from session");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
unbind_oauth_account(
|
|
&state.pool,
|
|
user_id,
|
|
&provider,
|
|
current_login_provider.as_deref(),
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::warn!(error = %e, "failed to unbind oauth account");
|
|
StatusCode::BAD_REQUEST
|
|
})?;
|
|
|
|
Ok(Redirect::to("/dashboard?unbound=1").into_response())
|
|
}
|
|
|
|
// ── Passphrase / Key setup API ────────────────────────────────────────────────
|
|
|
|
#[derive(Serialize)]
|
|
struct KeySaltResponse {
|
|
has_passphrase: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
salt: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
key_check: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
params: Option<serde_json::Value>,
|
|
}
|
|
|
|
async fn api_key_salt(
|
|
State(state): State<AppState>,
|
|
session: Session,
|
|
) -> Result<Json<KeySaltResponse>, StatusCode> {
|
|
let user_id = current_user_id(&session)
|
|
.await
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
|
|
let user = get_user_by_id(&state.pool, user_id)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, %user_id, "failed to load user for key-salt API");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
|
|
if user.key_salt.is_none() {
|
|
return Ok(Json(KeySaltResponse {
|
|
has_passphrase: false,
|
|
salt: None,
|
|
key_check: None,
|
|
params: None,
|
|
}));
|
|
}
|
|
|
|
Ok(Json(KeySaltResponse {
|
|
has_passphrase: true,
|
|
salt: user.key_salt.as_deref().map(hex::encode_hex),
|
|
key_check: user.key_check.as_deref().map(hex::encode_hex),
|
|
params: user.key_params,
|
|
}))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct KeySetupRequest {
|
|
/// Hex-encoded 32-byte random salt
|
|
salt: String,
|
|
/// Hex-encoded AES-256-GCM encryption of "secrets-mcp-key-check" with the derived key
|
|
key_check: String,
|
|
/// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000}
|
|
params: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct KeySetupResponse {
|
|
ok: bool,
|
|
}
|
|
|
|
async fn api_key_setup(
|
|
State(state): State<AppState>,
|
|
session: Session,
|
|
Json(body): Json<KeySetupRequest>,
|
|
) -> Result<Json<KeySetupResponse>, StatusCode> {
|
|
let user_id = current_user_id(&session)
|
|
.await
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
|
|
let salt = hex::decode_hex(&body.salt).map_err(|e| {
|
|
tracing::warn!(error = %e, "invalid hex in key-setup salt");
|
|
StatusCode::BAD_REQUEST
|
|
})?;
|
|
let key_check = hex::decode_hex(&body.key_check).map_err(|e| {
|
|
tracing::warn!(error = %e, "invalid hex in key-setup key_check");
|
|
StatusCode::BAD_REQUEST
|
|
})?;
|
|
|
|
if salt.len() != 32 {
|
|
tracing::warn!(salt_len = salt.len(), "key-setup salt must be 32 bytes");
|
|
return Err(StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
update_user_key_setup(&state.pool, user_id, &salt, &key_check, &body.params)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "failed to update key setup");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Json(KeySetupResponse { ok: true }))
|
|
}
|
|
|
|
// ── API Key management ────────────────────────────────────────────────────────
|
|
|
|
#[derive(Serialize)]
|
|
struct ApiKeyResponse {
|
|
api_key: String,
|
|
}
|
|
|
|
async fn api_apikey_get(
|
|
State(state): State<AppState>,
|
|
session: Session,
|
|
) -> Result<Json<ApiKeyResponse>, StatusCode> {
|
|
let user_id = current_user_id(&session)
|
|
.await
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
|
|
let api_key = ensure_api_key(&state.pool, user_id).await.map_err(|e| {
|
|
tracing::error!(error = %e, %user_id, "ensure_api_key failed");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Json(ApiKeyResponse { api_key }))
|
|
}
|
|
|
|
async fn api_apikey_regenerate(
|
|
State(state): State<AppState>,
|
|
session: Session,
|
|
) -> Result<Json<ApiKeyResponse>, StatusCode> {
|
|
let user_id = current_user_id(&session)
|
|
.await
|
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
|
|
let api_key = regenerate_api_key(&state.pool, user_id)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, %user_id, "regenerate_api_key failed");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Json(ApiKeyResponse { api_key }))
|
|
}
|
|
|
|
// ── Entry management (Web UI, non-sensitive fields only) ───────────────────────
|
|
|
|
#[derive(Deserialize)]
|
|
struct EntryPatchBody {
|
|
folder: String,
|
|
#[serde(rename = "type")]
|
|
entry_type: String,
|
|
name: String,
|
|
notes: String,
|
|
tags: Vec<String>,
|
|
metadata: serde_json::Value,
|
|
}
|
|
|
|
type EntryApiError = (StatusCode, Json<serde_json::Value>);
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum UiLang {
|
|
ZhCn,
|
|
ZhTw,
|
|
En,
|
|
}
|
|
|
|
fn request_ui_lang(headers: &HeaderMap) -> UiLang {
|
|
let Some(raw) = headers
|
|
.get(header::ACCEPT_LANGUAGE)
|
|
.and_then(|v| v.to_str().ok())
|
|
else {
|
|
return UiLang::ZhCn;
|
|
};
|
|
let lower = raw.to_ascii_lowercase();
|
|
if lower.contains("zh-tw") || lower.contains("zh-hk") || lower.contains("zh-hant") {
|
|
UiLang::ZhTw
|
|
} else if lower.contains("zh") {
|
|
UiLang::ZhCn
|
|
} else if lower.contains("en") {
|
|
UiLang::En
|
|
} else {
|
|
UiLang::ZhCn
|
|
}
|
|
}
|
|
|
|
fn tr(lang: UiLang, zh_cn: &'static str, zh_tw: &'static str, en: &'static str) -> &'static str {
|
|
match lang {
|
|
UiLang::ZhCn => zh_cn,
|
|
UiLang::ZhTw => zh_tw,
|
|
UiLang::En => en,
|
|
}
|
|
}
|
|
|
|
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") }),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
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))?;
|
|
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
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,
|
|
})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SecretCheckNameQuery {
|
|
name: String,
|
|
exclude_secret_id: Option<Uuid>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SecretCheckNameResponse {
|
|
ok: bool,
|
|
available: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
error: Option<String>,
|
|
}
|
|
|
|
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)]
|
|
struct SecretPatchBody {
|
|
name: Option<String>,
|
|
#[serde(rename = "type")]
|
|
secret_type: Option<String>,
|
|
}
|
|
|
|
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());
|
|
|
|
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() {
|
|
return Err((
|
|
StatusCode::BAD_REQUEST,
|
|
Json(
|
|
json!({ "error": tr(lang, "至少需要提供 name 或 type 之一", "至少需要提供 name 或 type 之一", "At least one of name or type is required") }),
|
|
),
|
|
));
|
|
}
|
|
|
|
let mut tx = state
|
|
.pool
|
|
.begin()
|
|
.await
|
|
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
|
|
|
|
let secret_row: Option<(String, String)> =
|
|
sqlx::query_as("SELECT name, type 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)) = 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 result = sqlx::query(
|
|
"UPDATE secrets SET name = $1, type = $2, version = version + 1, updated_at = NOW() \
|
|
WHERE id = $3",
|
|
)
|
|
.bind(&new_name)
|
|
.bind(&new_type)
|
|
.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),
|
|
"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,
|
|
"linked_entries": linked_entries,
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
tx.commit()
|
|
.await
|
|
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
|
|
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
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,
|
|
})))
|
|
}
|
|
|
|
// ── OAuth / Well-known ────────────────────────────────────────────────────────
|
|
|
|
/// RFC 9728 — OAuth 2.0 Protected Resource Metadata.
|
|
///
|
|
/// Advertises that this server accepts Bearer tokens in the `Authorization`
|
|
/// header. We deliberately omit `authorization_servers` because this service
|
|
/// issues its own API keys (no external OAuth AS is involved). MCP clients
|
|
/// that probe this endpoint will see the resource identifier and stop looking
|
|
/// for a delegated OAuth flow.
|
|
async fn oauth_protected_resource_metadata(State(state): State<AppState>) -> impl IntoResponse {
|
|
let body = serde_json::json!({
|
|
"resource": state.base_url,
|
|
"bearer_methods_supported": ["header"],
|
|
"resource_documentation": format!("{}/dashboard", state.base_url),
|
|
});
|
|
(
|
|
StatusCode::OK,
|
|
[(header::CONTENT_TYPE, "application/json")],
|
|
axum::Json(body),
|
|
)
|
|
}
|
|
|
|
// ── Helper ────────────────────────────────────────────────────────────────────
|
|
|
|
fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {
|
|
let html = tmpl.render().map_err(|e| {
|
|
tracing::error!(error = %e, "template render error");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
Ok(Html(html).into_response())
|
|
}
|
|
|
|
fn format_audit_target(folder: &str, entry_type: &str, name: &str) -> String {
|
|
// Auth events (folder="auth") use entry_type/name as provider-scoped target.
|
|
if folder == "auth" {
|
|
format!("{}/{}", entry_type, name)
|
|
} else if !folder.is_empty() && !entry_type.is_empty() {
|
|
format!("[{}/{}] {}", folder, entry_type, name)
|
|
} else if !folder.is_empty() {
|
|
format!("[{}] {}", folder, name)
|
|
} else {
|
|
name.to_string()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn request_ui_lang_prefers_zh_cn_over_en_fallback() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(header::ACCEPT_LANGUAGE, "zh-CN, en;q=0.5".parse().unwrap());
|
|
|
|
assert!(matches!(request_ui_lang(&headers), UiLang::ZhCn));
|
|
}
|
|
|
|
#[test]
|
|
fn request_ui_lang_detects_traditional_chinese_variants() {
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::ACCEPT_LANGUAGE,
|
|
"zh-Hant, en;q=0.5".parse().unwrap(),
|
|
);
|
|
|
|
assert!(matches!(request_ui_lang(&headers), UiLang::ZhTw));
|
|
}
|
|
}
|