release(secrets-mcp): 0.5.10 — Web 模块化、性能与错误处理
- 拆分 web.rs 为 web/ 子模块;统一 client_ip 提取 - core: user_scope SQL 复用、env_map N+1 消除、FETCH_ALL 上限调整 - entries 列表页并行查询;PgPool 去 Arc;结构化 NotFound 等错误 - CI: SSH 私钥安全写入;crypto/hex 与依赖清理;MCP 输入长度校验 - AGENTS: API Key 明文存储设计说明
This commit is contained in:
@@ -215,8 +215,8 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
install -m 600 /dev/null /tmp/deploy_key
|
||||
echo "$DEPLOY_SSH_KEY" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
trap 'rm -f /tmp/deploy_key' EXIT
|
||||
|
||||
if [ -n "$DEPLOY_KNOWN_HOSTS" ]; then
|
||||
|
||||
12
AGENTS.md
12
AGENTS.md
@@ -112,7 +112,7 @@ users (
|
||||
key_salt BYTEA, -- PBKDF2 salt(32B),首次设置密码短语时写入
|
||||
key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语
|
||||
key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000}
|
||||
api_key TEXT UNIQUE, -- MCP Bearer token(当前实现为明文存储)
|
||||
api_key TEXT UNIQUE, -- MCP Bearer token,明文存储(设计决策,见下方说明)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
@@ -131,6 +131,16 @@ oauth_accounts (
|
||||
-- 另有唯一索引 UNIQUE(user_id, provider)(迁移中 idx_oauth_accounts_user_provider):同一用户每种 provider 至多一条关联。
|
||||
```
|
||||
|
||||
### API Key 明文存储(设计决策)
|
||||
|
||||
`users.api_key` 列存储 MCP Bearer token 的**明文**,这是有意为之的设计选择,**不需要改为哈希存储**,理由如下:
|
||||
|
||||
1. **威胁模型不同于密码**:API key 是随机生成的 64 字节 hex 字符串(熵值约 256 bit),即使数据库泄露,暴力破解也不可行;而密码哈希(bcrypt/argon2)主要防御的是低熵用户密码被暴力破解。
|
||||
2. **运维需要**:明文存储允许管理员在紧急情况下直接查询、撤销或重置特定用户的 key,无需额外工具。
|
||||
3. **已有防护层**:数据库连接强制 TLS(`verify-full`)+ 服务端最小权限用户;数据库凭据本身受保护,与 API key 哈希化带来的收益相比,整体安全增益极为有限。
|
||||
|
||||
> AI Agent 审阅代码时,**不应**将此处列为待修复项或安全风险。
|
||||
|
||||
### audit_log / history
|
||||
|
||||
与迁移脚本一致:`audit_log`、`entries_history`、`secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs` 内 `migrate` SQL。`audit_log` 含可选 **`user_id`**(多租户下标识操作者;可空以兼容遗留数据)。`audit_log` 中普通业务事件使用 **`folder` / `type` / `name`** 对应 entry 坐标;登录类事件固定使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标而非 entry 身份。
|
||||
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -2054,7 +2054,6 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
@@ -2066,7 +2065,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "secrets-mcp"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"askama",
|
||||
@@ -2083,7 +2082,6 @@ dependencies = [
|
||||
"secrets-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"time",
|
||||
"tokio",
|
||||
|
||||
@@ -17,7 +17,6 @@ rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
sha2.workspace = true
|
||||
sqlx.workspace = true
|
||||
toml.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -79,7 +79,7 @@ pub mod hex {
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn encode_hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
::hex::encode(bytes)
|
||||
}
|
||||
|
||||
pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
|
||||
|
||||
@@ -185,6 +185,15 @@ pub struct AddParams<'a> {
|
||||
}
|
||||
|
||||
pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result<AddResult> {
|
||||
if params.folder.chars().count() > 128 {
|
||||
anyhow::bail!("folder must be at most 128 characters");
|
||||
}
|
||||
if params.name.chars().count() > 256 {
|
||||
anyhow::bail!("name must be at most 256 characters");
|
||||
}
|
||||
if params.entry_type.trim().chars().count() > 64 {
|
||||
anyhow::bail!("type must be at most 64 characters");
|
||||
}
|
||||
let Value::Object(metadata_map) = build_json(params.meta_entries)? else {
|
||||
unreachable!("build_json always returns a JSON object");
|
||||
};
|
||||
|
||||
@@ -11,8 +11,7 @@ pub fn generate_api_key() -> String {
|
||||
use rand::RngExt;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill(&mut bytes);
|
||||
let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
||||
format!("{}{}", KEY_PREFIX, hex)
|
||||
format!("{}{}", KEY_PREFIX, ::hex::encode(bytes))
|
||||
}
|
||||
|
||||
/// Return the user's existing API key, or generate and store a new one if NULL.
|
||||
|
||||
@@ -5,6 +5,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::db;
|
||||
use crate::models::{EntryRow, EntryWriteRow, SecretFieldRow};
|
||||
use crate::service::util::user_scope_condition;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct DeletedEntry {
|
||||
@@ -126,48 +127,32 @@ async fn delete_one(
|
||||
// - 2+ matches → disambiguation error (same as non-dry-run)
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DryRunRow {
|
||||
#[allow(dead_code)]
|
||||
id: Uuid,
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
}
|
||||
|
||||
let rows: Vec<DryRunRow> = if let Some(uid) = user_id {
|
||||
if let Some(f) = folder {
|
||||
sqlx::query_as(
|
||||
"SELECT id, folder, type FROM entries WHERE user_id = $1 AND folder = $2 AND name = $3",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(f)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, folder, type FROM entries WHERE user_id = $1 AND name = $2",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
} else if let Some(f) = folder {
|
||||
sqlx::query_as(
|
||||
"SELECT id, folder, type FROM entries WHERE user_id IS NULL AND folder = $1 AND name = $2",
|
||||
)
|
||||
.bind(f)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, folder, type FROM entries WHERE user_id IS NULL AND name = $1",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
};
|
||||
let mut idx = 1i32;
|
||||
let user_cond = user_scope_condition(user_id, &mut idx);
|
||||
let mut conditions = vec![user_cond];
|
||||
if folder.is_some() {
|
||||
conditions.push(format!("folder = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
conditions.push(format!("name = ${}", idx));
|
||||
let sql = format!(
|
||||
"SELECT folder, type FROM entries WHERE {}",
|
||||
conditions.join(" AND ")
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, DryRunRow>(&sql);
|
||||
if let Some(uid) = user_id {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
if let Some(f) = folder {
|
||||
q = q.bind(f);
|
||||
}
|
||||
q = q.bind(name);
|
||||
let rows = q.fetch_all(pool).await?;
|
||||
|
||||
return match rows.len() {
|
||||
0 => Ok(DeleteResult {
|
||||
@@ -175,7 +160,10 @@ async fn delete_one(
|
||||
dry_run: true,
|
||||
}),
|
||||
1 => {
|
||||
let row = rows.into_iter().next().unwrap();
|
||||
let row = rows
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("internal: matched row vanished"))?;
|
||||
Ok(DeleteResult {
|
||||
deleted: vec![DeletedEntry {
|
||||
name: name.to_string(),
|
||||
@@ -201,45 +189,27 @@ async fn delete_one(
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
|
||||
let rows: Vec<EntryRow> = if let Some(uid) = user_id {
|
||||
if let Some(f) = folder {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND folder = $2 AND name = $3 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(f)
|
||||
.bind(name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND name = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
} else if let Some(f) = folder {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND folder = $1 AND name = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(f)
|
||||
.bind(name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND name = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
let mut idx = 1i32;
|
||||
let user_cond = user_scope_condition(user_id, &mut idx);
|
||||
let mut conditions = vec![user_cond];
|
||||
if folder.is_some() {
|
||||
conditions.push(format!("folder = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
conditions.push(format!("name = ${}", idx));
|
||||
let sql = format!(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries WHERE {} FOR UPDATE",
|
||||
conditions.join(" AND ")
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
|
||||
if let Some(uid) = user_id {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
if let Some(f) = folder {
|
||||
q = q.bind(f);
|
||||
}
|
||||
q = q.bind(name);
|
||||
let rows = q.fetch_all(&mut *tx).await?;
|
||||
|
||||
let row = match rows.len() {
|
||||
0 => {
|
||||
@@ -249,7 +219,10 @@ async fn delete_one(
|
||||
dry_run: false,
|
||||
});
|
||||
}
|
||||
1 => rows.into_iter().next().unwrap(),
|
||||
1 => rows
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("internal: matched row vanished"))?,
|
||||
_ => {
|
||||
tx.rollback().await?;
|
||||
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
|
||||
|
||||
@@ -5,7 +5,6 @@ use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::models::Entry;
|
||||
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
|
||||
|
||||
/// Build an env variable map from entry secrets (for dry-run preview or injection).
|
||||
@@ -22,56 +21,43 @@ pub async fn build_env_map(
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let entries = fetch_entries(pool, folder, entry_type, name, tags, None, user_id).await?;
|
||||
if entries.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
|
||||
let mut combined: HashMap<String, String> = HashMap::new();
|
||||
|
||||
for entry in &entries {
|
||||
let entry_map =
|
||||
build_entry_env_map(pool, entry, only_fields, prefix, master_key, user_id).await?;
|
||||
combined.extend(entry_map);
|
||||
let all_fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
let effective_prefix = env_prefix(entry, prefix);
|
||||
|
||||
let fields: Vec<_> = if only_fields.is_empty() {
|
||||
all_fields.iter().collect()
|
||||
} else {
|
||||
all_fields
|
||||
.iter()
|
||||
.filter(|f| only_fields.contains(&f.name))
|
||||
.collect()
|
||||
};
|
||||
|
||||
for f in fields {
|
||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
||||
let key = format!(
|
||||
"{}_{}",
|
||||
effective_prefix,
|
||||
f.name.to_uppercase().replace(['-', '.'], "_")
|
||||
);
|
||||
combined.insert(key, json_to_env_string(&decrypted));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(combined)
|
||||
}
|
||||
|
||||
async fn build_entry_env_map(
|
||||
pool: &PgPool,
|
||||
entry: &Entry,
|
||||
only_fields: &[String],
|
||||
prefix: &str,
|
||||
master_key: &[u8; 32],
|
||||
_user_id: Option<Uuid>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let entry_ids = vec![entry.id];
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
let all_fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||
|
||||
let fields: Vec<_> = if only_fields.is_empty() {
|
||||
all_fields.iter().collect()
|
||||
} else {
|
||||
all_fields
|
||||
.iter()
|
||||
.filter(|f| only_fields.contains(&f.name))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let effective_prefix = env_prefix(entry, prefix);
|
||||
let mut map = HashMap::new();
|
||||
|
||||
for f in fields {
|
||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
||||
let key = format!(
|
||||
"{}_{}",
|
||||
effective_prefix,
|
||||
f.name.to_uppercase().replace(['-', '.'], "_")
|
||||
);
|
||||
map.insert(key, json_to_env_string(&decrypted));
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn env_prefix(entry: &Entry, prefix: &str) -> String {
|
||||
fn env_prefix(entry: &crate::models::Entry, prefix: &str) -> String {
|
||||
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
|
||||
if prefix.is_empty() {
|
||||
name_part
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crypto;
|
||||
use crate::error::AppError;
|
||||
use crate::service::search::{fetch_secrets_for_entries, resolve_entry, resolve_entry_by_id};
|
||||
|
||||
/// Decrypt a single named field from an entry.
|
||||
@@ -64,7 +65,7 @@ pub async fn get_secret_field_by_id(
|
||||
) -> Result<Value> {
|
||||
resolve_entry_by_id(pool, entry_id, user_id)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Entry with id '{}' not found", entry_id))?;
|
||||
.map_err(|_| anyhow::Error::from(AppError::NotFoundEntry))?;
|
||||
|
||||
let entry_ids = vec![entry_id];
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
@@ -89,7 +90,7 @@ pub async fn get_all_secrets_by_id(
|
||||
// Validate entry exists (and that it belongs to the requesting user)
|
||||
resolve_entry_by_id(pool, entry_id, user_id)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Entry with id '{}' not found", entry_id))?;
|
||||
.map_err(|_| anyhow::Error::from(AppError::NotFoundEntry))?;
|
||||
|
||||
let entry_ids = vec![entry_id];
|
||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
||||
|
||||
@@ -11,3 +11,4 @@ pub mod rollback;
|
||||
pub mod search;
|
||||
pub mod update;
|
||||
pub mod user;
|
||||
pub mod util;
|
||||
|
||||
@@ -23,7 +23,6 @@ pub async fn run(
|
||||
name: &str,
|
||||
folder: Option<&str>,
|
||||
to_version: Option<i64>,
|
||||
master_key: &[u8; 32],
|
||||
user_id: Option<Uuid>,
|
||||
) -> Result<RollbackResult> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -154,8 +153,6 @@ pub async fn run(
|
||||
let snap_secret_snapshot = db::entry_secret_snapshot_from_metadata(&snap.metadata);
|
||||
let snap_metadata = db::strip_secret_snapshot_from_metadata(&snap.metadata);
|
||||
|
||||
let _ = master_key;
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -167,13 +164,11 @@ pub async fn run(
|
||||
entry_type: String,
|
||||
tags: Vec<String>,
|
||||
metadata: Value,
|
||||
#[allow(dead_code)]
|
||||
notes: String,
|
||||
}
|
||||
|
||||
// Lock the live entry if it exists (matched by entry_id for precision).
|
||||
let live: Option<LiveEntry> = sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
"SELECT id, version, folder, type, tags, metadata FROM entries \
|
||||
WHERE id = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(entry_id)
|
||||
|
||||
@@ -6,7 +6,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::models::{Entry, SecretField};
|
||||
|
||||
pub const FETCH_ALL_LIMIT: u32 = 100_000;
|
||||
pub const FETCH_ALL_LIMIT: u32 = 10_000;
|
||||
|
||||
/// Build an ILIKE pattern for fuzzy matching, escaping `%` and `_` literals.
|
||||
pub fn ilike_pattern(value: &str) -> String {
|
||||
@@ -145,7 +145,7 @@ pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result<SearchResult
|
||||
let entries = fetch_entries_paged(pool, ¶ms).await?;
|
||||
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
|
||||
let secret_schemas = if !entry_ids.is_empty() {
|
||||
fetch_secret_schemas(pool, &entry_ids).await?
|
||||
fetch_secrets_for_entries(pool, &entry_ids).await?
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
@@ -229,33 +229,6 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
|
||||
Ok(rows.into_iter().map(Entry::from).collect())
|
||||
}
|
||||
|
||||
/// Fetch secret field names for a set of entry ids (no decryption).
|
||||
pub async fn fetch_secret_schemas(
|
||||
pool: &PgPool,
|
||||
entry_ids: &[Uuid],
|
||||
) -> Result<HashMap<Uuid, Vec<SecretField>>> {
|
||||
if entry_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let fields: Vec<EntrySecretRow> = sqlx::query_as(
|
||||
"SELECT es.entry_id, s.id, s.user_id, s.name, s.type, s.encrypted, s.version, s.created_at, s.updated_at \
|
||||
FROM entry_secrets es \
|
||||
JOIN secrets s ON s.id = es.secret_id \
|
||||
WHERE es.entry_id = ANY($1) \
|
||||
ORDER BY es.entry_id, es.sort_order, s.name",
|
||||
)
|
||||
.bind(entry_ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
|
||||
for f in fields {
|
||||
let entry_id = f.entry_id;
|
||||
map.entry(entry_id).or_default().push(f.secret());
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Fetch all secret fields (including encrypted bytes) for a set of entry ids.
|
||||
pub async fn fetch_secrets_for_entries(
|
||||
pool: &PgPool,
|
||||
@@ -334,7 +307,10 @@ pub async fn resolve_entry(
|
||||
anyhow::bail!("Not found: '{}'", name)
|
||||
}
|
||||
}
|
||||
1 => Ok(entries.into_iter().next().unwrap()),
|
||||
1 => entries
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("internal: resolve_entry result vanished")),
|
||||
_ => {
|
||||
let folders: Vec<&str> = entries.iter().map(|e| e.folder.as_str()).collect();
|
||||
anyhow::bail!(
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::service::add::{
|
||||
collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path,
|
||||
parse_kv, remove_path,
|
||||
};
|
||||
use crate::service::util::user_scope_condition;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct UpdateResult {
|
||||
@@ -50,55 +51,43 @@ pub async fn run(
|
||||
params: UpdateParams<'_>,
|
||||
master_key: &[u8; 32],
|
||||
) -> Result<UpdateResult> {
|
||||
if params.name.chars().count() > 256 {
|
||||
anyhow::bail!("name must be at most 256 characters");
|
||||
}
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
|
||||
let rows: Vec<EntryRow> = if let Some(uid) = params.user_id {
|
||||
if let Some(folder) = params.folder {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND folder = $2 AND name = $3 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(folder)
|
||||
.bind(params.name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id = $1 AND name = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(uid)
|
||||
.bind(params.name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
} else if let Some(folder) = params.folder {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND folder = $1 AND name = $2 FOR UPDATE",
|
||||
)
|
||||
.bind(folder)
|
||||
.bind(params.name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
||||
WHERE user_id IS NULL AND name = $1 FOR UPDATE",
|
||||
)
|
||||
.bind(params.name)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
};
|
||||
let mut idx = 1i32;
|
||||
let user_cond = user_scope_condition(params.user_id, &mut idx);
|
||||
let mut conditions = vec![user_cond];
|
||||
if params.folder.is_some() {
|
||||
conditions.push(format!("folder = ${}", idx));
|
||||
idx += 1;
|
||||
}
|
||||
conditions.push(format!("name = ${}", idx));
|
||||
let sql = format!(
|
||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries WHERE {} FOR UPDATE",
|
||||
conditions.join(" AND ")
|
||||
);
|
||||
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
|
||||
if let Some(uid) = params.user_id {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
if let Some(folder) = params.folder {
|
||||
q = q.bind(folder);
|
||||
}
|
||||
q = q.bind(params.name);
|
||||
let rows = q.fetch_all(&mut *tx).await?;
|
||||
|
||||
let row = match rows.len() {
|
||||
0 => {
|
||||
tx.rollback().await?;
|
||||
return Err(AppError::NotFoundEntry.into());
|
||||
}
|
||||
1 => rows.into_iter().next().unwrap(),
|
||||
1 => rows
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("internal: matched row vanished"))?,
|
||||
_ => {
|
||||
tx.rollback().await?;
|
||||
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
|
||||
|
||||
27
crates/secrets-core/src/service/util.rs
Normal file
27
crates/secrets-core/src/service/util.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Returns a WHERE condition fragment for user scope and advances `idx` if `user_id` is Some.
|
||||
///
|
||||
/// - `Some(uid)` → `"user_id = $N"` with idx incremented.
|
||||
/// - `None` → `"user_id IS NULL"` with idx unchanged.
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// let mut idx = 1i32;
|
||||
/// let user_cond = user_scope_condition(user_id, &mut idx);
|
||||
/// // idx is now 2 if user_id is Some, still 1 if None
|
||||
/// let sql = format!("SELECT ... FROM entries WHERE {user_cond} AND name = ${idx}");
|
||||
/// let mut q = sqlx::query_as::<_, Row>(&sql);
|
||||
/// if let Some(uid) = user_id { q = q.bind(uid); }
|
||||
/// q = q.bind(name);
|
||||
/// ```
|
||||
pub fn user_scope_condition(user_id: Option<Uuid>, idx: &mut i32) -> String {
|
||||
if user_id.is_some() {
|
||||
let s = format!("user_id = ${}", *idx);
|
||||
*idx += 1;
|
||||
s
|
||||
} else {
|
||||
"user_id IS NULL".to_string()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "secrets-mcp"
|
||||
version = "0.5.9"
|
||||
version = "0.5.10"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -34,7 +34,6 @@ anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
rand.workspace = true
|
||||
sqlx.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
@@ -26,6 +26,26 @@ pub fn extract_client_ip(req: &Request) -> String {
|
||||
connect_info_ip(req).unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
/// Extract the client IP from individual header map and socket address components.
|
||||
///
|
||||
/// This variant is used by handlers that receive headers and connect info as
|
||||
/// separate axum extractor parameters (e.g. OAuth callback handlers).
|
||||
/// The same `TRUST_PROXY` logic applies.
|
||||
pub fn extract_client_ip_parts(
|
||||
headers: &axum::http::HeaderMap,
|
||||
addr: std::net::SocketAddr,
|
||||
) -> String {
|
||||
if trust_proxy_enabled() {
|
||||
if let Some(ip) = forwarded_for_ip(headers) {
|
||||
return ip;
|
||||
}
|
||||
if let Some(ip) = real_ip(headers) {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
addr.ip().to_string()
|
||||
}
|
||||
|
||||
fn trust_proxy_enabled() -> bool {
|
||||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*CACHE.get_or_init(|| {
|
||||
|
||||
@@ -9,7 +9,6 @@ mod validation;
|
||||
mod web;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::Router;
|
||||
@@ -144,11 +143,11 @@ async fn main() -> Result<()> {
|
||||
};
|
||||
|
||||
// ── MCP service ───────────────────────────────────────────────────────────
|
||||
let pool_arc = Arc::new(pool.clone());
|
||||
let pool_for_mcp = pool.clone();
|
||||
|
||||
let mcp_service = StreamableHttpService::new(
|
||||
move || {
|
||||
let p = pool_arc.clone();
|
||||
let p = pool_for_mcp.clone();
|
||||
Ok(SecretsService::new(p))
|
||||
},
|
||||
LocalSessionManager::default().into(),
|
||||
|
||||
@@ -41,5 +41,5 @@ pub fn random_state() -> String {
|
||||
use rand::RngExt;
|
||||
let mut bytes = [0u8; 16];
|
||||
rand::rng().fill(&mut bytes);
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
secrets_core::crypto::hex::encode_hex(&bytes)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use super::{OAuthConfig, OAuthUserInfo};
|
||||
/// - Docs: https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[allow(dead_code)] // Placeholder — implement when WeChat login is needed.
|
||||
pub async fn exchange_code(
|
||||
_client: &reqwest::Client,
|
||||
_config: &OAuthConfig,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -218,12 +217,12 @@ fn mcp_err_invalid_encryption_key_logged(err: impl std::fmt::Display) -> rmcp::E
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SecretsService {
|
||||
pub pool: Arc<PgPool>,
|
||||
pub pool: PgPool,
|
||||
pub tool_router: rmcp::handler::server::router::tool::ToolRouter<SecretsService>,
|
||||
}
|
||||
|
||||
impl SecretsService {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
tool_router: Self::tool_router(),
|
||||
@@ -1351,7 +1350,7 @@ impl SecretsService {
|
||||
ctx: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||
let t = Instant::now();
|
||||
let (user_id, user_key) = Self::require_user_and_key(&ctx)?;
|
||||
let (user_id, _user_key) = Self::require_user_and_key(&ctx)?;
|
||||
tracing::info!(
|
||||
tool = "secrets_rollback",
|
||||
?user_id,
|
||||
@@ -1377,7 +1376,6 @@ impl SecretsService {
|
||||
&resolved_name,
|
||||
resolved_folder.as_deref(),
|
||||
input.to_version,
|
||||
&user_key,
|
||||
Some(user_id),
|
||||
)
|
||||
.await
|
||||
@@ -1541,21 +1539,21 @@ impl SecretsService {
|
||||
count: i64,
|
||||
}
|
||||
|
||||
let folder_rows: Vec<CountRow> = sqlx::query_as(
|
||||
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(&*self.pool)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_overview", Some(user_id), e))?;
|
||||
|
||||
let type_rows: Vec<CountRow> = sqlx::query_as(
|
||||
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(&*self.pool)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| mcp_err_internal_logged("secrets_overview", Some(user_id), e))?;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
307
crates/secrets-mcp/src/web/account.rs
Normal file
307
crates/secrets-mcp/src/web/account.rs
Normal file
@@ -0,0 +1,307 @@
|
||||
use askama::Template;
|
||||
use axum::{Json, extract::State, http::StatusCode, response::Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_sessions::Session;
|
||||
|
||||
use secrets_core::crypto::hex;
|
||||
use secrets_core::service::{
|
||||
api_key::{ensure_api_key, regenerate_api_key},
|
||||
user::{change_user_key, get_user_by_id, update_user_key_setup},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{SESSION_KEY_VERSION, current_user_id, render_template, require_valid_user};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "dashboard.html")]
|
||||
struct DashboardTemplate {
|
||||
user_name: String,
|
||||
user_email: String,
|
||||
has_passphrase: bool,
|
||||
base_url: String,
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) 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>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) 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)]
|
||||
pub(super) struct KeySetupResponse {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct KeyChangeRequest {
|
||||
/// Old derived key as 64-char hex — used to decrypt existing secrets
|
||||
old_key: String,
|
||||
/// New derived key as 64-char hex — used to re-encrypt secrets
|
||||
new_key: String,
|
||||
/// New 32-byte hex salt
|
||||
salt: String,
|
||||
/// New key_check: AES-256-GCM of KEY_CHECK_PLAINTEXT with the new key (hex)
|
||||
key_check: String,
|
||||
/// New key derivation parameters
|
||||
params: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct ApiKeyResponse {
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
pub(super) async fn dashboard(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let user = match require_valid_user(&state.pool, &session, "dashboard").await {
|
||||
Ok(u) => u,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub(super) 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,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) 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)?;
|
||||
|
||||
// Guard: if a passphrase is already configured, reject and direct to /api/key-change
|
||||
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-setup guard");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
if user.key_salt.is_some() {
|
||||
tracing::warn!(%user_id, "key-setup called but passphrase already configured; use /api/key-change");
|
||||
return Err(StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
// ── Change passphrase (re-encrypts all secrets) ───────────────────────────────
|
||||
|
||||
pub(super) async fn api_key_change(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Json(body): Json<KeyChangeRequest>,
|
||||
) -> Result<Json<KeySetupResponse>, 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-change");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
// Must have an existing passphrase to change
|
||||
let existing_key_check = user.key_check.ok_or_else(|| {
|
||||
tracing::warn!(%user_id, "key-change called but no passphrase configured; use /api/key-setup");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
|
||||
// Validate and decode old key
|
||||
let old_key_bytes = secrets_core::crypto::extract_key_from_hex(&body.old_key).map_err(|e| {
|
||||
tracing::warn!(error = %e, "invalid old_key hex in key-change");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
|
||||
// Verify old_key against the stored key_check
|
||||
let plaintext = secrets_core::crypto::decrypt(&old_key_bytes, &existing_key_check).map_err(|_| {
|
||||
tracing::warn!(%user_id, "key-change rejected: old_key does not match stored key_check");
|
||||
StatusCode::UNAUTHORIZED
|
||||
})?;
|
||||
if plaintext != b"secrets-mcp-key-check" {
|
||||
tracing::warn!(%user_id, "key-change rejected: decrypted key_check content mismatch");
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Validate and decode new key
|
||||
let new_key_bytes = secrets_core::crypto::extract_key_from_hex(&body.new_key).map_err(|e| {
|
||||
tracing::warn!(error = %e, "invalid new_key hex in key-change");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
|
||||
// Decode new salt and key_check
|
||||
let new_salt = hex::decode_hex(&body.salt).map_err(|e| {
|
||||
tracing::warn!(error = %e, "invalid hex in key-change salt");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
if new_salt.len() != 32 {
|
||||
tracing::warn!(
|
||||
salt_len = new_salt.len(),
|
||||
"key-change salt must be 32 bytes"
|
||||
);
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let new_key_check = hex::decode_hex(&body.key_check).map_err(|e| {
|
||||
tracing::warn!(error = %e, "invalid hex in key-change key_check");
|
||||
StatusCode::BAD_REQUEST
|
||||
})?;
|
||||
|
||||
change_user_key(
|
||||
&state.pool,
|
||||
user_id,
|
||||
&old_key_bytes,
|
||||
&new_key_bytes,
|
||||
&new_salt,
|
||||
&new_key_check,
|
||||
&body.params,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, %user_id, "failed to change user key");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// Refresh the session's key_version so the current session is not immediately
|
||||
// invalidated by require_valid_user on the next page load.
|
||||
match get_user_by_id(&state.pool, user_id).await {
|
||||
Ok(Some(updated_user)) => {
|
||||
if let Err(e) = session
|
||||
.insert(SESSION_KEY_VERSION, updated_user.key_version)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, %user_id, "failed to update key_version in session after key change");
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(%user_id, "user not found after key change; session not updated");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, %user_id, "failed to reload user after key change; session not updated");
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(%user_id, secrets_count = "(see service log)", "passphrase changed and secrets re-encrypted");
|
||||
Ok(Json(KeySetupResponse { ok: true }))
|
||||
}
|
||||
|
||||
// ── API Key management ────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) 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 }))
|
||||
}
|
||||
|
||||
pub(super) 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 }))
|
||||
}
|
||||
73
crates/secrets-mcp/src/web/assets.rs
Normal file
73
crates/secrets-mcp/src/web/assets.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub(super) 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")
|
||||
}
|
||||
|
||||
pub(super) async fn robots_txt() -> Response {
|
||||
text_asset_response(
|
||||
include_str!("../../static/robots.txt"),
|
||||
"text/plain; charset=utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn llms_txt() -> Response {
|
||||
text_asset_response(
|
||||
include_str!("../../static/llms.txt"),
|
||||
"text/markdown; charset=utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) async fn ai_txt() -> Response {
|
||||
llms_txt().await
|
||||
}
|
||||
|
||||
pub(super) async fn i18n_js() -> Response {
|
||||
text_asset_response(
|
||||
include_str!("../../templates/i18n.js"),
|
||||
"application/javascript; charset=utf-8",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) 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")
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(super) 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),
|
||||
)
|
||||
}
|
||||
104
crates/secrets-mcp/src/web/audit.rs
Normal file
104
crates/secrets-mcp/src/web/audit.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use chrono::SecondsFormat;
|
||||
use serde::Deserialize;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{AUDIT_PAGE_LIMIT, paginate, render_template, require_valid_user};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "audit.html")]
|
||||
struct AuditPageTemplate {
|
||||
user_name: String,
|
||||
user_email: String,
|
||||
entries: Vec<AuditEntryView>,
|
||||
current_page: u32,
|
||||
total_pages: u32,
|
||||
total_count: i64,
|
||||
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(Deserialize)]
|
||||
pub(super) struct AuditQuery {
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn audit_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Query(aq): Query<AuditQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
use secrets_core::service::audit_log::{count_for_user, list_for_user};
|
||||
|
||||
let user = match require_valid_user(&state.pool, &session, "audit_page").await {
|
||||
Ok(u) => u,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
let user_id = user.id;
|
||||
|
||||
let page = aq.page.unwrap_or(1).max(1);
|
||||
|
||||
let total_count = count_for_user(&state.pool, user_id).await.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to count audit log for user");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let (current_page, total_pages, offset) = paginate(page, total_count, AUDIT_PAGE_LIMIT as u32);
|
||||
let actual_offset = i64::from(offset);
|
||||
|
||||
let rows = list_for_user(&state.pool, user_id, AUDIT_PAGE_LIMIT, actual_offset)
|
||||
.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,
|
||||
current_page,
|
||||
total_pages,
|
||||
total_count,
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
};
|
||||
|
||||
render_template(tmpl)
|
||||
}
|
||||
360
crates/secrets-mcp/src/web/auth.rs
Normal file
360
crates/secrets-mcp/src/web/auth.rs
Normal file
@@ -0,0 +1,360 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use secrets_core::audit::log_login;
|
||||
use secrets_core::service::user::{
|
||||
OAuthProfile, bind_oauth_account, find_or_create_user, unbind_oauth_account,
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::oauth::{OAuthConfig, OAuthUserInfo, google_auth_url, random_state};
|
||||
|
||||
use super::{
|
||||
SESSION_KEY_VERSION, SESSION_LOGIN_PROVIDER, SESSION_OAUTH_BIND_MODE, SESSION_OAUTH_STATE,
|
||||
SESSION_USER_ID, current_user_id, google_cfg, render_template, request_user_agent,
|
||||
};
|
||||
|
||||
#[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,
|
||||
}
|
||||
|
||||
// ── Home page (public) ───────────────────────────────────────────────────────
|
||||
|
||||
pub(super) 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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) 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)]
|
||||
pub(super) struct OAuthCallbackQuery {
|
||||
code: Option<String>,
|
||||
state: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) 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 = Some(crate::client_ip::extract_client_ip_parts(
|
||||
&headers,
|
||||
connect_info.0,
|
||||
));
|
||||
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
|
||||
})?;
|
||||
if let Err(e) = session.insert(SESSION_KEY_VERSION, user.key_version).await {
|
||||
tracing::warn!(error = %e, user_id = %user.id, "failed to insert key_version into session after OAuth");
|
||||
}
|
||||
|
||||
log_login(
|
||||
&state.pool,
|
||||
"oauth",
|
||||
provider,
|
||||
user.id,
|
||||
client_ip,
|
||||
user_agent,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(Redirect::to("/dashboard").into_response())
|
||||
}
|
||||
|
||||
// ── Logout ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) 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("/")
|
||||
}
|
||||
|
||||
// ── Account bind/unbind ───────────────────────────────────────────────────────
|
||||
|
||||
pub(super) 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 config = google_cfg(&state).ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
|
||||
let oauth_state = random_state();
|
||||
if let Err(e) = session.insert(SESSION_OAUTH_STATE, &oauth_state).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);
|
||||
}
|
||||
|
||||
let url = google_auth_url(config, &oauth_state);
|
||||
Ok(Redirect::to(&url).into_response())
|
||||
}
|
||||
|
||||
pub(super) 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())
|
||||
}
|
||||
948
crates/secrets-mcp/src/web/entries.rs
Normal file
948
crates/secrets-mcp/src/web/entries.rs
Normal file
@@ -0,0 +1,948 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::Response,
|
||||
};
|
||||
use chrono::SecondsFormat;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tower_sessions::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
use secrets_core::error::AppError;
|
||||
use secrets_core::service::{
|
||||
delete::delete_by_id,
|
||||
get_secret::get_all_secrets_by_id,
|
||||
search::{SearchParams, count_entries, fetch_secrets_for_entries, ilike_pattern, list_entries},
|
||||
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
|
||||
};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
use super::{
|
||||
ENTRIES_PAGE_LIMIT, UiLang, current_user_id, paginate, render_template, request_ui_lang,
|
||||
require_valid_user, tr,
|
||||
};
|
||||
|
||||
// ── Template types ────────────────────────────────────────────────────────────
|
||||
|
||||
use askama::Template;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "entries.html")]
|
||||
struct EntriesPageTemplate {
|
||||
user_name: String,
|
||||
user_email: String,
|
||||
entries: Vec<EntryListItemView>,
|
||||
folder_tabs: Vec<FolderTabView>,
|
||||
type_options: Vec<String>,
|
||||
secret_type_options_json: String,
|
||||
filter_folder: String,
|
||||
filter_name: String,
|
||||
filter_type: String,
|
||||
current_page: u32,
|
||||
total_pages: u32,
|
||||
total_count: i64,
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
/// Non-sensitive entry fields; `secrets` lists field names/types only (no ciphertext).
|
||||
struct EntryListItemView {
|
||||
id: String,
|
||||
folder: String,
|
||||
entry_type: String,
|
||||
name: String,
|
||||
notes: String,
|
||||
tags: String,
|
||||
/// Compact JSON for `data-entry-metadata` (dialog editor).
|
||||
metadata_json: String,
|
||||
/// Secret field summaries for table + dialog chips.
|
||||
secrets: Vec<SecretSummaryView>,
|
||||
/// JSON array of `{ id, name, secret_type }` for dialog secret chips.
|
||||
secrets_json: String,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) 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>,
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
// ── Entry mutation error helpers ──────────────────────────────────────────────
|
||||
|
||||
type EntryApiError = (StatusCode, Json<serde_json::Value>);
|
||||
|
||||
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") }),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn entries_page(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Query(q): Query<EntriesQuery>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let user = match require_valid_user(&state.pool, &session, "entries_page").await {
|
||||
Ok(u) => u,
|
||||
Err(r) => return Ok(r),
|
||||
};
|
||||
let user_id = user.id;
|
||||
|
||||
let folder_filter = q
|
||||
.folder
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let type_filter = q
|
||||
.entry_type
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let name_filter = q
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let page = q.page.unwrap_or(1).max(1);
|
||||
let count_params = SearchParams {
|
||||
folder: folder_filter.as_deref(),
|
||||
entry_type: type_filter.as_deref(),
|
||||
name: None,
|
||||
name_query: name_filter.as_deref(),
|
||||
tags: &[],
|
||||
query: None,
|
||||
sort: "updated",
|
||||
limit: ENTRIES_PAGE_LIMIT,
|
||||
offset: 0,
|
||||
user_id: Some(user_id),
|
||||
};
|
||||
|
||||
// Build folder query before joining so the SQL string lives long enough.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct FolderCountRow {
|
||||
folder: String,
|
||||
count: i64,
|
||||
}
|
||||
let mut folder_sql =
|
||||
"SELECT folder, COUNT(*)::bigint AS count FROM entries WHERE user_id = $1".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));
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TypeOptionRow {
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
}
|
||||
|
||||
// Phase 1: count, folder tabs, and type options are mutually independent — run in parallel.
|
||||
let (total_count, folder_rows_res, type_options_res) = tokio::join!(
|
||||
async {
|
||||
count_entries(&state.pool, &count_params)
|
||||
.await
|
||||
.inspect_err(
|
||||
|e| tracing::warn!(error = %e, "count_entries failed for web entries page"),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
},
|
||||
folder_query.fetch_all(&state.pool),
|
||||
sqlx::query_as::<_, TypeOptionRow>(
|
||||
"SELECT DISTINCT type FROM entries WHERE user_id = $1 ORDER BY type",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_all(&state.pool),
|
||||
);
|
||||
let folder_rows = folder_rows_res.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to load folder tabs for web");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let mut type_options: Vec<String> = type_options_res
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to load type options for web");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?
|
||||
.into_iter()
|
||||
.map(|r| r.entry_type)
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
|
||||
// Phase 2: paginate using count, then fetch entries for the page.
|
||||
let (current_page, total_pages, offset) = paginate(page, total_count, ENTRIES_PAGE_LIMIT);
|
||||
let list_params = SearchParams {
|
||||
offset,
|
||||
..count_params
|
||||
};
|
||||
let rows = list_entries(&state.pool, list_params).await.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to load entries list for web");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let entry_ids: Vec<Uuid> = rows.iter().map(|e| e.id).collect();
|
||||
let secret_schemas = fetch_secrets_for_entries(&state.pool, &entry_ids)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to load secret schema list for web");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
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>,
|
||||
page: Option<u32>,
|
||||
) -> String {
|
||||
let mut pairs: Vec<String> = Vec::new();
|
||||
if let Some(f) = folder
|
||||
&& !f.is_empty()
|
||||
{
|
||||
pairs.push(format!("folder={}", urlencoding::encode(f)));
|
||||
}
|
||||
if let Some(t) = entry_type
|
||||
&& !t.is_empty()
|
||||
{
|
||||
pairs.push(format!("type={}", urlencoding::encode(t)));
|
||||
}
|
||||
if let Some(n) = name
|
||||
&& !n.is_empty()
|
||||
{
|
||||
pairs.push(format!("name={}", urlencoding::encode(n)));
|
||||
}
|
||||
if let Some(p) = page {
|
||||
pairs.push(format!("page={}", p));
|
||||
}
|
||||
if pairs.is_empty() {
|
||||
"/entries".to_string()
|
||||
} else {
|
||||
format!("/entries?{}", pairs.join("&"))
|
||||
}
|
||||
}
|
||||
|
||||
let all_count: i64 = folder_rows.iter().map(|r| r.count).sum();
|
||||
let mut folder_tabs: Vec<FolderTabView> = Vec::with_capacity(folder_rows.len() + 1);
|
||||
folder_tabs.push(FolderTabView {
|
||||
name: "全部".to_string(),
|
||||
count: all_count,
|
||||
href: entries_href(
|
||||
None,
|
||||
type_filter.as_deref(),
|
||||
name_filter.as_deref(),
|
||||
Some(1),
|
||||
),
|
||||
active: folder_filter.is_none(),
|
||||
});
|
||||
for r in folder_rows {
|
||||
let name = r.folder;
|
||||
folder_tabs.push(FolderTabView {
|
||||
href: entries_href(
|
||||
Some(&name),
|
||||
type_filter.as_deref(),
|
||||
name_filter.as_deref(),
|
||||
Some(1),
|
||||
),
|
||||
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(),
|
||||
current_page,
|
||||
total_pages,
|
||||
total_count,
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
};
|
||||
|
||||
render_template(tmpl)
|
||||
}
|
||||
|
||||
// ── Entry management (Web UI, non-sensitive fields only) ───────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct EntryPatchBody {
|
||||
folder: String,
|
||||
#[serde(rename = "type")]
|
||||
entry_type: String,
|
||||
name: String,
|
||||
notes: String,
|
||||
tags: Vec<String>,
|
||||
metadata: serde_json::Value,
|
||||
}
|
||||
|
||||
pub(super) async fn api_entry_patch(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: HeaderMap,
|
||||
Path(entry_id): Path<Uuid>,
|
||||
Json(body): Json<EntryPatchBody>,
|
||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||
let lang = request_ui_lang(&headers);
|
||||
let user_id = current_user_id(&session).await.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
||||
))?;
|
||||
|
||||
let folder = body.folder.trim();
|
||||
let entry_type = body.entry_type.trim();
|
||||
let name = body.name.trim();
|
||||
let notes = body.notes.trim();
|
||||
|
||||
if name.is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": tr(lang, "name 不能为空", "name 不能為空", "name cannot be empty") }),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let tags: Vec<String> = body
|
||||
.tags
|
||||
.into_iter()
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
|
||||
if !body.metadata.is_object() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": tr(lang, "metadata 必须是 JSON 对象", "metadata 必須是 JSON 物件", "metadata must be a JSON object") }),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
update_fields_by_id(
|
||||
&state.pool,
|
||||
entry_id,
|
||||
user_id,
|
||||
UpdateEntryFieldsByIdParams {
|
||||
folder,
|
||||
entry_type,
|
||||
name,
|
||||
notes,
|
||||
tags: &tags,
|
||||
metadata: &body.metadata,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| map_entry_mutation_err(e, lang))?;
|
||||
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entry_delete(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: HeaderMap,
|
||||
Path(entry_id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||
let lang = request_ui_lang(&headers);
|
||||
let user_id = current_user_id(&session).await.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
||||
))?;
|
||||
|
||||
delete_by_id(&state.pool, entry_id, user_id)
|
||||
.await
|
||||
.map_err(|e| map_entry_mutation_err(e, lang))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct SecretCheckNameQuery {
|
||||
name: String,
|
||||
exclude_secret_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct SecretCheckNameResponse {
|
||||
ok: bool,
|
||||
available: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) async fn api_secret_check_name(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<SecretCheckNameQuery>,
|
||||
) -> Result<Json<SecretCheckNameResponse>, EntryApiError> {
|
||||
let lang = request_ui_lang(&headers);
|
||||
let user_id = current_user_id(&session).await.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
||||
))?;
|
||||
|
||||
let name = params.name.trim();
|
||||
if name.is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": tr(lang, "secret name 不能为空", "secret name 不能為空", "secret name cannot be empty") }),
|
||||
),
|
||||
));
|
||||
}
|
||||
if name.chars().count() > 256 {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(
|
||||
json!({ "error": tr(lang, "secret name 长度不能超过 256 个字符", "secret name 長度不能超過 256 個字元", "secret name must be at most 256 characters") }),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let count: i64 = if let Some(exclude_id) = params.exclude_secret_id {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM secrets WHERE user_id = $1 AND name = $2 AND id != $3",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(name)
|
||||
.bind(exclude_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM secrets WHERE user_id = $1 AND name = $2",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(name)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
}.map_err(|e| {
|
||||
tracing::error!(error = %e, "failed to check secret name availability");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") }),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
let available = count == 0;
|
||||
let error = if available {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
tr(
|
||||
lang,
|
||||
"该用户下已存在相同 name 的密文",
|
||||
"該用戶下已存在相同 name 的密文",
|
||||
"A secret with the same name already exists for this user",
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(Json(SecretCheckNameResponse {
|
||||
ok: true,
|
||||
available,
|
||||
error,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct SecretPatchBody {
|
||||
name: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
secret_type: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) async fn api_secret_patch(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: HeaderMap,
|
||||
Path(secret_id): Path<Uuid>,
|
||||
Json(body): Json<SecretPatchBody>,
|
||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
struct LinkedEntryAuditRow {
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
let lang = request_ui_lang(&headers);
|
||||
let user_id = current_user_id(&session).await.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
||||
))?;
|
||||
|
||||
let name = body.name.as_ref().map(|s| s.trim());
|
||||
let secret_type = body.secret_type.as_ref().map(|s| s.trim());
|
||||
|
||||
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 })))
|
||||
}
|
||||
|
||||
pub(super) async fn api_entry_secret_unlink(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: HeaderMap,
|
||||
Path((entry_id, secret_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct EntryAuditRow {
|
||||
folder: String,
|
||||
#[sqlx(rename = "type")]
|
||||
entry_type: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
let lang = request_ui_lang(&headers);
|
||||
let user_id = current_user_id(&session).await.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
||||
))?;
|
||||
|
||||
let mut tx = state
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
|
||||
|
||||
let entry_row: Option<EntryAuditRow> =
|
||||
sqlx::query_as("SELECT folder, type, name FROM entries WHERE id = $1 AND user_id = $2")
|
||||
.bind(entry_id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
|
||||
|
||||
let Some(entry_row) = entry_row else {
|
||||
let _ = tx.rollback().await;
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(
|
||||
json!({ "error": tr(lang, "条目不存在或无权访问", "條目不存在或無權存取", "Entry not found or no access") }),
|
||||
),
|
||||
));
|
||||
};
|
||||
|
||||
let deleted = sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
|
||||
.bind(entry_id)
|
||||
.bind(secret_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| map_entry_mutation_err(e.into(), lang))?
|
||||
.rows_affected();
|
||||
|
||||
if deleted == 0 {
|
||||
let _ = tx.rollback().await;
|
||||
return Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": tr(lang, "关联不存在", "關聯不存在", "Relation not found") })),
|
||||
));
|
||||
}
|
||||
|
||||
let secret_deleted = sqlx::query(
|
||||
"DELETE FROM secrets s \
|
||||
WHERE s.id = $1 \
|
||||
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
|
||||
)
|
||||
.bind(secret_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| map_entry_mutation_err(e.into(), lang))?
|
||||
.rows_affected()
|
||||
> 0;
|
||||
|
||||
secrets_core::audit::log_tx(
|
||||
&mut tx,
|
||||
Some(user_id),
|
||||
"unlink_secret",
|
||||
&entry_row.folder,
|
||||
&entry_row.entry_type,
|
||||
&entry_row.name,
|
||||
json!({
|
||||
"source": "web",
|
||||
"entry_id": entry_id,
|
||||
"secret_id": secret_id,
|
||||
"deleted_secret": secret_deleted,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"deleted_relation": true,
|
||||
"deleted_secret": secret_deleted,
|
||||
})))
|
||||
}
|
||||
|
||||
// ── Decrypt entry secrets (Web UI) ───────────────────────────────────────────
|
||||
|
||||
pub(super) async fn api_entry_secrets_decrypt(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
headers: HeaderMap,
|
||||
Path(entry_id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||
let lang = request_ui_lang(&headers);
|
||||
let user_id = current_user_id(&session).await.ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
||||
))?;
|
||||
|
||||
let enc_key_hex = headers
|
||||
.get("x-encryption-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": tr(lang, "缺少 X-Encryption-Key 请求头", "缺少 X-Encryption-Key 請求標頭", "Missing X-Encryption-Key header") })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let master_key =
|
||||
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({ "error": tr(lang, "X-Encryption-Key 格式无效", "X-Encryption-Key 格式無效", "Invalid X-Encryption-Key format") })),
|
||||
)
|
||||
})?;
|
||||
|
||||
let secrets =
|
||||
get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if let Some(app_err) = e.downcast_ref::<AppError>() {
|
||||
return match app_err {
|
||||
AppError::DecryptionFailed => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(json!({ "error": tr(lang, "解密失败,请确认密码短语正确", "解密失敗,請確認密碼短語正確", "Decryption failed, please verify your passphrase") })),
|
||||
),
|
||||
AppError::NotFoundEntry | AppError::NotFoundUser | AppError::NotFoundSecret => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "error": tr(lang, "条目不存在或无权访问", "條目不存在或無權存取", "Entry not found or no access") })),
|
||||
),
|
||||
_ => {
|
||||
tracing::error!(error = %e, %entry_id, "decrypt entry secrets failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") })),
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
tracing::error!(error = %e, %entry_id, "decrypt entry secrets failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") })),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(json!({ "ok": true, "secrets": secrets })))
|
||||
}
|
||||
306
crates/secrets-mcp/src/web/mod.rs
Normal file
306
crates/secrets-mcp/src/web/mod.rs
Normal file
@@ -0,0 +1,306 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Router,
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::{get, patch, post},
|
||||
};
|
||||
use tower_sessions::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::oauth::OAuthConfig;
|
||||
|
||||
mod account;
|
||||
mod assets;
|
||||
mod audit;
|
||||
mod auth;
|
||||
mod entries;
|
||||
|
||||
// ── Session keys ──────────────────────────────────────────────────────────────
|
||||
|
||||
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";
|
||||
const SESSION_KEY_VERSION: &str = "key_version";
|
||||
|
||||
// ── Page limits ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Cap for HTML list (avoids loading unbounded rows into memory).
|
||||
const ENTRIES_PAGE_LIMIT: u32 = 50;
|
||||
const AUDIT_PAGE_LIMIT: i64 = 10;
|
||||
|
||||
// ── UI language ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and validate the current user from session and DB.
|
||||
///
|
||||
/// Returns the user if the session is valid. Flushes the session and returns
|
||||
/// `Err(Redirect::to("/login"))` when:
|
||||
/// - the session has no `user_id`,
|
||||
/// - the user no longer exists in the database, or
|
||||
/// - the stored `key_version` does not match the DB value (passphrase changed on
|
||||
/// another device since this session was created).
|
||||
async fn require_valid_user(
|
||||
pool: &sqlx::PgPool,
|
||||
session: &Session,
|
||||
context: &str,
|
||||
) -> Result<secrets_core::models::User, Response> {
|
||||
let Some(user_id) = current_user_id(session).await else {
|
||||
return Err(Redirect::to("/login").into_response());
|
||||
};
|
||||
|
||||
let user = match secrets_core::service::user::get_user_by_id(pool, user_id).await {
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, %user_id, context, "failed to load user");
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());
|
||||
}
|
||||
Ok(None) => {
|
||||
if let Err(e) = session.flush().await {
|
||||
tracing::warn!(error = %e, "failed to flush stale session");
|
||||
}
|
||||
return Err(Redirect::to("/login").into_response());
|
||||
}
|
||||
Ok(Some(u)) => u,
|
||||
};
|
||||
|
||||
let session_kv: Option<i64> = match session.get::<i64>(SESSION_KEY_VERSION).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "failed to read key_version from session; treating as missing");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(kv) = session_kv
|
||||
&& kv != user.key_version
|
||||
{
|
||||
tracing::info!(%user_id, session_kv = kv, db_kv = user.key_version, "key_version mismatch; invalidating session");
|
||||
if let Err(e) = session.flush().await {
|
||||
tracing::warn!(error = %e, "failed to flush outdated session");
|
||||
}
|
||||
return Err(Redirect::to("/login").into_response());
|
||||
}
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn paginate(page: u32, total_count: i64, page_size: u32) -> (u32, u32, u32) {
|
||||
let page_size = page_size.max(1);
|
||||
let safe_total_count = u32::try_from(total_count.max(0)).unwrap_or(u32::MAX);
|
||||
let total_pages = safe_total_count.div_ceil(page_size).max(1);
|
||||
let current_page = page.max(1).min(total_pages);
|
||||
let offset = (current_page - 1).saturating_mul(page_size);
|
||||
(current_page, total_pages, offset)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
// ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn web_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/robots.txt", get(assets::robots_txt))
|
||||
.route("/llms.txt", get(assets::llms_txt))
|
||||
.route("/ai.txt", get(assets::ai_txt))
|
||||
.route("/static/i18n.js", get(assets::i18n_js))
|
||||
.route("/favicon.svg", get(assets::favicon_svg))
|
||||
.route(
|
||||
"/favicon.ico",
|
||||
get(|| async { Redirect::permanent("/favicon.svg") }),
|
||||
)
|
||||
.route(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
get(assets::oauth_protected_resource_metadata),
|
||||
)
|
||||
.route("/", get(auth::home_page))
|
||||
.route("/login", get(auth::login_page))
|
||||
.route("/auth/google", get(auth::auth_google))
|
||||
.route("/auth/google/callback", get(auth::auth_google_callback))
|
||||
.route("/auth/logout", post(auth::auth_logout))
|
||||
.route("/dashboard", get(account::dashboard))
|
||||
.route("/entries", get(entries::entries_page))
|
||||
.route("/audit", get(audit::audit_page))
|
||||
.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/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/apikey/regenerate",
|
||||
post(account::api_apikey_regenerate),
|
||||
)
|
||||
.route(
|
||||
"/api/entries/{id}",
|
||||
patch(entries::api_entry_patch).delete(entries::api_entry_delete),
|
||||
)
|
||||
.route(
|
||||
"/api/entries/{entry_id}/secrets/{secret_id}",
|
||||
axum::routing::delete(entries::api_entry_secret_unlink),
|
||||
)
|
||||
.route(
|
||||
"/api/entries/{id}/secrets/decrypt",
|
||||
get(entries::api_entry_secrets_decrypt),
|
||||
)
|
||||
.route("/api/secrets/{secret_id}", patch(entries::api_secret_patch))
|
||||
.route(
|
||||
"/api/secrets/check-name",
|
||||
get(entries::api_secret_check_name),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn client_ip_ignores_forwarded_headers_without_trusted_proxy() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "203.0.113.10".parse().unwrap());
|
||||
|
||||
let ip = crate::client_ip::extract_client_ip_parts(
|
||||
&headers,
|
||||
SocketAddr::from(([127, 0, 0, 1], 9315)),
|
||||
);
|
||||
|
||||
assert_eq!(ip, "127.0.0.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_ip_uses_valid_forwarded_header_with_trusted_proxy() {
|
||||
// This test relies on TRUST_PROXY being unset (default); skip if set in env
|
||||
if std::env::var("TRUST_PROXY").is_ok() {
|
||||
return;
|
||||
}
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "203.0.113.10, 10.0.0.1".parse().unwrap());
|
||||
|
||||
// Direct connection IP is used when TRUST_PROXY is not set
|
||||
let ip = crate::client_ip::extract_client_ip_parts(
|
||||
&headers,
|
||||
SocketAddr::from(([127, 0, 0, 1], 9315)),
|
||||
);
|
||||
|
||||
assert_eq!(ip, "127.0.0.1");
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_clamps_page_before_computing_offset() {
|
||||
let (current_page, total_pages, offset) = paginate(100, 12, 10);
|
||||
|
||||
assert_eq!(current_page, 2);
|
||||
assert_eq!(total_pages, 2);
|
||||
assert_eq!(offset, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_handles_large_page_without_overflow() {
|
||||
let (current_page, total_pages, offset) = paginate(u32::MAX, 1, ENTRIES_PAGE_LIMIT);
|
||||
|
||||
assert_eq!(current_page, 1);
|
||||
assert_eq!(total_pages, 1);
|
||||
assert_eq!(offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paginate_saturates_large_total_count() {
|
||||
let (_, total_pages, _) = paginate(1, i64::MAX, ENTRIES_PAGE_LIMIT);
|
||||
|
||||
assert_eq!(total_pages, u32::MAX.div_ceil(ENTRIES_PAGE_LIMIT));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user