Compare commits

...

10 Commits

Author SHA1 Message Date
voson
1b2fbdae4d feat(secrets-mcp): /changelog 页(Markdown 渲染)、首页与 Dashboard 入口
Some checks failed
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Has been cancelled
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Has been cancelled
- 新增 CHANGELOG.md 构建嵌入、pulldown-cmark 渲染、路由 /changelog
- 首页页脚与 MCP Dashboard 页脚提供变更记录链接;同步 README、AGENTS
- 版本 secrets-mcp 0.5.24
2026-04-11 21:27:26 +08:00
voson
ab1e3329b9 docs: 同步 README/AGENTS — rollback 仅需 Bearer、env_map 命名规则、key_version 与 JSON 会话、导出 secret_types
本次不发版(仅文档)。
2026-04-11 20:41:20 +08:00
voson
c3b1a0df1a merge: code-review fixes (d7720662 baseline + 9f8a 文案常量化、env_prefix 测试、补充用例); secrets-mcp 0.5.21
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m59s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
- default 已 rebase 到 d7720662;合并说明见 plans/merge-code-review-fixes-2026-04-11.md
- Web PATCH 长度错误用 validation 常量拼接;env_map 单测;import/api_key 单测
- rustfmt 收尾
2026-04-11 20:39:01 +08:00
voson
d772066210 fix(secrets-mcp 0.5.20): code review plan — export secret types, env map, rollback, API key, MCP tools, web session & validation
- Export/import: optional secret_types map; AddResult includes entry_id
- env_map: dot→__ segment encoding; collision errors
- rollback: FOR UPDATE + txn-consistent snapshot; restore name from history
- regenerate_api_key: rows_affected guard
- MCP: find count propagates errors; add uses entry_id for relations; rollback no encryption key
- Web: load_session_user_strict + JSON handlers key_version; PATCH length limits
- Tests: ExportEntry serde, env segment
2026-04-11 17:10:28 +08:00
voson
2c7dbf890b docs: add code review fixes plan 2026-04-11 17:04:18 +08:00
voson
8c49316923 release(secrets-mcp): 0.5.19 — 条目列表文件夹列与筛选重置文案;偶数行名称列斑马纹;文档同步 Web 列说明
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m20s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
2026-04-11 15:33:05 +08:00
voson
cf93488c6a release(secrets-mcp): 0.5.18 — Web 条目密文值编辑,PATCH /api/secrets/:id 支持 value
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
2026-04-11 15:14:04 +08:00
137a4d42b0 release(secrets-mcp): 0.5.17 — 取消生产环境强制 PG TLS 校验
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m27s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 1m35s
移除 SECRETS_ENV=production 时对 verify-ca/verify-full 的硬性要求,
仍可通过 SECRETS_DATABASE_SSL_MODE 显式选择模式。

Made-with: Cursor
2026-04-10 17:10:55 +08:00
agent
ff2ea91e72 release(secrets-mcp): 0.5.16 — 回收站页面 title 国际化
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m32s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
2026-04-10 14:15:15 +08:00
agent
574c1c9967 release(secrets-mcp): 0.5.15 — 列设置面板锚定优化,移除查看密文隐藏功能
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m53s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
2026-04-10 11:54:20 +08:00
27 changed files with 1567 additions and 301 deletions

View File

@@ -42,7 +42,7 @@ secrets/
Cargo.toml
crates/
secrets-core/ # db / crypto / models / audit / service
secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard
secrets-mcp/ # rmcp tools、axum、OAuth、DashboardCHANGELOG.md → /changelog
scripts/
release-check.sh
setup-gitea-actions.sh
@@ -113,6 +113,7 @@ users (
key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语
key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000}
api_key TEXT UNIQUE, -- MCP Bearer token明文存储设计决策见下方说明
key_version BIGINT NOT NULL DEFAULT 0, -- 密码短语变更时递增,用于使其它设备会话失效
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
@@ -165,6 +166,22 @@ oauth_accounts (
| `secrets.type` | 密钥类型(调用方提供,默认 `text` | `text`, `password`, `key` |
| `secrets.encrypted` | 密文 | AES-GCM |
### Web 变更记录(`/changelog`
`crates/secrets-mcp/CHANGELOG.md` 在构建时嵌入,服务端以 **Markdown** 渲染为 HTML`pulldown-cmark`)。**首页**`/`)页脚与 **Dashboard**`/dashboard`MCP 配置页)页脚均提供「变更记录」链接;发版时随 `secrets-mcp` 版本更新该文件即可。
### Web JSON API 与会话
除页面路由使用的 `require_valid_user`(未登录或 `key_version` 与库不一致时重定向 `/login`JSON API`/api/...`)使用等价校验:会话中的 `key_version` 须与 `users.key_version` 一致,否则返回 **401** JSON避免仅校验 `user_id` 时与页面行为不一致。
### Web 条目页表格列(`/entries`
列表仅展示非敏感字段;**名称**与**操作**列为固定列(不可在「显示列」中关闭)。**文件夹**(对应 `entries.folder`)、类型、备注、标签、关联、密文等为**可选列**,由用户在「显示列」面板中勾选;可见性保存在浏览器 `localStorage`,键为 **`entries_col_vis`**。新增列会并入默认:若用户曾保存过旧版配置,缺失的列键会按当前默认补齐。**文件夹**列默认**显示**,便于在「全部」等跨 folder 视图下区分条目所属隔离空间。
### 导出 / 导入文件
JSON/TOML/YAML 导出可在每条目上包含 `secret_types`secret 名 → `text` / `password` / `key` 等),导入时写回 `secrets.type`**旧版导出无该字段**时导入仍成功,类型按 **`text`** 默认。
### 共享密钥N:N 关联)
多个 entry 可共享同一 secret 字段,通过 `entry_secrets` 中间表关联。

43
Cargo.lock generated
View File

@@ -740,6 +740,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "getopts"
version = "0.2.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -1578,6 +1587,25 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
dependencies = [
"bitflags",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quanta"
version = "0.12.6"
@@ -2065,7 +2093,7 @@ dependencies = [
[[package]]
name = "secrets-mcp"
version = "0.5.14"
version = "0.5.24"
dependencies = [
"anyhow",
"askama",
@@ -2075,6 +2103,7 @@ dependencies = [
"dotenvy",
"governor",
"http",
"pulldown-cmark",
"rand 0.10.0",
"reqwest",
"rmcp",
@@ -2985,6 +3014,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
@@ -3012,6 +3047,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"

View File

@@ -46,7 +46,7 @@ SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
SECRETS_ENV=production
```
- **Web**`BASE_URL`登录、Dashboard、设置密码短语、创建 API Key
- **Web**`BASE_URL`登录、Dashboard、设置密码短语、创建 API Key**变更记录**页 **`/changelog`**:内容来自 `crates/secrets-mcp/CHANGELOG.md`(构建时嵌入并以 Markdown 渲染);首页页脚与 DashboardMCP页脚均提供入口。**条目**页 `/entries` 支持 folder 标签与条件筛选;表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。
- **MCP**Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)。
## PostgreSQL TLS 加固
@@ -72,9 +72,9 @@ SECRETS_ENV=production
| `secrets_update` | 是 | 更新条目,支持 `id``name`+`folder` 定位 |
| `secrets_delete` | 否 | 删除条目,支持 `id``name`+`folder` 定位;`dry_run=true` 预览删除 |
| `secrets_history` | 否 | 查看条目历史,支持 `id``name`+`folder` 定位 |
| `secrets_rollback` | | 回滚条目到指定历史版本,支持 `id``name`+`folder` 定位 |
| `secrets_rollback` | | 回滚条目到指定历史版本(服务端按历史快照恢复元数据与密文关联),支持 `id`;仅需 **Bearer**,不要求 `X-Encryption-Key` |
| `secrets_export` | 是 | 导出条目(含解密明文),支持 JSON/TOML/YAML 格式 |
| `secrets_env_map` | 是 | 将 secrets 转为环境变量映射`UPPER(entry)_UPPER(field)` 格式),支持 `prefix` |
| `secrets_env_map` | 是 | 将 secrets 转为环境变量映射`PREFIX_ENTRYNAME_FIELDNAME`(字段名中 `.``__``-``_` 再转大写,避免与纯下划线字段名碰撞),支持 `prefix` |
| `secrets_overview` | 否 | 返回各 folder 和 type 的 entry 计数概览 |
### 消歧规则
@@ -226,7 +226,7 @@ crates/secrets-core/ # db / crypto / models / audit / service
src/
taxonomy.rs # SECRET_TYPE_OPTIONSsecret 字段类型下拉选项)
service/ # 业务逻辑add, search, update, delete, export, env_map 等)
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API KeyCHANGELOG.md 嵌入 /changelog
scripts/
release-check.sh # 发版前 fmt / clippy / test
setup-gitea-actions.sh

View File

@@ -8,7 +8,6 @@ pub struct DatabaseConfig {
pub url: String,
pub ssl_mode: Option<PgSslMode>,
pub ssl_root_cert: Option<PathBuf>,
pub enforce_strict_tls: bool,
}
/// Resolve database URL from environment.
@@ -63,20 +62,10 @@ fn resolve_ssl_root_cert_from_env() -> Result<Option<PathBuf>> {
Ok(Some(path))
}
fn is_production_env() -> bool {
matches!(
env_var_non_empty("SECRETS_ENV")
.as_deref()
.map(|value| value.to_ascii_lowercase()),
Some(value) if value == "prod" || value == "production"
)
}
pub fn resolve_db_config(override_url: &str) -> Result<DatabaseConfig> {
Ok(DatabaseConfig {
url: resolve_db_url(override_url)?,
ssl_mode: parse_ssl_mode_from_env()?,
ssl_root_cert: resolve_ssl_root_cert_from_env()?,
enforce_strict_tls: is_production_env(),
})
}

View File

@@ -3,7 +3,7 @@ use std::str::FromStr;
use anyhow::{Context, Result};
use serde_json::{Map, Value};
use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode};
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use crate::config::DatabaseConfig;
@@ -18,18 +18,6 @@ fn build_connect_options(config: &DatabaseConfig) -> Result<PgConnectOptions> {
options = options.ssl_root_cert(path);
}
if config.enforce_strict_tls
&& !matches!(
options.get_ssl_mode(),
PgSslMode::VerifyCa | PgSslMode::VerifyFull
)
{
anyhow::bail!(
"Refusing to start in production with weak PostgreSQL TLS mode. \
Set SECRETS_DATABASE_SSL_MODE=verify-ca or verify-full."
);
}
Ok(options)
}

View File

@@ -184,6 +184,9 @@ pub struct ExportEntry {
/// Decrypted secret fields. None means no secrets in this export (--no-secrets).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets: Option<BTreeMap<String, Value>>,
/// Per-secret types (`text`, `password`, `key`, …). Omitted in legacy exports; importers default to `"text"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secret_types: Option<BTreeMap<String, String>>,
}
// ── Multi-user models ──────────────────────────────────────────────────────────
@@ -311,3 +314,44 @@ pub fn toml_to_json_value(v: &toml::Value) -> Value {
}
}
}
#[cfg(test)]
mod export_entry_tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn export_entry_roundtrip_includes_secret_types() {
let mut secrets = BTreeMap::new();
secrets.insert("k".to_string(), serde_json::json!("v"));
let mut types = BTreeMap::new();
types.insert("k".to_string(), "password".to_string());
let e = ExportEntry {
name: "n".to_string(),
folder: "f".to_string(),
entry_type: "t".to_string(),
notes: "".to_string(),
tags: vec![],
metadata: serde_json::json!({}),
secrets: Some(secrets),
secret_types: Some(types),
};
let json = serde_json::to_string(&e).unwrap();
let back: ExportEntry = serde_json::from_str(&json).unwrap();
assert_eq!(
back.secret_types
.as_ref()
.unwrap()
.get("k")
.map(String::as_str),
Some("password")
);
}
#[test]
fn export_entry_legacy_json_without_secret_types_deserializes() {
let json = r#"{"name":"a","folder":"","type":"","notes":"","tags":[],"metadata":{},"secrets":{"x":"y"}}"#;
let e: ExportEntry = serde_json::from_str(json).unwrap();
assert!(e.secret_types.is_none());
}
}

View File

@@ -161,6 +161,7 @@ pub fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)>
#[derive(Debug, serde::Serialize)]
pub struct AddResult {
pub entry_id: Uuid,
pub name: String,
pub folder: String,
#[serde(rename = "type")]
@@ -477,6 +478,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
tx.commit().await?;
Ok(AddResult {
entry_id,
name: params.name.to_string(),
folder: params.folder.to_string(),
entry_type: entry_type.to_string(),

View File

@@ -47,11 +47,14 @@ pub async fn ensure_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
/// Generate a fresh API key for the user, replacing the old one.
pub async fn regenerate_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
let new_key = generate_api_key();
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
let res = sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
.bind(&new_key)
.bind(user_id)
.execute(pool)
.await?;
if res.rows_affected() == 0 {
return Err(AppError::NotFoundUser.into());
}
Ok(new_key)
}
@@ -63,3 +66,30 @@ pub async fn validate_api_key(pool: &PgPool, raw_key: &str) -> Result<Option<Uui
.await?;
Ok(row.map(|(id,)| id))
}
#[cfg(test)]
mod tests {
use sqlx::PgPool;
use super::regenerate_api_key;
use crate::error::AppError;
#[tokio::test]
async fn regenerate_api_key_unknown_user_returns_not_found() {
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
return;
};
let Ok(pool) = PgPool::connect(&url).await else {
return;
};
let id = uuid::Uuid::new_v4();
let err = regenerate_api_key(&pool, id)
.await
.err()
.expect("expected error");
assert!(matches!(
err.downcast_ref::<AppError>(),
Some(AppError::NotFoundUser)
));
}
}

View File

@@ -45,18 +45,27 @@ pub async fn build_env_map(
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));
let seg = secret_name_to_env_segment(&f.name);
let key = format!("{}_{}", effective_prefix, seg);
if let Some(_old) = combined.insert(key.clone(), json_to_env_string(&decrypted)) {
anyhow::bail!(
"environment variable name collision after normalization: '{}' (secret '{}')",
key,
f.name
);
}
}
}
Ok(combined)
}
/// Map a secret field name to an env key segment: `.` → `__`, `-` → `_`, then uppercase.
/// Avoids collisions between e.g. `db.password` and `db_password`.
fn secret_name_to_env_segment(name: &str) -> String {
name.replace('.', "__").replace('-', "_").to_uppercase()
}
fn env_prefix(entry: &crate::models::Entry, prefix: &str) -> String {
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
if prefix.is_empty() {
@@ -75,3 +84,39 @@ fn json_to_env_string(v: &Value) -> String {
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::{env_prefix, secret_name_to_env_segment};
use crate::models::Entry;
#[test]
fn secret_name_env_segment_disambiguates_dot_from_underscore() {
assert_eq!(secret_name_to_env_segment("db.password"), "DB__PASSWORD");
assert_eq!(secret_name_to_env_segment("db_password"), "DB_PASSWORD");
assert_eq!(secret_name_to_env_segment("api-key"), "API_KEY");
}
#[test]
fn env_prefix_with_and_without_prefix() {
let entry = Entry {
id: uuid::Uuid::new_v4(),
user_id: None,
folder: "test".into(),
entry_type: "server".into(),
name: "my-server".into(),
notes: String::new(),
tags: vec![],
metadata: Value::Null,
version: 1,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
deleted_at: None,
};
assert_eq!(env_prefix(&entry, ""), "MY_SERVER");
assert_eq!(env_prefix(&entry, "ALIYUN"), "ALIYUN_MY_SERVER");
assert_eq!(env_prefix(&entry, "aliyun_"), "ALIYUN_MY_SERVER");
}
}

View File

@@ -44,21 +44,23 @@ pub async fn export(
let mut export_entries: Vec<ExportEntry> = Vec::with_capacity(entries.len());
for entry in &entries {
let secrets = if params.no_secrets {
None
let (secrets, secret_types) = if params.no_secrets {
(None, None)
} else {
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
if fields.is_empty() {
Some(BTreeMap::new())
(Some(BTreeMap::new()), Some(BTreeMap::new()))
} else {
let mk = master_key
.ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?;
let mut map = BTreeMap::new();
let mut type_map = BTreeMap::new();
for f in fields {
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
map.insert(f.name.clone(), decrypted);
type_map.insert(f.name.clone(), f.secret_type.clone());
}
Some(map)
(Some(map), Some(type_map))
}
};
@@ -70,6 +72,7 @@ pub async fn export(
tags: entry.tags.clone(),
metadata: entry.metadata.clone(),
secrets,
secret_types,
});
}

View File

@@ -1,5 +1,6 @@
use anyhow::Result;
use sqlx::PgPool;
use std::collections::HashMap;
use uuid::Uuid;
use crate::models::ExportFormat;
@@ -80,6 +81,11 @@ pub async fn run(
let secret_entries = build_secret_entries(entry.secrets.as_ref());
let meta_entries = build_meta_entries(&entry.metadata);
let secret_types_map: HashMap<String, String> = entry
.secret_types
.as_ref()
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
match add_run(
pool,
@@ -91,7 +97,7 @@ pub async fn run(
tags: &entry.tags,
meta_entries: &meta_entries,
secret_entries: &secret_entries,
secret_types: &Default::default(),
secret_types: &secret_types_map,
link_secret_names: &[],
user_id: params.user_id,
},
@@ -125,3 +131,50 @@ pub async fn run(
dry_run: params.dry_run,
})
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashMap};
use crate::models::ExportEntry;
/// Mirrors the map built in `run` before `AddParams` (legacy files omit `secret_types`).
fn secret_types_for_add(entry: &ExportEntry) -> HashMap<String, String> {
entry
.secret_types
.as_ref()
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default()
}
#[test]
fn secret_types_three_kinds_round_trip_for_add_params() {
let mut types = BTreeMap::new();
types.insert("p".into(), "password".into());
types.insert("k".into(), "key".into());
types.insert("t".into(), "text".into());
let entry = ExportEntry {
name: "n".into(),
folder: "f".into(),
entry_type: "ty".into(),
notes: "".into(),
tags: vec![],
metadata: serde_json::json!({}),
secrets: Some(BTreeMap::new()),
secret_types: Some(types),
};
let m = secret_types_for_add(&entry);
assert_eq!(m.get("p").map(String::as_str), Some("password"));
assert_eq!(m.get("k").map(String::as_str), Some("key"));
assert_eq!(m.get("t").map(String::as_str), Some("text"));
}
#[test]
fn secret_types_absent_defaults_to_empty_map_like_legacy_export() {
let json =
r#"{"name":"a","folder":"","type":"","notes":"","tags":[],"metadata":{},"secrets":{}}"#;
let entry: ExportEntry = serde_json::from_str(json).unwrap();
assert!(entry.secret_types.is_none());
assert!(secret_types_for_add(&entry).is_empty());
}
}

View File

@@ -30,58 +30,61 @@ pub async fn run(
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
version: i64,
action: String,
tags: Vec<String>,
metadata: Value,
}
let live_entry: Option<EntryWriteRow> = if let Some(uid) = user_id {
let mut tx = pool.begin().await?;
let live: Option<EntryWriteRow> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL",
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE",
)
.bind(entry_id)
.bind(uid)
.fetch_optional(pool)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL",
WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL FOR UPDATE",
)
.bind(entry_id)
.fetch_optional(pool)
.fetch_optional(&mut *tx)
.await?
};
let live_entry = live_entry.ok_or(AppError::NotFoundEntry)?;
let lr = live.ok_or(AppError::NotFoundEntry)?;
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
sqlx::query_as(
"SELECT folder, type, version, action, tags, metadata \
"SELECT folder, type, name, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 AND version = $2 ORDER BY id ASC LIMIT 1",
)
.bind(entry_id)
.bind(ver)
.fetch_optional(pool)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT folder, type, version, action, tags, metadata \
"SELECT folder, type, name, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 ORDER BY id DESC LIMIT 1",
)
.bind(entry_id)
.fetch_optional(pool)
.fetch_optional(&mut *tx)
.await?
};
let snap = snap.ok_or_else(|| {
anyhow::anyhow!(
"No history found for entry '{}'{}.",
live_entry.name,
lr.name,
to_version
.map(|v| format!(" at version {}", v))
.unwrap_or_default()
@@ -91,17 +94,7 @@ 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 mut tx = pool.begin().await?;
let live: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND deleted_at IS NULL FOR UPDATE",
)
.bind(entry_id)
.fetch_optional(&mut *tx)
.await?;
let live_entry_id = if let Some(ref lr) = live {
let live_entry_id = {
let history_metadata =
match db::metadata_with_secret_snapshot(&mut tx, lr.id, &lr.metadata).await {
Ok(v) => v,
@@ -168,8 +161,8 @@ pub async fn run(
)
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(&live_entry.name)
.bind(&live_entry.notes)
.bind(&snap.name)
.bind(&lr.notes)
.bind(&snap.tags)
.bind(&snap_metadata)
.bind(lr.id)
@@ -177,8 +170,6 @@ pub async fn run(
.await?;
lr.id
} else {
return Err(AppError::NotFoundEntry.into());
};
if let Some(secret_snapshot) = snap_secret_snapshot {
@@ -191,7 +182,7 @@ pub async fn run(
"rollback",
&snap.folder,
&snap.entry_type,
&live_entry.name,
&snap.name,
serde_json::json!({
"entry_id": entry_id,
"restored_version": snap.version,
@@ -203,7 +194,7 @@ pub async fn run(
tx.commit().await?;
Ok(RollbackResult {
name: live_entry.name,
name: snap.name,
folder: snap.folder,
entry_type: snap.entry_type,
restored_version: snap.version,

View File

@@ -0,0 +1,20 @@
本文档在构建时嵌入 Web 的 `/changelog` 页面,并由服务端渲染为 HTML。
## [0.5.24] - 2026-04-11
### Changed
- 首页页脚将原「登录」入口改为「变更记录」(`/changelog`);顶部导航仍保留登录 / 进入控制台。
## [0.5.23] - 2026-04-11
### Added
- Changelog 页使用 **Markdown** 渲染(`pulldown-cmark`:表格、~~删除线~~、任务列表等)。
## [0.5.22] - 2026-04-11
### Added
- DashboardMCP页脚版本旁增加「变更记录」链接打开本变更说明页。

View File

@@ -1,6 +1,6 @@
[package]
name = "secrets-mcp"
version = "0.5.14"
version = "0.5.24"
edition.workspace = true
[[bin]]
@@ -45,3 +45,4 @@ urlencoding = "2"
schemars = "1"
http = "1"
url = "2"
pulldown-cmark = "0.13.3"

View File

@@ -345,15 +345,6 @@ impl SecretsService {
Self::extract_enc_key(ctx)
}
/// Require both user_id and encryption key (header only, no arg fallback).
fn require_user_and_key(
ctx: &RequestContext<RoleServer>,
) -> Result<(Uuid, [u8; 32]), rmcp::ErrorData> {
let user_id = Self::require_user_id(ctx)?;
let key = Self::extract_enc_key(ctx)?;
Ok((user_id, key))
}
/// Require both user_id and encryption key, preferring an explicit argument
/// value over the X-Encryption-Key header.
fn require_user_and_key_or_arg(
@@ -801,10 +792,7 @@ impl SecretsService {
let total_count = secrets_core::service::search::count_entries(&self.pool, &count_params)
.await
.inspect_err(
|e| tracing::warn!(tool = "secrets_find", error = %e, "count_entries failed"),
)
.unwrap_or(0);
.map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
let relation_map = get_relations_for_entries(
&self.pool,
&result
@@ -1135,11 +1123,8 @@ impl SecretsService {
.await
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
let created_entry = resolve_entry(&self.pool, &input.name, Some(folder), Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_add", Some(user_id), e))?;
for parent_id in parent_ids {
add_parent_relation(&self.pool, parent_id, created_entry.id, Some(user_id))
add_parent_relation(&self.pool, parent_id, result.entry_id, Some(user_id))
.await
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
}
@@ -1420,7 +1405,7 @@ impl SecretsService {
}
#[tool(
description = "Rollback an entry to a previous version. Requires X-Encryption-Key header. \
description = "Rollback an entry to a previous version. Requires Bearer API key only (no encryption key). \
Omit to_version to restore the most recent snapshot. \
Optionally pass 'id' (from secrets_find) to target directly.",
annotations(title = "Rollback Secret Entry", destructive_hint = true)
@@ -1431,7 +1416,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 = Self::require_user_id(&ctx)?;
tracing::info!(
tool = "secrets_rollback",
?user_id,

View File

@@ -11,7 +11,7 @@ use secrets_core::service::{
use crate::AppState;
use super::{SESSION_KEY_VERSION, current_user_id, render_template, require_valid_user};
use super::{SESSION_KEY_VERSION, load_session_user_strict, render_template, require_valid_user};
#[derive(Template)]
#[template(path = "dashboard.html")]
@@ -92,17 +92,11 @@ 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)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
if user.key_salt.is_none() {
return Ok(Json(KeySaltResponse {
@@ -126,19 +120,14 @@ pub(super) async fn api_key_setup(
session: Session,
Json(body): Json<KeySetupRequest>,
) -> Result<Json<KeySetupResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let user_id = user.id;
// 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);
@@ -175,17 +164,12 @@ pub(super) async fn api_key_change(
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)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let user_id = user.id;
// Must have an existing passphrase to change
let existing_key_check = user.key_check.ok_or_else(|| {
@@ -276,9 +260,12 @@ 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 user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let user_id = user.id;
let api_key = ensure_api_key(&state.pool, user_id).await.map_err(|e| {
tracing::error!(error = %e, %user_id, "ensure_api_key failed");
@@ -292,9 +279,12 @@ 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 user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let user_id = user.id;
let api_key = regenerate_api_key(&state.pool, user_id)
.await

View File

@@ -0,0 +1,48 @@
use askama::Template;
use axum::{extract::State, http::StatusCode, response::Response};
use pulldown_cmark::{Options, Parser, html};
use crate::AppState;
use super::render_template;
#[derive(Template)]
#[template(path = "changelog.html")]
pub(super) struct ChangelogTemplate {
pub base_url: String,
pub version: &'static str,
pub changelog_html: String,
}
fn markdown_to_html(md: &str) -> String {
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_TASKLISTS);
let parser = Parser::new_ext(md, opts);
let mut out = String::new();
html::push_html(&mut out, parser);
out
}
pub(super) async fn changelog_page(State(state): State<AppState>) -> Result<Response, StatusCode> {
let md = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/CHANGELOG.md"));
render_template(ChangelogTemplate {
base_url: state.base_url.clone(),
version: env!("CARGO_PKG_VERSION"),
changelog_html: markdown_to_html(md),
})
}
#[cfg(test)]
mod tests {
use super::markdown_to_html;
#[test]
fn markdown_renders_heading_and_list() {
let html = markdown_to_html("# Title\n\n- a\n");
assert!(html.contains("<h1"));
assert!(html.contains("Title"));
assert!(html.contains("<ul") || html.contains("<li"));
}
}

View File

@@ -25,8 +25,8 @@ use secrets_core::service::{
use crate::AppState;
use super::{
ENTRIES_PAGE_LIMIT, UiLang, current_user_id, paginate, render_template, request_ui_lang,
require_valid_user, tr,
ENTRIES_PAGE_LIMIT, UiLang, paginate, render_template, request_ui_lang, require_valid_user,
require_valid_user_json, tr,
};
// ── Template types ────────────────────────────────────────────────────────────
@@ -138,6 +138,25 @@ pub(super) struct EntryOptionQuery {
type EntryApiError = (StatusCode, Json<serde_json::Value>);
fn require_encryption_key(headers: &HeaderMap, lang: UiLang) -> Result<[u8; 32], EntryApiError> {
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") })),
)
})?;
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") })),
)
})
}
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);
@@ -597,10 +616,8 @@ pub(super) async fn api_entry_patch(
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 user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let folder = body.folder.trim();
let entry_type = body.entry_type.trim();
@@ -616,6 +633,71 @@ pub(super) async fn api_entry_patch(
));
}
if folder.chars().count() > crate::validation::MAX_FOLDER_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!(
"{} {} {}",
tr(
lang,
"folder 长度不能超过",
"folder 長度不能超過",
"folder must be at most"
),
crate::validation::MAX_FOLDER_LENGTH,
tr(lang, " 个字符", " 個字元", " characters")
) })),
));
}
if entry_type.chars().count() > crate::validation::MAX_ENTRY_TYPE_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!(
"{} {} {}",
tr(
lang,
"type 长度不能超过",
"type 長度不能超過",
"type must be at most"
),
crate::validation::MAX_ENTRY_TYPE_LENGTH,
tr(lang, " 个字符", " 個字元", " characters")
) })),
));
}
if name.chars().count() > crate::validation::MAX_NAME_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!(
"{} {} {}",
tr(
lang,
"name 长度不能超过",
"name 長度不能超過",
"name must be at most"
),
crate::validation::MAX_NAME_LENGTH,
tr(lang, " 个字符", " 個字元", " characters")
) })),
));
}
if notes.chars().count() > crate::validation::MAX_NOTES_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!(
"{} {} {}",
tr(
lang,
"notes 长度不能超过",
"notes 長度不能超過",
"notes must be at most"
),
crate::validation::MAX_NOTES_LENGTH,
tr(lang, " 个字符", " 個字元", " characters")
) })),
));
}
let tags: Vec<String> = body
.tags
.into_iter()
@@ -664,10 +746,8 @@ pub(super) async fn api_entry_options(
Query(q): Query<EntryOptionQuery>,
) -> 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 user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let query =
q.q.as_deref()
@@ -719,10 +799,8 @@ pub(super) async fn api_entry_delete(
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 user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
delete_by_id(&state.pool, entry_id, user_id)
.await
@@ -741,10 +819,8 @@ pub(super) async fn api_trash_restore(
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 user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
restore_deleted_by_id(&state.pool, entry_id, user_id)
.await
@@ -763,10 +839,8 @@ pub(super) async fn api_trash_purge(
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 user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
purge_deleted_by_id(&state.pool, entry_id, user_id)
.await
@@ -799,10 +873,8 @@ pub(super) async fn api_secret_check_name(
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 user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let name = params.name.trim();
if name.is_empty() {
@@ -876,6 +948,7 @@ pub(super) struct SecretPatchBody {
name: Option<String>,
#[serde(rename = "type")]
secret_type: Option<String>,
value: Option<serde_json::Value>,
}
pub(super) async fn api_secret_patch(
@@ -894,13 +967,12 @@ pub(super) async fn api_secret_patch(
}
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 user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let name = body.name.as_ref().map(|s| s.trim());
let secret_type = body.secret_type.as_ref().map(|s| s.trim());
let secret_value = body.value.as_ref();
if let Some(n) = name {
if n.is_empty() {
@@ -940,30 +1012,37 @@ pub(super) async fn api_secret_patch(
}
}
if name.is_none() && secret_type.is_none() {
if name.is_none() && secret_type.is_none() && secret_value.is_none() {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "至少需要提供 name 或 type 之一", "至少需要提供 nametype 之一", "At least one of name or type is required") }),
json!({ "error": tr(lang, "至少需要提供 name、type 或 value 之一", "至少需要提供 nametype 或 value 之一", "At least one of name, type, or value is required") }),
),
));
}
let master_key = if secret_value.is_some() {
Some(require_encryption_key(&headers, lang)?)
} else {
None
};
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 secret_row: Option<(String, String, Vec<u8>)> = sqlx::query_as(
"SELECT name, type, encrypted 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 Some((old_name, old_type, old_encrypted)) = secret_row else {
let _ = tx.rollback().await;
return Err((
StatusCode::NOT_FOUND,
@@ -988,13 +1067,47 @@ pub(super) async fn api_secret_patch(
let new_name = name.unwrap_or(&old_name).to_string();
let new_type = secret_type.unwrap_or(&old_type).to_string();
let new_encrypted = if let Some(value) = secret_value {
let encrypted = secrets_core::crypto::encrypt_json(
master_key
.as_ref()
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "请先设置密码短语后再编辑密文值", "請先設定密碼短語後再編輯密文值", "Unlock your passphrase before editing secret values") })),
)
})?,
value,
)
.map_err(|e| map_entry_mutation_err(e, lang))?;
Some(encrypted)
} else {
None
};
let value_changed = new_encrypted.is_some();
if let Err(e) = secrets_core::db::snapshot_secret_history(
&mut tx,
secrets_core::db::SecretSnapshotParams {
secret_id,
name: &old_name,
encrypted: &old_encrypted,
action: if value_changed { "update" } else { "rename" },
},
)
.await
{
tracing::warn!(error = %e, %secret_id, "failed to snapshot secret history before patch");
}
let result = sqlx::query(
"UPDATE secrets SET name = $1, type = $2, version = version + 1, updated_at = NOW() \
WHERE id = $3",
"UPDATE secrets SET name = $1, type = $2, encrypted = $3, version = version + 1, updated_at = NOW() \
WHERE id = $4",
)
.bind(&new_name)
.bind(&new_type)
.bind(new_encrypted.as_deref().unwrap_or(&old_encrypted))
.bind(secret_id)
.execute(&mut *tx)
.await;
@@ -1018,7 +1131,11 @@ pub(super) async fn api_secret_patch(
secrets_core::audit::log_tx(
&mut tx,
Some(user_id),
"rename_secret",
if value_changed {
"update_secret"
} else {
"rename_secret"
},
"",
"",
&old_name,
@@ -1029,6 +1146,7 @@ pub(super) async fn api_secret_patch(
"new_name": new_name,
"old_type": old_type,
"new_type": new_type,
"value_updated": value_changed,
"linked_entries": linked_entries,
}),
)
@@ -1056,10 +1174,8 @@ pub(super) async fn api_entry_secret_unlink(
}
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 user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let mut tx = state
.pool
@@ -1149,28 +1265,10 @@ pub(super) async fn api_entry_secrets_decrypt(
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 user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
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 master_key = require_encryption_key(&headers, lang)?;
let secrets =
get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id))

View File

@@ -1,10 +1,11 @@
use askama::Template;
use axum::{
Router,
Json, Router,
http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, patch, post},
};
use serde_json::json;
use tower_sessions::Session;
use uuid::Uuid;
@@ -15,6 +16,7 @@ mod account;
mod assets;
mod audit;
mod auth;
mod changelog;
mod entries;
// ── Session keys ──────────────────────────────────────────────────────────────
@@ -34,7 +36,7 @@ const AUDIT_PAGE_LIMIT: i64 = 10;
// ── UI language ───────────────────────────────────────────────────────────────
#[derive(Clone, Copy)]
enum UiLang {
pub(super) enum UiLang {
ZhCn,
ZhTw,
En,
@@ -143,6 +145,71 @@ async fn require_valid_user(
Ok(user)
}
/// `Ok(None)` — unauthenticated or session invalidated (including `key_version` mismatch).
/// `Err(())` — database error loading the user.
pub(super) async fn load_session_user_strict(
pool: &sqlx::PgPool,
session: &Session,
) -> Result<Option<secrets_core::models::User>, ()> {
let Some(user_id) = current_user_id(session).await else {
return Ok(None);
};
let user = match secrets_core::service::user::get_user_by_id(pool, user_id).await {
Err(e) => {
tracing::error!(error = %e, %user_id, "load_session_user_strict: failed to load user");
return Err(());
}
Ok(None) => {
if let Err(e) = session.flush().await {
tracing::warn!(error = %e, "failed to flush stale session");
}
return Ok(None);
}
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 (API)");
if let Err(e) = session.flush().await {
tracing::warn!(error = %e, "failed to flush outdated session");
}
return Ok(None);
}
Ok(Some(user))
}
/// JSON API equivalent of [`require_valid_user`]: returns `401` with a JSON body instead of redirecting.
pub(super) async fn require_valid_user_json(
pool: &sqlx::PgPool,
session: &Session,
lang: UiLang,
) -> Result<secrets_core::models::User, (StatusCode, Json<serde_json::Value>)> {
match load_session_user_strict(pool, session).await {
Ok(Some(user)) => Ok(user),
Ok(None) => Err((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
)),
Err(()) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") }),
),
)),
}
}
fn request_user_agent(headers: &HeaderMap) -> Option<String> {
headers
.get(header::USER_AGENT)
@@ -187,6 +254,7 @@ pub fn web_router() -> Router<AppState> {
get(assets::oauth_protected_resource_metadata),
)
.route("/", get(auth::home_page))
.route("/changelog", get(changelog::changelog_page))
.route("/login", get(auth::login_page))
.route("/auth/google", get(auth::auth_google))
.route("/auth/google/callback", get(auth::auth_google_callback))

View File

@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="canonical" href="{{ base_url }}/changelog">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title data-i18n="docTitle">变更记录 — Secrets</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117; --surface: #161b22;
--border: #30363d; --text: #e6edf3; --text-muted: #8b949e;
--accent: #58a6ff; --accent-hover: #79b8ff;
}
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; min-height: 100vh; }
.wrap { max-width: 880px; margin: 0 auto; padding: 24px 20px 48px; }
.top {
display: flex; align-items: center; flex-wrap: wrap; gap: 12px 16px;
margin-bottom: 24px; padding-bottom: 16px;
border-bottom: 1px solid rgba(240,246,252,0.08);
}
.brand {
font-size: 18px; font-weight: 700; color: #fff; text-decoration: none;
}
.brand:hover { color: var(--accent); }
.top-actions { margin-left: auto; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.lang-bar { display: flex; gap: 2px; background: rgba(240,246,252,0.06); border-radius: 8px; padding: 2px; }
.lang-btn { padding: 4px 10px; border: none; background: none; color: #8b949e;
font-size: 12px; cursor: pointer; border-radius: 6px; }
.lang-btn.active { background: rgba(240,246,252,0.1); color: #fff; }
.link-dash {
font-size: 13px; color: var(--accent); text-decoration: none;
}
.link-dash:hover { text-decoration: underline; }
h1 { font-size: 22px; font-weight: 700; margin-bottom: 16px; color: #fff; }
.card {
background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
padding: 20px 22px;
}
/* Rendered Markdown (pulldown-cmark) */
.changelog-md {
font-size: 14px;
line-height: 1.65;
color: #c9d1d9;
}
.changelog-md > :first-child { margin-top: 0; }
.changelog-md > :last-child { margin-bottom: 0; }
.changelog-md h1 {
font-size: 1.5rem; font-weight: 700; color: #fff;
margin: 1.25em 0 0.5em; padding-bottom: 0.35em;
border-bottom: 1px solid rgba(240,246,252,0.1);
}
.changelog-md h2 {
font-size: 1.2rem; font-weight: 650; color: #f0f6fc;
margin: 1.35em 0 0.5em;
}
.changelog-md h3 { font-size: 1.05rem; font-weight: 600; color: #e6edf3; margin: 1.1em 0 0.45em; }
.changelog-md h4, .changelog-md h5, .changelog-md h6 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 1em 0 0.4em; }
.changelog-md p { margin: 0.65em 0; }
.changelog-md ul, .changelog-md ol { margin: 0.65em 0; padding-left: 1.35em; }
.changelog-md li { margin: 0.3em 0; }
.changelog-md li > p { margin: 0.35em 0; }
.changelog-md a { color: var(--accent); text-decoration: none; }
.changelog-md a:hover { text-decoration: underline; }
.changelog-md code {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.88em;
background: rgba(240,246,252,0.08);
padding: 0.12em 0.4em;
border-radius: 5px;
}
.changelog-md pre {
margin: 0.85em 0;
padding: 12px 14px;
overflow-x: auto;
background: #0d1117;
border: 1px solid rgba(240,246,252,0.1);
border-radius: 10px;
font-size: 12px;
line-height: 1.5;
}
.changelog-md pre code {
background: none;
padding: 0;
font-size: inherit;
border-radius: 0;
}
.changelog-md blockquote {
margin: 0.75em 0;
padding-left: 1em;
border-left: 3px solid rgba(56,139,253,0.45);
color: var(--text-muted);
}
.changelog-md hr {
margin: 1.25em 0;
border: none;
border-top: 1px solid rgba(240,246,252,0.1);
}
.changelog-md table {
width: 100%;
border-collapse: collapse;
margin: 0.85em 0;
font-size: 13px;
}
.changelog-md th, .changelog-md td {
border: 1px solid var(--border);
padding: 8px 10px;
text-align: left;
}
.changelog-md th { background: rgba(240,246,252,0.06); color: #f0f6fc; }
.changelog-md input[type="checkbox"] { margin-right: 0.35em; vertical-align: middle; }
.foot {
margin-top: 28px; text-align: center; font-size: 11px; color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
}
.foot a { color: var(--accent); text-decoration: none; }
.foot a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="wrap">
<header class="top">
<a href="/" class="brand">secrets</a>
<div class="top-actions">
<a href="/dashboard" class="link-dash" data-i18n="backDash">控制台</a>
<div class="lang-bar" role="group" aria-label="Language">
<button type="button" class="lang-btn" onclick="setLang('zh-CN')"></button>
<button type="button" class="lang-btn" onclick="setLang('zh-TW')"></button>
<button type="button" class="lang-btn" onclick="setLang('en')">EN</button>
</div>
</div>
</header>
<h1 data-i18n="pageTitle">变更记录</h1>
<div class="card changelog-md">
{{ changelog_html|safe }}
</div>
<footer class="foot">
<span data-i18n="versionLabel">版本</span> {{ version }}
</footer>
</div>
<script>
const T = {
'zh-CN': {
docTitle: '变更记录 — Secrets',
pageTitle: '变更记录',
backDash: '控制台',
versionLabel: '版本',
},
'zh-TW': {
docTitle: '變更記錄 — Secrets',
pageTitle: '變更記錄',
backDash: '控制台',
versionLabel: '版本',
},
'en': {
docTitle: 'Changelog — Secrets',
pageTitle: 'Changelog',
backDash: 'Dashboard',
versionLabel: 'Version',
}
};
let currentLang = localStorage.getItem('lang') || 'zh-CN';
function t(key) { return (T[currentLang] && T[currentLang][key]) || T['en'][key] || key; }
function applyLang() {
document.documentElement.lang = currentLang;
document.title = t('docTitle');
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.getAttribute('data-i18n'));
});
document.querySelectorAll('.lang-btn').forEach(btn => {
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
btn.classList.toggle('active', btn.textContent === map[currentLang]);
});
}
function setLang(lang) {
currentLang = lang;
localStorage.setItem('lang', lang);
applyLang();
}
applyLang();
</script>
</body>
</html>

View File

@@ -57,6 +57,8 @@
font-family: 'JetBrains Mono', monospace;
margin-top: auto;
}
.app-footer a { color: var(--accent); text-decoration: none; }
.app-footer a:hover { text-decoration: underline; }
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
padding: 20px; width: 100%; }
.card-title { font-size: 22px; font-weight: 700; margin-bottom: 24px; color: #fff; }
@@ -288,7 +290,7 @@
</div>
</div>
</div>
<footer class="app-footer">{{ version }}</footer>
<footer class="app-footer">{{ version }} · <a href="/changelog" data-i18n="changelogLink">变更记录</a></footer>
</div><!-- /main -->
</div><!-- /content-shell -->
</div><!-- /layout -->
@@ -379,6 +381,7 @@ const T = {
regenFailed: '重置失败,请刷新页面重试。',
ariaShowPw: '显示密码',
ariaHidePw: '隐藏密码',
changelogLink: '变更记录',
},
'zh-TW': {
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
@@ -417,6 +420,7 @@ const T = {
regenFailed: '重置失敗,請重新整理頁面再試。',
ariaShowPw: '顯示密碼',
ariaHidePw: '隱藏密碼',
changelogLink: '變更記錄',
},
'en': {
navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit',
@@ -455,6 +459,7 @@ const T = {
regenFailed: 'Reset failed. Please refresh and try again.',
ariaShowPw: 'Show password',
ariaHidePw: 'Hide password',
changelogLink: 'Changelog',
}
};

View File

@@ -73,8 +73,7 @@
border-color: rgba(56,139,253,0.3);
color: #fff;
}
.filter-bar {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
.filter-bar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
margin-bottom: 18px; padding: 16px; background: #0d1117; border: 1px solid rgba(240,246,252,0.08);
border-radius: 12px;
}
@@ -114,6 +113,29 @@
color: #8b949e; font-size: 13px; text-decoration: none; cursor: pointer;
}
.btn-clear:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.btn-col-toggle {
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
background: transparent; color: #8b949e; font-size: 16px; cursor: pointer;
}
.btn-col-toggle:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.col-menu { position: relative; }
.col-panel {
display: none; position: absolute; top: calc(100% + 6px); right: 0; z-index: 20;
background: #161b22; border: 1px solid rgba(240,246,252,0.12); border-radius: 10px;
padding: 10px 14px; min-width: 180px; box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.col-panel.open { display: block; }
.col-panel-group { font-size: 11px; color: #6e7681; text-transform: uppercase; letter-spacing: 0.5px; margin: 8px 0 4px; }
.col-panel-group:first-child { margin-top: 0; }
.col-panel-item {
display: flex; align-items: center; gap: 8px; padding: 4px 0;
font-size: 13px; color: #c9d1d9; cursor: pointer; user-select: none;
}
.col-panel-item input[type="checkbox"] {
accent-color: var(--accent); width: 15px; height: 15px; cursor: pointer;
}
.col-panel-item.disabled { color: #6e7681; cursor: default; }
.col-panel-item.disabled input[type="checkbox"] { cursor: default; }
.empty { color: #8b949e; font-size: 14px; padding: 20px 0; }
.table-wrap {
overflow: auto;
@@ -123,10 +145,19 @@
}
table {
width: 100%;
min-width: 960px;
min-width: 1240px;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
col[data-col="name"] { width: 220px; }
col[data-col="folder"] { width: 140px; }
col[data-col="type"] { width: 120px; }
col[data-col="notes"] { width: 320px; }
col[data-col="tags"] { width: 220px; }
col[data-col="relations"] { width: 220px; }
col[data-col="secrets"] { width: 320px; }
col[data-col="actions"] { width: 132px; }
th, td { text-align: left; vertical-align: middle; padding: 14px 12px; border-top: 1px solid rgba(240,246,252,0.08); }
th {
color: #8b949e;
@@ -143,12 +174,20 @@
td { font-size: 13px; line-height: 1.45; color: #c9d1d9; }
tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); }
.mono { font-family: 'JetBrains Mono', monospace; }
.col-type { min-width: 108px; width: 1%; text-align: center; vertical-align: middle; }
.col-name { min-width: 180px; max-width: 260px; text-align: center; vertical-align: middle; }
.col-tags { min-width: 160px; max-width: 220px; }
.col-secrets { min-width: 220px; max-width: 420px; vertical-align: middle; }
.col-folder { text-align: center; vertical-align: middle; }
.col-type { text-align: center; vertical-align: middle; }
.col-secrets { vertical-align: middle; }
.col-secrets .secret-list { max-height: 120px; overflow: auto; }
.col-actions { min-width: 132px; width: 1%; text-align: center; vertical-align: middle; }
.col-actions { text-align: right; vertical-align: middle; }
.col-name { position: sticky; left: 0; z-index: 1; background: #0d1117; overflow: hidden; }
th.col-name { z-index: 3; background: #111827; }
.col-name::after {
content: ''; position: absolute; top: 0; right: -8px; bottom: 0; width: 8px;
background: linear-gradient(to right, rgba(0,0,0,0.15), transparent);
pointer-events: none;
}
th[data-col="actions"], td[data-col="actions"] { text-align: right; }
[data-col].col-hidden { display: none !important; }
.cell-name, .cell-tags-val {
overflow-wrap: anywhere;
word-break: break-word;
@@ -199,6 +238,17 @@
border-left: 1px solid rgba(240,246,252,0.08);
padding-left: 6px;
}
a.secret-chip {
color: var(--accent);
text-decoration: none;
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
a.secret-chip:hover {
color: var(--accent-hover);
border-color: var(--accent);
background: rgba(88,166,255,0.12);
}
.btn-unlink-secret {
border: none;
background: transparent;
@@ -357,6 +407,9 @@
content: attr(data-label);
}
.col-name, .col-type, .col-actions { text-align: left; }
.col-name { position: static; }
.col-name::after { display: none; }
.col-panel { position: fixed; left: 12px; right: 12px; width: auto; }
th, td { vertical-align: top; }
.row-actions { justify-content: flex-start; }
.detail, .notes-scroll, .secret-list { max-width: none; }
@@ -404,7 +457,16 @@
padding: 7px 10px; word-break: break-all; white-space: pre-wrap;
max-height: 140px; overflow: auto; color: #c9d1d9; line-height: 1.5;
}
.view-secret-value.masked { letter-spacing: 2px; user-select: none; filter: blur(4px); }
.view-secret-editor {
width: 100%; min-height: 108px; resize: vertical; box-sizing: border-box;
font-family: 'JetBrains Mono', monospace; font-size: 12px; line-height: 1.5;
background: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
color: #c9d1d9; padding: 10px 12px; outline: none;
}
.view-secret-editor:focus { border-color: rgba(56,139,253,0.5); }
.view-secret-hint {
margin-top: 6px; font-size: 12px; color: #8b949e; line-height: 1.5;
}
.btn-icon {
padding: 6px 10px; border-radius: 8px; font-size: 12px; cursor: pointer;
border: 1px solid rgba(240,246,252,0.12); background: #161b22; color: #8b949e;
@@ -432,7 +494,7 @@
.btn-view-edit { color: #58a6ff; }
.btn-view-save { color: #3fb950; }
.btn-view-cancel { color: #8b949e; }
.btn-view-unlink { color: #f85149; font-size: 14px; }
.btn-view-unlink { color: #f85149; font-size: 12px; white-space: nowrap; }
</style>
</head>
<body>
@@ -496,7 +558,11 @@
</div>
<div class="filter-actions">
<button type="submit" class="btn-filter" data-i18n="filterSubmit">筛选</button>
<a href="/entries" class="btn-clear" data-i18n="filterClear">清空</a>
<a href="/entries" class="btn-clear" data-i18n="filterClear">重置</a>
<div class="col-menu">
<button type="button" class="btn-col-toggle" id="col-toggle-btn" data-i18n-title="columnSettings" title="显示列"></button>
<div class="col-panel" id="col-panel"></div>
</div>
</div>
</form>
@@ -505,25 +571,37 @@
{% else %}
<div class="table-wrap">
<table>
<colgroup>
<col data-col="name">
<col data-col="folder">
<col data-col="type">
<col data-col="notes">
<col data-col="tags">
<col data-col="relations">
<col data-col="secrets">
<col data-col="actions">
</colgroup>
<thead>
<tr>
<th data-i18n="colName">名称</th>
<th data-i18n="colType">类型</th>
<th data-i18n="colNotes">备注</th>
<th data-i18n="colTags">标签</th>
<th data-i18n="colRelations">关联</th>
<th data-i18n="colSecrets">密文</th>
<th data-i18n="colActions">操作</th>
<th data-col="name" data-i18n="colName">名称</th>
<th data-col="folder" data-i18n="colFolder">文件夹</th>
<th data-col="type" data-i18n="colType">类型</th>
<th data-col="notes" data-i18n="colNotes">备注</th>
<th data-col="tags" data-i18n="colTags">标签</th>
<th data-col="relations" data-i18n="colRelations">关联</th>
<th data-col="secrets" data-i18n="colSecrets">密文</th>
<th data-col="actions" data-i18n="colActions">操作</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr data-entry-id="{{ entry.id }}" data-entry-folder="{{ entry.folder }}" data-entry-metadata="{{ entry.metadata_json }}" data-entry-secrets="{{ entry.secrets_json }}" data-entry-parents="{{ entry.parents_json }}" data-updated-at="{{ entry.updated_at_iso }}">
<td class="col-name mono cell-name" data-label="名称">{{ entry.name }}</td>
<td class="col-type mono cell-type" data-label="类型">{{ entry.entry_type }}</td>
<td class="col-notes cell-notes" data-label="备注">{% if !entry.notes.is_empty() %}<div class="notes-scroll cell-notes-val">{{ entry.notes }}</div>{% endif %}</td>
<td class="col-tags mono cell-tags-val" data-label="标签">{{ entry.tags }}</td>
<td class="col-relations" data-label="关联">
<td class="col-name mono cell-name" data-col="name" data-label="名称">{{ entry.name }}</td>
<td class="col-folder mono cell-folder" data-col="folder" data-label="文件夹">{{ entry.folder }}</td>
<td class="col-type mono cell-type" data-col="type" data-label="类型">{{ entry.entry_type }}</td>
<td class="col-notes cell-notes" data-col="notes" data-label="备注">{% if !entry.notes.is_empty() %}<div class="notes-scroll cell-notes-val">{{ entry.notes }}</div>{% endif %}</td>
<td class="col-tags mono cell-tags-val" data-col="tags" data-label="标签">{{ entry.tags }}</td>
<td class="col-relations" data-col="relations" data-label="关联">
<div class="secret-list">
{% for parent in entry.parents %}
<a class="secret-chip" href="{{ parent.href }}" title="{{ parent.folder }} / {{ parent.name }}">
@@ -539,7 +617,7 @@
{% endfor %}
</div>
</td>
<td class="col-secrets" data-label="密文">
<td class="col-secrets" data-col="secrets" data-label="密文">
<div class="secret-list">
{% for s in entry.secrets %}
<span class="secret-chip">
@@ -549,7 +627,7 @@
{% endfor %}
</div>
</td>
<td class="col-actions" data-label="操作">
<td class="col-actions" data-col="actions" data-label="操作">
<div class="row-actions">
<button type="button" class="btn-row btn-view-secrets" data-i18n="rowView">查看密文</button>
<button type="button" class="btn-row btn-edit" data-i18n="rowEdit">编辑条目</button>
@@ -627,6 +705,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
I18N_PAGE = {
'zh-CN': {
pageTitle: 'Secrets — 条目',
columnSettings: '显示列',
fixedColumns: '固定列',
optionalColumns: '可选列',
navTrash: '回收站',
entriesTitle: '我的条目',
allTab: '全部',
@@ -637,9 +718,10 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
filterTypeLabel: '类型',
filterTypeAll: '全部',
filterSubmit: '筛选',
filterClear: '清空',
filterClear: '重置',
emptyEntries: '暂无条目。',
colName: '名称',
colFolder: '文件夹',
colType: '类型',
colNotes: '备注',
colTags: '标签',
@@ -663,6 +745,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
modalCancel: '取消',
modalSave: '保存',
mobileLabelName: '名称',
mobileLabelFolder: '文件夹',
mobileLabelType: '类型',
mobileLabelNotes: '备注',
mobileLabelTags: '标签',
@@ -694,9 +777,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
viewDecryptError: '解密失败,请确认密码短语与加密时一致。',
viewCopy: '复制',
viewCopied: '已复制',
viewShow: '显示',
viewHide: '隐藏',
viewLoading: '解密中…',
viewEditSecret: '编辑密文',
viewValueHintJson: '此值按 JSON 保存,请输入合法 JSON。',
viewValueInvalidJson: '请输入合法 JSON 值',
viewValueUnlockRequired: '请先在 MCP 配置页解锁密码短语后再修改密文值。',
viewSaveChanges: '保存更改',
viewChangesSaved: '已保存',
viewUnlinkConfirm: '确定解除密文关联「{name}」?',
@@ -706,6 +791,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
},
'zh-TW': {
pageTitle: 'Secrets — 條目',
columnSettings: '顯示列',
fixedColumns: '固定列',
optionalColumns: '可選列',
navTrash: '回收站',
entriesTitle: '我的條目',
allTab: '全部',
@@ -716,9 +804,10 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
filterTypeLabel: '類型',
filterTypeAll: '全部',
filterSubmit: '篩選',
filterClear: '清除',
filterClear: '重置',
emptyEntries: '暫無條目。',
colName: '名稱',
colFolder: '資料夾',
colType: '類型',
colNotes: '備註',
colTags: '標籤',
@@ -742,6 +831,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
modalCancel: '取消',
modalSave: '儲存',
mobileLabelName: '名稱',
mobileLabelFolder: '資料夾',
mobileLabelType: '類型',
mobileLabelNotes: '備註',
mobileLabelTags: '標籤',
@@ -773,9 +863,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
viewDecryptError: '解密失敗,請確認密碼短語與加密時一致。',
viewCopy: '複製',
viewCopied: '已複製',
viewShow: '顯示',
viewHide: '隱藏',
viewLoading: '解密中…',
viewEditSecret: '編輯密文',
viewValueHintJson: '此值會以 JSON 儲存,請輸入合法 JSON。',
viewValueInvalidJson: '請輸入合法 JSON 值',
viewValueUnlockRequired: '請先在 MCP 設定頁解鎖密碼短語,再修改密文值。',
viewSaveChanges: '儲存變更',
viewChangesSaved: '已儲存',
viewUnlinkConfirm: '確定解除密文關聯「{name}」?',
@@ -785,6 +877,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
},
en: {
pageTitle: 'Secrets — Entries',
columnSettings: 'Columns',
fixedColumns: 'Fixed',
optionalColumns: 'Optional',
navTrash: 'Trash',
entriesTitle: 'My entries',
allTab: 'All',
@@ -795,9 +890,10 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
filterTypeLabel: 'Type',
filterTypeAll: 'All',
filterSubmit: 'Filter',
filterClear: 'Clear',
filterClear: 'Reset',
emptyEntries: 'No entries.',
colName: 'Name',
colFolder: 'Folder',
colType: 'Type',
colNotes: 'Notes',
colTags: 'Tags',
@@ -821,6 +917,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
modalCancel: 'Cancel',
modalSave: 'Save',
mobileLabelName: 'Name',
mobileLabelFolder: 'Folder',
mobileLabelType: 'Type',
mobileLabelNotes: 'Notes',
mobileLabelTags: 'Tags',
@@ -852,9 +949,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
viewDecryptError: 'Decryption failed. Please verify your passphrase matches the one used when encrypting.',
viewCopy: 'Copy',
viewCopied: 'Copied',
viewShow: 'Show',
viewHide: 'Hide',
viewLoading: 'Decrypting…',
viewEditSecret: 'Edit secret',
viewValueHintJson: 'This value is stored as JSON. Enter valid JSON.',
viewValueInvalidJson: 'Enter a valid JSON value',
viewValueUnlockRequired: 'Unlock your passphrase on the MCP config page before editing secret values.',
viewSaveChanges: 'Save changes',
viewChangesSaved: 'Saved',
viewUnlinkConfirm: 'Unlink secret "{name}"?',
@@ -873,6 +972,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
var map = {
'.col-name': 'mobileLabelName',
'.col-folder': 'mobileLabelFolder',
'.col-type': 'mobileLabelType',
'.col-notes': 'mobileLabelNotes',
'.col-tags': 'mobileLabelTags',
@@ -885,8 +985,105 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
if (td) td.setAttribute('data-label', t(map[sel]));
});
});
rebuildColPanel();
};
var COL_ORDER = ['name', 'folder', 'type', 'notes', 'tags', 'relations', 'secrets', 'actions'];
var COL_ALWAYS_ON = { name: true, actions: true };
var COL_DEFAULTS = { name: true, folder: true, type: true, notes: false, tags: true, relations: true, secrets: false, actions: true };
var COL_STORAGE_KEY = 'entries_col_vis';
var colPanel = document.getElementById('col-panel');
var colToggleBtn = document.getElementById('col-toggle-btn');
function getColVis() {
try {
var saved = localStorage.getItem(COL_STORAGE_KEY);
if (saved) {
var parsed = JSON.parse(saved);
if (parsed && typeof parsed === 'object') {
var merged = {};
COL_ORDER.forEach(function (col) {
merged[col] = parsed[col] !== undefined ? parsed[col] : COL_DEFAULTS[col];
});
return merged;
}
}
} catch (e) {}
var defaults = {};
COL_ORDER.forEach(function (col) { defaults[col] = COL_DEFAULTS[col]; });
return defaults;
}
function saveColVis(vis) {
try { localStorage.setItem(COL_STORAGE_KEY, JSON.stringify(vis)); } catch (e) {}
}
function applyColVis(vis) {
COL_ORDER.forEach(function (col) {
var visible = vis[col] !== false;
document.querySelectorAll('[data-col="' + col + '"]').forEach(function (el) {
if (visible) {
el.classList.remove('col-hidden');
} else {
el.classList.add('col-hidden');
}
});
});
}
function rebuildColPanel() {
var vis = getColVis();
colPanel.innerHTML = '';
var fixedCols = ['name', 'actions'];
var optionalCols = COL_ORDER.filter(function (c) { return fixedCols.indexOf(c) === -1; });
function renderGroup(cols, groupKey) {
var groupLabel = document.createElement('div');
groupLabel.className = 'col-panel-group';
groupLabel.textContent = t(groupKey);
colPanel.appendChild(groupLabel);
cols.forEach(function (col) {
var item = document.createElement('label');
item.className = 'col-panel-item';
var cb = document.createElement('input');
cb.type = 'checkbox';
var i18nKey = 'col' + col.charAt(0).toUpperCase() + col.slice(1);
cb.checked = vis[col] !== false;
if (COL_ALWAYS_ON[col]) {
cb.disabled = true;
item.classList.add('disabled');
}
cb.addEventListener('change', function () {
vis[col] = cb.checked;
saveColVis(vis);
applyColVis(vis);
});
var span = document.createElement('span');
span.textContent = t(i18nKey) || col;
item.appendChild(cb);
item.appendChild(span);
colPanel.appendChild(item);
});
}
renderGroup(fixedCols, 'fixedColumns');
renderGroup(optionalCols, 'optionalColumns');
}
var colMenu = document.querySelector('.col-menu');
colToggleBtn.addEventListener('click', function (e) {
e.stopPropagation();
colPanel.classList.toggle('open');
});
document.addEventListener('click', function (e) {
if (!colMenu.contains(e.target)) {
colPanel.classList.remove('open');
}
});
applyColVis(getColVis());
rebuildColPanel();
var editOverlay = document.getElementById('edit-overlay');
var editError = document.getElementById('edit-error');
var editFolder = document.getElementById('edit-folder');
@@ -1005,9 +1202,81 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
if (e.target === viewOverlay) closeView();
});
function parseEntrySecretSchema(tr) {
if (!tr) return [];
try {
var raw = JSON.parse(tr.getAttribute('data-entry-secrets') || '[]');
return Array.isArray(raw) ? raw : [];
} catch (err) {
return [];
}
}
function renderEntrySecretChips(tr, secretSchema) {
if (!tr) return;
var list = tr.querySelector('.col-secrets .secret-list');
if (!list) return;
list.innerHTML = '';
(secretSchema || []).forEach(function (secret) {
var chip = document.createElement('span');
chip.className = 'secret-chip';
chip.setAttribute('data-secret-id', secret.id || '');
var name = document.createElement('span');
name.className = 'secret-name';
name.title = secret.name || '';
name.textContent = secret.name || '';
var type = document.createElement('span');
type.className = 'secret-type';
type.textContent = secret.secret_type || 'text';
chip.appendChild(name);
chip.appendChild(type);
list.appendChild(chip);
});
var viewBtn = tr.querySelector('.btn-view-secrets');
if (viewBtn) viewBtn.disabled = !(secretSchema && secretSchema.length);
}
function writeEntrySecretSchema(entryId, secretSchema) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (!tr) return;
tr.setAttribute('data-entry-secrets', JSON.stringify(secretSchema || []));
renderEntrySecretChips(tr, secretSchema || []);
}
function updateEntrySecretSchema(entryId, secretId, updater) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (!tr) return;
var changed = false;
var next = parseEntrySecretSchema(tr).map(function (secret) {
if (String(secret.id || '') !== String(secretId || '')) return secret;
changed = true;
return updater(Object.assign({}, secret));
});
if (changed) writeEntrySecretSchema(entryId, next);
}
function removeEntrySecretSchema(entryId, secretId) {
var tr = document.querySelector('tr[data-entry-id="' + entryId + '"]');
if (!tr) return;
var schema = parseEntrySecretSchema(tr);
writeEntrySecretSchema(entryId, schema.filter(function (secret) {
return String(secret.id || '') !== String(secretId || '');
}));
}
function renderViewSecrets(secrets, secretSchema) {
viewBody.innerHTML = '';
var names = Object.keys(secrets);
var names = [];
(secretSchema || []).forEach(function (secret) {
if (Object.prototype.hasOwnProperty.call(secrets, secret.name)) names.push(secret.name);
});
Object.keys(secrets).forEach(function (name) {
if (names.indexOf(name) === -1) names.push(name);
});
if (names.length === 0) {
var msg = document.createElement('div');
msg.className = 'view-locked-msg';
@@ -1021,21 +1290,42 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
names.forEach(function (name) {
var raw = secrets[name];
var valueStr = (raw === null || raw === undefined) ? '' :
(typeof raw === 'object') ? JSON.stringify(raw, null, 2) : String(raw);
var isPassword = (name === 'password' || name === 'passwd' || name === 'secret');
var masked = isPassword;
var currentName = name;
var valueMode = (typeof raw === 'string') ? 'text' : 'json';
var valueStr = (typeof raw === 'string') ? raw : JSON.stringify(raw, null, 2);
var schema = schemaMap[name] || {};
var secretId = schema.id || '';
var secretType = schema.secret_type || 'text';
var originalName = name;
var hasChanges = false;
var currentType = schema.secret_type || 'text';
function formatSecretValue(value) {
return (typeof value === 'string') ? value : JSON.stringify(value, null, 2);
}
function parseEditedSecretValue(text) {
if (valueMode === 'text') return { ok: true, value: text };
try {
return { ok: true, value: JSON.parse(text) };
} catch (err) {
return { ok: false, error: t('viewValueInvalidJson') };
}
}
function comparableSecretValue(value) {
return JSON.stringify(value);
}
function applyCurrentSecretValue(value) {
raw = value;
valueStr = formatSecretValue(value);
valueEl.textContent = valueStr;
valueEditor.value = valueStr;
}
var row = document.createElement('div');
row.className = 'view-secret-row';
row.setAttribute('data-secret-id', secretId);
row.setAttribute('data-original-name', originalName);
row.setAttribute('data-original-name', currentName);
var header = document.createElement('div');
header.className = 'view-secret-header';
@@ -1050,14 +1340,13 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.className = 'view-secret-name-input';
nameInput.value = name;
nameInput.value = currentName;
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;
typeBadge.textContent = currentType;
var typeSelect = document.createElement('select');
typeSelect.className = 'view-secret-type-select';
@@ -1066,13 +1355,13 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var option = document.createElement('option');
option.value = opt;
option.textContent = opt;
if (opt === secretType) option.selected = true;
if (opt === currentType) option.selected = true;
typeSelect.appendChild(option);
});
if (SECRET_TYPE_OPTIONS.indexOf(secretType) === -1 && secretType) {
if (SECRET_TYPE_OPTIONS.indexOf(currentType) === -1 && currentType) {
var fallback = document.createElement('option');
fallback.value = secretType;
fallback.textContent = secretType;
fallback.value = currentType;
fallback.textContent = currentType;
fallback.selected = true;
typeSelect.appendChild(fallback);
}
@@ -1089,8 +1378,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn-icon btn-view-edit';
editBtn.textContent = '✎';
editBtn.title = t('renameSecretTitle');
editBtn.textContent = t('viewEditSecret');
var saveBtn = document.createElement('button');
saveBtn.type = 'button';
@@ -1104,19 +1392,6 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
cancelBtn.textContent = t('modalCancel');
cancelBtn.hidden = true;
if (isPassword) {
var toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'btn-icon btn-toggle-mask';
toggleBtn.textContent = t('viewShow');
toggleBtn.addEventListener('click', function () {
masked = !masked;
valueEl.classList.toggle('masked', masked);
toggleBtn.textContent = masked ? t('viewShow') : t('viewHide');
});
actions.appendChild(toggleBtn);
}
var copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'btn-icon';
@@ -1132,8 +1407,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var unlinkBtn = document.createElement('button');
unlinkBtn.type = 'button';
unlinkBtn.className = 'btn-icon btn-view-unlink';
unlinkBtn.textContent = '×';
unlinkBtn.title = t('unlinkTitle');
unlinkBtn.textContent = t('unlinkTitle');
actions.appendChild(unlinkBtn);
actions.appendChild(editBtn);
@@ -1145,11 +1419,25 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
var valueWrap = document.createElement('div');
valueWrap.className = 'view-secret-value-wrap';
var valueEl = document.createElement('div');
valueEl.className = 'view-secret-value' + (masked ? ' masked' : '');
valueEl.className = 'view-secret-value';
valueEl.textContent = valueStr;
var valueEditor = document.createElement('textarea');
valueEditor.className = 'view-secret-editor';
valueEditor.hidden = true;
valueEditor.value = valueStr;
valueWrap.appendChild(valueEl);
valueWrap.appendChild(valueEditor);
row.appendChild(valueWrap);
var valueHint = null;
if (valueMode === 'json') {
valueHint = document.createElement('div');
valueHint.className = 'view-secret-hint';
valueHint.hidden = true;
valueHint.textContent = t('viewValueHintJson');
row.appendChild(valueHint);
}
var nameStatus = document.createElement('div');
nameStatus.className = 'secret-name-status';
nameStatus.setAttribute('data-status', 'idle');
@@ -1161,8 +1449,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
function enterEditMode() {
nameSpan.hidden = true;
typeBadge.hidden = true;
valueEl.hidden = true;
nameInput.hidden = false;
typeSelect.hidden = false;
valueEditor.hidden = false;
if (valueHint) valueHint.hidden = false;
saveBtn.hidden = false;
cancelBtn.hidden = false;
editBtn.hidden = true;
@@ -1173,8 +1464,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
function exitEditMode() {
nameSpan.hidden = false;
typeBadge.hidden = false;
valueEl.hidden = false;
nameInput.hidden = true;
typeSelect.hidden = true;
valueEditor.hidden = true;
if (valueHint) valueHint.hidden = true;
saveBtn.hidden = true;
cancelBtn.hidden = true;
editBtn.hidden = false;
@@ -1182,7 +1476,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
nameStatus.className = 'secret-name-status';
nameInput.value = nameSpan.textContent;
typeSelect.value = typeBadge.textContent;
hasChanges = false;
valueEditor.value = valueStr;
}
editBtn.addEventListener('click', enterEditMode);
@@ -1197,7 +1491,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
nameStatus.className = 'secret-name-status';
debounceTimer = setTimeout(function () {
var newName = nameInput.value.trim();
if (!newName || newName === originalName) return;
if (!newName || newName === currentName) return;
nameStatus.textContent = t('checkingSecretName');
nameStatus.className = 'secret-name-status checking';
var checkId = Date.now();
@@ -1212,18 +1506,15 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
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);
});
@@ -1232,21 +1523,46 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
if (e.key === 'Enter') { e.preventDefault(); saveBtn.click(); }
if (e.key === 'Escape') { cancelBtn.click(); }
});
valueEditor.addEventListener('keydown', function (e) {
if ((e.metaKey || e.ctrlKey) && 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;
var parsedValue = parseEditedSecretValue(valueEditor.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; }
if (!parsedValue.ok) { nameStatus.textContent = parsedValue.error; nameStatus.className = 'secret-name-status error'; return; }
var nextValue = parsedValue.value;
var patchBody = {};
if (newName !== originalName) patchBody.name = newName;
if (newType !== secretType) patchBody.type = newType;
var valueChanged = comparableSecretValue(nextValue) !== comparableSecretValue(raw);
if (newName !== currentName) patchBody.name = newName;
if (newType !== currentType) patchBody.type = newType;
if (valueChanged) patchBody.value = nextValue;
if (Object.keys(patchBody).length === 0) { exitEditMode(); return; }
var encKey = sessionStorage.getItem('enc_key');
if (valueChanged && !encKey) {
nameStatus.textContent = t('viewValueUnlockRequired');
nameStatus.className = 'secret-name-status error';
return;
}
saveBtn.textContent = '...';
saveBtn.disabled = true;
cancelBtn.disabled = true;
editBtn.disabled = true;
fetch('/api/secrets/' + encodeURIComponent(secretId), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
headers: Object.assign(
{ 'Content-Type': 'application/json' },
valueChanged ? { 'X-Encryption-Key': encKey } : {}
),
credentials: 'same-origin',
body: JSON.stringify(patchBody)
}).then(function (r) {
@@ -1255,22 +1571,28 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
return data;
});
}).then(function () {
currentName = newName;
currentType = newType;
nameSpan.textContent = newName;
typeBadge.textContent = newType;
originalName = newName;
nameInput.setAttribute('data-original-name', newName);
applyCurrentSecretValue(nextValue);
saveBtn.textContent = t('viewChangesSaved');
nameStatus.textContent = t('viewChangesSaved');
nameStatus.className = 'secret-name-status success';
updateEntrySecretSchema(viewBody.getAttribute('data-entry-id'), secretId, function (secret) {
secret.name = newName;
secret.secret_type = newType;
return secret;
});
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');
}).finally(function () {
saveBtn.disabled = false;
cancelBtn.disabled = false;
editBtn.disabled = false;
});
});
@@ -1294,15 +1616,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
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();
}
}
removeEntrySecretSchema(viewBody.getAttribute('data-entry-id'), secretId);
}).catch(function (err) {
alert(err.message || String(err));
});

View File

@@ -178,10 +178,8 @@
<a href="/llms.txt">llms.txt</a>
<span data-i18n="sep"> · </span>
<a href="https://gitea.refining.dev/refining/secrets" target="_blank" rel="noopener noreferrer" data-i18n="footRepo">源码仓库</a>
{% if !is_logged_in %}
<span data-i18n="sep"> · </span>
<a href="/login" data-i18n="footLogin"></a>
{% endif %}
<a href="/changelog" data-i18n="footChangelog">变更记</a>
</footer>
<script>
const T = {
@@ -200,7 +198,7 @@
versionLabel: '版本',
sep: ' · ',
footRepo: '源码仓库',
footLogin: '录',
footChangelog: '变更记录',
},
'zh-TW': {
docTitle: 'Secrets MCP — 端到端加密的金鑰管理',
@@ -217,7 +215,7 @@
versionLabel: '版本',
sep: ' · ',
footRepo: '原始碼倉庫',
footLogin: '登入',
footChangelog: '變更記錄',
},
'en': {
docTitle: 'Secrets MCP — End-to-end encrypted secrets',
@@ -234,7 +232,7 @@
versionLabel: 'Version',
sep: ' · ',
footRepo: 'Source repository',
footLogin: 'Sign in',
footChangelog: 'Changelog',
}
};

View File

@@ -65,6 +65,10 @@ function applyLang() {
var key = el.getAttribute('data-i18n-ph');
el.placeholder = t(key);
});
document.querySelectorAll('[data-i18n-title]').forEach(function (el) {
var key = el.getAttribute('data-i18n-title');
el.title = t(key);
});
document.querySelectorAll('.lang-btn').forEach(function (btn) {
var map = { 'zh-CN': '简', 'zh-TW': '繁', en: 'EN' };
btn.classList.toggle('active', btn.textContent === map[currentLang]);

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title>Secrets — 回收站</title>
<title data-i18n="pageTitle">Secrets — 回收站</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
@@ -188,6 +188,7 @@
(function () {
I18N_PAGE = {
'zh-CN': {
pageTitle: 'Secrets — 回收站',
navMcp: 'MCP', navEntries: '条目', navTrash: '回收站', navAudit: '审计',
signOut: '退出', trashTitle: '回收站', trashSubtitle: '已删除条目会保留 3 个月,可在此恢复或永久删除。',
emptyTrash: '回收站为空。', colName: '名称', colType: '类型', colFolder: '文件夹',
@@ -197,6 +198,7 @@
mobileLabelDeletedAt: '删除时间', mobileLabelActions: '操作'
},
'zh-TW': {
pageTitle: 'Secrets — 回收站',
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
signOut: '退出', trashTitle: '回收站', trashSubtitle: '已刪除條目會保留 3 個月,可在此恢復或永久刪除。',
emptyTrash: '回收站為空。', colName: '名稱', colType: '類型', colFolder: '文件夾',
@@ -206,6 +208,7 @@
mobileLabelDeletedAt: '刪除時間', mobileLabelActions: '操作'
},
en: {
pageTitle: 'Secrets — Trash',
navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit',
signOut: 'Sign out', trashTitle: 'Trash', trashSubtitle: 'Deleted entries are kept for 3 months. Restore or permanently delete them here.',
emptyTrash: 'Trash is empty.', colName: 'Name', colType: 'Type', colFolder: 'Folder',

View File

@@ -0,0 +1,201 @@
# Code Review 修复计划
**日期**: 2026-04-11
**来源**: 三份 code review 报告提炼合并
**范围**: 7 项修复 + 1 项风险提示(不纳入本次修复)
---
## 1. `secrets-core` 导入/导出链路
**目标**: 修复"导入丢失 secret type"。
**涉及文件**:
- `crates/secrets-core/src/models.rs`
- `crates/secrets-core/src/service/export.rs`
- `crates/secrets-core/src/service/import.rs`
**实施项**:
1.`ExportEntry` 增加可选字段 `secret_types: Option<BTreeMap<String, String>>`,键为 secret 名,值为 type。
2. 修改导出逻辑,除了导出 `secrets` 的值,也把每个 secret 的 `type` 一并导出。
3. 修改导入逻辑,把 `entry.secret_types` 传给 `AddParams.secret_types`,不再用 `&Default::default()`
4. 明确兼容旧导出文件:`secret_types` 缺失时继续默认 `"text"`
**验证**:
1. 新增 round-trip 测试:带 `password` / `key` / `text` 三种类型的导出再导入,类型保持不变。
2. 新增向后兼容测试:旧格式导入时仍成功,默认回落到 `"text"`
---
## 2. `secrets-core` env map
**目标**: 修复环境变量名碰撞。
**涉及文件**:
- `crates/secrets-core/src/service/env_map.rs`
**实施项**:
1. 统一分隔符策略:把字段名里的 `.` 替换成 `__`(双下划线),保留原始 `_` 为单下划线,避免 `db.password``db_password` 碰撞。
2. 如仍发生碰撞,显式返回错误,而不是 `HashMap::insert` 静默覆盖。
**验证**:
1. 添加测试覆盖:
- `db.password` vs `db_password`
-`prefix` 的情况
- 多 entry 合并时碰撞
2. 确认输出变量名文档与实现一致。
---
## 3. `secrets-core` rollback
**目标**: 修复回滚路径中的 TOCTOU 和"使用旧 live 值"问题。
**涉及文件**:
- `crates/secrets-core/src/service/rollback.rs`
**实施项**:
1. 把首次读取 live entry 的逻辑移入事务内,并与 `FOR UPDATE` 合并,避免在加锁前基于过期数据做决策。
2. 在拿到加锁后的 live row 后,再决定错误消息、审计字段和更新语句输入。
3. 明确回滚语义:
- 若产品希望"完全回到快照",则 `name/notes` 也从快照恢复。
- 若希望保留当前标识,则代码和文档都要显式说明此设计决策。
4. 避免混用事务前的 `live_entry` 与事务内的 `lr`
**验证**:
1. 新增测试覆盖:
- 回滚时恢复字段是否符合预期
- 并发更新 + 回滚场景不再依赖过期值
---
## 4. `secrets-core` API key
**目标**: 修复 `regenerate_api_key` 返回未持久化 key。
**涉及文件**:
- `crates/secrets-core/src/service/api_key.rs`
**实施项**:
1. 检查 `UPDATE``rows_affected()`
2. 若为 `0`,返回 `NotFoundUser` 或等价业务错误。
3. 可选:改成 `UPDATE ... RETURNING api_key`,减少"先生成后判断"的分支。
**验证**:
1. 新增测试:
- 正常用户可返回新 key
- 不存在用户时返回错误,而不是返回伪成功 key
---
## 5. `secrets-mcp` tools
**目标**: 修复 MCP 层三个问题:
- `secrets_add` 提交后再回查 entry 的竞态
- `secrets_find` 计数失败被吞成 0
- `secrets_rollback` 冗余要求 encryption key
**涉及文件**:
- `crates/secrets-mcp/src/tools.rs`
- 可能连带 `crates/secrets-core/src/service/add.rs` 的返回结构
**实施项**:
1.`svc_add` 直接返回 `entry_id`MCP 不再在提交后用 `name+folder+user_id` 二次 `resolve_entry`
2. `secrets_find` 中移除 `.unwrap_or(0)`
- 要么把计数错误向上传播
- 要么返回 `total_count: null` / `count_unavailable: true`
3. `secrets_rollback` 改为只要求用户身份,不要求 encryption key。
4. 同步修正工具描述文案,避免 schema 与实际行为不一致。
**验证**:
1. `secrets_add`:父子关系新增时直接使用返回的 `entry_id`
2. `secrets_find`:模拟 count 失败时,结果不再伪装成 `0`
3. `secrets_rollback`:无 key 时可执行,工具描述与行为一致。
---
## 6. `secrets-mcp` Web 会话校验
**目标**: 让 JSON API 与页面路由的 `key_version` 校验对齐。
**涉及文件**:
- `crates/secrets-mcp/src/web/mod.rs`
- `crates/secrets-mcp/src/web/entries.rs`
- `crates/secrets-mcp/src/web/account.rs`
- 其他仅使用 `current_user_id()` 的 JSON handler
**实施项**:
1. 抽一个适用于 JSON API 的用户校验辅助函数。
2. 该函数应同时完成:
- session 取 `user_id`
- 加载用户
- 比对 `SESSION_KEY_VERSION` 与 DB `key_version`
3. 页面路由继续保留 `require_valid_user`JSON 路由统一改用等价校验。
4. 统一失败语义:
- HTML 路由:重定向 `/login`
- JSON 路由:返回 `401`
**验证**:
1. 新增测试覆盖:
- `key_version` 一致时 JSON API 正常
- `key_version` 不一致时 JSON API 返回 `401`
- 用户删除/会话损坏时返回正确错误
---
## 7. Web API 输入校验
**目标**: 补齐 Web JSON API 的长度校验,避免 DB 约束错误落成 500。
**涉及文件**:
- `crates/secrets-mcp/src/web/entries.rs`
- 如有需要,抽公共 helper 到 `web/mod.rs` 或单独模块
**实施项**:
1.`api_entry_patch` 对齐 MCP 的长度规则:
- `folder <= 128`
- `type <= 64`
- `name <= 256`
- `notes <= 10000`
2. 视情况复用 `validation` 常量,避免 Web/MCP 两套上限漂移。
3. 保持错误返回为 `400`,而不是依赖数据库报错。
**验证**:
1. 为超长 `folder/type/name/notes` 分别补测试。
2. 确认错误文案和现有本地化风格一致。
---
## 8. 暂不纳入本次修复(风险提示)
- 调试日志记录加密密钥片段(`extract_enc_key_or_arg` 在 debug 级别记录 key 前后缀)
- `encryption_key` 作为工具参数带来的日志/对话暴露面
**处理方式**: 记录为"接口安全风险提示",待后续单独决定是否收紧 debug 日志、调整工具描述或限制参数传 key 的路径。
---
## 实施顺序
1. `secrets-core` 导入/导出链路
2. `secrets-core` env map
3. `secrets-core` rollback
4. `secrets-core` API key
5. `secrets-mcp` tools
6. `secrets-mcp` Web 会话校验
7. `secrets-mcp` Web 输入校验
**原因**: 先修 core 语义和返回结构,再修上层接入。`svc_add` 返回结构、rollback 语义、export/import 格式都属于底层契约,适合先稳定。
---
## 验证与收尾
1. 先跑相关单元/集成测试。
2. 再跑全量:
```bash
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked
```
3. 若涉及 `crates/**` 的实际改动,按 `AGENTS.md` 约定检查版本/tag并执行 `./scripts/release-check.sh`。

View File

@@ -0,0 +1,143 @@
# Code Review 修复方案合并计划
**日期**: 2026-04-11
**来源**: 两个 AI 实现对比评估
**比较对象**:
- `d7720662` (`/Users/voson/work/refining/secrets-cr-fixes-ws`)
- `9f8a68cd` (`/Users/voson/work/refining/secrets/plan-impl`)
---
## 结论
**`d7720662`** 为主线采纳。
**原因**:
1. `rollback` 的 live row 加锁与 snapshot 读取都在事务内完成,更符合原计划里对 TOCTOU 的修复要求。
2. Web JSON API 的 session 校验保留了按 `UiLang` 返回错误信息的行为,没有把错误消息退化成固定英文。
3. `svc_add` 返回 `entry_id`MCP 层直接使用返回值建立 parent relation和计划第 5 项更一致。
---
## 采纳策略
### 1. 主线保留 `d7720662`
保留以下实现,不从另一份实现回退:
- `crates/secrets-core/src/service/rollback.rs`
- `crates/secrets-mcp/src/web/mod.rs`
- `crates/secrets-core/src/service/add.rs`
- `crates/secrets-mcp/src/tools.rs`
### 2. 从 `9f8a68cd` 手动吸收的小改动
仅吸收下面两处,手动改写,不直接整文件 cherry-pick
1. `crates/secrets-mcp/src/web/entries.rs`
- 把长度校验报错文案改成基于 `crate::validation::*` 常量拼接,避免上限数字硬编码在文案里。
2. `crates/secrets-core/src/service/env_map.rs`
-`env_prefix_with_and_without_prefix` 单测。
---
## 明确不采纳的实现
### 不采纳 `9f8a68cd` 的 `rollback.rs`
原因:
- 它仍然先在事务外读取 `entries_history`,再开启事务并锁 live row。
- 对“回滚到最近快照”的路径仍存在先读后锁的时间窗口。
### 不采纳 `9f8a68cd` 的 `web/mod.rs`
原因:
- `load_session_user_strict()` / `require_valid_user_json()` 返回固定英文 JSON 错误。
- 会丢失现有多语言错误语义。
### 不采纳 `9f8a68cd` 的 `AddResult.id`
原因:
- 本轮计划里明确要求 `svc_add` 返回 `entry_id`
- `d7720662` 的字段命名与 MCP 使用方式更贴近计划要求。
---
## 与原计划的覆盖情况
两份实现都完成了大部分代码修改,但验证项整体没有补齐,当前更像“代码已改,测试仍不足”。
### 已基本完成
1. 导入/导出链路补 `secret_types`
2. env map `.` -> `__`,并在冲突时返回错误
3. API key `rows_affected()` 检查
4. MCP `secrets_add` 避免提交后二次回查竞态
5. MCP `secrets_find` 不再把 count 错误吞成 `0`
6. MCP `secrets_rollback` 不再要求 encryption key
7. Web JSON API 引入 `key_version` 严格校验
8. Web PATCH 补长度校验
### 尚需补齐的验证
1. export/import round-trip 测试
- `password` / `key` / `text` 三种类型导出再导入后保持不变
2. legacy import 测试
- 老格式缺失 `secret_types` 时默认回落到 `text`
3. env map 测试
- `db.password` vs `db_password`
-`prefix`
- 多 entry 合并冲突
4. rollback 测试
- 恢复字段是否符合预期
- 并发更新 + 回滚不依赖过期值
5. `regenerate_api_key` 测试
- 正常用户返回新 key
- 不存在用户返回错误
6. MCP tool 测试
- `secrets_find` count 失败路径
- `secrets_rollback` 无 encryption key 也可执行
7. Web session / validation 测试
- `key_version` mismatch -> `401`
- 用户不存在 / session 损坏 -> 正确错误
- `folder/type/name/notes` 超长 -> `400`
---
## 执行步骤
1.`d7720662` 对应实现为合并基线。
2. 手动吸收 `9f8a68cd``web/entries.rs` 的常量化长度报错文案。
3. 手动吸收 `9f8a68cd``env_map.rs``env_prefix_with_and_without_prefix` 测试。
4. 不引入 `9f8a68cd``rollback.rs``web/mod.rs``AddResult.id` 方案。
5. 针对原计划缺失的验证项补测试。
6. 跑质量门禁:
```bash
cargo fmt -- --check
cargo clippy --locked -- -D warnings
cargo test --locked
```
7. 跑发布前检查:
```bash
./scripts/release-check.sh
```
8. 确认版本和 tag
- `crates/secrets-mcp/Cargo.toml` 已 bump合并执行时为 `0.5.21`,因 `crates/**` 有变更)
- `jj tag list`
---
## 备注
如果后续要做最终合并,建议以 `d7720662` 为基础继续补测试,而不是尝试把两份实现整合成第三套逻辑。这样改动面最小,风险也最低。