Compare commits
4 Commits
secrets-mc
...
secrets-mc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fde982c20 | ||
|
|
a2a80a1744 | ||
| dfe282095c | |||
|
|
59084a409d |
@@ -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.11"
|
||||
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.11"
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -577,20 +577,16 @@ function buildSecretsConfigText(apiKey, encKey) {
|
||||
return lines.length < 3 ? wrapped : lines.slice(1, -1).join('\n');
|
||||
}
|
||||
|
||||
/** OpenCode: local stdio bridge to Streamable HTTP MCP (mcp-remote --transport http-only). */
|
||||
/** OpenCode: native Streamable HTTP transport (no mcp-remote bridge needed). */
|
||||
function buildOpencodeEntry(apiKey, encKey) {
|
||||
return {
|
||||
type: 'local',
|
||||
command: [
|
||||
'npx', '-y', 'mcp-remote',
|
||||
BASE_URL + '/mcp',
|
||||
'--header',
|
||||
'Authorization: Bearer ' + apiKey,
|
||||
'--header',
|
||||
'X-Encryption-Key: ' + encKey,
|
||||
'--transport',
|
||||
'http-only'
|
||||
]
|
||||
type: 'remote',
|
||||
url: BASE_URL + '/mcp',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + apiKey,
|
||||
'X-Encryption-Key': encKey
|
||||
},
|
||||
oauth: false
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -416,6 +416,23 @@
|
||||
line-height: 1.6; text-align: center;
|
||||
}
|
||||
.view-locked-msg a { color: var(--accent); }
|
||||
.view-secret-name-wrap { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex: 1; min-width: 0; }
|
||||
.view-secret-name-input {
|
||||
width: 180px; max-width: 100%; background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 4px; color: var(--text); padding: 2px 8px; font-size: 12px;
|
||||
font-family: 'JetBrains Mono', monospace; outline: none;
|
||||
}
|
||||
.view-secret-name-input:focus { border-color: var(--accent); }
|
||||
.view-secret-type-select {
|
||||
background: var(--surface2); border: 1px solid var(--border); border-radius: 4px;
|
||||
color: var(--text); padding: 2px 6px; font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace; outline: none; cursor: pointer;
|
||||
}
|
||||
.view-secret-type-select:focus { border-color: var(--accent); }
|
||||
.btn-view-edit { color: var(--accent); }
|
||||
.btn-view-save { color: #3fb950; }
|
||||
.btn-view-cancel { color: var(--text-muted); }
|
||||
.btn-view-unlink { color: #f85149; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -506,7 +523,6 @@
|
||||
<span class="secret-chip">
|
||||
<span class="secret-name" title="{{ s.name }}">{{ s.name }}</span>
|
||||
<span class="secret-type">{{ s.secret_type }}</span>
|
||||
<button type="button" class="btn-unlink-secret" data-secret-id="{{ s.id }}" data-secret-name="{{ s.name }}" title="解除关联">×</button>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -556,7 +572,6 @@
|
||||
<div class="modal-field"><label for="edit-tags" data-i18n="modalTags">标签(逗号分隔)</label><input id="edit-tags" type="text" autocomplete="off"></div>
|
||||
<div class="modal-field"><label data-i18n="modalUpdated">更新</label><div id="edit-updated-at" class="modal-readonly-value" aria-live="polite"></div></div>
|
||||
<div class="modal-field"><label for="edit-metadata" data-i18n="modalMetadata">元数据(JSON 对象)</label><textarea id="edit-metadata" class="metadata-edit"></textarea></div>
|
||||
<div class="modal-field modal-secrets"><label data-i18n="modalSecrets">密文</label><div id="edit-secrets-list" class="secret-list"></div></div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-modal" id="edit-cancel" data-i18n="modalCancel">取消</button>
|
||||
<button type="button" class="btn-modal primary" id="edit-save" data-i18n="modalSave">保存</button>
|
||||
@@ -661,6 +676,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
viewShow: '显示',
|
||||
viewHide: '隐藏',
|
||||
viewLoading: '解密中…',
|
||||
viewSaveChanges: '保存更改',
|
||||
viewChangesSaved: '已保存',
|
||||
viewUnlinkConfirm: '确定解除密文关联「{name}」?',
|
||||
},
|
||||
'zh-TW': {
|
||||
pageTitle: 'Secrets — 條目',
|
||||
@@ -729,6 +747,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
viewShow: '顯示',
|
||||
viewHide: '隱藏',
|
||||
viewLoading: '解密中…',
|
||||
viewSaveChanges: '儲存變更',
|
||||
viewChangesSaved: '已儲存',
|
||||
viewUnlinkConfirm: '確定解除密文關聯「{name}」?',
|
||||
},
|
||||
en: {
|
||||
pageTitle: 'Secrets — Entries',
|
||||
@@ -797,6 +818,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
viewShow: 'Show',
|
||||
viewHide: 'Hide',
|
||||
viewLoading: 'Decrypting…',
|
||||
viewSaveChanges: 'Save changes',
|
||||
viewChangesSaved: 'Saved',
|
||||
viewUnlinkConfirm: 'Unlink secret "{name}"?',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -820,15 +844,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
if (td) td.setAttribute('data-label', t(map[sel]));
|
||||
});
|
||||
});
|
||||
editSecretsList.querySelectorAll('.btn-unlink-secret').forEach(function (btn) {
|
||||
btn.title = t('unlinkTitle');
|
||||
});
|
||||
editSecretsList.querySelectorAll('.secret-name-input').forEach(function (input) {
|
||||
input.placeholder = t('renameSecretPlaceholder');
|
||||
});
|
||||
document.querySelectorAll('.table-wrap .btn-unlink-secret').forEach(function (btn) {
|
||||
btn.title = t('unlinkTitle');
|
||||
});
|
||||
};
|
||||
|
||||
var editOverlay = document.getElementById('edit-overlay');
|
||||
@@ -840,7 +855,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
var editTags = document.getElementById('edit-tags');
|
||||
var editMetadata = document.getElementById('edit-metadata');
|
||||
var editUpdatedAt = document.getElementById('edit-updated-at');
|
||||
var editSecretsList = document.getElementById('edit-secrets-list');
|
||||
var deleteOverlay = document.getElementById('delete-overlay');
|
||||
var deleteError = document.getElementById('delete-error');
|
||||
var deleteMessage = document.getElementById('delete-message');
|
||||
@@ -863,7 +877,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
if (e.target === viewOverlay) closeView();
|
||||
});
|
||||
|
||||
function renderViewSecrets(secrets) {
|
||||
function renderViewSecrets(secrets, secretSchema) {
|
||||
viewBody.innerHTML = '';
|
||||
var names = Object.keys(secrets);
|
||||
if (names.length === 0) {
|
||||
@@ -873,6 +887,10 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
viewBody.appendChild(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
var schemaMap = {};
|
||||
(secretSchema || []).forEach(function (s) { schemaMap[s.name] = s; });
|
||||
|
||||
names.forEach(function (name) {
|
||||
var raw = secrets[name];
|
||||
var valueStr = (raw === null || raw === undefined) ? '' :
|
||||
@@ -880,20 +898,84 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
var isPassword = (name === 'password' || name === 'passwd' || name === 'secret');
|
||||
var masked = isPassword;
|
||||
|
||||
var schema = schemaMap[name] || {};
|
||||
var secretId = schema.id || '';
|
||||
var secretType = schema.secret_type || 'text';
|
||||
var originalName = name;
|
||||
var hasChanges = false;
|
||||
|
||||
var row = document.createElement('div');
|
||||
row.className = 'view-secret-row';
|
||||
row.setAttribute('data-secret-id', secretId);
|
||||
row.setAttribute('data-original-name', originalName);
|
||||
|
||||
var header = document.createElement('div');
|
||||
header.className = 'view-secret-header';
|
||||
|
||||
var nameWrap = document.createElement('div');
|
||||
nameWrap.className = 'view-secret-name-wrap';
|
||||
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'view-secret-name';
|
||||
nameSpan.textContent = name;
|
||||
header.appendChild(nameSpan);
|
||||
|
||||
var nameInput = document.createElement('input');
|
||||
nameInput.type = 'text';
|
||||
nameInput.className = 'view-secret-name-input';
|
||||
nameInput.value = name;
|
||||
nameInput.placeholder = t('renameSecretPlaceholder');
|
||||
nameInput.setAttribute('data-original-name', originalName);
|
||||
nameInput.hidden = true;
|
||||
|
||||
var typeBadge = document.createElement('span');
|
||||
typeBadge.className = 'view-secret-type';
|
||||
typeBadge.textContent = secretType;
|
||||
|
||||
var typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'view-secret-type-select';
|
||||
typeSelect.hidden = true;
|
||||
SECRET_TYPE_OPTIONS.forEach(function (opt) {
|
||||
var option = document.createElement('option');
|
||||
option.value = opt;
|
||||
option.textContent = opt;
|
||||
if (opt === secretType) option.selected = true;
|
||||
typeSelect.appendChild(option);
|
||||
});
|
||||
if (SECRET_TYPE_OPTIONS.indexOf(secretType) === -1 && secretType) {
|
||||
var fallback = document.createElement('option');
|
||||
fallback.value = secretType;
|
||||
fallback.textContent = secretType;
|
||||
fallback.selected = true;
|
||||
typeSelect.appendChild(fallback);
|
||||
}
|
||||
|
||||
nameWrap.appendChild(nameSpan);
|
||||
nameWrap.appendChild(nameInput);
|
||||
nameWrap.appendChild(typeBadge);
|
||||
nameWrap.appendChild(typeSelect);
|
||||
header.appendChild(nameWrap);
|
||||
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'view-secret-actions';
|
||||
|
||||
var editBtn = document.createElement('button');
|
||||
editBtn.type = 'button';
|
||||
editBtn.className = 'btn-icon btn-view-edit';
|
||||
editBtn.textContent = '✎';
|
||||
editBtn.title = t('renameSecretTitle');
|
||||
|
||||
var saveBtn = document.createElement('button');
|
||||
saveBtn.type = 'button';
|
||||
saveBtn.className = 'btn-icon btn-view-save';
|
||||
saveBtn.textContent = t('viewSaveChanges');
|
||||
saveBtn.hidden = true;
|
||||
|
||||
var cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'btn-icon btn-view-cancel';
|
||||
cancelBtn.textContent = t('modalCancel');
|
||||
cancelBtn.hidden = true;
|
||||
|
||||
if (isPassword) {
|
||||
var toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
@@ -919,6 +1001,16 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
});
|
||||
actions.appendChild(copyBtn);
|
||||
|
||||
var unlinkBtn = document.createElement('button');
|
||||
unlinkBtn.type = 'button';
|
||||
unlinkBtn.className = 'btn-icon btn-view-unlink';
|
||||
unlinkBtn.textContent = '×';
|
||||
unlinkBtn.title = t('unlinkTitle');
|
||||
actions.appendChild(unlinkBtn);
|
||||
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(saveBtn);
|
||||
actions.appendChild(cancelBtn);
|
||||
header.appendChild(actions);
|
||||
row.appendChild(header);
|
||||
|
||||
@@ -930,7 +1022,163 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
valueWrap.appendChild(valueEl);
|
||||
row.appendChild(valueWrap);
|
||||
|
||||
var nameStatus = document.createElement('div');
|
||||
nameStatus.className = 'secret-name-status';
|
||||
nameStatus.setAttribute('data-status', 'idle');
|
||||
row.appendChild(nameStatus);
|
||||
|
||||
viewBody.appendChild(row);
|
||||
|
||||
// ── Edit mode toggle ──
|
||||
function enterEditMode() {
|
||||
nameSpan.hidden = true;
|
||||
typeBadge.hidden = true;
|
||||
nameInput.hidden = false;
|
||||
typeSelect.hidden = false;
|
||||
saveBtn.hidden = false;
|
||||
cancelBtn.hidden = false;
|
||||
editBtn.hidden = true;
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}
|
||||
|
||||
function exitEditMode() {
|
||||
nameSpan.hidden = false;
|
||||
typeBadge.hidden = false;
|
||||
nameInput.hidden = true;
|
||||
typeSelect.hidden = true;
|
||||
saveBtn.hidden = true;
|
||||
cancelBtn.hidden = true;
|
||||
editBtn.hidden = false;
|
||||
nameStatus.textContent = '';
|
||||
nameStatus.className = 'secret-name-status';
|
||||
nameInput.value = nameSpan.textContent;
|
||||
typeSelect.value = typeBadge.textContent;
|
||||
hasChanges = false;
|
||||
}
|
||||
|
||||
editBtn.addEventListener('click', enterEditMode);
|
||||
cancelBtn.addEventListener('click', exitEditMode);
|
||||
|
||||
// ── Name validation ──
|
||||
var debounceTimer = null;
|
||||
var currentCheck = null;
|
||||
nameInput.addEventListener('input', function () {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
nameStatus.textContent = '';
|
||||
nameStatus.className = 'secret-name-status';
|
||||
debounceTimer = setTimeout(function () {
|
||||
var newName = nameInput.value.trim();
|
||||
if (!newName || newName === originalName) return;
|
||||
nameStatus.textContent = t('checkingSecretName');
|
||||
nameStatus.className = 'secret-name-status checking';
|
||||
var checkId = Date.now();
|
||||
currentCheck = checkId;
|
||||
var params = new URLSearchParams();
|
||||
params.set('name', newName);
|
||||
params.set('exclude_secret_id', secretId);
|
||||
fetch('/api/secrets/check-name?' + params.toString(), { credentials: 'same-origin' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (currentCheck !== checkId) return;
|
||||
if (data.ok && data.available) {
|
||||
nameStatus.textContent = t('secretNameAvailable');
|
||||
nameStatus.className = 'secret-name-status success';
|
||||
hasChanges = true;
|
||||
} else {
|
||||
nameStatus.textContent = data.error || t('secretNameTaken');
|
||||
nameStatus.className = 'secret-name-status error';
|
||||
hasChanges = false;
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (currentCheck !== checkId) return;
|
||||
nameStatus.textContent = t('secretNameCheckError');
|
||||
nameStatus.className = 'secret-name-status error';
|
||||
hasChanges = false;
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
nameInput.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); saveBtn.click(); }
|
||||
if (e.key === 'Escape') { cancelBtn.click(); }
|
||||
});
|
||||
|
||||
// ── Save ──
|
||||
saveBtn.addEventListener('click', function () {
|
||||
var newName = nameInput.value.trim();
|
||||
var newType = typeSelect.value;
|
||||
if (!newName) { nameStatus.textContent = t('secretNameInvalid'); nameStatus.className = 'secret-name-status error'; return; }
|
||||
if (!newType) { nameStatus.textContent = t('secretTypeInvalid'); nameStatus.className = 'secret-name-status error'; return; }
|
||||
var patchBody = {};
|
||||
if (newName !== originalName) patchBody.name = newName;
|
||||
if (newType !== secretType) patchBody.type = newType;
|
||||
if (Object.keys(patchBody).length === 0) { exitEditMode(); return; }
|
||||
saveBtn.textContent = '...';
|
||||
fetch('/api/secrets/' + encodeURIComponent(secretId), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(patchBody)
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (data) {
|
||||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
return data;
|
||||
});
|
||||
}).then(function () {
|
||||
nameSpan.textContent = newName;
|
||||
typeBadge.textContent = newType;
|
||||
originalName = newName;
|
||||
nameInput.setAttribute('data-original-name', newName);
|
||||
saveBtn.textContent = t('viewChangesSaved');
|
||||
setTimeout(function () { exitEditMode(); saveBtn.textContent = t('viewSaveChanges'); }, 1200);
|
||||
// Update table row chip
|
||||
var tableRow = document.querySelector('tr[data-entry-id="' + viewBody.getAttribute('data-entry-id') + '"]');
|
||||
if (tableRow) {
|
||||
var chip = tableRow.querySelector('.secret-chip .secret-name');
|
||||
if (chip && chip.textContent === name) chip.textContent = newName;
|
||||
}
|
||||
}).catch(function (err) {
|
||||
nameStatus.textContent = err.message || String(err);
|
||||
nameStatus.className = 'secret-name-status error';
|
||||
saveBtn.textContent = t('viewSaveChanges');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Unlink ──
|
||||
unlinkBtn.addEventListener('click', function () {
|
||||
if (!confirm(tf('viewUnlinkConfirm', { name: nameSpan.textContent }))) return;
|
||||
fetch('/api/entries/' + encodeURIComponent(viewBody.getAttribute('data-entry-id')) + '/secrets/' + encodeURIComponent(secretId), {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (data) {
|
||||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
return data;
|
||||
});
|
||||
}).then(function () {
|
||||
row.remove();
|
||||
if (!viewBody.querySelector('.view-secret-row')) {
|
||||
viewBody.innerHTML = '';
|
||||
var msg = document.createElement('div');
|
||||
msg.className = 'view-locked-msg';
|
||||
msg.textContent = t('viewNoSecrets');
|
||||
viewBody.appendChild(msg);
|
||||
}
|
||||
// Update table row
|
||||
var tableRow = document.querySelector('tr[data-entry-id="' + viewBody.getAttribute('data-entry-id') + '"]');
|
||||
if (tableRow) {
|
||||
var chip = tableRow.querySelector('.secret-chip');
|
||||
if (chip) {
|
||||
var chipName = chip.querySelector('.secret-name');
|
||||
if (chipName && chipName.textContent === name) chip.remove();
|
||||
}
|
||||
}
|
||||
}).catch(function (err) {
|
||||
alert(err.message || String(err));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -942,6 +1190,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
|
||||
viewEntryName.textContent = entryName;
|
||||
viewBody.innerHTML = '';
|
||||
viewBody.setAttribute('data-entry-id', entryId);
|
||||
viewOverlay.hidden = false;
|
||||
|
||||
if (!encKey) {
|
||||
@@ -957,6 +1206,10 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
loadingMsg.textContent = t('viewLoading');
|
||||
viewBody.appendChild(loadingMsg);
|
||||
|
||||
var sj = tr.getAttribute('data-entry-secrets') || '[]';
|
||||
var secretSchema;
|
||||
try { secretSchema = JSON.parse(sj); } catch (e) { secretSchema = []; }
|
||||
|
||||
fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/decrypt', {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Encryption-Key': encKey }
|
||||
@@ -966,7 +1219,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
return data;
|
||||
});
|
||||
}).then(function (data) {
|
||||
renderViewSecrets(data.secrets || {});
|
||||
renderViewSecrets(data.secrets || {}, secretSchema);
|
||||
}).catch(function (e) {
|
||||
viewBody.innerHTML = '';
|
||||
var errMsg = document.createElement('div');
|
||||
@@ -990,172 +1243,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' });
|
||||
}
|
||||
|
||||
function renderEditSecrets(secrets) {
|
||||
editSecretsList.innerHTML = '';
|
||||
if (!Array.isArray(secrets) || secrets.length === 0) return;
|
||||
secrets.forEach(function (s) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'secret-edit-row';
|
||||
row.setAttribute('data-secret-id', s.id || '');
|
||||
row.setAttribute('data-secret-name', s.name || '');
|
||||
row.setAttribute('data-secret-type', s.secret_type || '');
|
||||
|
||||
var main = document.createElement('div');
|
||||
main.className = 'secret-edit-main';
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'secret-name-input';
|
||||
input.value = s.name || '';
|
||||
input.placeholder = t('renameSecretPlaceholder');
|
||||
input.setAttribute('data-original-name', s.name || '');
|
||||
|
||||
var typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'secret-type-select';
|
||||
var currentType = s.secret_type || 'text';
|
||||
var hasCurrentInOptions = SECRET_TYPE_OPTIONS.indexOf(currentType) !== -1;
|
||||
SECRET_TYPE_OPTIONS.forEach(function (opt) {
|
||||
var option = document.createElement('option');
|
||||
option.value = opt;
|
||||
option.textContent = opt;
|
||||
if (opt === currentType) option.selected = true;
|
||||
typeSelect.appendChild(option);
|
||||
});
|
||||
if (!hasCurrentInOptions && currentType) {
|
||||
var fallback = document.createElement('option');
|
||||
fallback.value = currentType;
|
||||
fallback.textContent = currentType;
|
||||
fallback.selected = true;
|
||||
typeSelect.appendChild(fallback);
|
||||
}
|
||||
|
||||
var unlinkBtn = document.createElement('button');
|
||||
unlinkBtn.type = 'button';
|
||||
unlinkBtn.className = 'btn-unlink-secret';
|
||||
unlinkBtn.setAttribute('data-secret-id', s.id || '');
|
||||
unlinkBtn.setAttribute('data-secret-name', s.name || '');
|
||||
unlinkBtn.title = t('unlinkTitle');
|
||||
unlinkBtn.textContent = '\u00d7';
|
||||
|
||||
main.appendChild(typeSelect);
|
||||
main.appendChild(input);
|
||||
main.appendChild(unlinkBtn);
|
||||
|
||||
var status = document.createElement('div');
|
||||
status.className = 'secret-name-status';
|
||||
status.setAttribute('data-status', 'idle');
|
||||
|
||||
row.appendChild(main);
|
||||
row.appendChild(status);
|
||||
editSecretsList.appendChild(row);
|
||||
|
||||
bindSecretValidation(row, s.id, s.name, s.secret_type || '');
|
||||
});
|
||||
}
|
||||
|
||||
function bindSecretValidation(row, secretId, originalName, originalType) {
|
||||
var input = row.querySelector('.secret-name-input');
|
||||
var typeSelect = row.querySelector('.secret-type-select');
|
||||
var status = row.querySelector('.secret-name-status');
|
||||
var debounceTimer = null;
|
||||
var currentCheck = null;
|
||||
var lastValidatedName = originalName;
|
||||
|
||||
function setStatus(text, type) {
|
||||
status.textContent = text || '';
|
||||
status.className = 'secret-name-status';
|
||||
if (type) status.classList.add(type);
|
||||
row.setAttribute('data-validation-state', type || 'idle');
|
||||
}
|
||||
|
||||
function setLastValidatedName(name) {
|
||||
row.setAttribute('data-last-validated-name', name || '');
|
||||
}
|
||||
|
||||
function invalidateValidationState() {
|
||||
currentCheck = null;
|
||||
lastValidatedName = null;
|
||||
setLastValidatedName('');
|
||||
setStatus('', 'idle');
|
||||
input.className = 'secret-name-input';
|
||||
}
|
||||
|
||||
function checkNameAvailability(name) {
|
||||
if (!name || name === originalName) {
|
||||
setStatus('', 'idle');
|
||||
input.className = 'secret-name-input';
|
||||
setLastValidatedName(originalName);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (name.length > 256) {
|
||||
setStatus(t('secretNameInvalid'), 'error');
|
||||
input.className = 'secret-name-input invalid';
|
||||
setLastValidatedName('');
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
setStatus(t('checkingSecretName'), 'checking');
|
||||
input.className = 'secret-name-input';
|
||||
|
||||
var checkId = Date.now();
|
||||
currentCheck = checkId;
|
||||
|
||||
var params = new URLSearchParams();
|
||||
params.set('name', name);
|
||||
params.set('exclude_secret_id', secretId);
|
||||
|
||||
return fetch('/api/secrets/check-name?' + params.toString(), {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (r) {
|
||||
return r.json();
|
||||
}).then(function (data) {
|
||||
if (currentCheck !== checkId) return false;
|
||||
if (data.ok && data.available) {
|
||||
setStatus(t('secretNameAvailable'), 'success');
|
||||
input.className = 'secret-name-input valid';
|
||||
lastValidatedName = name;
|
||||
setLastValidatedName(name);
|
||||
return true;
|
||||
} else {
|
||||
setStatus(data.error || t('secretNameTaken'), 'error');
|
||||
input.className = 'secret-name-input invalid';
|
||||
setLastValidatedName('');
|
||||
return false;
|
||||
}
|
||||
}).catch(function () {
|
||||
if (currentCheck !== checkId) return false;
|
||||
setStatus(t('secretNameCheckError'), 'error');
|
||||
input.className = 'secret-name-input invalid';
|
||||
setLastValidatedName('');
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
row.secretValidateNow = function () {
|
||||
return checkNameAvailability(input.value.trim());
|
||||
};
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
var newName = input.value.trim();
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
invalidateValidationState();
|
||||
debounceTimer = setTimeout(function () {
|
||||
checkNameAvailability(newName);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
checkNameAvailability(input.value.trim());
|
||||
} else if (e.key === 'Escape') {
|
||||
input.value = originalName;
|
||||
invalidateValidationState();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openEdit(tr) {
|
||||
var id = tr.getAttribute('data-entry-id');
|
||||
if (!id) return;
|
||||
@@ -1177,12 +1264,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
} catch (err) {
|
||||
editMetadata.value = md;
|
||||
}
|
||||
var sj = tr.getAttribute('data-entry-secrets') || '[]';
|
||||
try {
|
||||
renderEditSecrets(JSON.parse(sj));
|
||||
} catch (err) {
|
||||
renderEditSecrets([]);
|
||||
}
|
||||
editOverlay.hidden = false;
|
||||
}
|
||||
|
||||
@@ -1190,7 +1271,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
editOverlay.hidden = true;
|
||||
currentEntryId = null;
|
||||
showEditErr('');
|
||||
editSecretsList.innerHTML = '';
|
||||
editUpdatedAt.textContent = '';
|
||||
editUpdatedAt.title = '';
|
||||
}
|
||||
@@ -1223,7 +1303,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
showDeleteErr('');
|
||||
}
|
||||
|
||||
function refreshListAfterSave(entryId, body, secretRows) {
|
||||
function refreshListAfterSave(entryId, body) {
|
||||
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
|
||||
if (!tr) { window.location.reload(); return; }
|
||||
var nameCell = tr.querySelector('.cell-name');
|
||||
@@ -1237,38 +1317,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
}
|
||||
var tagsCell = tr.querySelector('.cell-tags-val');
|
||||
if (tagsCell) tagsCell.textContent = body.tags.join(', ');
|
||||
var secretsList = tr.querySelector('.secret-list');
|
||||
if (secretsList) {
|
||||
secretsList.innerHTML = '';
|
||||
secretRows.forEach(function (info) {
|
||||
var chip = document.createElement('span');
|
||||
chip.className = 'secret-chip';
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'secret-name';
|
||||
nameSpan.textContent = info.newName;
|
||||
nameSpan.title = info.newName;
|
||||
var typeSpan = document.createElement('span');
|
||||
typeSpan.className = 'secret-type';
|
||||
typeSpan.textContent = info.newType || 'text';
|
||||
var unlinkBtn = document.createElement('button');
|
||||
unlinkBtn.type = 'button';
|
||||
unlinkBtn.className = 'btn-unlink-secret';
|
||||
unlinkBtn.setAttribute('data-secret-id', info.secretId);
|
||||
unlinkBtn.setAttribute('data-secret-name', info.newName);
|
||||
unlinkBtn.title = t('unlinkTitle');
|
||||
unlinkBtn.textContent = '\u00d7';
|
||||
chip.appendChild(nameSpan);
|
||||
chip.appendChild(typeSpan);
|
||||
chip.appendChild(unlinkBtn);
|
||||
secretsList.appendChild(chip);
|
||||
});
|
||||
}
|
||||
tr.setAttribute('data-entry-folder', body.folder);
|
||||
tr.setAttribute('data-entry-metadata', JSON.stringify(body.metadata));
|
||||
var updatedSecrets = secretRows.map(function (info) {
|
||||
return { id: info.secretId, name: info.newName, secret_type: info.newType || 'text' };
|
||||
});
|
||||
tr.setAttribute('data-entry-secrets', JSON.stringify(updatedSecrets));
|
||||
}
|
||||
|
||||
function refreshListAfterDelete(entryId) {
|
||||
@@ -1318,19 +1368,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
}
|
||||
}
|
||||
|
||||
function refreshListAfterUnlink(entryId, secretId) {
|
||||
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
|
||||
if (!tr) return;
|
||||
var chip = tr.querySelector('.btn-unlink-secret[data-secret-id="' + secretId + '"]');
|
||||
if (chip && chip.parentElement) chip.parentElement.remove();
|
||||
var secrets = tr.getAttribute('data-entry-secrets');
|
||||
try {
|
||||
var arr = JSON.parse(secrets);
|
||||
arr = arr.filter(function (s) { return s.id !== secretId; });
|
||||
tr.setAttribute('data-entry-secrets', JSON.stringify(arr));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
document.getElementById('delete-cancel').addEventListener('click', closeDelete);
|
||||
deleteOverlay.addEventListener('click', function (e) {
|
||||
if (e.target === deleteOverlay) closeDelete();
|
||||
@@ -1374,145 +1411,22 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
tags: tags,
|
||||
metadata: meta
|
||||
};
|
||||
var secretRows = Array.from(editSecretsList.querySelectorAll('.secret-edit-row')).map(function (row) {
|
||||
var input = row.querySelector('.secret-name-input');
|
||||
var typeSelect = row.querySelector('.secret-type-select');
|
||||
return {
|
||||
row: row,
|
||||
input: input,
|
||||
typeSelect: typeSelect,
|
||||
secretId: row.getAttribute('data-secret-id') || '',
|
||||
originalName: input ? (input.getAttribute('data-original-name') || '') : '',
|
||||
newName: input ? input.value.trim() : '',
|
||||
originalType: row.getAttribute('data-secret-type') || '',
|
||||
newType: typeSelect ? typeSelect.value : ''
|
||||
};
|
||||
});
|
||||
showEditErr('');
|
||||
Promise.all(secretRows.map(function (info) {
|
||||
if (!info.secretId || info.newName === info.originalName) return Promise.resolve(true);
|
||||
if (typeof info.row.secretValidateNow === 'function') {
|
||||
return info.row.secretValidateNow();
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
})).then(function () {
|
||||
var invalidSecret = secretRows.find(function (info) {
|
||||
if (!info.secretId || info.newName === info.originalName) return false;
|
||||
var state = info.row.getAttribute('data-validation-state') || 'idle';
|
||||
var lastValidated = info.row.getAttribute('data-last-validated-name') || '';
|
||||
return state !== 'success' || lastValidated !== info.newName;
|
||||
});
|
||||
if (invalidSecret) {
|
||||
if (invalidSecret.input) invalidSecret.input.focus();
|
||||
throw new Error(t('secretNameFixBeforeSave'));
|
||||
}
|
||||
|
||||
return fetch('/api/entries/' + encodeURIComponent(currentEntryId), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (data) {
|
||||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
return data;
|
||||
});
|
||||
});
|
||||
}).then(function () {
|
||||
var changedSecrets = secretRows.filter(function (info) {
|
||||
return info.secretId && (info.newName !== info.originalName || info.newType !== info.originalType);
|
||||
});
|
||||
return Promise.all(changedSecrets.map(function (info) {
|
||||
var patchBody = {};
|
||||
if (info.newName !== info.originalName) patchBody.name = info.newName;
|
||||
if (info.newType !== info.originalType) patchBody.type = info.newType;
|
||||
return fetch('/api/secrets/' + encodeURIComponent(info.secretId), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(patchBody)
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (data) {
|
||||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
return data;
|
||||
});
|
||||
}).catch(function (err) {
|
||||
var msg = err.message || String(err);
|
||||
var status = info.row.querySelector('.secret-name-status');
|
||||
info.row.setAttribute('data-validation-state', 'error');
|
||||
info.row.setAttribute('data-last-validated-name', '');
|
||||
if (status) {
|
||||
status.textContent = msg;
|
||||
status.className = 'secret-name-status error';
|
||||
}
|
||||
if (info.input) {
|
||||
info.input.className = 'secret-name-input invalid';
|
||||
}
|
||||
if (info.typeSelect) {
|
||||
info.typeSelect.className = 'secret-type-select invalid';
|
||||
}
|
||||
if (info.input) info.input.focus();
|
||||
throw new Error(tf('errRenameSecret', { error: msg }));
|
||||
});
|
||||
}));
|
||||
}).then(function () {
|
||||
closeEdit();
|
||||
refreshListAfterSave(currentEntryId, body, secretRows);
|
||||
}).catch(function (e) {
|
||||
showEditErr(e.message || String(e));
|
||||
});
|
||||
});
|
||||
|
||||
var tableWrap = document.querySelector('.table-wrap');
|
||||
if (tableWrap) {
|
||||
tableWrap.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.btn-unlink-secret');
|
||||
if (!btn || !tableWrap.contains(btn)) return;
|
||||
var tr = btn.closest('tr[data-entry-id]');
|
||||
var entryId = tr && tr.getAttribute('data-entry-id');
|
||||
var secretId = btn.getAttribute('data-secret-id');
|
||||
var secretName = btn.getAttribute('data-secret-name') || '';
|
||||
if (!entryId || !secretId) return;
|
||||
fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/' + encodeURIComponent(secretId), {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (data) {
|
||||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
return data;
|
||||
});
|
||||
}).then(function () {
|
||||
refreshListAfterUnlink(entryId, secretId);
|
||||
}).catch(function (err) {
|
||||
alert(err.message || String(err));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
editSecretsList.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.btn-unlink-secret');
|
||||
if (!btn || !editSecretsList.contains(btn)) return;
|
||||
var entryId = currentEntryId;
|
||||
var secretId = btn.getAttribute('data-secret-id');
|
||||
var secretName = btn.getAttribute('data-secret-name') || '';
|
||||
if (!entryId || !secretId) return;
|
||||
fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/' + encodeURIComponent(secretId), {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin'
|
||||
fetch('/api/entries/' + encodeURIComponent(currentEntryId), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (data) {
|
||||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
||||
return data;
|
||||
});
|
||||
}).then(function () {
|
||||
btn.closest('.secret-edit-row').remove();
|
||||
var tableRow = document.querySelector('tr[data-entry-id="' + entryId + '"]');
|
||||
if (tableRow) {
|
||||
var chip = tableRow.querySelector('.btn-unlink-secret[data-secret-id="' + secretId + '"]');
|
||||
if (chip && chip.parentElement) chip.parentElement.remove();
|
||||
}
|
||||
}).catch(function (err) {
|
||||
alert(err.message || String(err));
|
||||
closeEdit();
|
||||
refreshListAfterSave(currentEntryId, body);
|
||||
}).catch(function (e) {
|
||||
showEditErr(e.message || String(e));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
54
plans/move-secret-management-to-view.md
Normal file
54
plans/move-secret-management-to-view.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 将编辑弹窗中的密文管理功能移到查看密文弹窗
|
||||
|
||||
## 当前状态
|
||||
|
||||
- **编辑弹窗**:密文重命名(input)、类型修改(select)、解绑(×按钮)、name 可用性校验
|
||||
- **查看密文弹窗**:解密后显示值、复制、显示/隐藏密码
|
||||
- **列表行**:密文 chips(name+type)+ 解绑按钮
|
||||
|
||||
## 变更内容
|
||||
|
||||
### 1. 编辑弹窗 — 移除密文区域
|
||||
|
||||
- 移除 HTML 中 `#edit-secrets-list` 所在的 `.modal-secrets` div(第559行)
|
||||
- 移除 JS 中 `renderEditSecrets`、`bindSecretValidation` 函数
|
||||
- 移除 `openEdit` 中读取/渲染 `data-entry-secrets` 的逻辑
|
||||
- 移除 `edit-save` 中 secret rename/type PATCH 逻辑
|
||||
- 移除编辑弹窗内的 unlink 事件监听器(第1492-1517行)
|
||||
- `refreshListAfterSave` 不再处理 secretRows 参数
|
||||
|
||||
### 2. 查看密文弹窗 — 增加管理功能
|
||||
|
||||
在每个解密字段行中增加:
|
||||
- **重命名输入框**(inline edit,带 debounce 校验)
|
||||
- **类型下拉选择**
|
||||
- **解绑按钮**
|
||||
- **保存按钮**(逐行或统一保存)
|
||||
- 复用现有的 `PATCH /api/secrets/{id}` 和 `DELETE /api/entries/{entry_id}/secrets/{secret_id}` 接口
|
||||
|
||||
需要在 `openView` 中额外传入 `data-entry-secrets`(含 secret id/name/type),以便将管理功能与解密值关联。
|
||||
|
||||
### 3. 列表行 — 保留只读摘要
|
||||
|
||||
- 保留密文 chips 的 name + type 展示
|
||||
- **移除** chips 上的解绑按钮(×)
|
||||
- **移除**列表行的 unlink 事件监听器(第1466-1490行)
|
||||
|
||||
### 4. i18n 更新
|
||||
|
||||
- 为查看弹窗新增重命名、类型修改、解绑相关的中英文翻译
|
||||
- 清理编辑弹窗中不再需要的 i18n key
|
||||
|
||||
### 5. CSS 调整
|
||||
|
||||
- 查看弹窗中为管理控件添加样式(input/select/button 行内布局)
|
||||
|
||||
## 不涉及的变更
|
||||
|
||||
- 后端 API 无需修改(复用现有接口)
|
||||
- 版本 bump(视为前次 0.5.11 的一部分,tag 尚未被 CI 创建)
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `crates/secrets-mcp/templates/entries.html`(HTML + JS + CSS)
|
||||
- `crates/secrets-mcp/src/web/entries.rs`(无需修改,复用现有 API)
|
||||
Reference in New Issue
Block a user