Compare commits
5 Commits
secrets-mc
...
secrets-mc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b2fbdae4d | ||
|
|
ab1e3329b9 | ||
|
|
c3b1a0df1a | ||
|
|
d772066210 | ||
|
|
2c7dbf890b |
15
AGENTS.md
15
AGENTS.md
@@ -42,7 +42,7 @@ secrets/
|
|||||||
Cargo.toml
|
Cargo.toml
|
||||||
crates/
|
crates/
|
||||||
secrets-core/ # db / crypto / models / audit / service
|
secrets-core/ # db / crypto / models / audit / service
|
||||||
secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard
|
secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard;CHANGELOG.md → /changelog
|
||||||
scripts/
|
scripts/
|
||||||
release-check.sh
|
release-check.sh
|
||||||
setup-gitea-actions.sh
|
setup-gitea-actions.sh
|
||||||
@@ -113,6 +113,7 @@ users (
|
|||||||
key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语
|
key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语
|
||||||
key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000}
|
key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000}
|
||||||
api_key TEXT UNIQUE, -- MCP Bearer token,明文存储(设计决策,见下方说明)
|
api_key TEXT UNIQUE, -- MCP Bearer token,明文存储(设计决策,见下方说明)
|
||||||
|
key_version BIGINT NOT NULL DEFAULT 0, -- 密码短语变更时递增,用于使其它设备会话失效
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
)
|
)
|
||||||
@@ -165,10 +166,22 @@ oauth_accounts (
|
|||||||
| `secrets.type` | 密钥类型(调用方提供,默认 `text`) | `text`, `password`, `key` |
|
| `secrets.type` | 密钥类型(调用方提供,默认 `text`) | `text`, `password`, `key` |
|
||||||
| `secrets.encrypted` | 密文 | AES-GCM |
|
| `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`)
|
### Web 条目页表格列(`/entries`)
|
||||||
|
|
||||||
列表仅展示非敏感字段;**名称**与**操作**列为固定列(不可在「显示列」中关闭)。**文件夹**(对应 `entries.folder`)、类型、备注、标签、关联、密文等为**可选列**,由用户在「显示列」面板中勾选;可见性保存在浏览器 `localStorage`,键为 **`entries_col_vis`**。新增列会并入默认:若用户曾保存过旧版配置,缺失的列键会按当前默认补齐。**文件夹**列默认**显示**,便于在「全部」等跨 folder 视图下区分条目所属隔离空间。
|
列表仅展示非敏感字段;**名称**与**操作**列为固定列(不可在「显示列」中关闭)。**文件夹**(对应 `entries.folder`)、类型、备注、标签、关联、密文等为**可选列**,由用户在「显示列」面板中勾选;可见性保存在浏览器 `localStorage`,键为 **`entries_col_vis`**。新增列会并入默认:若用户曾保存过旧版配置,缺失的列键会按当前默认补齐。**文件夹**列默认**显示**,便于在「全部」等跨 folder 视图下区分条目所属隔离空间。
|
||||||
|
|
||||||
|
### 导出 / 导入文件
|
||||||
|
|
||||||
|
JSON/TOML/YAML 导出可在每条目上包含 `secret_types`(secret 名 → `text` / `password` / `key` 等),导入时写回 `secrets.type`;**旧版导出无该字段**时导入仍成功,类型按 **`text`** 默认。
|
||||||
|
|
||||||
### 共享密钥(N:N 关联)
|
### 共享密钥(N:N 关联)
|
||||||
|
|
||||||
多个 entry 可共享同一 secret 字段,通过 `entry_secrets` 中间表关联。
|
多个 entry 可共享同一 secret 字段,通过 `entry_secrets` 中间表关联。
|
||||||
|
|||||||
43
Cargo.lock
generated
43
Cargo.lock
generated
@@ -740,6 +740,15 @@ dependencies = [
|
|||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1578,6 +1587,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "quanta"
|
name = "quanta"
|
||||||
version = "0.12.6"
|
version = "0.12.6"
|
||||||
@@ -2065,7 +2093,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.5.19"
|
version = "0.5.24"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"askama",
|
"askama",
|
||||||
@@ -2075,6 +2103,7 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"governor",
|
"governor",
|
||||||
"http",
|
"http",
|
||||||
|
"pulldown-cmark",
|
||||||
"rand 0.10.0",
|
"rand 0.10.0",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rmcp",
|
"rmcp",
|
||||||
@@ -2985,6 +3014,12 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
@@ -3012,6 +3047,12 @@ version = "0.1.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
|
|||||||
SECRETS_ENV=production
|
SECRETS_ENV=production
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Web**:`BASE_URL`(登录、Dashboard、设置密码短语、创建 API Key)。**条目**页 `/entries` 支持 folder 标签与条件筛选;表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。
|
- **Web**:`BASE_URL`(登录、Dashboard、设置密码短语、创建 API Key)。**变更记录**页 **`/changelog`**:内容来自 `crates/secrets-mcp/CHANGELOG.md`(构建时嵌入并以 Markdown 渲染);首页页脚与 Dashboard(MCP)页脚均提供入口。**条目**页 `/entries` 支持 folder 标签与条件筛选;表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。
|
||||||
- **MCP**:Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)。
|
- **MCP**:Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)。
|
||||||
|
|
||||||
## PostgreSQL TLS 加固
|
## PostgreSQL TLS 加固
|
||||||
@@ -72,9 +72,9 @@ SECRETS_ENV=production
|
|||||||
| `secrets_update` | 是 | 更新条目,支持 `id` 或 `name`+`folder` 定位 |
|
| `secrets_update` | 是 | 更新条目,支持 `id` 或 `name`+`folder` 定位 |
|
||||||
| `secrets_delete` | 否 | 删除条目,支持 `id` 或 `name`+`folder` 定位;`dry_run=true` 预览删除 |
|
| `secrets_delete` | 否 | 删除条目,支持 `id` 或 `name`+`folder` 定位;`dry_run=true` 预览删除 |
|
||||||
| `secrets_history` | 否 | 查看条目历史,支持 `id` 或 `name`+`folder` 定位 |
|
| `secrets_history` | 否 | 查看条目历史,支持 `id` 或 `name`+`folder` 定位 |
|
||||||
| `secrets_rollback` | 是 | 回滚条目到指定历史版本,支持 `id` 或 `name`+`folder` 定位 |
|
| `secrets_rollback` | 否 | 回滚条目到指定历史版本(服务端按历史快照恢复元数据与密文关联),支持 `id`;仅需 **Bearer**,不要求 `X-Encryption-Key` |
|
||||||
| `secrets_export` | 是 | 导出条目(含解密明文),支持 JSON/TOML/YAML 格式 |
|
| `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 计数概览 |
|
| `secrets_overview` | 否 | 返回各 folder 和 type 的 entry 计数概览 |
|
||||||
|
|
||||||
### 消歧规则
|
### 消歧规则
|
||||||
@@ -226,7 +226,7 @@ crates/secrets-core/ # db / crypto / models / audit / service
|
|||||||
src/
|
src/
|
||||||
taxonomy.rs # SECRET_TYPE_OPTIONS(secret 字段类型下拉选项)
|
taxonomy.rs # SECRET_TYPE_OPTIONS(secret 字段类型下拉选项)
|
||||||
service/ # 业务逻辑(add, search, update, delete, export, env_map 等)
|
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 Key;CHANGELOG.md 嵌入 /changelog
|
||||||
scripts/
|
scripts/
|
||||||
release-check.sh # 发版前 fmt / clippy / test
|
release-check.sh # 发版前 fmt / clippy / test
|
||||||
setup-gitea-actions.sh
|
setup-gitea-actions.sh
|
||||||
|
|||||||
@@ -184,6 +184,9 @@ pub struct ExportEntry {
|
|||||||
/// Decrypted secret fields. None means no secrets in this export (--no-secrets).
|
/// Decrypted secret fields. None means no secrets in this export (--no-secrets).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub secrets: Option<BTreeMap<String, Value>>,
|
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 ──────────────────────────────────────────────────────────
|
// ── 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ pub fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)>
|
|||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
pub struct AddResult {
|
pub struct AddResult {
|
||||||
|
pub entry_id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub folder: String,
|
pub folder: String,
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
@@ -477,6 +478,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
|
|||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|
||||||
Ok(AddResult {
|
Ok(AddResult {
|
||||||
|
entry_id,
|
||||||
name: params.name.to_string(),
|
name: params.name.to_string(),
|
||||||
folder: params.folder.to_string(),
|
folder: params.folder.to_string(),
|
||||||
entry_type: entry_type.to_string(),
|
entry_type: entry_type.to_string(),
|
||||||
|
|||||||
@@ -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.
|
/// 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> {
|
pub async fn regenerate_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
|
||||||
let new_key = generate_api_key();
|
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(&new_key)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
if res.rows_affected() == 0 {
|
||||||
|
return Err(AppError::NotFoundUser.into());
|
||||||
|
}
|
||||||
Ok(new_key)
|
Ok(new_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,3 +66,30 @@ pub async fn validate_api_key(pool: &PgPool, raw_key: &str) -> Result<Option<Uui
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(row.map(|(id,)| id))
|
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)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,18 +45,27 @@ pub async fn build_env_map(
|
|||||||
|
|
||||||
for f in fields {
|
for f in fields {
|
||||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
||||||
let key = format!(
|
let seg = secret_name_to_env_segment(&f.name);
|
||||||
"{}_{}",
|
let key = format!("{}_{}", effective_prefix, seg);
|
||||||
effective_prefix,
|
if let Some(_old) = combined.insert(key.clone(), json_to_env_string(&decrypted)) {
|
||||||
f.name.to_uppercase().replace(['-', '.'], "_")
|
anyhow::bail!(
|
||||||
);
|
"environment variable name collision after normalization: '{}' (secret '{}')",
|
||||||
combined.insert(key, json_to_env_string(&decrypted));
|
key,
|
||||||
|
f.name
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(combined)
|
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 {
|
fn env_prefix(entry: &crate::models::Entry, prefix: &str) -> String {
|
||||||
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
|
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
|
||||||
if prefix.is_empty() {
|
if prefix.is_empty() {
|
||||||
@@ -75,3 +84,39 @@ fn json_to_env_string(v: &Value) -> String {
|
|||||||
other => other.to_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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,21 +44,23 @@ pub async fn export(
|
|||||||
|
|
||||||
let mut export_entries: Vec<ExportEntry> = Vec::with_capacity(entries.len());
|
let mut export_entries: Vec<ExportEntry> = Vec::with_capacity(entries.len());
|
||||||
for entry in &entries {
|
for entry in &entries {
|
||||||
let secrets = if params.no_secrets {
|
let (secrets, secret_types) = if params.no_secrets {
|
||||||
None
|
(None, None)
|
||||||
} else {
|
} else {
|
||||||
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
||||||
if fields.is_empty() {
|
if fields.is_empty() {
|
||||||
Some(BTreeMap::new())
|
(Some(BTreeMap::new()), Some(BTreeMap::new()))
|
||||||
} else {
|
} else {
|
||||||
let mk = master_key
|
let mk = master_key
|
||||||
.ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?;
|
.ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?;
|
||||||
let mut map = BTreeMap::new();
|
let mut map = BTreeMap::new();
|
||||||
|
let mut type_map = BTreeMap::new();
|
||||||
for f in fields {
|
for f in fields {
|
||||||
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
|
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
|
||||||
map.insert(f.name.clone(), decrypted);
|
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(),
|
tags: entry.tags.clone(),
|
||||||
metadata: entry.metadata.clone(),
|
metadata: entry.metadata.clone(),
|
||||||
secrets,
|
secrets,
|
||||||
|
secret_types,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use std::collections::HashMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::ExportFormat;
|
use crate::models::ExportFormat;
|
||||||
@@ -80,6 +81,11 @@ pub async fn run(
|
|||||||
|
|
||||||
let secret_entries = build_secret_entries(entry.secrets.as_ref());
|
let secret_entries = build_secret_entries(entry.secrets.as_ref());
|
||||||
let meta_entries = build_meta_entries(&entry.metadata);
|
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(
|
match add_run(
|
||||||
pool,
|
pool,
|
||||||
@@ -91,7 +97,7 @@ pub async fn run(
|
|||||||
tags: &entry.tags,
|
tags: &entry.tags,
|
||||||
meta_entries: &meta_entries,
|
meta_entries: &meta_entries,
|
||||||
secret_entries: &secret_entries,
|
secret_entries: &secret_entries,
|
||||||
secret_types: &Default::default(),
|
secret_types: &secret_types_map,
|
||||||
link_secret_names: &[],
|
link_secret_names: &[],
|
||||||
user_id: params.user_id,
|
user_id: params.user_id,
|
||||||
},
|
},
|
||||||
@@ -125,3 +131,50 @@ pub async fn run(
|
|||||||
dry_run: params.dry_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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,58 +30,61 @@ pub async fn run(
|
|||||||
folder: String,
|
folder: String,
|
||||||
#[sqlx(rename = "type")]
|
#[sqlx(rename = "type")]
|
||||||
entry_type: String,
|
entry_type: String,
|
||||||
|
name: String,
|
||||||
version: i64,
|
version: i64,
|
||||||
action: String,
|
action: String,
|
||||||
tags: Vec<String>,
|
tags: Vec<String>,
|
||||||
metadata: Value,
|
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(
|
sqlx::query_as(
|
||||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
"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(entry_id)
|
||||||
.bind(uid)
|
.bind(uid)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(&mut *tx)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
|
"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)
|
.bind(entry_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(&mut *tx)
|
||||||
.await?
|
.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 {
|
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT folder, type, version, action, tags, metadata \
|
"SELECT folder, type, name, version, action, tags, metadata \
|
||||||
FROM entries_history \
|
FROM entries_history \
|
||||||
WHERE entry_id = $1 AND version = $2 ORDER BY id ASC LIMIT 1",
|
WHERE entry_id = $1 AND version = $2 ORDER BY id ASC LIMIT 1",
|
||||||
)
|
)
|
||||||
.bind(entry_id)
|
.bind(entry_id)
|
||||||
.bind(ver)
|
.bind(ver)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(&mut *tx)
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as(
|
sqlx::query_as(
|
||||||
"SELECT folder, type, version, action, tags, metadata \
|
"SELECT folder, type, name, version, action, tags, metadata \
|
||||||
FROM entries_history \
|
FROM entries_history \
|
||||||
WHERE entry_id = $1 ORDER BY id DESC LIMIT 1",
|
WHERE entry_id = $1 ORDER BY id DESC LIMIT 1",
|
||||||
)
|
)
|
||||||
.bind(entry_id)
|
.bind(entry_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(&mut *tx)
|
||||||
.await?
|
.await?
|
||||||
};
|
};
|
||||||
|
|
||||||
let snap = snap.ok_or_else(|| {
|
let snap = snap.ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
"No history found for entry '{}'{}.",
|
"No history found for entry '{}'{}.",
|
||||||
live_entry.name,
|
lr.name,
|
||||||
to_version
|
to_version
|
||||||
.map(|v| format!(" at version {}", v))
|
.map(|v| format!(" at version {}", v))
|
||||||
.unwrap_or_default()
|
.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_secret_snapshot = db::entry_secret_snapshot_from_metadata(&snap.metadata);
|
||||||
let snap_metadata = db::strip_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_entry_id = {
|
||||||
|
|
||||||
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 history_metadata =
|
let history_metadata =
|
||||||
match db::metadata_with_secret_snapshot(&mut tx, lr.id, &lr.metadata).await {
|
match db::metadata_with_secret_snapshot(&mut tx, lr.id, &lr.metadata).await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
@@ -168,8 +161,8 @@ pub async fn run(
|
|||||||
)
|
)
|
||||||
.bind(&snap.folder)
|
.bind(&snap.folder)
|
||||||
.bind(&snap.entry_type)
|
.bind(&snap.entry_type)
|
||||||
.bind(&live_entry.name)
|
.bind(&snap.name)
|
||||||
.bind(&live_entry.notes)
|
.bind(&lr.notes)
|
||||||
.bind(&snap.tags)
|
.bind(&snap.tags)
|
||||||
.bind(&snap_metadata)
|
.bind(&snap_metadata)
|
||||||
.bind(lr.id)
|
.bind(lr.id)
|
||||||
@@ -177,8 +170,6 @@ pub async fn run(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
lr.id
|
lr.id
|
||||||
} else {
|
|
||||||
return Err(AppError::NotFoundEntry.into());
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(secret_snapshot) = snap_secret_snapshot {
|
if let Some(secret_snapshot) = snap_secret_snapshot {
|
||||||
@@ -191,7 +182,7 @@ pub async fn run(
|
|||||||
"rollback",
|
"rollback",
|
||||||
&snap.folder,
|
&snap.folder,
|
||||||
&snap.entry_type,
|
&snap.entry_type,
|
||||||
&live_entry.name,
|
&snap.name,
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"entry_id": entry_id,
|
"entry_id": entry_id,
|
||||||
"restored_version": snap.version,
|
"restored_version": snap.version,
|
||||||
@@ -203,7 +194,7 @@ pub async fn run(
|
|||||||
tx.commit().await?;
|
tx.commit().await?;
|
||||||
|
|
||||||
Ok(RollbackResult {
|
Ok(RollbackResult {
|
||||||
name: live_entry.name,
|
name: snap.name,
|
||||||
folder: snap.folder,
|
folder: snap.folder,
|
||||||
entry_type: snap.entry_type,
|
entry_type: snap.entry_type,
|
||||||
restored_version: snap.version,
|
restored_version: snap.version,
|
||||||
|
|||||||
20
crates/secrets-mcp/CHANGELOG.md
Normal file
20
crates/secrets-mcp/CHANGELOG.md
Normal 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
|
||||||
|
|
||||||
|
- Dashboard(MCP)页脚版本旁增加「变更记录」链接,打开本变更说明页。
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.5.19"
|
version = "0.5.24"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
@@ -45,3 +45,4 @@ urlencoding = "2"
|
|||||||
schemars = "1"
|
schemars = "1"
|
||||||
http = "1"
|
http = "1"
|
||||||
url = "2"
|
url = "2"
|
||||||
|
pulldown-cmark = "0.13.3"
|
||||||
|
|||||||
@@ -345,15 +345,6 @@ impl SecretsService {
|
|||||||
Self::extract_enc_key(ctx)
|
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
|
/// Require both user_id and encryption key, preferring an explicit argument
|
||||||
/// value over the X-Encryption-Key header.
|
/// value over the X-Encryption-Key header.
|
||||||
fn require_user_and_key_or_arg(
|
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)
|
let total_count = secrets_core::service::search::count_entries(&self.pool, &count_params)
|
||||||
.await
|
.await
|
||||||
.inspect_err(
|
.map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
|
||||||
|e| tracing::warn!(tool = "secrets_find", error = %e, "count_entries failed"),
|
|
||||||
)
|
|
||||||
.unwrap_or(0);
|
|
||||||
let relation_map = get_relations_for_entries(
|
let relation_map = get_relations_for_entries(
|
||||||
&self.pool,
|
&self.pool,
|
||||||
&result
|
&result
|
||||||
@@ -1135,11 +1123,8 @@ impl SecretsService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
|
.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 {
|
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
|
.await
|
||||||
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
|
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
|
||||||
}
|
}
|
||||||
@@ -1420,7 +1405,7 @@ impl SecretsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[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. \
|
Omit to_version to restore the most recent snapshot. \
|
||||||
Optionally pass 'id' (from secrets_find) to target directly.",
|
Optionally pass 'id' (from secrets_find) to target directly.",
|
||||||
annotations(title = "Rollback Secret Entry", destructive_hint = true)
|
annotations(title = "Rollback Secret Entry", destructive_hint = true)
|
||||||
@@ -1431,7 +1416,7 @@ impl SecretsService {
|
|||||||
ctx: RequestContext<RoleServer>,
|
ctx: RequestContext<RoleServer>,
|
||||||
) -> Result<CallToolResult, rmcp::ErrorData> {
|
) -> Result<CallToolResult, rmcp::ErrorData> {
|
||||||
let t = Instant::now();
|
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!(
|
tracing::info!(
|
||||||
tool = "secrets_rollback",
|
tool = "secrets_rollback",
|
||||||
?user_id,
|
?user_id,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use secrets_core::service::{
|
|||||||
|
|
||||||
use crate::AppState;
|
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)]
|
#[derive(Template)]
|
||||||
#[template(path = "dashboard.html")]
|
#[template(path = "dashboard.html")]
|
||||||
@@ -92,17 +92,11 @@ pub(super) async fn api_key_salt(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> Result<Json<KeySaltResponse>, StatusCode> {
|
) -> Result<Json<KeySaltResponse>, StatusCode> {
|
||||||
let user_id = current_user_id(&session)
|
let user = match load_session_user_strict(&state.pool, &session).await {
|
||||||
.await
|
Ok(Some(u)) => u,
|
||||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
|
||||||
|
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
let user = get_user_by_id(&state.pool, user_id)
|
};
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
tracing::error!(error = %e, %user_id, "failed to load user for key-salt API");
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?
|
|
||||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
||||||
|
|
||||||
if user.key_salt.is_none() {
|
if user.key_salt.is_none() {
|
||||||
return Ok(Json(KeySaltResponse {
|
return Ok(Json(KeySaltResponse {
|
||||||
@@ -126,19 +120,14 @@ pub(super) async fn api_key_setup(
|
|||||||
session: Session,
|
session: Session,
|
||||||
Json(body): Json<KeySetupRequest>,
|
Json(body): Json<KeySetupRequest>,
|
||||||
) -> Result<Json<KeySetupResponse>, StatusCode> {
|
) -> Result<Json<KeySetupResponse>, StatusCode> {
|
||||||
let user_id = current_user_id(&session)
|
let user = match load_session_user_strict(&state.pool, &session).await {
|
||||||
.await
|
Ok(Some(u)) => u,
|
||||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
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
|
// 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() {
|
if user.key_salt.is_some() {
|
||||||
tracing::warn!(%user_id, "key-setup called but passphrase already configured; use /api/key-change");
|
tracing::warn!(%user_id, "key-setup called but passphrase already configured; use /api/key-change");
|
||||||
return Err(StatusCode::CONFLICT);
|
return Err(StatusCode::CONFLICT);
|
||||||
@@ -175,17 +164,12 @@ pub(super) async fn api_key_change(
|
|||||||
session: Session,
|
session: Session,
|
||||||
Json(body): Json<KeyChangeRequest>,
|
Json(body): Json<KeyChangeRequest>,
|
||||||
) -> Result<Json<KeySetupResponse>, StatusCode> {
|
) -> Result<Json<KeySetupResponse>, StatusCode> {
|
||||||
let user_id = current_user_id(&session)
|
let user = match load_session_user_strict(&state.pool, &session).await {
|
||||||
.await
|
Ok(Some(u)) => u,
|
||||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
|
||||||
|
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||||
let user = get_user_by_id(&state.pool, user_id)
|
};
|
||||||
.await
|
let user_id = user.id;
|
||||||
.map_err(|e| {
|
|
||||||
tracing::error!(error = %e, %user_id, "failed to load user for key-change");
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?
|
|
||||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
|
||||||
|
|
||||||
// Must have an existing passphrase to change
|
// Must have an existing passphrase to change
|
||||||
let existing_key_check = user.key_check.ok_or_else(|| {
|
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>,
|
State(state): State<AppState>,
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> Result<Json<ApiKeyResponse>, StatusCode> {
|
) -> Result<Json<ApiKeyResponse>, StatusCode> {
|
||||||
let user_id = current_user_id(&session)
|
let user = match load_session_user_strict(&state.pool, &session).await {
|
||||||
.await
|
Ok(Some(u)) => u,
|
||||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
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| {
|
let api_key = ensure_api_key(&state.pool, user_id).await.map_err(|e| {
|
||||||
tracing::error!(error = %e, %user_id, "ensure_api_key failed");
|
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>,
|
State(state): State<AppState>,
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> Result<Json<ApiKeyResponse>, StatusCode> {
|
) -> Result<Json<ApiKeyResponse>, StatusCode> {
|
||||||
let user_id = current_user_id(&session)
|
let user = match load_session_user_strict(&state.pool, &session).await {
|
||||||
.await
|
Ok(Some(u)) => u,
|
||||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
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)
|
let api_key = regenerate_api_key(&state.pool, user_id)
|
||||||
.await
|
.await
|
||||||
|
|||||||
48
crates/secrets-mcp/src/web/changelog.rs
Normal file
48
crates/secrets-mcp/src/web/changelog.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,8 +25,8 @@ use secrets_core::service::{
|
|||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ENTRIES_PAGE_LIMIT, UiLang, current_user_id, paginate, render_template, request_ui_lang,
|
ENTRIES_PAGE_LIMIT, UiLang, paginate, render_template, request_ui_lang, require_valid_user,
|
||||||
require_valid_user, tr,
|
require_valid_user_json, tr,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Template types ────────────────────────────────────────────────────────────
|
// ── Template types ────────────────────────────────────────────────────────────
|
||||||
@@ -616,10 +616,8 @@ pub(super) async fn api_entry_patch(
|
|||||||
Json(body): Json<EntryPatchBody>,
|
Json(body): Json<EntryPatchBody>,
|
||||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||||
let lang = request_ui_lang(&headers);
|
let lang = request_ui_lang(&headers);
|
||||||
let user_id = current_user_id(&session).await.ok_or((
|
let user = require_valid_user_json(&state.pool, &session, lang).await?;
|
||||||
StatusCode::UNAUTHORIZED,
|
let user_id = user.id;
|
||||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let folder = body.folder.trim();
|
let folder = body.folder.trim();
|
||||||
let entry_type = body.entry_type.trim();
|
let entry_type = body.entry_type.trim();
|
||||||
@@ -635,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
|
let tags: Vec<String> = body
|
||||||
.tags
|
.tags
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -683,10 +746,8 @@ pub(super) async fn api_entry_options(
|
|||||||
Query(q): Query<EntryOptionQuery>,
|
Query(q): Query<EntryOptionQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||||
let lang = request_ui_lang(&headers);
|
let lang = request_ui_lang(&headers);
|
||||||
let user_id = current_user_id(&session).await.ok_or((
|
let user = require_valid_user_json(&state.pool, &session, lang).await?;
|
||||||
StatusCode::UNAUTHORIZED,
|
let user_id = user.id;
|
||||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let query =
|
let query =
|
||||||
q.q.as_deref()
|
q.q.as_deref()
|
||||||
@@ -738,10 +799,8 @@ pub(super) async fn api_entry_delete(
|
|||||||
Path(entry_id): Path<Uuid>,
|
Path(entry_id): Path<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||||
let lang = request_ui_lang(&headers);
|
let lang = request_ui_lang(&headers);
|
||||||
let user_id = current_user_id(&session).await.ok_or((
|
let user = require_valid_user_json(&state.pool, &session, lang).await?;
|
||||||
StatusCode::UNAUTHORIZED,
|
let user_id = user.id;
|
||||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
delete_by_id(&state.pool, entry_id, user_id)
|
delete_by_id(&state.pool, entry_id, user_id)
|
||||||
.await
|
.await
|
||||||
@@ -760,10 +819,8 @@ pub(super) async fn api_trash_restore(
|
|||||||
Path(entry_id): Path<Uuid>,
|
Path(entry_id): Path<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||||
let lang = request_ui_lang(&headers);
|
let lang = request_ui_lang(&headers);
|
||||||
let user_id = current_user_id(&session).await.ok_or((
|
let user = require_valid_user_json(&state.pool, &session, lang).await?;
|
||||||
StatusCode::UNAUTHORIZED,
|
let user_id = user.id;
|
||||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
restore_deleted_by_id(&state.pool, entry_id, user_id)
|
restore_deleted_by_id(&state.pool, entry_id, user_id)
|
||||||
.await
|
.await
|
||||||
@@ -782,10 +839,8 @@ pub(super) async fn api_trash_purge(
|
|||||||
Path(entry_id): Path<Uuid>,
|
Path(entry_id): Path<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||||
let lang = request_ui_lang(&headers);
|
let lang = request_ui_lang(&headers);
|
||||||
let user_id = current_user_id(&session).await.ok_or((
|
let user = require_valid_user_json(&state.pool, &session, lang).await?;
|
||||||
StatusCode::UNAUTHORIZED,
|
let user_id = user.id;
|
||||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
purge_deleted_by_id(&state.pool, entry_id, user_id)
|
purge_deleted_by_id(&state.pool, entry_id, user_id)
|
||||||
.await
|
.await
|
||||||
@@ -818,10 +873,8 @@ pub(super) async fn api_secret_check_name(
|
|||||||
Query(params): Query<SecretCheckNameQuery>,
|
Query(params): Query<SecretCheckNameQuery>,
|
||||||
) -> Result<Json<SecretCheckNameResponse>, EntryApiError> {
|
) -> Result<Json<SecretCheckNameResponse>, EntryApiError> {
|
||||||
let lang = request_ui_lang(&headers);
|
let lang = request_ui_lang(&headers);
|
||||||
let user_id = current_user_id(&session).await.ok_or((
|
let user = require_valid_user_json(&state.pool, &session, lang).await?;
|
||||||
StatusCode::UNAUTHORIZED,
|
let user_id = user.id;
|
||||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let name = params.name.trim();
|
let name = params.name.trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
@@ -914,10 +967,8 @@ pub(super) async fn api_secret_patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lang = request_ui_lang(&headers);
|
let lang = request_ui_lang(&headers);
|
||||||
let user_id = current_user_id(&session).await.ok_or((
|
let user = require_valid_user_json(&state.pool, &session, lang).await?;
|
||||||
StatusCode::UNAUTHORIZED,
|
let user_id = user.id;
|
||||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let name = body.name.as_ref().map(|s| s.trim());
|
let name = body.name.as_ref().map(|s| s.trim());
|
||||||
let secret_type = body.secret_type.as_ref().map(|s| s.trim());
|
let secret_type = body.secret_type.as_ref().map(|s| s.trim());
|
||||||
@@ -1123,10 +1174,8 @@ pub(super) async fn api_entry_secret_unlink(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lang = request_ui_lang(&headers);
|
let lang = request_ui_lang(&headers);
|
||||||
let user_id = current_user_id(&session).await.ok_or((
|
let user = require_valid_user_json(&state.pool, &session, lang).await?;
|
||||||
StatusCode::UNAUTHORIZED,
|
let user_id = user.id;
|
||||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let mut tx = state
|
let mut tx = state
|
||||||
.pool
|
.pool
|
||||||
@@ -1216,10 +1265,8 @@ pub(super) async fn api_entry_secrets_decrypt(
|
|||||||
Path(entry_id): Path<Uuid>,
|
Path(entry_id): Path<Uuid>,
|
||||||
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||||
let lang = request_ui_lang(&headers);
|
let lang = request_ui_lang(&headers);
|
||||||
let user_id = current_user_id(&session).await.ok_or((
|
let user = require_valid_user_json(&state.pool, &session, lang).await?;
|
||||||
StatusCode::UNAUTHORIZED,
|
let user_id = user.id;
|
||||||
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let master_key = require_encryption_key(&headers, lang)?;
|
let master_key = require_encryption_key(&headers, lang)?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Json, Router,
|
||||||
http::{HeaderMap, StatusCode, header},
|
http::{HeaderMap, StatusCode, header},
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
routing::{get, patch, post},
|
routing::{get, patch, post},
|
||||||
};
|
};
|
||||||
|
use serde_json::json;
|
||||||
use tower_sessions::Session;
|
use tower_sessions::Session;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ mod account;
|
|||||||
mod assets;
|
mod assets;
|
||||||
mod audit;
|
mod audit;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod changelog;
|
||||||
mod entries;
|
mod entries;
|
||||||
|
|
||||||
// ── Session keys ──────────────────────────────────────────────────────────────
|
// ── Session keys ──────────────────────────────────────────────────────────────
|
||||||
@@ -34,7 +36,7 @@ const AUDIT_PAGE_LIMIT: i64 = 10;
|
|||||||
// ── UI language ───────────────────────────────────────────────────────────────
|
// ── UI language ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum UiLang {
|
pub(super) enum UiLang {
|
||||||
ZhCn,
|
ZhCn,
|
||||||
ZhTw,
|
ZhTw,
|
||||||
En,
|
En,
|
||||||
@@ -143,6 +145,71 @@ async fn require_valid_user(
|
|||||||
Ok(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> {
|
fn request_user_agent(headers: &HeaderMap) -> Option<String> {
|
||||||
headers
|
headers
|
||||||
.get(header::USER_AGENT)
|
.get(header::USER_AGENT)
|
||||||
@@ -187,6 +254,7 @@ pub fn web_router() -> Router<AppState> {
|
|||||||
get(assets::oauth_protected_resource_metadata),
|
get(assets::oauth_protected_resource_metadata),
|
||||||
)
|
)
|
||||||
.route("/", get(auth::home_page))
|
.route("/", get(auth::home_page))
|
||||||
|
.route("/changelog", get(changelog::changelog_page))
|
||||||
.route("/login", get(auth::login_page))
|
.route("/login", get(auth::login_page))
|
||||||
.route("/auth/google", get(auth::auth_google))
|
.route("/auth/google", get(auth::auth_google))
|
||||||
.route("/auth/google/callback", get(auth::auth_google_callback))
|
.route("/auth/google/callback", get(auth::auth_google_callback))
|
||||||
|
|||||||
185
crates/secrets-mcp/templates/changelog.html
Normal file
185
crates/secrets-mcp/templates/changelog.html
Normal 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>
|
||||||
@@ -57,6 +57,8 @@
|
|||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
margin-top: auto;
|
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;
|
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
|
||||||
padding: 20px; width: 100%; }
|
padding: 20px; width: 100%; }
|
||||||
.card-title { font-size: 22px; font-weight: 700; margin-bottom: 24px; color: #fff; }
|
.card-title { font-size: 22px; font-weight: 700; margin-bottom: 24px; color: #fff; }
|
||||||
@@ -288,7 +290,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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><!-- /main -->
|
||||||
</div><!-- /content-shell -->
|
</div><!-- /content-shell -->
|
||||||
</div><!-- /layout -->
|
</div><!-- /layout -->
|
||||||
@@ -379,6 +381,7 @@ const T = {
|
|||||||
regenFailed: '重置失败,请刷新页面重试。',
|
regenFailed: '重置失败,请刷新页面重试。',
|
||||||
ariaShowPw: '显示密码',
|
ariaShowPw: '显示密码',
|
||||||
ariaHidePw: '隐藏密码',
|
ariaHidePw: '隐藏密码',
|
||||||
|
changelogLink: '变更记录',
|
||||||
},
|
},
|
||||||
'zh-TW': {
|
'zh-TW': {
|
||||||
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
|
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
|
||||||
@@ -417,6 +420,7 @@ const T = {
|
|||||||
regenFailed: '重置失敗,請重新整理頁面再試。',
|
regenFailed: '重置失敗,請重新整理頁面再試。',
|
||||||
ariaShowPw: '顯示密碼',
|
ariaShowPw: '顯示密碼',
|
||||||
ariaHidePw: '隱藏密碼',
|
ariaHidePw: '隱藏密碼',
|
||||||
|
changelogLink: '變更記錄',
|
||||||
},
|
},
|
||||||
'en': {
|
'en': {
|
||||||
navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit',
|
navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit',
|
||||||
@@ -455,6 +459,7 @@ const T = {
|
|||||||
regenFailed: 'Reset failed. Please refresh and try again.',
|
regenFailed: 'Reset failed. Please refresh and try again.',
|
||||||
ariaShowPw: 'Show password',
|
ariaShowPw: 'Show password',
|
||||||
ariaHidePw: 'Hide password',
|
ariaHidePw: 'Hide password',
|
||||||
|
changelogLink: 'Changelog',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -178,10 +178,8 @@
|
|||||||
<a href="/llms.txt">llms.txt</a>
|
<a href="/llms.txt">llms.txt</a>
|
||||||
<span data-i18n="sep"> · </span>
|
<span data-i18n="sep"> · </span>
|
||||||
<a href="https://gitea.refining.dev/refining/secrets" target="_blank" rel="noopener noreferrer" data-i18n="footRepo">源码仓库</a>
|
<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>
|
<span data-i18n="sep"> · </span>
|
||||||
<a href="/login" data-i18n="footLogin">登录</a>
|
<a href="/changelog" data-i18n="footChangelog">变更记录</a>
|
||||||
{% endif %}
|
|
||||||
</footer>
|
</footer>
|
||||||
<script>
|
<script>
|
||||||
const T = {
|
const T = {
|
||||||
@@ -200,7 +198,7 @@
|
|||||||
versionLabel: '版本',
|
versionLabel: '版本',
|
||||||
sep: ' · ',
|
sep: ' · ',
|
||||||
footRepo: '源码仓库',
|
footRepo: '源码仓库',
|
||||||
footLogin: '登录',
|
footChangelog: '变更记录',
|
||||||
},
|
},
|
||||||
'zh-TW': {
|
'zh-TW': {
|
||||||
docTitle: 'Secrets MCP — 端到端加密的金鑰管理',
|
docTitle: 'Secrets MCP — 端到端加密的金鑰管理',
|
||||||
@@ -217,7 +215,7 @@
|
|||||||
versionLabel: '版本',
|
versionLabel: '版本',
|
||||||
sep: ' · ',
|
sep: ' · ',
|
||||||
footRepo: '原始碼倉庫',
|
footRepo: '原始碼倉庫',
|
||||||
footLogin: '登入',
|
footChangelog: '變更記錄',
|
||||||
},
|
},
|
||||||
'en': {
|
'en': {
|
||||||
docTitle: 'Secrets MCP — End-to-end encrypted secrets',
|
docTitle: 'Secrets MCP — End-to-end encrypted secrets',
|
||||||
@@ -234,7 +232,7 @@
|
|||||||
versionLabel: 'Version',
|
versionLabel: 'Version',
|
||||||
sep: ' · ',
|
sep: ' · ',
|
||||||
footRepo: 'Source repository',
|
footRepo: 'Source repository',
|
||||||
footLogin: 'Sign in',
|
footChangelog: 'Changelog',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
201
plans/code-review-fixes-2026-04-11.md
Normal file
201
plans/code-review-fixes-2026-04-11.md
Normal 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`。
|
||||||
143
plans/merge-code-review-fixes-2026-04-11.md
Normal file
143
plans/merge-code-review-fixes-2026-04-11.md
Normal 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` 为基础继续补测试,而不是尝试把两份实现整合成第三套逻辑。这样改动面最小,风险也最低。
|
||||||
Reference in New Issue
Block a user