Compare commits

..

4 Commits

Author SHA1 Message Date
agent
6fde982c20 refactor(entries): 将编辑弹窗中的密文管理功能移到查看密文弹窗
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m6s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
- 编辑弹窗移除密文区域(重命名、类型修改、解绑)
- 查看密文弹窗增加:重命名(带 debounce 校验)、类型选择、解绑、保存
- 列表行密文 chips 保留只读展示,移除解绑按钮
- 简化编辑弹窗保存逻辑,不再处理密文变更
2026-04-07 13:25:33 +08:00
agent
a2a80a1744 fix(dashboard): 修正 OpenCode MCP 配置,移除 enabled 字段、添加 oauth: false
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m50s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-07 11:17:03 +08:00
dfe282095c feat(dashboard): OpenCode MCP 配置改用原生 Streamable HTTP,移除 mcp-remote 中转;bump 0.5.11
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m10s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
2026-04-07 10:43:17 +08:00
voson
59084a409d release(secrets-mcp): 0.5.10 — Web 模块化、性能与错误处理
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m3s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s
- 拆分 web.rs 为 web/ 子模块;统一 client_ip 提取
- core: user_scope SQL 复用、env_map N+1 消除、FETCH_ALL 上限调整
- entries 列表页并行查询;PgPool 去 Arc;结构化 NotFound 等错误
- CI: SSH 私钥安全写入;crypto/hex 与依赖清理;MCP 输入长度校验
- AGENTS: API Key 明文存储设计说明
2026-04-06 23:41:07 +08:00
31 changed files with 2640 additions and 2630 deletions

View File

@@ -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

View File

@@ -112,7 +112,7 @@ users (
key_salt BYTEA, -- PBKDF2 salt32B首次设置密码短语时写入
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
View File

@@ -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",

View File

@@ -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

View File

@@ -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>> {

View File

@@ -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");
};

View File

@@ -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.

View File

@@ -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();

View File

@@ -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

View File

@@ -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?;

View File

@@ -11,3 +11,4 @@ pub mod rollback;
pub mod search;
pub mod update;
pub mod user;
pub mod util;

View File

@@ -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)

View File

@@ -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, &params).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!(

View File

@@ -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();

View 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()
}
}

View File

@@ -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

View File

@@ -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(|| {

View File

@@ -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(),

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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

View 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 }))
}

View 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),
)
}

View 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)
}

View 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())
}

View 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 })))
}

View 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));
}
}

View File

@@ -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
};
}

View File

@@ -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));
});
});

View File

@@ -0,0 +1,54 @@
# 将编辑弹窗中的密文管理功能移到查看密文弹窗
## 当前状态
- **编辑弹窗**密文重命名input、类型修改select、解绑×按钮、name 可用性校验
- **查看密文弹窗**:解密后显示值、复制、显示/隐藏密码
- **列表行**:密文 chipsname+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