Compare commits
4 Commits
secrets-mc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f86d12b80e | ||
|
|
43d6164a15 | ||
|
|
1b2fbdae4d | ||
|
|
ab1e3329b9 |
21
AGENTS.md
21
AGENTS.md
@@ -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、Dashboard;CHANGELOG.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,10 +166,28 @@ 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` 版本更新该文件即可。
|
||||
|
||||
### Google OAuth 出站 HTTP
|
||||
|
||||
换 token(`POST https://oauth2.googleapis.com/token`)与拉取 userinfo 使用工作区 **`reqwest`**。根目录 `Cargo.toml` 中为 `reqwest` 启用了 **`system-proxy`**(因 `default-features = false` 须显式打开),以便在 **macOS / Windows** 上读取**系统代理**,避免「浏览器能上 Google、服务端换 token 超时」这类代理不一致。若仅提供端口代理、系统代理未生效,可设 **`HTTPS_PROXY` / `NO_PROXY`**,见 `deploy/.env.example`。
|
||||
|
||||
### 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 视图下区分条目所属隔离空间。
|
||||
|
||||
筛选栏支持查询参数 **`tags`**(逗号分隔,多标签 **AND**,语义同 `SearchParams.tags` / `tags @> ARRAY[...]`);分页与 folder 标签计数与当前筛选一致。
|
||||
|
||||
### 导出 / 导入文件
|
||||
|
||||
JSON/TOML/YAML 导出可在每条目上包含 `secret_types`(secret 名 → `text` / `password` / `key` 等),导入时写回 `secrets.type`;**旧版导出无该字段**时导入仍成功,类型按 **`text`** 默认。
|
||||
|
||||
### 共享密钥(N:N 关联)
|
||||
|
||||
多个 entry 可共享同一 secret 字段,通过 `entry_secrets` 中间表关联。
|
||||
|
||||
87
Cargo.lock
generated
87
Cargo.lock
generated
@@ -356,6 +356,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -740,6 +750,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"
|
||||
@@ -1016,9 +1035,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1578,6 +1599,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 +2105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "secrets-mcp"
|
||||
version = "0.5.21"
|
||||
version = "0.5.27"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"askama",
|
||||
@@ -2075,6 +2115,7 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"governor",
|
||||
"http",
|
||||
"pulldown-cmark",
|
||||
"rand 0.10.0",
|
||||
"reqwest",
|
||||
"rmcp",
|
||||
@@ -2582,6 +2623,27 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -2985,6 +3047,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 +3080,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"
|
||||
@@ -3337,6 +3411,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
|
||||
@@ -36,4 +36,5 @@ tracing-subscriber = { version = "^0.3", features = ["env-filter"] }
|
||||
dotenvy = "^0.15"
|
||||
|
||||
# HTTP
|
||||
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
# system-proxy:与浏览器一致,读取 macOS/Windows 系统代理(禁用 default 后须显式开启,否则 OAuth 出站不走 Clash 等)
|
||||
reqwest = { version = "^0.12", default-features = false, features = ["rustls-tls", "json", "system-proxy"] }
|
||||
|
||||
11
README.md
11
README.md
@@ -23,7 +23,8 @@ cargo build --release -p secrets-mcp
|
||||
| `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式(`prefer`、`disable`、`allow`、`require`)。 |
|
||||
| `BASE_URL` | 对外访问基址;OAuth 回调为 `{BASE_URL}/auth/google/callback`。默认 `http://localhost:9315`。 |
|
||||
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`。容器内或直接对外暴露端口时请改为 `0.0.0.0:9315`;反代时常为 `127.0.0.1:9315`。 |
|
||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。 |
|
||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;不配置则无 Google 登录入口。运行时从环境读取,勿写入 CI、勿打入二进制。换 token 须访问 `oauth2.googleapis.com`:工作区 **`reqwest` 已启用 `system-proxy`**,与浏览器一致可走 macOS/Windows **系统代理**(如 Clash 系统代理模式)。 |
|
||||
| `HTTPS_PROXY` / `NO_PROXY` | 可选。仅当系统代理未被进程识别、又需走本地端口代理时设置;示例见 [`deploy/.env.example`](deploy/.env.example)。 |
|
||||
| `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 |
|
||||
| `SECRETS_DATABASE_POOL_SIZE` | 可选。连接池最大连接数,默认 `10`。 |
|
||||
| `SECRETS_DATABASE_ACQUIRE_TIMEOUT` | 可选。获取连接超时秒数,默认 `5`。 |
|
||||
@@ -46,7 +47,7 @@ SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
|
||||
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 标签与条件筛选(含 **`tags`** 逗号分隔、多标签同时匹配);表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。
|
||||
- **MCP**:Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)。
|
||||
|
||||
## PostgreSQL TLS 加固
|
||||
@@ -72,9 +73,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 +227,7 @@ crates/secrets-core/ # db / crypto / models / audit / service
|
||||
src/
|
||||
taxonomy.rs # SECRET_TYPE_OPTIONS(secret 字段类型下拉选项)
|
||||
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/
|
||||
release-check.sh # 发版前 fmt / clippy / test
|
||||
setup-gitea-actions.sh
|
||||
|
||||
38
crates/secrets-mcp/CHANGELOG.md
Normal file
38
crates/secrets-mcp/CHANGELOG.md
Normal file
@@ -0,0 +1,38 @@
|
||||
本文档在构建时嵌入 Web 的 `/changelog` 页面,并由服务端渲染为 HTML。
|
||||
|
||||
## [0.5.27] - 2026-04-11
|
||||
|
||||
### Added
|
||||
|
||||
- Web **`/entries`**:按 **tags** 筛选(逗号分隔、trim、多标签 **AND** 语义,与 `SearchParams` / MCP 一致);folder 标签计数、分页与筛选栏状态同步保留 `tags`。
|
||||
|
||||
## [0.5.26] - 2026-04-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Google OAuth**:工作区 `reqwest` 此前关闭默认特性且未启用 **`system-proxy`**,进程不读取 macOS/Windows 系统代理,易出现与浏览器不一致(本机可上 Google 但换 token 超时)。已显式启用 `system-proxy`。
|
||||
|
||||
## [0.5.25] - 2026-04-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Google OAuth:token / userinfo 请求单独 **45s** 超时(避免仅触达默认客户端 15s);失败时区分超时、连接错误,并在非 2xx 时记录/返回 Google 响应体片段(如 `invalid_grant`、`redirect_uri_mismatch`)。
|
||||
|
||||
## [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]
|
||||
name = "secrets-mcp"
|
||||
version = "0.5.21"
|
||||
version = "0.5.27"
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
@@ -45,3 +45,4 @@ urlencoding = "2"
|
||||
schemars = "1"
|
||||
http = "1"
|
||||
url = "2"
|
||||
pulldown-cmark = "0.13.3"
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{OAuthConfig, OAuthUserInfo};
|
||||
|
||||
/// OAuth token / userinfo calls can be slow on poor routes; keep above client default if needed.
|
||||
const OAUTH_HTTP_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
@@ -20,14 +25,28 @@ struct UserInfo {
|
||||
picture: Option<String>,
|
||||
}
|
||||
|
||||
fn map_reqwest_send_err(e: reqwest::Error) -> anyhow::Error {
|
||||
if e.is_timeout() {
|
||||
anyhow::anyhow!(
|
||||
"timeout reaching Google OAuth ({}s); ensure outbound HTTPS to oauth2.googleapis.com works (firewall/proxy/VPN if Google is unreachable)",
|
||||
OAUTH_HTTP_TIMEOUT.as_secs()
|
||||
)
|
||||
} else if e.is_connect() {
|
||||
anyhow::anyhow!("connection error to Google OAuth: {e}")
|
||||
} else {
|
||||
anyhow::Error::new(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange authorization code for tokens and fetch user profile.
|
||||
pub async fn exchange_code(
|
||||
client: &reqwest::Client,
|
||||
config: &OAuthConfig,
|
||||
code: &str,
|
||||
) -> Result<OAuthUserInfo> {
|
||||
let token_resp: TokenResponse = client
|
||||
let token_http = client
|
||||
.post("https://oauth2.googleapis.com/token")
|
||||
.timeout(OAUTH_HTTP_TIMEOUT)
|
||||
.form(&[
|
||||
("code", code),
|
||||
("client_id", &config.client_id),
|
||||
@@ -37,24 +56,55 @@ pub async fn exchange_code(
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.context("failed to exchange Google code")?
|
||||
.error_for_status()
|
||||
.context("Google token endpoint error")?
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse Google token response")?;
|
||||
.map_err(map_reqwest_send_err)
|
||||
.context("Google token HTTP request failed")?;
|
||||
|
||||
let user: UserInfo = client
|
||||
let status = token_http.status();
|
||||
let body_bytes = token_http
|
||||
.bytes()
|
||||
.await
|
||||
.context("read Google token response body")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let body_lossy = String::from_utf8_lossy(&body_bytes);
|
||||
tracing::warn!(%status, body = %body_lossy, "Google token endpoint error");
|
||||
anyhow::bail!(
|
||||
"Google token error {}: {}",
|
||||
status,
|
||||
body_lossy.chars().take(512).collect::<String>()
|
||||
);
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse =
|
||||
serde_json::from_slice(&body_bytes).context("failed to parse Google token JSON")?;
|
||||
|
||||
let user_http = client
|
||||
.get("https://openidconnect.googleapis.com/v1/userinfo")
|
||||
.timeout(OAUTH_HTTP_TIMEOUT)
|
||||
.bearer_auth(&token_resp.access_token)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to fetch Google userinfo")?
|
||||
.error_for_status()
|
||||
.context("Google userinfo endpoint error")?
|
||||
.json()
|
||||
.map_err(map_reqwest_send_err)
|
||||
.context("Google userinfo HTTP request failed")?;
|
||||
|
||||
let status = user_http.status();
|
||||
let body_bytes = user_http
|
||||
.bytes()
|
||||
.await
|
||||
.context("failed to parse Google userinfo")?;
|
||||
.context("read Google userinfo body")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let body_lossy = String::from_utf8_lossy(&body_bytes);
|
||||
tracing::warn!(%status, body = %body_lossy, "Google userinfo endpoint error");
|
||||
anyhow::bail!(
|
||||
"Google userinfo error {}: {}",
|
||||
status,
|
||||
body_lossy.chars().take(512).collect::<String>()
|
||||
);
|
||||
}
|
||||
|
||||
let user: UserInfo =
|
||||
serde_json::from_slice(&body_bytes).context("failed to parse Google userinfo JSON")?;
|
||||
|
||||
Ok(OAuthUserInfo {
|
||||
provider: "google".to_string(),
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ struct EntriesPageTemplate {
|
||||
filter_folder: String,
|
||||
filter_name: String,
|
||||
filter_metadata_query: String,
|
||||
filter_tags: String,
|
||||
filter_type: String,
|
||||
current_page: u32,
|
||||
total_pages: u32,
|
||||
@@ -125,6 +126,8 @@ pub(super) struct EntriesQuery {
|
||||
/// URL query key is `type` (maps to DB column `entries.type`).
|
||||
#[serde(rename = "type")]
|
||||
entry_type: Option<String>,
|
||||
/// Comma-separated tags (AND semantics); matches `SearchParams.tags`.
|
||||
tags: Option<String>,
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -252,6 +255,18 @@ fn relation_views(items: &[RelationEntrySummary]) -> Vec<RelationSummaryView> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse Web `tags` query: comma-separated, trim, drop empties (AND semantics via `SearchParams`).
|
||||
fn parse_tags_filter(raw: Option<&str>) -> Vec<String> {
|
||||
let Some(s) = raw else {
|
||||
return Vec::new();
|
||||
};
|
||||
s.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub(super) async fn entries_page(
|
||||
@@ -289,13 +304,15 @@ pub(super) async fn entries_page(
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
let filter_tags = q.tags.clone().unwrap_or_default();
|
||||
let tag_vec = parse_tags_filter(q.tags.as_deref());
|
||||
let page = q.page.unwrap_or(1).max(1);
|
||||
let count_params = SearchParams {
|
||||
folder: folder_filter.as_deref(),
|
||||
entry_type: type_filter.as_deref(),
|
||||
name: None,
|
||||
name_query: name_filter.as_deref(),
|
||||
tags: &[],
|
||||
tags: tag_vec.as_slice(),
|
||||
query: None,
|
||||
metadata_query: metadata_query_filter.as_deref(),
|
||||
sort: "updated",
|
||||
@@ -328,6 +345,16 @@ pub(super) async fn entries_page(
|
||||
));
|
||||
bind_idx += 1;
|
||||
}
|
||||
if !tag_vec.is_empty() {
|
||||
let placeholders: Vec<String> = (0..tag_vec.len())
|
||||
.map(|i| format!("${}", bind_idx + i as i32))
|
||||
.collect();
|
||||
folder_sql.push_str(&format!(
|
||||
" AND tags @> ARRAY[{}]::text[]",
|
||||
placeholders.join(", ")
|
||||
));
|
||||
bind_idx += tag_vec.len() as i32;
|
||||
}
|
||||
let _ = bind_idx;
|
||||
folder_sql.push_str(" GROUP BY folder ORDER BY folder");
|
||||
let mut folder_query = sqlx::query_as::<_, FolderCountRow>(&folder_sql).bind(user_id);
|
||||
@@ -340,6 +367,9 @@ pub(super) async fn entries_page(
|
||||
if let Some(v) = metadata_query_filter.as_deref() {
|
||||
folder_query = folder_query.bind(ilike_pattern(v));
|
||||
}
|
||||
for t in &tag_vec {
|
||||
folder_query = folder_query.bind(t);
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TypeOptionRow {
|
||||
@@ -414,6 +444,7 @@ pub(super) async fn entries_page(
|
||||
entry_type: Option<&str>,
|
||||
name: Option<&str>,
|
||||
metadata_query: Option<&str>,
|
||||
tags: Option<&str>,
|
||||
page: Option<u32>,
|
||||
) -> String {
|
||||
let mut pairs: Vec<String> = Vec::new();
|
||||
@@ -437,6 +468,11 @@ pub(super) async fn entries_page(
|
||||
{
|
||||
pairs.push(format!("metadata_query={}", urlencoding::encode(v)));
|
||||
}
|
||||
if let Some(tg) = tags
|
||||
&& !tg.is_empty()
|
||||
{
|
||||
pairs.push(format!("tags={}", urlencoding::encode(tg)));
|
||||
}
|
||||
if let Some(p) = page {
|
||||
pairs.push(format!("page={}", p));
|
||||
}
|
||||
@@ -447,6 +483,7 @@ pub(super) async fn entries_page(
|
||||
}
|
||||
}
|
||||
|
||||
let tags_for_href = (!filter_tags.is_empty()).then_some(filter_tags.as_str());
|
||||
let all_count: i64 = folder_rows.iter().map(|r| r.count).sum();
|
||||
let mut folder_tabs: Vec<FolderTabView> = Vec::with_capacity(folder_rows.len() + 1);
|
||||
folder_tabs.push(FolderTabView {
|
||||
@@ -457,6 +494,7 @@ pub(super) async fn entries_page(
|
||||
type_filter.as_deref(),
|
||||
name_filter.as_deref(),
|
||||
metadata_query_filter.as_deref(),
|
||||
tags_for_href,
|
||||
Some(1),
|
||||
),
|
||||
active: folder_filter.is_none(),
|
||||
@@ -469,6 +507,7 @@ pub(super) async fn entries_page(
|
||||
type_filter.as_deref(),
|
||||
name_filter.as_deref(),
|
||||
metadata_query_filter.as_deref(),
|
||||
tags_for_href,
|
||||
Some(1),
|
||||
),
|
||||
active: folder_filter.as_deref() == Some(name.as_str()),
|
||||
@@ -534,6 +573,7 @@ pub(super) async fn entries_page(
|
||||
filter_folder: folder_filter.unwrap_or_default(),
|
||||
filter_name: name_filter.unwrap_or_default(),
|
||||
filter_metadata_query: metadata_query_filter.unwrap_or_default(),
|
||||
filter_tags,
|
||||
filter_type: type_filter.unwrap_or_default(),
|
||||
current_page,
|
||||
total_pages,
|
||||
@@ -1302,3 +1342,19 @@ pub(super) async fn api_entry_secrets_decrypt(
|
||||
|
||||
Ok(Json(json!({ "ok": true, "secrets": secrets })))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tags_filter_tests {
|
||||
use super::parse_tags_filter;
|
||||
|
||||
#[test]
|
||||
fn parse_tags_comma_trim_skip_empty() {
|
||||
let v = parse_tags_filter(Some(" prod , aliyun ,, "));
|
||||
assert_eq!(v, vec!["prod".to_string(), "aliyun".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tags_none_empty() {
|
||||
assert!(parse_tags_filter(None).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ mod account;
|
||||
mod assets;
|
||||
mod audit;
|
||||
mod auth;
|
||||
mod changelog;
|
||||
mod entries;
|
||||
|
||||
// ── Session keys ──────────────────────────────────────────────────────────────
|
||||
@@ -253,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))
|
||||
|
||||
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;
|
||||
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',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -543,6 +543,10 @@
|
||||
<label for="filter-name" data-i18n="filterNameLabel">名称</label>
|
||||
<input id="filter-name" name="name" type="text" value="{{ filter_name }}" data-i18n-ph="filterNamePlaceholder" placeholder="输入关键字" autocomplete="off">
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label for="filter-tags" data-i18n="filterTagsLabel">标签</label>
|
||||
<input id="filter-tags" name="tags" type="text" value="{{ filter_tags }}" data-i18n-ph="filterTagsPlaceholder" placeholder="多个标签用逗号分隔" autocomplete="off">
|
||||
</div>
|
||||
<div class="filter-field">
|
||||
<label for="filter-metadata-query" data-i18n="filterMetadataLabel">元数据值</label>
|
||||
<input id="filter-metadata-query" name="metadata_query" type="text" value="{{ filter_metadata_query }}" data-i18n-ph="filterMetadataPlaceholder" placeholder="搜索元数据值" autocomplete="off">
|
||||
@@ -643,13 +647,13 @@
|
||||
{% if total_count > 0 %}
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
|
||||
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_tags.is_empty() %}tags={{ filter_tags | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
|
||||
{% else %}
|
||||
<span class="page-btn page-btn-disabled" data-i18n="prevPage">上一页</span>
|
||||
{% endif %}
|
||||
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
|
||||
{% if current_page < total_pages %}
|
||||
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
|
||||
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}{% if !filter_tags.is_empty() %}tags={{ filter_tags | urlencode }}&{% endif %}{% if !filter_metadata_query.is_empty() %}metadata_query={{ filter_metadata_query | urlencode }}&{% endif %}page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
|
||||
{% else %}
|
||||
<span class="page-btn page-btn-disabled" data-i18n="nextPage">下一页</span>
|
||||
{% endif %}
|
||||
@@ -713,6 +717,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
allTab: '全部',
|
||||
filterNameLabel: '名称',
|
||||
filterNamePlaceholder: '输入关键字',
|
||||
filterTagsLabel: '标签',
|
||||
filterTagsPlaceholder: '多个标签用逗号分隔',
|
||||
filterMetadataLabel: '元数据值',
|
||||
filterMetadataPlaceholder: '搜索元数据值',
|
||||
filterTypeLabel: '类型',
|
||||
@@ -799,6 +805,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
allTab: '全部',
|
||||
filterNameLabel: '名稱',
|
||||
filterNamePlaceholder: '輸入關鍵字',
|
||||
filterTagsLabel: '標籤',
|
||||
filterTagsPlaceholder: '多個標籤用逗號分隔',
|
||||
filterMetadataLabel: '中繼資料值',
|
||||
filterMetadataPlaceholder: '搜尋中繼資料值',
|
||||
filterTypeLabel: '類型',
|
||||
@@ -885,6 +893,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
||||
allTab: 'All',
|
||||
filterNameLabel: 'Name',
|
||||
filterNamePlaceholder: 'Enter keywords',
|
||||
filterTagsLabel: 'Tags',
|
||||
filterTagsPlaceholder: 'Comma-separated tags',
|
||||
filterMetadataLabel: 'Metadata value',
|
||||
filterMetadataPlaceholder: 'Search metadata values',
|
||||
filterTypeLabel: 'Type',
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -20,9 +20,14 @@ BASE_URL=https://secrets.example.com
|
||||
|
||||
# ─── Google OAuth ─────────────────────────────────────────────────────
|
||||
# Google Cloud Console → APIs & Services → Credentials
|
||||
# 授权回调 URI 须配置为:${BASE_URL}/auth/google/callback
|
||||
# 授权回调 URI 须与 BASE_URL 完全一致:${BASE_URL}/auth/google/callback(含 http/https、主机名、端口)
|
||||
# 运行 secrets-mcp 的机器须能访问 Google(oauth2.googleapis.com)。若本机用 Clash/Surge「系统代理」上网:
|
||||
# 构建时已启用 reqwest 的 system-proxy,进程会跟随系统代理;仍失败时可设 HTTPS_PROXY(见下方)。
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# 若仍无法换 token(仅提供端口代理、无系统代理):可取消注释并改为本机代理地址
|
||||
# HTTPS_PROXY=http://127.0.0.1:7890
|
||||
# NO_PROXY=localhost,127.0.0.1
|
||||
|
||||
# ─── 微信登录(暂未开放,预留)───────────────────────────────────────
|
||||
# WECHAT_APP_CLIENT_ID=
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
**日期**: 2026-04-11
|
||||
**来源**: 两个 AI 实现对比评估
|
||||
**比较对象**:
|
||||
|
||||
- `d7720662` (`/Users/voson/work/refining/secrets-cr-fixes-ws`)
|
||||
- `9f8a68cd` (`/Users/voson/work/refining/secrets/plan-impl`)
|
||||
|
||||
@@ -10,9 +11,10 @@
|
||||
|
||||
## 结论
|
||||
|
||||
以 **`d7720662`** 为主线采纳。
|
||||
以 `**d7720662`** 为主线采纳。
|
||||
|
||||
**原因**:
|
||||
|
||||
1. `rollback` 的 live row 加锁与 snapshot 读取都在事务内完成,更符合原计划里对 TOCTOU 的修复要求。
|
||||
2. Web JSON API 的 session 校验保留了按 `UiLang` 返回错误信息的行为,没有把错误消息退化成固定英文。
|
||||
3. `svc_add` 返回 `entry_id`,MCP 层直接使用返回值建立 parent relation,和计划第 5 项更一致。
|
||||
@@ -35,8 +37,7 @@
|
||||
仅吸收下面两处,手动改写,不直接整文件 cherry-pick:
|
||||
|
||||
1. `crates/secrets-mcp/src/web/entries.rs`
|
||||
- 把长度校验报错文案改成基于 `crate::validation::*` 常量拼接,避免上限数字硬编码在文案里。
|
||||
|
||||
- 把长度校验报错文案改成基于 `crate::validation::`* 常量拼接,避免上限数字硬编码在文案里。
|
||||
2. `crates/secrets-core/src/service/env_map.rs`
|
||||
- 补 `env_prefix_with_and_without_prefix` 单测。
|
||||
|
||||
@@ -47,18 +48,21 @@
|
||||
### 不采纳 `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 使用方式更贴近计划要求。
|
||||
|
||||
@@ -83,27 +87,21 @@
|
||||
|
||||
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 损坏 -> 正确错误
|
||||
@@ -126,13 +124,13 @@ cargo clippy --locked -- -D warnings
|
||||
cargo test --locked
|
||||
```
|
||||
|
||||
7. 跑发布前检查:
|
||||
1. 跑发布前检查:
|
||||
|
||||
```bash
|
||||
./scripts/release-check.sh
|
||||
```
|
||||
|
||||
8. 确认版本和 tag:
|
||||
1. 确认版本和 tag:
|
||||
- `crates/secrets-mcp/Cargo.toml` 已 bump(合并执行时为 `0.5.21`,因 `crates/**` 有变更)
|
||||
- `jj tag list`
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Add a new `metadata_query` filter to `SearchParams` that uses PostgreSQL `jsonb_
|
||||
|
||||
#### secrets-core
|
||||
|
||||
**`crates/secrets-core/src/service/search.rs`**
|
||||
`**crates/secrets-core/src/service/search.rs`**
|
||||
|
||||
- Add `metadata_query: Option<&'a str>` field to `SearchParams`
|
||||
- In `entry_where_clause_and_next_idx`, when `metadata_query` is set, add:
|
||||
@@ -42,7 +42,7 @@ EXISTS (
|
||||
|
||||
#### secrets-mcp (MCP tools)
|
||||
|
||||
**`crates/secrets-mcp/src/tools.rs`**
|
||||
`**crates/secrets-mcp/src/tools.rs**`
|
||||
|
||||
- Add `metadata_query` field to `FindInput`:
|
||||
|
||||
@@ -56,7 +56,7 @@ metadata_query: Option<String>,
|
||||
|
||||
#### secrets-mcp (Web)
|
||||
|
||||
**`crates/secrets-mcp/src/web/entries.rs`**
|
||||
`**crates/secrets-mcp/src/web/entries.rs**`
|
||||
|
||||
- Add `metadata_query: Option<String>` to `EntriesQuery`
|
||||
- Thread it into all `SearchParams` usages (count, list, folder counts)
|
||||
@@ -64,18 +64,20 @@ metadata_query: Option<String>,
|
||||
- Add `metadata_query` to `EntriesPageTemplate` and filter form hidden fields
|
||||
- Include `metadata_query` in pagination `href` links
|
||||
|
||||
**`crates/secrets-mcp/templates/entries.html`**
|
||||
`**crates/secrets-mcp/templates/entries.html**`
|
||||
|
||||
- Add a "metadata 值" text input to the filter bar (after name, before type)
|
||||
- Preserve value in the input on re-render
|
||||
|
||||
### i18n Keys
|
||||
|
||||
|
||||
| Key | zh | zh-Hant | en |
|
||||
|-----|-----|---------|-----|
|
||||
| ----------------------- | ------ | ------- | ---------------------- |
|
||||
| `filterMetaLabel` | 元数据值 | 元数据值 | Metadata value |
|
||||
| `filterMetaPlaceholder` | 搜索元数据值 | 搜尋元資料值 | Search metadata values |
|
||||
|
||||
|
||||
### Performance Notes
|
||||
|
||||
- The `jsonb_path_query` with `$.**` scans all nested values recursively; this is a sequential scan on the metadata column per row
|
||||
@@ -191,25 +193,23 @@ pub async fn get_relations_for_entries(
|
||||
) -> Result<HashMap<Uuid, Vec<RelationSummary>>>
|
||||
```
|
||||
|
||||
**`crates/secrets-core/src/service/mod.rs`** — add `pub mod relations;`
|
||||
`**crates/secrets-core/src/service/mod.rs**` — add `pub mod relations;`
|
||||
|
||||
**`crates/secrets-core/src/db.rs`** — add entry_relations table creation in `migrate()`
|
||||
`**crates/secrets-core/src/db.rs**` — add entry_relations table creation in `migrate()`
|
||||
|
||||
**`crates/secrets-core/src/error.rs`** — no new error variant needed; use `AppError::Validation { message }` for cycle detection and permission errors
|
||||
`**crates/secrets-core/src/error.rs**` — no new error variant needed; use `AppError::Validation { message }` for cycle detection and permission errors
|
||||
|
||||
### MCP Tool Changes
|
||||
|
||||
**`crates/secrets-mcp/src/tools.rs`**
|
||||
`**crates/secrets-mcp/src/tools.rs**`
|
||||
|
||||
1. **`secrets_add`** (`AddInput`): add optional `parent_ids: Option<Vec<String>>` field
|
||||
1. `**secrets_add**` (`AddInput`): add optional `parent_ids: Option<Vec<String>>` field
|
||||
- Description: "UUIDs of parent entries to link. Creates parent→child relations."
|
||||
- After creating the entry, call `relations::add_relation` for each parent
|
||||
|
||||
2. **`secrets_update`** (`UpdateInput`): add two fields:
|
||||
2. `**secrets_update**` (`UpdateInput`): add two fields:
|
||||
- `add_parent_ids: Option<Vec<String>>` — "UUIDs of parent entries to link"
|
||||
- `remove_parent_ids: Option<Vec<String>>` — "UUIDs of parent entries to unlink"
|
||||
|
||||
3. **`secrets_find`** and `secrets_search` output: add `parents` and `children` arrays to each entry result:
|
||||
3. `**secrets_find**` and `secrets_search` output: add `parents` and `children` arrays to each entry result:
|
||||
```json
|
||||
{
|
||||
"id": "...",
|
||||
@@ -222,27 +222,22 @@ pub async fn get_relations_for_entries(
|
||||
|
||||
### Web Changes
|
||||
|
||||
**`crates/secrets-mcp/src/web/entries.rs`**
|
||||
`**crates/secrets-mcp/src/web/entries.rs**`
|
||||
|
||||
1. **New API endpoints:**
|
||||
|
||||
- `POST /api/entries/{id}/relations` — add parent relation
|
||||
- Body: `{ "parent_id": "uuid" }`
|
||||
- Validates same-user ownership and cycle detection
|
||||
|
||||
- `DELETE /api/entries/{id}/relations/{parent_id}` — remove parent relation
|
||||
|
||||
- `GET /api/entries/options?q=xxx` — lightweight search for parent selection modal
|
||||
- Returns `[{ "id": "...", "name": "...", "folder": "...", "type": "..." }]`
|
||||
- Used by the edit dialog's parent selection autocomplete
|
||||
|
||||
2. **Entry list template data** — include parent/child counts per entry row
|
||||
|
||||
3. **`api_entry_patch`** — extend `EntryPatchBody` with optional `parent_ids: Option<Vec<Uuid>>`
|
||||
3. `**api_entry_patch`** — extend `EntryPatchBody` with optional `parent_ids: Option<Vec<Uuid>>`
|
||||
- When present, replace all parent relations for this entry with the given list
|
||||
- This is simpler than incremental add/remove in the Web UI context
|
||||
|
||||
**`crates/secrets-mcp/templates/entries.html`**
|
||||
`**crates/secrets-mcp/templates/entries.html**`
|
||||
|
||||
1. **List table**: add a "关联" (relations) column showing parent/child counts as clickable chips
|
||||
2. **Edit dialog**: add "上级条目" (parent entries) section
|
||||
@@ -255,8 +250,9 @@ pub async fn get_relations_for_entries(
|
||||
|
||||
### i18n Keys (Entry Relations)
|
||||
|
||||
|
||||
| Key | zh | zh-Hant | en |
|
||||
|-----|-----|---------|-----|
|
||||
| -------------------------- | ------------ | ------------ | -------------------------------- |
|
||||
| `colRelations` | 关联 | 關聯 | Relations |
|
||||
| `parentEntriesLabel` | 上级条目 | 上級條目 | Parent entries |
|
||||
| `childrenEntriesLabel` | 下级条目 | 下級條目 | Child entries |
|
||||
@@ -267,6 +263,7 @@ pub async fn get_relations_for_entries(
|
||||
| `noChildren` | 无下级 | 無下級 | No children |
|
||||
| `relationCycleError` | 无法添加:会形成循环引用 | 無法新增:會形成循環引用 | Cannot add: would create a cycle |
|
||||
|
||||
|
||||
### Audit Logging
|
||||
|
||||
Log relation changes in the existing `audit::log_tx` system:
|
||||
@@ -276,7 +273,7 @@ Log relation changes in the existing `audit::log_tx` system:
|
||||
|
||||
### Export / Import
|
||||
|
||||
**`ExportEntry`** — add optional `parents: Vec<ParentRef>` where:
|
||||
`**ExportEntry`** — add optional `parents: Vec<ParentRef>` where:
|
||||
|
||||
```rust
|
||||
pub struct ParentRef {
|
||||
@@ -368,25 +365,28 @@ This is idempotent (uses `IF NOT EXISTS`) and will run automatically on next sta
|
||||
## Testing Checklist
|
||||
|
||||
### Metadata Search
|
||||
- [ ] `metadata_query=1.2.3.4` matches entries where any metadata value contains "1.2.3.4"
|
||||
- [ ] `metadata_query=1.2.3.4` does NOT match entries where only the key contains "1.2.3.4"
|
||||
- [ ] `metadata_query` works with nested metadata (e.g. `{"server": {"ip": "1.2.3.4"}}`)
|
||||
- [ ] `metadata_query` combined with `folder`/`type`/`tags` filters works correctly
|
||||
- [ ] `metadata_query` with special characters (`%`, `_`) is properly escaped
|
||||
- [ ] Existing `query` parameter behavior is unchanged
|
||||
- [ ] Web filter bar preserves `metadata_query` across pagination and folder tab clicks
|
||||
|
||||
- `metadata_query=1.2.3.4` matches entries where any metadata value contains "1.2.3.4"
|
||||
- `metadata_query=1.2.3.4` does NOT match entries where only the key contains "1.2.3.4"
|
||||
- `metadata_query` works with nested metadata (e.g. `{"server": {"ip": "1.2.3.4"}}`)
|
||||
- `metadata_query` combined with `folder`/`type`/`tags` filters works correctly
|
||||
- `metadata_query` with special characters (`%`, `_`) is properly escaped
|
||||
- Existing `query` parameter behavior is unchanged
|
||||
- Web filter bar preserves `metadata_query` across pagination and folder tab clicks
|
||||
|
||||
### Entry Relations
|
||||
- [ ] Can add a parent→child relation between two entries
|
||||
- [ ] Can add multiple parents to a single entry
|
||||
- [ ] Cannot add self-referencing relation (CHECK constraint)
|
||||
- [ ] Cannot create a direct cycle (A→B→A)
|
||||
- [ ] Cannot create an indirect cycle (A→B→C→A)
|
||||
- [ ] Cannot link entries from different users
|
||||
- [ ] Deleting an entry removes all its relation edges but leaves related entries intact
|
||||
- [ ] MCP `secrets_add` with `parent_ids` creates relations
|
||||
- [ ] MCP `secrets_update` with `add_parent_ids`/`remove_parent_ids` modifies relations
|
||||
- [ ] MCP `secrets_find`/`secrets_search` output includes `parents` and `children`
|
||||
- [ ] Web entry list shows relation counts
|
||||
- [ ] Web edit dialog allows adding/removing parents
|
||||
- [ ] Web entry view shows children with navigation links
|
||||
|
||||
- Can add a parent→child relation between two entries
|
||||
- Can add multiple parents to a single entry
|
||||
- Cannot add self-referencing relation (CHECK constraint)
|
||||
- Cannot create a direct cycle (A→B→A)
|
||||
- Cannot create an indirect cycle (A→B→C→A)
|
||||
- Cannot link entries from different users
|
||||
- Deleting an entry removes all its relation edges but leaves related entries intact
|
||||
- MCP `secrets_add` with `parent_ids` creates relations
|
||||
- MCP `secrets_update` with `add_parent_ids`/`remove_parent_ids` modifies relations
|
||||
- MCP `secrets_find`/`secrets_search` output includes `parents` and `children`
|
||||
- Web entry list shows relation counts
|
||||
- Web edit dialog allows adding/removing parents
|
||||
- Web entry view shows children with navigation links
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
### 2. 查看密文弹窗 — 增加管理功能
|
||||
|
||||
在每个解密字段行中增加:
|
||||
|
||||
- **重命名输入框**(inline edit,带 debounce 校验)
|
||||
- **类型下拉选择**
|
||||
- **解绑按钮**
|
||||
|
||||
178
plans/web-tags-filter.md
Normal file
178
plans/web-tags-filter.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Web 条目页 tags 筛选计划
|
||||
|
||||
## 目标
|
||||
|
||||
在 Web `/entries` 页面补齐 `tags` 筛选能力,使现有 `tags` 字段在 Web、MCP、数据层三者之间保持一致。
|
||||
|
||||
本次只做最小实现:支持用户在 Web 上输入 tags 条件并筛选条目,不改动数据库结构,不新增 MCP tool 参数,不改动条目编辑语义。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 数据层已支持 `tags` 过滤:`crates/secrets-core/src/service/search.rs`
|
||||
- MCP 已支持 `tags` 参数:`crates/secrets-mcp/src/tools.rs`
|
||||
- Web `/entries` 仅展示 tags 列与编辑字段,没有筛选入口:
|
||||
- 查询参数缺少 `tags`:`crates/secrets-mcp/src/web/entries.rs`
|
||||
- 模板筛选栏缺少 tags 输入:`crates/secrets-mcp/templates/entries.html`
|
||||
- Web 查询当前固定传 `tags: &[]`
|
||||
|
||||
## 范围
|
||||
|
||||
### 包含
|
||||
|
||||
- `/entries` 页面增加 tags 筛选输入
|
||||
- 后端将 tags 解析并传入 `SearchParams`
|
||||
- 分页、folder tabs、筛选重置后的 URL 状态保持一致
|
||||
- i18n 文案补齐
|
||||
|
||||
### 不包含
|
||||
|
||||
- MCP 工具改造
|
||||
- 数据库迁移或索引变更
|
||||
- `/trash` 页面筛选增强
|
||||
- 新增 tags 自动补全、标签选择器、标签管理页
|
||||
|
||||
## 交互定义
|
||||
|
||||
### 输入方式
|
||||
|
||||
- 在 `/entries` 筛选栏增加一个 `tags` 文本输入框
|
||||
- 输入格式采用逗号分隔,例如:`prod, aliyun`
|
||||
- 服务端按逗号拆分、`trim`、去掉空字符串
|
||||
|
||||
### 匹配语义
|
||||
|
||||
- 继续复用现有搜索层语义:`tags @> ARRAY[...]::text[]`
|
||||
- 即:用户输入多个 tags 时,要求条目同时包含这些 tags(AND 语义)
|
||||
|
||||
### 状态保持
|
||||
|
||||
- 筛选提交后,输入框保留原值
|
||||
- 分页上一页/下一页链接保留 `tags`
|
||||
- folder tabs 切换时保留 `tags`
|
||||
- `重置` 仍回到 `/entries`,清空所有筛选条件
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 1. 扩展 Web 查询参数与模板上下文
|
||||
|
||||
文件:`crates/secrets-mcp/src/web/entries.rs`
|
||||
|
||||
- 在 `EntriesQuery` 中增加 `tags: Option<String>`
|
||||
- 在 `EntriesPageTemplate` 中增加 `filter_tags: String`
|
||||
- 在 `entries_page` 中读取原始 tags 字符串,用于模板回填
|
||||
- 将原始字符串解析为 `Vec<String>`,供 `SearchParams.tags` 使用
|
||||
|
||||
建议新增一个局部辅助函数,职责仅限于:
|
||||
|
||||
- 接收 `Option<&str>`
|
||||
- 按逗号分割
|
||||
- `trim`
|
||||
- 过滤空值
|
||||
- 返回 `Vec<String>`
|
||||
|
||||
保持逻辑局部化,避免把 tags 解析散落到多个位置。
|
||||
|
||||
### 2. 将 tags 传入条目查询与计数
|
||||
|
||||
文件:`crates/secrets-mcp/src/web/entries.rs`
|
||||
|
||||
- 更新 `count_params`,不再使用 `tags: &[]`
|
||||
- 更新 `list_params`,复用相同 tags 切片
|
||||
- 确保总数统计、分页列表与实际筛选条件一致
|
||||
|
||||
## 3. 让 folder tabs 计数遵循相同 tags 条件
|
||||
|
||||
文件:`crates/secrets-mcp/src/web/entries.rs`
|
||||
|
||||
当前 folder tabs 使用手写 SQL 统计各 folder 数量,需要同步加入 tags 条件,否则会出现:
|
||||
|
||||
- 列表已按 tags 过滤
|
||||
- 但 folder tab 数量仍是未过滤结果
|
||||
|
||||
实现方式:
|
||||
|
||||
- 在构建 `folder_sql` 时,当 tags 非空,追加 `tags @> ARRAY[...]::text[]`
|
||||
- 对应补齐 bind 参数
|
||||
- 保持与 `SearchParams` 的过滤语义完全一致
|
||||
|
||||
## 4. 让 URL 生成函数保留 tags
|
||||
|
||||
文件:`crates/secrets-mcp/src/web/entries.rs`
|
||||
|
||||
- 扩展 `entries_href(...)` 参数,加入 `tags: Option<&str>`
|
||||
- 在 folder tabs 链接中传入当前 tags
|
||||
- 在需要保留筛选状态的地方一并传递 tags
|
||||
|
||||
## 5. 更新模板筛选栏与分页链接
|
||||
|
||||
文件:`crates/secrets-mcp/templates/entries.html`
|
||||
|
||||
- 在筛选表单中新增 tags 输入框
|
||||
- 输入框 value 绑定 `filter_tags`
|
||||
- 为 tags 输入框增加 i18n label / placeholder
|
||||
- 分页链接 `上一页/下一页` 补充 `tags` query 参数
|
||||
|
||||
建议放置位置:名称与元数据值之间或元数据值与类型之间,保持现有筛选栏布局最小改动。
|
||||
|
||||
## 6. 补齐前端文案
|
||||
|
||||
文件:`crates/secrets-mcp/templates/entries.html`
|
||||
|
||||
新增 i18n key:
|
||||
|
||||
- `filterTagsLabel`
|
||||
- `filterTagsPlaceholder`
|
||||
|
||||
建议文案:
|
||||
|
||||
- zh-CN: `标签` / `多个标签用逗号分隔`
|
||||
- zh-Hant: `標籤` / `多個標籤用逗號分隔`
|
||||
- en: `Tags` / `Comma-separated tags`
|
||||
|
||||
## 验收标准
|
||||
|
||||
### 功能验收
|
||||
|
||||
- 访问 `/entries?tags=prod` 时,只返回包含 `prod` 的条目
|
||||
- 访问 `/entries?tags=prod,aliyun` 时,只返回同时包含 `prod` 与 `aliyun` 的条目
|
||||
- tags 两侧空格不影响结果,例如 `prod, aliyun`
|
||||
- 空字符串、重复逗号不会报错,例如 `prod,,aliyun`
|
||||
- 分页后 `tags` 不丢失
|
||||
- 切换 folder tab 后 `tags` 不丢失
|
||||
- 重置后清空 `tags`
|
||||
|
||||
### 一致性验收
|
||||
|
||||
- 页面总数、列表内容、folder tab 数量使用同一组 tags 条件
|
||||
- Web 语义与 MCP / `SearchParams` 语义一致,均为 AND 匹配
|
||||
|
||||
## 风险与注意点
|
||||
|
||||
- folder tabs 计数 SQL 是手写的,最容易漏掉 tags 绑定顺序
|
||||
- `list_params` 基于 `count_params` 结构展开,注意借用生命周期不要引入临时值悬垂
|
||||
- 分页链接和 `entries_href` 若漏传 `tags`,用户会感觉筛选“偶尔失效”
|
||||
- 现阶段不做 tags 规范化;输入 `Prod` 与存储 `prod` 是否匹配,取决于数组元素本身是否完全一致
|
||||
|
||||
## 可选后续
|
||||
|
||||
如果上线后确认 `tags` 仍被频繁使用,可以继续做:
|
||||
|
||||
1. tags chip UI,而不是纯文本输入
|
||||
2. 常用 tags 自动补全
|
||||
3. 在 Web 过滤栏里明确提示“多个标签为同时匹配”
|
||||
4. 评估是否需要大小写规范化策略
|
||||
|
||||
## 验证建议
|
||||
|
||||
实现后至少执行:
|
||||
|
||||
```bash
|
||||
cargo fmt -- --check
|
||||
cargo test --locked
|
||||
```
|
||||
|
||||
如果本次提交涉及 `crates/**`,按仓库规则在提交前再执行:
|
||||
|
||||
```bash
|
||||
./scripts/release-check.sh
|
||||
```
|
||||
Reference in New Issue
Block a user