Compare commits

...

14 Commits

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

Made-with: Cursor
2026-04-10 17:10:55 +08:00
agent
ff2ea91e72 release(secrets-mcp): 0.5.16 — 回收站页面 title 国际化
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m32s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 5s
2026-04-10 14:15:15 +08:00
agent
574c1c9967 release(secrets-mcp): 0.5.15 — 列设置面板锚定优化,移除查看密文隐藏功能
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m53s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
2026-04-10 11:54:20 +08:00
voson
98d69f5f12 fix(update): include deleted_at in SELECT for EntryWriteRow mapping
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 5m43s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m35s
The update_fields_by_id query was missing deleted_at column, causing
sqlx FromRow mapping to fail against EntryWriteRow struct.
2026-04-09 20:55:05 +08:00
agent
089d0b4b58 style(dashboard): move version footer out of card
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m30s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m37s
2026-04-09 17:32:40 +08:00
40 changed files with 4033 additions and 810 deletions

View File

@@ -42,7 +42,7 @@ secrets/
Cargo.toml
crates/
secrets-core/ # db / crypto / models / audit / service
secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard
secrets-mcp/ # rmcp tools、axum、OAuth、DashboardCHANGELOG.md → /changelog
scripts/
release-check.sh
setup-gitea-actions.sh
@@ -113,6 +113,7 @@ users (
key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语
key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000}
api_key TEXT UNIQUE, -- MCP Bearer token明文存储设计决策见下方说明
key_version BIGINT NOT NULL DEFAULT 0, -- 密码短语变更时递增,用于使其它设备会话失效
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
@@ -165,6 +166,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
View File

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

View File

@@ -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"] }

View File

@@ -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
- **Web**`BASE_URL`登录、Dashboard、设置密码短语、创建 API Key**变更记录**页 **`/changelog`**:内容来自 `crates/secrets-mcp/CHANGELOG.md`(构建时嵌入并以 Markdown 渲染);首页页脚与 DashboardMCP页脚均提供入口。**条目**页 `/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_OPTIONSsecret 字段类型下拉选项)
service/ # 业务逻辑add, search, update, delete, export, env_map 等)
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API KeyCHANGELOG.md 嵌入 /changelog
scripts/
release-check.sh # 发版前 fmt / clippy / test
setup-gitea-actions.sh

View File

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

View File

@@ -3,7 +3,7 @@ use std::str::FromStr;
use anyhow::{Context, Result};
use serde_json::{Map, Value};
use sqlx::PgPool;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode};
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use crate::config::DatabaseConfig;
@@ -18,18 +18,6 @@ fn build_connect_options(config: &DatabaseConfig) -> Result<PgConnectOptions> {
options = options.ssl_root_cert(path);
}
if config.enforce_strict_tls
&& !matches!(
options.get_ssl_mode(),
PgSslMode::VerifyCa | PgSslMode::VerifyFull
)
{
anyhow::bail!(
"Refusing to start in production with weak PostgreSQL TLS mode. \
Set SECRETS_DATABASE_SSL_MODE=verify-ca or verify-full."
);
}
Ok(options)
}
@@ -80,10 +68,12 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
metadata JSONB NOT NULL DEFAULT '{}',
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- Legacy unique constraint without user_id (single-user mode)
-- NOTE: These are rebuilt below with `deleted_at IS NULL` for soft-delete support.
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
ON entries(folder, name)
WHERE user_id IS NULL;
@@ -127,6 +117,17 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
);
CREATE INDEX IF NOT EXISTS idx_entry_secrets_secret_id ON entry_secrets(secret_id);
-- ── entry_relations: parent-child links between entries ──────────────────
CREATE TABLE IF NOT EXISTS entry_relations (
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY(parent_entry_id, child_entry_id),
CHECK (parent_entry_id <> child_entry_id)
);
CREATE INDEX IF NOT EXISTS idx_entry_relations_parent ON entry_relations(parent_entry_id);
CREATE INDEX IF NOT EXISTS idx_entry_relations_child ON entry_relations(child_entry_id);
-- ── audit_log: append-only operation log ─────────────────────────────────
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
@@ -170,6 +171,7 @@ pub async fn migrate(pool: &PgPool) -> Result<()> {
-- Backfill: add notes to entries if not present (fresh installs already have it)
ALTER TABLE entries ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- ── secrets_history: field-level snapshot ────────────────────────────────
CREATE TABLE IF NOT EXISTS secrets_history (
@@ -404,11 +406,11 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> {
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
ON entries(folder, name)
WHERE user_id IS NULL;
WHERE user_id IS NULL AND deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
ON entries(user_id, folder, name)
WHERE user_id IS NOT NULL;
WHERE user_id IS NOT NULL AND deleted_at IS NULL;
-- ── Replace old namespace/kind indexes ────────────────────────────────────
DROP INDEX IF EXISTS idx_entries_namespace;
@@ -420,6 +422,8 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> {
ON entries(folder) WHERE folder <> '';
CREATE INDEX IF NOT EXISTS idx_entries_type
ON entries(type) WHERE type <> '';
CREATE INDEX IF NOT EXISTS idx_entries_deleted_at
ON entries(deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type
ON audit_log(folder, type);
CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name

View File

@@ -21,6 +21,7 @@ pub struct Entry {
pub version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
/// A single encrypted field belonging to an Entry.
@@ -52,6 +53,7 @@ pub struct EntryRow {
pub tags: Vec<String>,
pub metadata: Value,
pub notes: String,
pub name: String,
}
/// Entry row including `name` (used for id-scoped web / service updates).
@@ -66,6 +68,7 @@ pub struct EntryWriteRow {
pub tags: Vec<String>,
pub metadata: Value,
pub notes: String,
pub deleted_at: Option<DateTime<Utc>>,
}
impl From<&EntryWriteRow> for EntryRow {
@@ -78,6 +81,7 @@ impl From<&EntryWriteRow> for EntryRow {
tags: r.tags.clone(),
metadata: r.metadata.clone(),
notes: r.notes.clone(),
name: r.name.clone(),
}
}
}
@@ -180,6 +184,9 @@ pub struct ExportEntry {
/// Decrypted secret fields. None means no secrets in this export (--no-secrets).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets: Option<BTreeMap<String, Value>>,
/// Per-secret types (`text`, `password`, `key`, …). Omitted in legacy exports; importers default to `"text"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secret_types: Option<BTreeMap<String, String>>,
}
// ── Multi-user models ──────────────────────────────────────────────────────────
@@ -307,3 +314,44 @@ pub fn toml_to_json_value(v: &toml::Value) -> Value {
}
}
}
#[cfg(test)]
mod export_entry_tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn export_entry_roundtrip_includes_secret_types() {
let mut secrets = BTreeMap::new();
secrets.insert("k".to_string(), serde_json::json!("v"));
let mut types = BTreeMap::new();
types.insert("k".to_string(), "password".to_string());
let e = ExportEntry {
name: "n".to_string(),
folder: "f".to_string(),
entry_type: "t".to_string(),
notes: "".to_string(),
tags: vec![],
metadata: serde_json::json!({}),
secrets: Some(secrets),
secret_types: Some(types),
};
let json = serde_json::to_string(&e).unwrap();
let back: ExportEntry = serde_json::from_str(&json).unwrap();
assert_eq!(
back.secret_types
.as_ref()
.unwrap()
.get("k")
.map(String::as_str),
Some("password")
);
}
#[test]
fn export_entry_legacy_json_without_secret_types_deserializes() {
let json = r#"{"name":"a","folder":"","type":"","notes":"","tags":[],"metadata":{},"secrets":{"x":"y"}}"#;
let e: ExportEntry = serde_json::from_str(json).unwrap();
assert!(e.secret_types.is_none());
}
}

View File

@@ -161,6 +161,7 @@ pub fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)>
#[derive(Debug, serde::Serialize)]
pub struct AddResult {
pub entry_id: Uuid,
pub name: String,
pub folder: String,
#[serde(rename = "type")]
@@ -213,8 +214,8 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
// Fetch existing entry by (user_id, folder, name) — the natural unique key
let existing: Option<EntryRow> = if let Some(uid) = params.user_id {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id = $1 AND folder = $2 AND name = $3",
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
WHERE user_id = $1 AND folder = $2 AND name = $3 AND deleted_at IS NULL",
)
.bind(uid)
.bind(params.folder)
@@ -223,8 +224,8 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
.await?
} else {
sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
WHERE user_id IS NULL AND folder = $1 AND name = $2",
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
WHERE user_id IS NULL AND folder = $1 AND name = $2 AND deleted_at IS NULL",
)
.bind(params.folder)
.bind(params.name)
@@ -477,6 +478,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
tx.commit().await?;
Ok(AddResult {
entry_id,
name: params.name.to_string(),
folder: params.folder.to_string(),
entry_type: entry_type.to_string(),

View File

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

View File

@@ -4,6 +4,7 @@ use sqlx::PgPool;
use uuid::Uuid;
use crate::db;
use crate::error::AppError;
use crate::models::{EntryRow, EntryWriteRow, SecretFieldRow};
use crate::service::util::user_scope_condition;
@@ -21,6 +22,17 @@ pub struct DeleteResult {
pub dry_run: bool,
}
#[derive(Debug, serde::Serialize, sqlx::FromRow)]
pub struct TrashEntry {
pub id: Uuid,
pub name: String,
pub folder: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub entry_type: String,
pub deleted_at: chrono::DateTime<chrono::Utc>,
}
pub struct DeleteParams<'a> {
/// If set, delete a single entry by name.
pub name: Option<&'a str>,
@@ -36,12 +48,156 @@ pub struct DeleteParams<'a> {
/// Prevents accidental mass deletion when filters are too broad.
pub const MAX_BULK_DELETE: usize = 1000;
pub async fn list_deleted_entries(
pool: &PgPool,
user_id: Uuid,
limit: u32,
offset: u32,
) -> Result<Vec<TrashEntry>> {
sqlx::query_as(
"SELECT id, name, folder, type, deleted_at FROM entries \
WHERE user_id = $1 AND deleted_at IS NOT NULL \
ORDER BY deleted_at DESC, name ASC LIMIT $2 OFFSET $3",
)
.bind(user_id)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(pool)
.await
.map_err(Into::into)
}
pub async fn count_deleted_entries(pool: &PgPool, user_id: Uuid) -> Result<i64> {
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*)::bigint FROM entries WHERE user_id = $1 AND deleted_at IS NOT NULL",
)
.bind(user_id)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn restore_deleted_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<()> {
let mut tx = pool.begin().await?;
let row: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL FOR UPDATE",
)
.bind(entry_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let row = match row {
Some(r) => r,
None => {
tx.rollback().await?;
return Err(AppError::NotFoundEntry.into());
}
};
let conflict_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE user_id = $1 AND folder = $2 AND name = $3 AND deleted_at IS NULL AND id <> $4)",
)
.bind(user_id)
.bind(&row.folder)
.bind(&row.name)
.bind(row.id)
.fetch_one(&mut *tx)
.await?;
if conflict_exists {
tx.rollback().await?;
return Err(AppError::ConflictEntryName {
folder: row.folder,
name: row.name,
}
.into());
}
sqlx::query("UPDATE entries SET deleted_at = NULL, updated_at = NOW() WHERE id = $1")
.bind(row.id)
.execute(&mut *tx)
.await?;
crate::audit::log_tx(
&mut tx,
Some(user_id),
"restore",
&row.folder,
&row.entry_type,
&row.name,
json!({ "entry_id": row.id }),
)
.await;
tx.commit().await?;
Ok(())
}
pub async fn purge_deleted_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<()> {
let mut tx = pool.begin().await?;
let row: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL FOR UPDATE",
)
.bind(entry_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await?;
let row = match row {
Some(r) => r,
None => {
tx.rollback().await?;
return Err(AppError::NotFoundEntry.into());
}
};
purge_entry_record(&mut tx, row.id).await?;
crate::audit::log_tx(
&mut tx,
Some(user_id),
"purge",
&row.folder,
&row.entry_type,
&row.name,
json!({ "entry_id": row.id }),
)
.await;
tx.commit().await?;
Ok(())
}
pub async fn purge_expired_deleted_entries(pool: &PgPool) -> Result<u64> {
#[derive(sqlx::FromRow)]
struct ExpiredRow {
id: Uuid,
}
let mut tx = pool.begin().await?;
let rows: Vec<ExpiredRow> = sqlx::query_as(
"SELECT id FROM entries \
WHERE deleted_at IS NOT NULL \
AND deleted_at < NOW() - INTERVAL '3 months' \
FOR UPDATE",
)
.fetch_all(&mut *tx)
.await?;
for row in &rows {
purge_entry_record(&mut tx, row.id).await?;
}
tx.commit().await?;
Ok(rows.len() as u64)
}
/// Delete a single entry by id (multi-tenant: `user_id` must match).
pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<DeleteResult> {
let mut tx = pool.begin().await?;
let row: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \
WHERE id = $1 AND user_id = $2 FOR UPDATE",
"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 FOR UPDATE",
)
.bind(entry_id)
.bind(user_id)
@@ -61,7 +217,7 @@ pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Resul
let name = row.name.clone();
let entry_row: EntryRow = (&row).into();
snapshot_and_delete(
snapshot_and_soft_delete(
&mut tx,
&folder,
&entry_type,
@@ -141,7 +297,7 @@ async fn delete_one(
}
conditions.push(format!("name = ${}", idx));
let sql = format!(
"SELECT folder, type FROM entries WHERE {}",
"SELECT folder, type FROM entries WHERE {} AND deleted_at IS NULL",
conditions.join(" AND ")
);
let mut q = sqlx::query_as::<_, DryRunRow>(&sql);
@@ -198,7 +354,8 @@ async fn delete_one(
}
conditions.push(format!("name = ${}", idx));
let sql = format!(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries WHERE {} FOR UPDATE",
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
WHERE {} AND deleted_at IS NULL FOR UPDATE",
conditions.join(" AND ")
);
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
@@ -238,7 +395,7 @@ async fn delete_one(
let folder = row.folder.clone();
let entry_type = row.entry_type.clone();
snapshot_and_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
snapshot_and_soft_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
crate::audit::log_tx(
&mut tx,
user_id,
@@ -305,7 +462,7 @@ async fn delete_bulk(
if dry_run {
let sql = format!(
"SELECT id, version, folder, type, name, metadata, tags, notes \
FROM entries {where_clause} ORDER BY type, name"
FROM entries {where_clause} AND deleted_at IS NULL ORDER BY type, name"
);
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
if let Some(uid) = user_id {
@@ -337,7 +494,7 @@ async fn delete_bulk(
let sql = format!(
"SELECT id, version, folder, type, name, metadata, tags, notes \
FROM entries {where_clause} ORDER BY type, name FOR UPDATE"
FROM entries {where_clause} AND deleted_at IS NULL ORDER BY type, name FOR UPDATE"
);
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
if let Some(uid) = user_id {
@@ -371,8 +528,9 @@ async fn delete_bulk(
tags: row.tags.clone(),
metadata: row.metadata.clone(),
notes: row.notes.clone(),
name: row.name.clone(),
};
snapshot_and_delete(
snapshot_and_soft_delete(
&mut tx,
&row.folder,
&row.entry_type,
@@ -406,7 +564,7 @@ async fn delete_bulk(
})
}
async fn snapshot_and_delete(
async fn snapshot_and_soft_delete(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
folder: &str,
entry_type: &str,
@@ -468,11 +626,33 @@ async fn snapshot_and_delete(
}
}
sqlx::query("DELETE FROM entries WHERE id = $1")
sqlx::query("UPDATE entries SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1")
.bind(row.id)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn purge_entry_record(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
entry_id: Uuid,
) -> Result<()> {
let fields: Vec<SecretFieldRow> = sqlx::query_as(
"SELECT s.id, s.name, s.encrypted \
FROM entry_secrets es \
JOIN secrets s ON s.id = es.secret_id \
WHERE es.entry_id = $1",
)
.bind(entry_id)
.fetch_all(&mut **tx)
.await?;
sqlx::query("DELETE FROM entries WHERE id = $1")
.bind(entry_id)
.execute(&mut **tx)
.await?;
let secret_ids: Vec<Uuid> = fields.iter().map(|f| f.id).collect();
if !secret_ids.is_empty() {
sqlx::query(

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ pub mod export;
pub mod get_secret;
pub mod history;
pub mod import;
pub mod relations;
pub mod rollback;
pub mod search;
pub mod update;

View File

@@ -0,0 +1,324 @@
use std::collections::{BTreeSet, HashMap};
use anyhow::Result;
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::AppError;
#[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)]
pub struct RelationEntrySummary {
pub id: Uuid,
pub folder: String,
#[serde(rename = "type")]
#[sqlx(rename = "type")]
pub entry_type: String,
pub name: String,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct EntryRelations {
pub parents: Vec<RelationEntrySummary>,
pub children: Vec<RelationEntrySummary>,
}
pub async fn add_parent_relation(
pool: &PgPool,
parent_entry_id: Uuid,
child_entry_id: Uuid,
user_id: Option<Uuid>,
) -> Result<()> {
if parent_entry_id == child_entry_id {
return Err(AppError::Validation {
message: "entry cannot reference itself".to_string(),
}
.into());
}
let mut tx = pool.begin().await?;
validate_live_entries(&mut tx, &[parent_entry_id, child_entry_id], user_id).await?;
let cycle_exists: bool = sqlx::query_scalar(
"WITH RECURSIVE descendants AS ( \
SELECT child_entry_id FROM entry_relations WHERE parent_entry_id = $1 \
UNION \
SELECT er.child_entry_id \
FROM entry_relations er \
JOIN descendants d ON d.child_entry_id = er.parent_entry_id \
) \
SELECT EXISTS(SELECT 1 FROM descendants WHERE child_entry_id = $2)",
)
.bind(child_entry_id)
.bind(parent_entry_id)
.fetch_one(&mut *tx)
.await?;
if cycle_exists {
tx.rollback().await?;
return Err(AppError::Validation {
message: "adding this relation would create a cycle".to_string(),
}
.into());
}
sqlx::query(
"INSERT INTO entry_relations (parent_entry_id, child_entry_id) \
VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(parent_entry_id)
.bind(child_entry_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn remove_parent_relation(
pool: &PgPool,
parent_entry_id: Uuid,
child_entry_id: Uuid,
user_id: Option<Uuid>,
) -> Result<()> {
let mut tx = pool.begin().await?;
validate_live_entries(&mut tx, &[parent_entry_id, child_entry_id], user_id).await?;
sqlx::query("DELETE FROM entry_relations WHERE parent_entry_id = $1 AND child_entry_id = $2")
.bind(parent_entry_id)
.bind(child_entry_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn set_parent_relations(
pool: &PgPool,
child_entry_id: Uuid,
parent_entry_ids: &[Uuid],
user_id: Option<Uuid>,
) -> Result<()> {
let deduped: Vec<Uuid> = parent_entry_ids
.iter()
.copied()
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
if deduped.contains(&child_entry_id) {
return Err(AppError::Validation {
message: "entry cannot reference itself".to_string(),
}
.into());
}
let mut tx = pool.begin().await?;
let mut validate_ids = Vec::with_capacity(deduped.len() + 1);
validate_ids.push(child_entry_id);
validate_ids.extend(deduped.iter().copied());
validate_live_entries(&mut tx, &validate_ids, user_id).await?;
let current_parent_ids: Vec<Uuid> =
sqlx::query_scalar("SELECT parent_entry_id FROM entry_relations WHERE child_entry_id = $1")
.bind(child_entry_id)
.fetch_all(&mut *tx)
.await?;
let current: BTreeSet<Uuid> = current_parent_ids.into_iter().collect();
let target: BTreeSet<Uuid> = deduped.iter().copied().collect();
for parent_id in current.difference(&target) {
sqlx::query(
"DELETE FROM entry_relations WHERE parent_entry_id = $1 AND child_entry_id = $2",
)
.bind(*parent_id)
.bind(child_entry_id)
.execute(&mut *tx)
.await?;
}
for parent_id in target.difference(&current) {
let cycle_exists: bool = sqlx::query_scalar(
"WITH RECURSIVE descendants AS ( \
SELECT child_entry_id FROM entry_relations WHERE parent_entry_id = $1 \
UNION \
SELECT er.child_entry_id \
FROM entry_relations er \
JOIN descendants d ON d.child_entry_id = er.parent_entry_id \
) \
SELECT EXISTS(SELECT 1 FROM descendants WHERE child_entry_id = $2)",
)
.bind(child_entry_id)
.bind(*parent_id)
.fetch_one(&mut *tx)
.await?;
if cycle_exists {
tx.rollback().await?;
return Err(AppError::Validation {
message: "adding this relation would create a cycle".to_string(),
}
.into());
}
sqlx::query(
"INSERT INTO entry_relations (parent_entry_id, child_entry_id) VALUES ($1, $2) \
ON CONFLICT DO NOTHING",
)
.bind(*parent_id)
.bind(child_entry_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub async fn get_relations_for_entries(
pool: &PgPool,
entry_ids: &[Uuid],
user_id: Option<Uuid>,
) -> Result<HashMap<Uuid, EntryRelations>> {
if entry_ids.is_empty() {
return Ok(HashMap::new());
}
#[derive(sqlx::FromRow)]
struct ParentRow {
owner_entry_id: Uuid,
id: Uuid,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
}
#[derive(sqlx::FromRow)]
struct ChildRow {
owner_entry_id: Uuid,
id: Uuid,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
}
let (parents, children): (Vec<ParentRow>, Vec<ChildRow>) = if let Some(uid) = user_id {
let parents = sqlx::query_as(
"SELECT er.child_entry_id AS owner_entry_id, p.id, p.folder, p.type, p.name \
FROM entry_relations er \
JOIN entries p ON p.id = er.parent_entry_id \
JOIN entries c ON c.id = er.child_entry_id \
WHERE er.child_entry_id = ANY($1) \
AND p.user_id = $2 AND c.user_id = $2 \
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
ORDER BY er.child_entry_id, p.name ASC",
)
.bind(entry_ids)
.bind(uid)
.fetch_all(pool);
let children = sqlx::query_as(
"SELECT er.parent_entry_id AS owner_entry_id, c.id, c.folder, c.type, c.name \
FROM entry_relations er \
JOIN entries c ON c.id = er.child_entry_id \
JOIN entries p ON p.id = er.parent_entry_id \
WHERE er.parent_entry_id = ANY($1) \
AND p.user_id = $2 AND c.user_id = $2 \
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
ORDER BY er.parent_entry_id, c.name ASC",
)
.bind(entry_ids)
.bind(uid)
.fetch_all(pool);
(parents.await?, children.await?)
} else {
let parents = sqlx::query_as(
"SELECT er.child_entry_id AS owner_entry_id, p.id, p.folder, p.type, p.name \
FROM entry_relations er \
JOIN entries p ON p.id = er.parent_entry_id \
JOIN entries c ON c.id = er.child_entry_id \
WHERE er.child_entry_id = ANY($1) \
AND p.user_id IS NULL AND c.user_id IS NULL \
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
ORDER BY er.child_entry_id, p.name ASC",
)
.bind(entry_ids)
.fetch_all(pool);
let children = sqlx::query_as(
"SELECT er.parent_entry_id AS owner_entry_id, c.id, c.folder, c.type, c.name \
FROM entry_relations er \
JOIN entries c ON c.id = er.child_entry_id \
JOIN entries p ON p.id = er.parent_entry_id \
WHERE er.parent_entry_id = ANY($1) \
AND p.user_id IS NULL AND c.user_id IS NULL \
AND p.deleted_at IS NULL AND c.deleted_at IS NULL \
ORDER BY er.parent_entry_id, c.name ASC",
)
.bind(entry_ids)
.fetch_all(pool);
(parents.await?, children.await?)
};
let mut map: HashMap<Uuid, EntryRelations> = entry_ids
.iter()
.copied()
.map(|id| (id, EntryRelations::default()))
.collect();
for row in parents {
map.entry(row.owner_entry_id)
.or_default()
.parents
.push(RelationEntrySummary {
id: row.id,
folder: row.folder,
entry_type: row.entry_type,
name: row.name,
});
}
for row in children {
map.entry(row.owner_entry_id)
.or_default()
.children
.push(RelationEntrySummary {
id: row.id,
folder: row.folder,
entry_type: row.entry_type,
name: row.name,
});
}
Ok(map)
}
async fn validate_live_entries(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
entry_ids: &[Uuid],
user_id: Option<Uuid>,
) -> Result<()> {
let unique_ids: Vec<Uuid> = entry_ids
.iter()
.copied()
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let live_count: i64 = if let Some(uid) = user_id {
sqlx::query_scalar(
"SELECT COUNT(*)::bigint FROM entries \
WHERE id = ANY($1) AND user_id = $2 AND deleted_at IS NULL",
)
.bind(&unique_ids)
.bind(uid)
.fetch_one(&mut **tx)
.await?
} else {
sqlx::query_scalar(
"SELECT COUNT(*)::bigint FROM entries \
WHERE id = ANY($1) AND user_id IS NULL AND deleted_at IS NULL",
)
.bind(&unique_ids)
.fetch_one(&mut **tx)
.await?
};
if live_count != unique_ids.len() as i64 {
return Err(AppError::NotFoundEntry.into());
}
Ok(())
}

View File

@@ -6,6 +6,8 @@ use sqlx::PgPool;
use uuid::Uuid;
use crate::db;
use crate::error::AppError;
use crate::models::EntryWriteRow;
#[derive(Debug, serde::Serialize)]
pub struct RollbackResult {
@@ -17,11 +19,9 @@ pub struct RollbackResult {
}
/// Roll back entry `name` to `to_version` (or the most recent snapshot if None).
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
pub async fn run(
pool: &PgPool,
name: &str,
folder: Option<&str>,
entry_id: Uuid,
to_version: Option<i64>,
user_id: Option<Uuid>,
) -> Result<RollbackResult> {
@@ -30,120 +30,61 @@ pub async fn run(
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
name: String,
version: i64,
action: String,
tags: Vec<String>,
metadata: Value,
}
// Disambiguate: find the unique entry_id for (name, folder).
// Query entries_history by entry_id once we know it; first resolve via name + optional folder.
let entry_id: Option<Uuid> = if let Some(uid) = user_id {
if let Some(f) = folder {
sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND folder = $2 AND user_id = $3 LIMIT 1",
)
.bind(name)
.bind(f)
.bind(uid)
.fetch_optional(pool)
.await?
} else {
let ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND user_id = $2",
)
.bind(name)
.bind(uid)
.fetch_all(pool)
.await?;
match ids.len() {
0 => None,
1 => Some(ids[0]),
_ => {
let folders: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT folder FROM entries_history \
WHERE name = $1 AND user_id = $2",
)
.bind(name)
.bind(uid)
.fetch_all(pool)
.await?;
anyhow::bail!(
"Ambiguous: entries named '{}' exist in folders: [{}]. \
Specify 'folder' to disambiguate.",
name,
folders.join(", ")
)
}
}
}
} else if let Some(f) = folder {
sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND folder = $2 AND user_id IS NULL LIMIT 1",
let mut tx = pool.begin().await?;
let live: Option<EntryWriteRow> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL FOR UPDATE",
)
.bind(name)
.bind(f)
.fetch_optional(pool)
.bind(entry_id)
.bind(uid)
.fetch_optional(&mut *tx)
.await?
} else {
let ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT DISTINCT entry_id FROM entries_history \
WHERE name = $1 AND user_id IS NULL",
sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes, deleted_at FROM entries \
WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL FOR UPDATE",
)
.bind(name)
.fetch_all(pool)
.await?;
match ids.len() {
0 => None,
1 => Some(ids[0]),
_ => {
let folders: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT folder FROM entries_history \
WHERE name = $1 AND user_id IS NULL",
)
.bind(name)
.fetch_all(pool)
.await?;
anyhow::bail!(
"Ambiguous: entries named '{}' exist in folders: [{}]. \
Specify 'folder' to disambiguate.",
name,
folders.join(", ")
)
}
}
.bind(entry_id)
.fetch_optional(&mut *tx)
.await?
};
let entry_id = entry_id.ok_or_else(|| anyhow::anyhow!("No history found for '{}'", name))?;
let lr = live.ok_or(AppError::NotFoundEntry)?;
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
sqlx::query_as(
"SELECT folder, type, version, action, tags, metadata \
"SELECT folder, type, name, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 AND version = $2 ORDER BY id ASC LIMIT 1",
)
.bind(entry_id)
.bind(ver)
.fetch_optional(pool)
.fetch_optional(&mut *tx)
.await?
} else {
sqlx::query_as(
"SELECT folder, type, version, action, tags, metadata \
"SELECT folder, type, name, version, action, tags, metadata \
FROM entries_history \
WHERE entry_id = $1 ORDER BY id DESC LIMIT 1",
)
.bind(entry_id)
.fetch_optional(pool)
.fetch_optional(&mut *tx)
.await?
};
let snap = snap.ok_or_else(|| {
anyhow::anyhow!(
"No history found for '{}'{}.",
name,
"No history found for entry '{}'{}.",
lr.name,
to_version
.map(|v| format!(" at version {}", v))
.unwrap_or_default()
@@ -153,29 +94,7 @@ pub async fn run(
let snap_secret_snapshot = db::entry_secret_snapshot_from_metadata(&snap.metadata);
let snap_metadata = db::strip_secret_snapshot_from_metadata(&snap.metadata);
let mut tx = pool.begin().await?;
#[derive(sqlx::FromRow)]
struct LiveEntry {
id: Uuid,
version: i64,
folder: String,
#[sqlx(rename = "type")]
entry_type: String,
tags: Vec<String>,
metadata: Value,
}
// Lock the live entry if it exists (matched by entry_id for precision).
let live: Option<LiveEntry> = sqlx::query_as(
"SELECT id, version, folder, type, tags, metadata FROM entries \
WHERE id = $1 FOR UPDATE",
)
.bind(entry_id)
.fetch_optional(&mut *tx)
.await?;
let live_entry_id = if let Some(ref lr) = live {
let live_entry_id = {
let history_metadata =
match db::metadata_with_secret_snapshot(&mut tx, lr.id, &lr.metadata).await {
Ok(v) => v,
@@ -192,7 +111,7 @@ pub async fn run(
user_id,
folder: &lr.folder,
entry_type: &lr.entry_type,
name,
name: &lr.name,
version: lr.version,
action: "rollback",
tags: &lr.tags,
@@ -237,11 +156,13 @@ pub async fn run(
}
sqlx::query(
"UPDATE entries SET folder = $1, type = $2, tags = $3, metadata = $4, version = version + 1, \
updated_at = NOW() WHERE id = $5",
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
version = version + 1, updated_at = NOW() WHERE id = $7",
)
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(&snap.name)
.bind(&lr.notes)
.bind(&snap.tags)
.bind(&snap_metadata)
.bind(lr.id)
@@ -249,37 +170,6 @@ pub async fn run(
.await?;
lr.id
} else {
if let Some(uid) = user_id {
sqlx::query_scalar(
"INSERT INTO entries \
(user_id, folder, type, name, notes, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, $4, '', $5, $6, $7, NOW()) RETURNING id",
)
.bind(uid)
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(name)
.bind(&snap.tags)
.bind(&snap_metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
} else {
sqlx::query_scalar(
"INSERT INTO entries \
(folder, type, name, notes, tags, metadata, version, updated_at) \
VALUES ($1, $2, $3, '', $4, $5, $6, NOW()) RETURNING id",
)
.bind(&snap.folder)
.bind(&snap.entry_type)
.bind(name)
.bind(&snap.tags)
.bind(&snap_metadata)
.bind(snap.version)
.fetch_one(&mut *tx)
.await?
}
};
if let Some(secret_snapshot) = snap_secret_snapshot {
@@ -292,8 +182,9 @@ pub async fn run(
"rollback",
&snap.folder,
&snap.entry_type,
name,
&snap.name,
serde_json::json!({
"entry_id": entry_id,
"restored_version": snap.version,
"original_action": snap.action,
}),
@@ -303,7 +194,7 @@ pub async fn run(
tx.commit().await?;
Ok(RollbackResult {
name: name.to_string(),
name: snap.name,
folder: snap.folder,
entry_type: snap.entry_type,
restored_version: snap.version,

View File

@@ -27,6 +27,7 @@ pub struct SearchParams<'a> {
pub name_query: Option<&'a str>,
pub tags: &'a [String],
pub query: Option<&'a str>,
pub metadata_query: Option<&'a str>,
pub sort: &'a str,
pub limit: u32,
pub offset: u32,
@@ -75,6 +76,10 @@ pub async fn count_entries(pool: &PgPool, a: &SearchParams<'_>) -> Result<i64> {
let pattern = ilike_pattern(v);
q = q.bind(pattern);
}
if let Some(v) = a.metadata_query {
let pattern = ilike_pattern(v);
q = q.bind(pattern);
}
let n = q.fetch_one(pool).await?;
Ok(n)
}
@@ -90,6 +95,7 @@ fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
} else {
conditions.push("user_id IS NULL".to_string());
}
conditions.push("deleted_at IS NULL".to_string());
if a.folder.is_some() {
conditions.push(format!("folder = ${}", idx));
@@ -132,6 +138,14 @@ fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
));
idx += 1;
}
if a.metadata_query.is_some() {
conditions.push(format!(
"EXISTS (SELECT 1 FROM jsonb_path_query(metadata, 'strict $.** ? (@.type() != \"object\" && @.type() != \"array\")') AS val \
WHERE (val #>> '{{}}') ILIKE ${} ESCAPE '\\')",
idx
));
idx += 1;
}
let where_clause = if conditions.is_empty() {
String::new()
@@ -164,6 +178,7 @@ pub async fn fetch_entries(
name: Option<&str>,
tags: &[String],
query: Option<&str>,
metadata_query: Option<&str>,
user_id: Option<Uuid>,
) -> Result<Vec<Entry>> {
let params = SearchParams {
@@ -173,6 +188,7 @@ pub async fn fetch_entries(
name_query: None,
tags,
query,
metadata_query,
sort: "name",
limit: FETCH_ALL_LIMIT,
offset: 0,
@@ -195,7 +211,7 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
let sql = format!(
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
created_at, updated_at \
created_at, updated_at, deleted_at \
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
);
@@ -223,6 +239,10 @@ async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<
let pattern = ilike_pattern(v);
q = q.bind(pattern);
}
if let Some(v) = a.metadata_query {
let pattern = ilike_pattern(v);
q = q.bind(pattern);
}
q = q.bind(a.limit as i64).bind(a.offset as i64);
let rows = q.fetch_all(pool).await?;
@@ -267,7 +287,7 @@ pub async fn resolve_entry_by_id(
let row: Option<EntryRaw> = if let Some(uid) = user_id {
sqlx::query_as(
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
created_at, updated_at FROM entries WHERE id = $1 AND user_id = $2",
created_at, updated_at, deleted_at FROM entries WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL",
)
.bind(id)
.bind(uid)
@@ -276,7 +296,7 @@ pub async fn resolve_entry_by_id(
} else {
sqlx::query_as(
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
created_at, updated_at FROM entries WHERE id = $1 AND user_id IS NULL",
created_at, updated_at, deleted_at FROM entries WHERE id = $1 AND user_id IS NULL AND deleted_at IS NULL",
)
.bind(id)
.fetch_optional(pool)
@@ -298,7 +318,7 @@ pub async fn resolve_entry(
folder: Option<&str>,
user_id: Option<Uuid>,
) -> Result<crate::models::Entry> {
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, user_id).await?;
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, None, user_id).await?;
match entries.len() {
0 => {
if let Some(f) = folder {
@@ -339,6 +359,7 @@ struct EntryRaw {
version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
deleted_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl From<EntryRaw> for Entry {
@@ -355,6 +376,7 @@ impl From<EntryRaw> for Entry {
version: r.version,
created_at: r.created_at,
updated_at: r.updated_at,
deleted_at: r.deleted_at,
}
}
}

View File

@@ -66,7 +66,8 @@ pub async fn run(
}
conditions.push(format!("name = ${}", idx));
let sql = format!(
"SELECT id, version, folder, type, tags, metadata, notes FROM entries WHERE {} FOR UPDATE",
"SELECT id, version, folder, type, tags, metadata, notes, name FROM entries \
WHERE {} AND deleted_at IS NULL FOR UPDATE",
conditions.join(" AND ")
);
let mut q = sqlx::query_as::<_, EntryRow>(&sql);
@@ -463,8 +464,8 @@ pub async fn update_fields_by_id(
let mut tx = pool.begin().await?;
let row: Option<EntryWriteRow> = sqlx::query_as(
"SELECT id, version, folder, type, name, tags, metadata, notes FROM entries \
WHERE id = $1 AND user_id = $2 FOR UPDATE",
"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 FOR UPDATE",
)
.bind(entry_id)
.bind(user_id)

View 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 OAuthtoken / 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
- DashboardMCP页脚版本旁增加「变更记录」链接打开本变更说明页。

View File

@@ -1,6 +1,6 @@
[package]
name = "secrets-mcp"
version = "0.5.12"
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"

View File

@@ -26,6 +26,7 @@ use tracing_subscriber::fmt::time::FormatTime;
use secrets_core::config::resolve_db_config;
use secrets_core::db::{create_pool, migrate};
use secrets_core::service::delete::purge_expired_deleted_entries;
use crate::oauth::OAuthConfig;
use crate::tools::SecretsService;
@@ -169,6 +170,7 @@ async fn main() -> Result<()> {
// Rate limiting
let rate_limit_state = rate_limit::RateLimitState::new();
let rate_limit_cleanup = rate_limit::spawn_cleanup_task(rate_limit_state.ip_limiter.clone());
let recycle_bin_cleanup = tokio::spawn(start_recycle_bin_cleanup_task(pool.clone()));
let router = Router::new()
.merge(web::web_router())
@@ -212,9 +214,28 @@ async fn main() -> Result<()> {
session_cleanup.abort();
rate_limit_cleanup.abort();
recycle_bin_cleanup.abort();
Ok(())
}
async fn start_recycle_bin_cleanup_task(pool: PgPool) {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(24 * 60 * 60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
interval.tick().await;
match purge_expired_deleted_entries(&pool).await {
Ok(count) if count > 0 => {
tracing::info!(purged_count = count, "purged expired recycle bin entries");
}
Ok(_) => {}
Err(error) => {
tracing::warn!(error = %error, "failed to purge expired recycle bin entries");
}
}
}
}
async fn shutdown_signal() {
let ctrl_c = tokio::signal::ctrl_c();

View File

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

View File

@@ -168,8 +168,9 @@ use secrets_core::service::{
export::{ExportParams, export as svc_export},
get_secret::{get_all_secrets_by_id, get_secret_field_by_id},
history::run as svc_history,
relations::{add_parent_relation, get_relations_for_entries, remove_parent_relation},
rollback::run as svc_rollback,
search::{SearchParams, resolve_entry_by_id, run as svc_search},
search::{SearchParams, resolve_entry, resolve_entry_by_id, run as svc_search},
update::{UpdateParams, run as svc_update},
};
@@ -344,15 +345,6 @@ impl SecretsService {
Self::extract_enc_key(ctx)
}
/// Require both user_id and encryption key (header only, no arg fallback).
fn require_user_and_key(
ctx: &RequestContext<RoleServer>,
) -> Result<(Uuid, [u8; 32]), rmcp::ErrorData> {
let user_id = Self::require_user_id(ctx)?;
let key = Self::extract_enc_key(ctx)?;
Ok((user_id, key))
}
/// Require both user_id and encryption key, preferring an explicit argument
/// value over the X-Encryption-Key header.
fn require_user_and_key_or_arg(
@@ -373,6 +365,8 @@ struct FindInput {
description = "Fuzzy search across name, folder, type, notes, tags, and metadata values"
)]
query: Option<String>,
#[schemars(description = "Fuzzy search across metadata values only (keys excluded)")]
metadata_query: Option<String>,
#[schemars(description = "Exact folder filter (e.g. 'refining', 'ricnsmart')")]
folder: Option<String>,
#[schemars(
@@ -401,6 +395,8 @@ struct FindInput {
struct SearchInput {
#[schemars(description = "Fuzzy search across name, folder, type, notes, tags, metadata")]
query: Option<String>,
#[schemars(description = "Fuzzy search across metadata values only (keys excluded)")]
metadata_query: Option<String>,
#[schemars(description = "Folder filter (e.g. 'refining', 'personal', 'family')")]
folder: Option<String>,
#[schemars(
@@ -486,6 +482,9 @@ struct AddInput {
)]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
link_secret_names: Option<Vec<String>>,
#[schemars(description = "UUIDs of parent entries to link to this entry")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
parent_ids: Option<Vec<String>>,
#[schemars(description = "Encryption key as a 64-char hex string. \
If provided, takes priority over the X-Encryption-Key HTTP header. \
Use this when the MCP client cannot reliably forward custom headers.")]
@@ -551,6 +550,12 @@ struct UpdateInput {
)]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
unlink_secret_names: Option<Vec<String>>,
#[schemars(description = "UUIDs of parent entries to link")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
add_parent_ids: Option<Vec<String>>,
#[schemars(description = "UUIDs of parent entries to unlink")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
remove_parent_ids: Option<Vec<String>>,
#[schemars(description = "Encryption key as a 64-char hex string. \
If provided, takes priority over the X-Encryption-Key HTTP header. \
Use this when the MCP client cannot reliably forward custom headers.")]
@@ -596,16 +601,8 @@ struct HistoryInput {
#[derive(Debug, Deserialize, JsonSchema)]
struct RollbackInput {
#[schemars(description = "Name of the entry")]
name: String,
#[schemars(
description = "Folder for disambiguation when multiple entries share the same name (optional)"
)]
folder: Option<String>,
#[schemars(
description = "Entry UUID (from secrets_find). If provided, name/folder are ignored."
)]
id: Option<String>,
#[schemars(description = "Entry UUID (from secrets_find) for an existing, non-deleted entry")]
id: String,
#[schemars(description = "Target version number. Omit to restore the most recent snapshot.")]
#[serde(default, deserialize_with = "deser::option_i64_from_string")]
to_version: Option<i64>,
@@ -725,6 +722,10 @@ fn parse_uuid(s: &str) -> Result<Uuid, rmcp::ErrorData> {
.map_err(|_| rmcp::ErrorData::invalid_request(format!("Invalid UUID: '{}'", s), None))
}
fn parse_uuid_list(values: &[String]) -> Result<Vec<Uuid>, rmcp::ErrorData> {
values.iter().map(|value| parse_uuid(value)).collect()
}
// ── Tool implementations ──────────────────────────────────────────────────────
#[tool_router]
@@ -752,6 +753,7 @@ impl SecretsService {
name = input.name.as_deref(),
name_query = input.name_query.as_deref(),
query = input.query.as_deref(),
metadata_query = input.metadata_query.as_deref(),
"tool call start",
);
let tags = input.tags.unwrap_or_default();
@@ -764,6 +766,7 @@ impl SecretsService {
name_query: input.name_query.as_deref(),
tags: &tags,
query: input.query.as_deref(),
metadata_query: input.metadata_query.as_deref(),
sort: "name",
limit: input.limit.unwrap_or(20),
offset: input.offset.unwrap_or(0),
@@ -780,6 +783,7 @@ impl SecretsService {
name_query: input.name_query.as_deref(),
tags: &tags,
query: input.query.as_deref(),
metadata_query: input.metadata_query.as_deref(),
sort: "name",
limit: 0,
offset: 0,
@@ -788,15 +792,24 @@ impl SecretsService {
let total_count = secrets_core::service::search::count_entries(&self.pool, &count_params)
.await
.inspect_err(
|e| tracing::warn!(tool = "secrets_find", error = %e, "count_entries failed"),
)
.unwrap_or(0);
.map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
let relation_map = get_relations_for_entries(
&self.pool,
&result
.entries
.iter()
.map(|entry| entry.id)
.collect::<Vec<_>>(),
Some(user_id),
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
let entries: Vec<serde_json::Value> = result
.entries
.iter()
.map(|e| {
let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
let schema: Vec<serde_json::Value> = result
.secret_schemas
.get(&e.id)
@@ -819,6 +832,8 @@ impl SecretsService {
"type": e.entry_type,
"tags": e.tags,
"metadata": e.metadata,
"parents": relations.parents,
"children": relations.children,
"secret_fields": schema,
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
@@ -867,6 +882,7 @@ impl SecretsService {
name = input.name.as_deref(),
name_query = input.name_query.as_deref(),
query = input.query.as_deref(),
metadata_query = input.metadata_query.as_deref(),
"tool call start",
);
let tags = input.tags.unwrap_or_default();
@@ -879,6 +895,7 @@ impl SecretsService {
name_query: input.name_query.as_deref(),
tags: &tags,
query: input.query.as_deref(),
metadata_query: input.metadata_query.as_deref(),
sort: input.sort.as_deref().unwrap_or("name"),
limit: input.limit.unwrap_or(20),
offset: input.offset.unwrap_or(0),
@@ -887,12 +904,24 @@ impl SecretsService {
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_search", Some(user_id), e))?;
let relation_map = get_relations_for_entries(
&self.pool,
&result
.entries
.iter()
.map(|entry| entry.id)
.collect::<Vec<_>>(),
Some(user_id),
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_search", Some(user_id), e))?;
let summary = input.summary.unwrap_or(false);
let entries: Vec<serde_json::Value> = result
.entries
.iter()
.map(|e| {
let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
if summary {
serde_json::json!({
"name": e.name,
@@ -900,6 +929,8 @@ impl SecretsService {
"type": e.entry_type,
"tags": e.tags,
"notes": e.notes,
"parents": relations.parents,
"children": relations.children,
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
})
} else {
@@ -926,6 +957,8 @@ impl SecretsService {
"notes": e.notes,
"tags": e.tags,
"metadata": e.metadata,
"parents": relations.parents,
"children": relations.children,
"secret_fields": schema,
"version": e.version,
"updated_at": e.updated_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
@@ -1066,6 +1099,7 @@ impl SecretsService {
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string())))
.collect();
let link_secret_names = input.link_secret_names.unwrap_or_default();
let parent_ids = parse_uuid_list(&input.parent_ids.unwrap_or_default())?;
let folder = input.folder.as_deref().unwrap_or("");
let entry_type = input.entry_type.as_deref().unwrap_or("");
let notes = input.notes.as_deref().unwrap_or("");
@@ -1089,6 +1123,12 @@ impl SecretsService {
.await
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
for parent_id in parent_ids {
add_parent_relation(&self.pool, parent_id, result.entry_id, Some(user_id))
.await
.map_err(|e| mcp_err_from_anyhow("secrets_add", Some(user_id), e))?;
}
tracing::info!(
tool = "secrets_add",
?user_id,
@@ -1176,6 +1216,8 @@ impl SecretsService {
let remove_secrets = input.remove_secrets.unwrap_or_default();
let link_secret_names = input.link_secret_names.unwrap_or_default();
let unlink_secret_names = input.unlink_secret_names.unwrap_or_default();
let add_parent_ids = parse_uuid_list(&input.add_parent_ids.unwrap_or_default())?;
let remove_parent_ids = parse_uuid_list(&input.remove_parent_ids.unwrap_or_default())?;
let result = svc_update(
&self.pool,
@@ -1199,6 +1241,30 @@ impl SecretsService {
.await
.map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
let entry_id = if let Some(id_str) = input.id.as_deref() {
parse_uuid(id_str)?
} else {
resolve_entry(
&self.pool,
&resolved_name,
resolved_folder.as_deref(),
Some(user_id),
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_update", Some(user_id), e))?
.id
};
for parent_id in add_parent_ids {
add_parent_relation(&self.pool, parent_id, entry_id, Some(user_id))
.await
.map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
}
for parent_id in remove_parent_ids {
remove_parent_relation(&self.pool, parent_id, entry_id, Some(user_id))
.await
.map_err(|e| mcp_err_from_anyhow("secrets_update", Some(user_id), e))?;
}
tracing::info!(
tool = "secrets_update",
?user_id,
@@ -1339,7 +1405,7 @@ impl SecretsService {
}
#[tool(
description = "Rollback an entry to a previous version. Requires X-Encryption-Key header. \
description = "Rollback an entry to a previous version. Requires Bearer API key only (no encryption key). \
Omit to_version to restore the most recent snapshot. \
Optionally pass 'id' (from secrets_find) to target directly.",
annotations(title = "Rollback Secret Entry", destructive_hint = true)
@@ -1350,36 +1416,19 @@ impl SecretsService {
ctx: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let t = Instant::now();
let (user_id, _user_key) = Self::require_user_and_key(&ctx)?;
let user_id = Self::require_user_id(&ctx)?;
tracing::info!(
tool = "secrets_rollback",
?user_id,
name = %input.name,
id = ?input.id,
id = %input.id,
to_version = input.to_version,
"tool call start",
);
let entry_id = parse_uuid(&input.id)?;
let (resolved_name, resolved_folder): (String, Option<String>) =
if let Some(ref id_str) = input.id {
let eid = parse_uuid(id_str)?;
let entry = resolve_entry_by_id(&self.pool, eid, Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
(entry.name, Some(entry.folder))
} else {
(input.name.clone(), input.folder.clone())
};
let result = svc_rollback(
&self.pool,
&resolved_name,
resolved_folder.as_deref(),
input.to_version,
Some(user_id),
)
.await
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
let result = svc_rollback(&self.pool, entry_id, input.to_version, Some(user_id))
.await
.map_err(|e| mcp_err_internal_logged("secrets_rollback", Some(user_id), e))?;
tracing::info!(
tool = "secrets_rollback",

View File

@@ -11,7 +11,7 @@ use secrets_core::service::{
use crate::AppState;
use super::{SESSION_KEY_VERSION, current_user_id, render_template, require_valid_user};
use super::{SESSION_KEY_VERSION, load_session_user_strict, render_template, require_valid_user};
#[derive(Template)]
#[template(path = "dashboard.html")]
@@ -92,17 +92,11 @@ pub(super) async fn api_key_salt(
State(state): State<AppState>,
session: Session,
) -> Result<Json<KeySaltResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = get_user_by_id(&state.pool, user_id)
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for key-salt API");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
if user.key_salt.is_none() {
return Ok(Json(KeySaltResponse {
@@ -126,19 +120,14 @@ pub(super) async fn api_key_setup(
session: Session,
Json(body): Json<KeySetupRequest>,
) -> Result<Json<KeySetupResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let user_id = user.id;
// Guard: if a passphrase is already configured, reject and direct to /api/key-change
let user = get_user_by_id(&state.pool, user_id)
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for key-setup guard");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::UNAUTHORIZED)?;
if user.key_salt.is_some() {
tracing::warn!(%user_id, "key-setup called but passphrase already configured; use /api/key-change");
return Err(StatusCode::CONFLICT);
@@ -175,17 +164,12 @@ pub(super) async fn api_key_change(
session: Session,
Json(body): Json<KeyChangeRequest>,
) -> Result<Json<KeySetupResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = get_user_by_id(&state.pool, user_id)
.await
.map_err(|e| {
tracing::error!(error = %e, %user_id, "failed to load user for key-change");
StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let user_id = user.id;
// Must have an existing passphrase to change
let existing_key_check = user.key_check.ok_or_else(|| {
@@ -276,9 +260,12 @@ pub(super) async fn api_apikey_get(
State(state): State<AppState>,
session: Session,
) -> Result<Json<ApiKeyResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let user_id = user.id;
let api_key = ensure_api_key(&state.pool, user_id).await.map_err(|e| {
tracing::error!(error = %e, %user_id, "ensure_api_key failed");
@@ -292,9 +279,12 @@ pub(super) async fn api_apikey_regenerate(
State(state): State<AppState>,
session: Session,
) -> Result<Json<ApiKeyResponse>, StatusCode> {
let user_id = current_user_id(&session)
.await
.ok_or(StatusCode::UNAUTHORIZED)?;
let user = match load_session_user_strict(&state.pool, &session).await {
Ok(Some(u)) => u,
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
Err(()) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let user_id = user.id;
let api_key = regenerate_api_key(&state.pool, user_id)
.await

View File

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

View File

@@ -12,8 +12,12 @@ use uuid::Uuid;
use secrets_core::error::AppError;
use secrets_core::service::{
delete::delete_by_id,
delete::{
count_deleted_entries, delete_by_id, list_deleted_entries, purge_deleted_by_id,
restore_deleted_by_id,
},
get_secret::get_all_secrets_by_id,
relations::{RelationEntrySummary, get_relations_for_entries, set_parent_relations},
search::{SearchParams, count_entries, fetch_secrets_for_entries, ilike_pattern, list_entries},
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
};
@@ -21,8 +25,8 @@ use secrets_core::service::{
use crate::AppState;
use super::{
ENTRIES_PAGE_LIMIT, UiLang, current_user_id, paginate, render_template, request_ui_lang,
require_valid_user, tr,
ENTRIES_PAGE_LIMIT, UiLang, paginate, render_template, request_ui_lang, require_valid_user,
require_valid_user_json, tr,
};
// ── Template types ────────────────────────────────────────────────────────────
@@ -40,6 +44,8 @@ struct EntriesPageTemplate {
secret_type_options_json: String,
filter_folder: String,
filter_name: String,
filter_metadata_query: String,
filter_tags: String,
filter_type: String,
current_page: u32,
total_pages: u32,
@@ -47,6 +53,18 @@ struct EntriesPageTemplate {
version: &'static str,
}
#[derive(Template)]
#[template(path = "trash.html")]
struct TrashPageTemplate {
user_name: String,
user_email: String,
entries: Vec<TrashEntryView>,
current_page: u32,
total_pages: u32,
total_count: i64,
version: &'static str,
}
/// Non-sensitive entry fields; `secrets` lists field names/types only (no ciphertext).
struct EntryListItemView {
id: String,
@@ -61,6 +79,9 @@ struct EntryListItemView {
secrets: Vec<SecretSummaryView>,
/// JSON array of `{ id, name, secret_type }` for dialog secret chips.
secrets_json: String,
parents: Vec<RelationSummaryView>,
children: Vec<RelationSummaryView>,
parents_json: String,
/// RFC3339 UTC; shown in edit dialog.
updated_at_iso: String,
}
@@ -72,6 +93,15 @@ struct SecretSummaryView {
secret_type: String,
}
#[derive(Clone, Serialize)]
struct RelationSummaryView {
id: String,
name: String,
folder: String,
entry_type: String,
href: String,
}
struct FolderTabView {
name: String,
count: i64,
@@ -79,20 +109,57 @@ struct FolderTabView {
active: bool,
}
struct TrashEntryView {
id: String,
name: String,
folder: String,
entry_type: String,
deleted_at_iso: String,
deleted_at_label: String,
}
#[derive(Deserialize)]
pub(super) struct EntriesQuery {
folder: Option<String>,
name: Option<String>,
metadata_query: Option<String>,
/// 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>,
}
#[derive(Deserialize)]
pub(super) struct EntryOptionQuery {
q: Option<String>,
exclude_id: Option<Uuid>,
}
// ── Entry mutation error helpers ──────────────────────────────────────────────
type EntryApiError = (StatusCode, Json<serde_json::Value>);
fn require_encryption_key(headers: &HeaderMap, lang: UiLang) -> Result<[u8; 32], EntryApiError> {
let enc_key_hex = headers
.get("x-encryption-key")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "缺少 X-Encryption-Key 请求头", "缺少 X-Encryption-Key 請求標頭", "Missing X-Encryption-Key header") })),
)
})?;
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "X-Encryption-Key 格式无效", "X-Encryption-Key 格式無效", "Invalid X-Encryption-Key format") })),
)
})
}
fn map_entry_mutation_err(e: anyhow::Error, lang: UiLang) -> EntryApiError {
if let Some(app_err) = e.downcast_ref::<AppError>() {
return map_app_error(app_err, lang);
@@ -171,6 +238,35 @@ fn map_app_error(err: &AppError, lang: UiLang) -> EntryApiError {
}
}
fn relation_views(items: &[RelationEntrySummary]) -> Vec<RelationSummaryView> {
items
.iter()
.map(|item| RelationSummaryView {
id: item.id.to_string(),
name: item.name.clone(),
folder: item.folder.clone(),
entry_type: item.entry_type.clone(),
href: format!(
"/entries?folder={}&name={}",
urlencoding::encode(&item.folder),
urlencoding::encode(&item.name)
),
})
.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(
@@ -202,14 +298,23 @@ pub(super) async fn entries_page(
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let metadata_query_filter = q
.metadata_query
.as_ref()
.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",
limit: ENTRIES_PAGE_LIMIT,
offset: 0,
@@ -223,7 +328,7 @@ pub(super) async fn entries_page(
count: i64,
}
let mut folder_sql =
"SELECT folder, COUNT(*)::bigint AS count FROM entries WHERE user_id = $1".to_string();
"SELECT folder, COUNT(*)::bigint AS count FROM entries WHERE user_id = $1 AND deleted_at IS NULL".to_string();
let mut bind_idx = 2;
if type_filter.is_some() {
folder_sql.push_str(&format!(" AND type = ${bind_idx}"));
@@ -233,6 +338,23 @@ pub(super) async fn entries_page(
folder_sql.push_str(&format!(" AND name ILIKE ${bind_idx} ESCAPE '\\'"));
bind_idx += 1;
}
if metadata_query_filter.is_some() {
folder_sql.push_str(&format!(
" AND EXISTS (SELECT 1 FROM jsonb_path_query(metadata, 'strict $.** ? (@.type() != \"object\" && @.type() != \"array\")') AS val \
WHERE (val #>> '{{}}') ILIKE ${bind_idx} ESCAPE '\\')"
));
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);
@@ -242,6 +364,12 @@ pub(super) async fn entries_page(
if let Some(n) = name_filter.as_deref() {
folder_query = folder_query.bind(ilike_pattern(n));
}
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 {
@@ -261,7 +389,7 @@ pub(super) async fn entries_page(
},
folder_query.fetch_all(&state.pool),
sqlx::query_as::<_, TypeOptionRow>(
"SELECT DISTINCT type FROM entries WHERE user_id = $1 ORDER BY type",
"SELECT DISTINCT type FROM entries WHERE user_id = $1 AND deleted_at IS NULL ORDER BY type",
)
.bind(user_id)
.fetch_all(&state.pool),
@@ -297,6 +425,12 @@ pub(super) async fn entries_page(
tracing::error!(error = %e, "failed to load secret schema list for web");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let relation_map = get_relations_for_entries(&state.pool, &entry_ids, Some(user_id))
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to load relation list for web");
StatusCode::INTERNAL_SERVER_ERROR
})?;
if let Some(current) = type_filter.as_ref()
&& !current.is_empty()
&& !type_options.iter().any(|t| t == current)
@@ -309,6 +443,8 @@ pub(super) async fn entries_page(
folder: Option<&str>,
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();
@@ -327,6 +463,16 @@ pub(super) async fn entries_page(
{
pairs.push(format!("name={}", urlencoding::encode(n)));
}
if let Some(v) = metadata_query
&& !v.is_empty()
{
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));
}
@@ -337,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 {
@@ -346,6 +493,8 @@ pub(super) async fn entries_page(
None,
type_filter.as_deref(),
name_filter.as_deref(),
metadata_query_filter.as_deref(),
tags_for_href,
Some(1),
),
active: folder_filter.is_none(),
@@ -357,6 +506,8 @@ pub(super) async fn entries_page(
Some(&name),
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()),
@@ -368,6 +519,7 @@ pub(super) async fn entries_page(
let entries = rows
.into_iter()
.map(|e| {
let relations = relation_map.get(&e.id).cloned().unwrap_or_default();
let secrets: Vec<SecretSummaryView> = secret_schemas
.get(&e.id)
.map(|fields| {
@@ -384,6 +536,9 @@ pub(super) async fn entries_page(
let secrets_json = serde_json::to_string(&secrets).unwrap_or_else(|_| "[]".to_string());
let metadata_json =
serde_json::to_string(&e.metadata).unwrap_or_else(|_| "{}".to_string());
let parents = relation_views(&relations.parents);
let children = relation_views(&relations.children);
let parents_json = serde_json::to_string(&parents).unwrap_or_else(|_| "[]".to_string());
EntryListItemView {
id: e.id.to_string(),
folder: e.folder,
@@ -394,6 +549,9 @@ pub(super) async fn entries_page(
metadata_json,
secrets,
secrets_json,
parents,
children,
parents_json,
updated_at_iso: e.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true),
}
})
@@ -414,6 +572,8 @@ pub(super) async fn entries_page(
.unwrap_or_default(),
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,
@@ -424,6 +584,56 @@ pub(super) async fn entries_page(
render_template(tmpl)
}
pub(super) async fn trash_page(
State(state): State<AppState>,
session: Session,
Query(q): Query<EntriesQuery>,
) -> Result<Response, StatusCode> {
let user = match require_valid_user(&state.pool, &session, "trash_page").await {
Ok(u) => u,
Err(r) => return Ok(r),
};
let page = q.page.unwrap_or(1).max(1);
let total_count = count_deleted_entries(&state.pool, user.id)
.await
.map_err(|e| {
tracing::error!(error = %e, user_id = %user.id, "failed to count trash entries");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let (current_page, total_pages, offset) = paginate(page, total_count, ENTRIES_PAGE_LIMIT);
let rows = list_deleted_entries(&state.pool, user.id, ENTRIES_PAGE_LIMIT, offset)
.await
.map_err(|e| {
tracing::error!(error = %e, user_id = %user.id, "failed to load trash entries");
StatusCode::INTERNAL_SERVER_ERROR
})?;
let entries = rows
.into_iter()
.map(|entry| TrashEntryView {
id: entry.id.to_string(),
name: entry.name,
folder: entry.folder,
entry_type: entry.entry_type,
deleted_at_iso: entry.deleted_at.to_rfc3339_opts(SecondsFormat::Secs, true),
deleted_at_label: entry.deleted_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
})
.collect();
let tmpl = TrashPageTemplate {
user_name: user.name.clone(),
user_email: user.email.clone().unwrap_or_default(),
entries,
current_page,
total_pages,
total_count,
version: env!("CARGO_PKG_VERSION"),
};
render_template(tmpl)
}
// ── Entry management (Web UI, non-sensitive fields only) ───────────────────────
#[derive(Deserialize)]
@@ -435,6 +645,7 @@ pub(super) struct EntryPatchBody {
notes: String,
tags: Vec<String>,
metadata: serde_json::Value,
parent_ids: Option<Vec<Uuid>>,
}
pub(super) async fn api_entry_patch(
@@ -445,10 +656,8 @@ pub(super) async fn api_entry_patch(
Json(body): Json<EntryPatchBody>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let folder = body.folder.trim();
let entry_type = body.entry_type.trim();
@@ -464,6 +673,71 @@ pub(super) async fn api_entry_patch(
));
}
if folder.chars().count() > crate::validation::MAX_FOLDER_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!(
"{} {} {}",
tr(
lang,
"folder 长度不能超过",
"folder 長度不能超過",
"folder must be at most"
),
crate::validation::MAX_FOLDER_LENGTH,
tr(lang, " 个字符", " 個字元", " characters")
) })),
));
}
if entry_type.chars().count() > crate::validation::MAX_ENTRY_TYPE_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!(
"{} {} {}",
tr(
lang,
"type 长度不能超过",
"type 長度不能超過",
"type must be at most"
),
crate::validation::MAX_ENTRY_TYPE_LENGTH,
tr(lang, " 个字符", " 個字元", " characters")
) })),
));
}
if name.chars().count() > crate::validation::MAX_NAME_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!(
"{} {} {}",
tr(
lang,
"name 长度不能超过",
"name 長度不能超過",
"name must be at most"
),
crate::validation::MAX_NAME_LENGTH,
tr(lang, " 个字符", " 個字元", " characters")
) })),
));
}
if notes.chars().count() > crate::validation::MAX_NOTES_LENGTH {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({ "error": format!(
"{} {} {}",
tr(
lang,
"notes 长度不能超过",
"notes 長度不能超過",
"notes must be at most"
),
crate::validation::MAX_NOTES_LENGTH,
tr(lang, " 个字符", " 個字元", " characters")
) })),
));
}
let tags: Vec<String> = body
.tags
.into_iter()
@@ -496,9 +770,68 @@ pub(super) async fn api_entry_patch(
.await
.map_err(|e| map_entry_mutation_err(e, lang))?;
if let Some(parent_ids) = body.parent_ids.as_deref() {
set_parent_relations(&state.pool, entry_id, parent_ids, Some(user_id))
.await
.map_err(|e| map_entry_mutation_err(e, lang))?;
}
Ok(Json(json!({ "ok": true })))
}
pub(super) async fn api_entry_options(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Query(q): Query<EntryOptionQuery>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let query =
q.q.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("");
if query.is_empty() {
return Ok(Json(json!([])));
}
let rows = list_entries(
&state.pool,
SearchParams {
folder: None,
entry_type: None,
name: None,
name_query: Some(query),
tags: &[],
query: None,
metadata_query: None,
sort: "name",
limit: 10,
offset: 0,
user_id: Some(user_id),
},
)
.await
.map_err(|e| map_entry_mutation_err(e, lang))?;
let options: Vec<_> = rows
.into_iter()
.filter(|entry| Some(entry.id) != q.exclude_id)
.map(|entry| {
json!({
"id": entry.id,
"name": entry.name,
"folder": entry.folder,
"type": entry.entry_type,
})
})
.collect();
Ok(Json(serde_json::Value::Array(options)))
}
pub(super) async fn api_entry_delete(
State(state): State<AppState>,
session: Session,
@@ -506,10 +839,8 @@ pub(super) async fn api_entry_delete(
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
delete_by_id(&state.pool, entry_id, user_id)
.await
@@ -517,6 +848,47 @@ pub(super) async fn api_entry_delete(
Ok(Json(json!({
"ok": true,
"deleted": true,
})))
}
pub(super) async fn api_trash_restore(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
restore_deleted_by_id(&state.pool, entry_id, user_id)
.await
.map_err(|e| map_entry_mutation_err(e, lang))?;
Ok(Json(json!({
"ok": true,
"restored": true,
})))
}
pub(super) async fn api_trash_purge(
State(state): State<AppState>,
session: Session,
headers: HeaderMap,
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
purge_deleted_by_id(&state.pool, entry_id, user_id)
.await
.map_err(|e| map_entry_mutation_err(e, lang))?;
Ok(Json(json!({
"ok": true,
"purged": true,
})))
}
@@ -541,10 +913,8 @@ pub(super) async fn api_secret_check_name(
Query(params): Query<SecretCheckNameQuery>,
) -> Result<Json<SecretCheckNameResponse>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let name = params.name.trim();
if name.is_empty() {
@@ -618,6 +988,7 @@ pub(super) struct SecretPatchBody {
name: Option<String>,
#[serde(rename = "type")]
secret_type: Option<String>,
value: Option<serde_json::Value>,
}
pub(super) async fn api_secret_patch(
@@ -636,13 +1007,12 @@ pub(super) async fn api_secret_patch(
}
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let name = body.name.as_ref().map(|s| s.trim());
let secret_type = body.secret_type.as_ref().map(|s| s.trim());
let secret_value = body.value.as_ref();
if let Some(n) = name {
if n.is_empty() {
@@ -682,30 +1052,37 @@ pub(super) async fn api_secret_patch(
}
}
if name.is_none() && secret_type.is_none() {
if name.is_none() && secret_type.is_none() && secret_value.is_none() {
return Err((
StatusCode::BAD_REQUEST,
Json(
json!({ "error": tr(lang, "至少需要提供 name 或 type 之一", "至少需要提供 nametype 之一", "At least one of name or type is required") }),
json!({ "error": tr(lang, "至少需要提供 name、type 或 value 之一", "至少需要提供 nametype 或 value 之一", "At least one of name, type, or value is required") }),
),
));
}
let master_key = if secret_value.is_some() {
Some(require_encryption_key(&headers, lang)?)
} else {
None
};
let mut tx = state
.pool
.begin()
.await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
let secret_row: Option<(String, String)> =
sqlx::query_as("SELECT name, type FROM secrets WHERE id = $1 AND user_id = $2 FOR UPDATE")
.bind(secret_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
let secret_row: Option<(String, String, Vec<u8>)> = sqlx::query_as(
"SELECT name, type, encrypted FROM secrets WHERE id = $1 AND user_id = $2 FOR UPDATE",
)
.bind(secret_id)
.bind(user_id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| map_entry_mutation_err(e.into(), lang))?;
let Some((old_name, old_type)) = secret_row else {
let Some((old_name, old_type, old_encrypted)) = secret_row else {
let _ = tx.rollback().await;
return Err((
StatusCode::NOT_FOUND,
@@ -730,13 +1107,47 @@ pub(super) async fn api_secret_patch(
let new_name = name.unwrap_or(&old_name).to_string();
let new_type = secret_type.unwrap_or(&old_type).to_string();
let new_encrypted = if let Some(value) = secret_value {
let encrypted = secrets_core::crypto::encrypt_json(
master_key
.as_ref()
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "请先设置密码短语后再编辑密文值", "請先設定密碼短語後再編輯密文值", "Unlock your passphrase before editing secret values") })),
)
})?,
value,
)
.map_err(|e| map_entry_mutation_err(e, lang))?;
Some(encrypted)
} else {
None
};
let value_changed = new_encrypted.is_some();
if let Err(e) = secrets_core::db::snapshot_secret_history(
&mut tx,
secrets_core::db::SecretSnapshotParams {
secret_id,
name: &old_name,
encrypted: &old_encrypted,
action: if value_changed { "update" } else { "rename" },
},
)
.await
{
tracing::warn!(error = %e, %secret_id, "failed to snapshot secret history before patch");
}
let result = sqlx::query(
"UPDATE secrets SET name = $1, type = $2, version = version + 1, updated_at = NOW() \
WHERE id = $3",
"UPDATE secrets SET name = $1, type = $2, encrypted = $3, version = version + 1, updated_at = NOW() \
WHERE id = $4",
)
.bind(&new_name)
.bind(&new_type)
.bind(new_encrypted.as_deref().unwrap_or(&old_encrypted))
.bind(secret_id)
.execute(&mut *tx)
.await;
@@ -760,7 +1171,11 @@ pub(super) async fn api_secret_patch(
secrets_core::audit::log_tx(
&mut tx,
Some(user_id),
"rename_secret",
if value_changed {
"update_secret"
} else {
"rename_secret"
},
"",
"",
&old_name,
@@ -771,6 +1186,7 @@ pub(super) async fn api_secret_patch(
"new_name": new_name,
"old_type": old_type,
"new_type": new_type,
"value_updated": value_changed,
"linked_entries": linked_entries,
}),
)
@@ -798,10 +1214,8 @@ pub(super) async fn api_entry_secret_unlink(
}
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let mut tx = state
.pool
@@ -891,28 +1305,10 @@ pub(super) async fn api_entry_secrets_decrypt(
Path(entry_id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, EntryApiError> {
let lang = request_ui_lang(&headers);
let user_id = current_user_id(&session).await.ok_or((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
))?;
let user = require_valid_user_json(&state.pool, &session, lang).await?;
let user_id = user.id;
let enc_key_hex = headers
.get("x-encryption-key")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "缺少 X-Encryption-Key 请求头", "缺少 X-Encryption-Key 請求標頭", "Missing X-Encryption-Key header") })),
)
})?;
let master_key =
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
(
StatusCode::BAD_REQUEST,
Json(json!({ "error": tr(lang, "X-Encryption-Key 格式无效", "X-Encryption-Key 格式無效", "Invalid X-Encryption-Key format") })),
)
})?;
let master_key = require_encryption_key(&headers, lang)?;
let secrets =
get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id))
@@ -946,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());
}
}

View File

@@ -1,10 +1,11 @@
use askama::Template;
use axum::{
Router,
Json, Router,
http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, patch, post},
};
use serde_json::json;
use tower_sessions::Session;
use uuid::Uuid;
@@ -15,6 +16,7 @@ mod account;
mod assets;
mod audit;
mod auth;
mod changelog;
mod entries;
// ── Session keys ──────────────────────────────────────────────────────────────
@@ -34,7 +36,7 @@ const AUDIT_PAGE_LIMIT: i64 = 10;
// ── UI language ───────────────────────────────────────────────────────────────
#[derive(Clone, Copy)]
enum UiLang {
pub(super) enum UiLang {
ZhCn,
ZhTw,
En,
@@ -143,6 +145,71 @@ async fn require_valid_user(
Ok(user)
}
/// `Ok(None)` — unauthenticated or session invalidated (including `key_version` mismatch).
/// `Err(())` — database error loading the user.
pub(super) async fn load_session_user_strict(
pool: &sqlx::PgPool,
session: &Session,
) -> Result<Option<secrets_core::models::User>, ()> {
let Some(user_id) = current_user_id(session).await else {
return Ok(None);
};
let user = match secrets_core::service::user::get_user_by_id(pool, user_id).await {
Err(e) => {
tracing::error!(error = %e, %user_id, "load_session_user_strict: failed to load user");
return Err(());
}
Ok(None) => {
if let Err(e) = session.flush().await {
tracing::warn!(error = %e, "failed to flush stale session");
}
return Ok(None);
}
Ok(Some(u)) => u,
};
let session_kv: Option<i64> = match session.get::<i64>(SESSION_KEY_VERSION).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "failed to read key_version from session; treating as missing");
None
}
};
if let Some(kv) = session_kv
&& kv != user.key_version
{
tracing::info!(%user_id, session_kv = kv, db_kv = user.key_version, "key_version mismatch; invalidating session (API)");
if let Err(e) = session.flush().await {
tracing::warn!(error = %e, "failed to flush outdated session");
}
return Ok(None);
}
Ok(Some(user))
}
/// JSON API equivalent of [`require_valid_user`]: returns `401` with a JSON body instead of redirecting.
pub(super) async fn require_valid_user_json(
pool: &sqlx::PgPool,
session: &Session,
lang: UiLang,
) -> Result<secrets_core::models::User, (StatusCode, Json<serde_json::Value>)> {
match load_session_user_strict(pool, session).await {
Ok(Some(user)) => Ok(user),
Ok(None) => Err((
StatusCode::UNAUTHORIZED,
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
)),
Err(()) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(
json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") }),
),
)),
}
}
fn request_user_agent(headers: &HeaderMap) -> Option<String> {
headers
.get(header::USER_AGENT)
@@ -187,12 +254,14 @@ 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))
.route("/auth/logout", post(auth::auth_logout))
.route("/dashboard", get(account::dashboard))
.route("/entries", get(entries::entries_page))
.route("/trash", get(entries::trash_page))
.route("/audit", get(audit::audit_page))
.route("/account/bind/google", get(auth::account_bind_google))
.route("/account/unbind/{provider}", post(auth::account_unbind))
@@ -200,6 +269,7 @@ pub fn web_router() -> Router<AppState> {
.route("/api/key-setup", post(account::api_key_setup))
.route("/api/key-change", post(account::api_key_change))
.route("/api/apikey", get(account::api_apikey_get))
.route("/api/entries/options", get(entries::api_entry_options))
.route(
"/api/apikey/regenerate",
post(account::api_apikey_regenerate),
@@ -208,6 +278,11 @@ pub fn web_router() -> Router<AppState> {
"/api/entries/{id}",
patch(entries::api_entry_patch).delete(entries::api_entry_delete),
)
.route("/api/trash/{id}/restore", post(entries::api_trash_restore))
.route(
"/api/trash/{id}",
axum::routing::delete(entries::api_trash_purge),
)
.route(
"/api/entries/{entry_id}/secrets/{secret_id}",
axum::routing::delete(entries::api_entry_secret_unlink),

View File

@@ -13,77 +13,87 @@
--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; }
body { background: #0d1117; color: #c9d1d9; font-family: 'Inter', sans-serif; min-height: 100vh; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
color: var(--text); text-decoration: none; padding: 0 10px; }
.sidebar-logo span { color: var(--accent); }
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
color: #fff; text-decoration: none; padding: 0 10px; }
.sidebar-menu { display: grid; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
border: 1px solid transparent; font-size: 13px; font-weight: 500;
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link:hover { background: rgba(56,139,253,0.14); color: #fff; }
.sidebar-link.active {
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
background: rgba(56,139,253,0.14); color: #fff;
}
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 52px;
background: transparent; border-bottom: none; padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 44px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); }
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
font-size: 12px; cursor: pointer; border-radius: 4px; }
.lang-btn.active { background: var(--border); color: var(--text); }
.nav-user { font-size: 14px; color: #8b949e; }
.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; }
.btn-sign-out {
padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 12px; text-decoration: none; cursor: pointer;
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
}
.btn-sign-out:hover { background: var(--surface2); }
.main { padding: 32px 24px 40px; flex: 1; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 100%; max-width: 1180px; margin: 0 auto; }
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.main { padding: 16px 16px 24px; flex: 1; }
.card { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
padding: 20px; width: 100%; }
.card-title-row {
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
margin-bottom: 20px;
margin-bottom: 18px;
}
.card-title { font-size: 20px; font-weight: 600; margin: 0; }
.card-title { font-size: 22px; font-weight: 700; margin: 0; color: #fff; }
.card-title-count {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 8px;
border: 1px solid var(--border);
border: 1px solid rgba(240,246,252,0.08);
border-radius: 999px;
background: var(--bg);
color: var(--text-muted);
background: #0d1117;
color: #8b949e;
font-size: 12px;
font-weight: 600;
line-height: 1;
font-family: 'JetBrains Mono', monospace;
}
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
.empty { color: #8b949e; font-size: 14px; padding: 20px 0; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
th { color: var(--text-muted); font-size: 12px; font-weight: 600; }
td { font-size: 13px; }
th, td { text-align: left; vertical-align: top; padding: 14px 12px; border-top: 1px solid rgba(240,246,252,0.08); }
th { color: #8b949e; font-size: 12px; font-weight: 600; }
td { font-size: 13px; color: #c9d1d9; }
.mono { font-family: 'JetBrains Mono', monospace; }
.detail {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
padding: 10px; white-space: pre-wrap; word-break: break-word; font-size: 12px;
max-width: 460px;
.col-detail { min-width: 260px; max-width: 460px; }
.detail-scroll {
height: calc(1.5em * 3 + 20px);
min-height: calc(1.5em * 3 + 20px);
overflow: auto;
resize: vertical;
white-space: pre-wrap;
word-break: break-word;
padding: 10px;
background: #0d1117;
border: 1px solid rgba(240,246,252,0.08);
border-radius: 10px;
font-size: 12px;
font-family: 'JetBrains Mono', monospace;
margin: 0;
}
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
width: 100%; border-right: none; border-bottom: 1px solid rgba(240,246,252,0.08);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; }
@@ -93,42 +103,43 @@
.topbar { padding: 12px 16px; flex-wrap: wrap; }
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { border-top: 1px solid var(--border); padding: 12px 0; }
tr { border-top: 1px solid rgba(240,246,252,0.08); padding: 12px 0; }
td { border-top: none; padding: 6px 0; }
td::before {
display: block; color: var(--text-muted); font-size: 11px;
display: block; color: #8b949e; font-size: 11px;
margin-bottom: 4px; text-transform: uppercase;
content: attr(data-label);
}
.detail { max-width: none; }
}
.pagination {
display: flex; align-items: center; gap: 8px; margin-top: 20px;
display: flex; align-items: center; gap: 12px; margin-top: 18px;
justify-content: center; padding: 12px 0;
}
.page-btn {
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text); text-decoration: none;
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
background: #161b22; color: #c9d1d9; text-decoration: none;
font-size: 13px; cursor: pointer;
}
.page-btn:hover { background: var(--surface2); }
.page-btn:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.page-btn-disabled {
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-muted); font-size: 13px;
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
background: #161b22; color: #6e7681; font-size: 13px;
opacity: 0.5; cursor: not-allowed;
}
.page-info {
color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace;
color: #8b949e; font-size: 13px; font-family: 'JetBrains Mono', monospace;
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<a href="/dashboard" class="sidebar-logo">secrets</a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
<a href="/entries" class="sidebar-link" data-i18n="navEntries">条目</a>
<a href="/trash" class="sidebar-link" data-i18n="navTrash">回收站</a>
<a href="/audit" class="sidebar-link active" data-i18n="navAudit">审计</a>
</nav>
</aside>
@@ -172,7 +183,7 @@
<td class="col-time mono" data-label="时间"><time class="audit-local-time" datetime="{{ entry.created_at_iso }}">{{ entry.created_at_iso }}</time></td>
<td class="col-action mono" data-label="动作">{{ entry.action }}</td>
<td class="col-target mono" data-label="目标">{{ entry.target }}</td>
<td class="col-detail" data-label="详情"><pre class="detail">{{ entry.detail }}</pre></td>
<td class="col-detail" data-label="详情">{% if !entry.detail.is_empty() %}<pre class="detail-scroll">{{ entry.detail }}</pre>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
@@ -198,7 +209,7 @@
</main>
</div>
</div>
<script src="/static/i18n.js"></script>
<script src="/static/i18n.js?v={{ version }}"></script>
<script>
(function () {
I18N_PAGE = {
@@ -228,6 +239,7 @@
el.title = raw + ' (UTC)';
}
});
applyLang();
})();
</script>

View File

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

View File

@@ -18,110 +18,110 @@
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
padding: 24px 16px; display: flex; flex-direction: column; gap: 20px;
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600;
color: var(--text); text-decoration: none; padding: 0 10px; }
.sidebar-logo span { color: var(--accent); }
.sidebar-menu { display: flex; flex-direction: column; gap: 6px; }
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
color: #fff; text-decoration: none; padding: 0 10px; }
.sidebar-menu { display: grid; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 8px; color: var(--text-muted); text-decoration: none;
border: 1px solid transparent; font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: var(--surface2); color: var(--text); }
.sidebar-link.active {
background: rgba(88,166,255,0.12); color: var(--text); border-color: rgba(88,166,255,0.35);
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: rgba(56,139,253,0.14); color: #fff; }
.sidebar-link.active { background: rgba(56,139,253,0.14); color: #fff; }
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border); padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 52px;
background: transparent; border-bottom: none; padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 44px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 13px; color: var(--text-muted); }
.lang-bar { display: flex; gap: 2px; background: var(--surface2); border-radius: 6px; padding: 2px; }
.lang-btn { padding: 3px 9px; border: none; background: none; color: var(--text-muted);
font-size: 12px; cursor: pointer; border-radius: 4px; }
.lang-btn.active { background: var(--border); color: var(--text); }
.btn-sign-out { padding: 5px 12px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 12px; cursor: pointer; }
.btn-sign-out:hover { background: var(--surface2); }
.nav-user { font-size: 14px; color: #8b949e; }
.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; }
.btn-sign-out {
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
}
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
/* Main content column */
.main { display: flex; flex-direction: column; align-items: center;
padding: 24px 20px 8px; min-height: 0; }
.main { padding: 16px 16px 0; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.app-footer {
margin-top: auto;
text-align: center;
padding: 4px 20px 12px;
font-size: 12px;
color: #9da7b3;
padding: 12px 0;
font-size: 11px;
color: var(--text-muted);
font-family: 'JetBrains Mono', monospace;
margin-top: auto;
}
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 24px; width: 100%; max-width: 980px; }
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 24px; }
.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; }
/* Form */
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
.field input { width: 100%; background: var(--bg); border: 1px solid var(--border);
color: var(--text); padding: 9px 12px; border-radius: 6px;
.field label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 5px; }
.field input { width: 100%; background: #0d1117; border: 1px solid rgba(240,246,252,0.08);
color: #c9d1d9; padding: 9px 12px; border-radius: 10px;
font-size: 13px; outline: none; }
.field input:focus { border-color: var(--accent); }
.field input:focus { border-color: rgba(56,139,253,0.5); }
.pw-field { position: relative; }
.pw-field > input { padding-right: 42px; }
.pw-toggle {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border: none; border-radius: 6px;
background: transparent; color: var(--text-muted); cursor: pointer;
width: 32px; height: 32px; border: none; border-radius: 8px;
background: transparent; color: #8b949e; cursor: pointer;
}
.pw-toggle:hover { color: var(--text); background: var(--surface2); }
.pw-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.pw-toggle:hover { color: #c9d1d9; background: rgba(240,246,252,0.06); }
.pw-toggle:focus-visible { outline: 2px solid rgba(56,139,253,0.5); outline-offset: 2px; }
.pw-icon svg { display: block; }
.pw-icon.hidden { display: none; }
.error-msg { color: var(--danger); font-size: 12px; margin-top: 6px; display: none; }
.error-msg { color: #f85149; font-size: 12px; margin-top: 6px; display: none; }
/* Buttons */
.btn-primary { display: inline-flex; align-items: center; gap: 6px; width: 100%;
justify-content: center; padding: 10px 20px; border-radius: 7px;
border: none; background: var(--accent); color: #0d1117;
justify-content: center; padding: 10px 20px; border-radius: 10px;
border: none; background: #388bfd; color: #fff;
font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:hover { background: #58a6ff; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-sm { display: inline-flex; align-items: center; gap: 4px; padding: 5px 12px;
border-radius: 5px; border: 1px solid var(--border); background: none;
color: var(--text-muted); font-size: 12px; cursor: pointer; }
.btn-sm:hover { color: var(--text); border-color: var(--text-muted); }
.btn-sm { display: inline-flex; align-items: center; gap: 4px; padding: 8px 12px;
border-radius: 10px; border: 1px solid rgba(240,246,252,0.12); background: #161b22;
color: #8b949e; font-size: 13px; cursor: pointer; font-family: inherit; }
.btn-sm:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.btn-copy { display: flex; align-items: center; gap: 8px; width: 100%; justify-content: center;
padding: 11px 20px; border-radius: 7px; border: 1px solid var(--success);
background: rgba(63,185,80,0.1); color: var(--success);
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
padding: 11px 20px; border-radius: 10px; border: 1px solid #3fb950;
background: rgba(63,185,80,0.1); color: #3fb950;
font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.15s; font-family: inherit; }
.btn-copy:hover { background: rgba(63,185,80,0.2); }
.btn-copy.copied { background: var(--success); color: #0d1117; border-color: var(--success); }
.btn-copy.copied { background: #3fb950; color: #0d1117; border-color: #3fb950; }
/* Config format switcher */
.config-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 12px; }
.config-tab { padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
background: var(--surface2); color: var(--text-muted); cursor: pointer;
.config-tab { padding: 12px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.08);
background: #161b22; color: #8b949e; cursor: pointer;
font-family: inherit; text-align: left; transition: border-color 0.15s, background 0.15s, transform 0.15s; }
.config-tab:hover { color: var(--text); border-color: var(--accent); transform: translateY(-1px); }
.config-tab.active { background: rgba(88,166,255,0.1); color: var(--text); border-color: var(--accent); }
.config-tab:hover { color: #c9d1d9; border-color: rgba(56,139,253,0.45); transform: translateY(-1px); }
.config-tab.active { background: rgba(56,139,253,0.14); color: #fff; border-color: rgba(56,139,253,0.3); }
.config-tab-title { display: block; font-size: 13px; font-weight: 600; color: inherit; }
/* Config box */
.config-wrap { position: relative; margin-bottom: 14px; }
.config-box { background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
.config-box { background: #0d1117; border: 1px solid rgba(240,246,252,0.08); border-radius: 10px;
padding: 16px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
line-height: 1.7; color: var(--text); overflow-x: auto; white-space: pre; }
.config-box.locked { color: var(--text-muted); filter: blur(3px); user-select: none;
line-height: 1.7; color: #c9d1d9; overflow-x: auto; white-space: pre; }
.config-box.locked { color: #8b949e; filter: blur(3px); user-select: none;
pointer-events: none; }
.config-key { color: #79c0ff; }
.config-str { color: #a5d6ff; }
.config-val { color: var(--accent); }
.config-val { color: #58a6ff; }
/* Divider */
.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
.divider { border: none; border-top: 1px solid rgba(240,246,252,0.08); margin: 20px 0; }
/* Actions row */
.actions-row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
@@ -135,34 +135,29 @@
.modal-bd { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75);
z-index: 100; align-items: center; justify-content: center; }
.modal-bd.open { display: flex; }
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
.modal { background: #111827; border: 1px solid rgba(240,246,252,0.08); border-radius: 18px;
padding: 28px; width: 100%; max-width: 420px; }
.modal h3 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
.modal h3 { font-size: 18px; font-weight: 700; margin-bottom: 16px; color: #fff; }
.modal-actions { display: flex; gap: 8px; margin-top: 16px; }
.btn-modal-ok { flex: 1; padding: 8px; border-radius: 6px; border: none;
background: var(--accent); color: #0d1117; font-size: 13px;
font-weight: 600; cursor: pointer; }
.btn-modal-ok:hover { background: var(--accent-hover); }
.btn-modal-cancel { padding: 8px 16px; border-radius: 6px; border: 1px solid var(--border);
background: none; color: var(--text); font-size: 13px; cursor: pointer; }
.btn-modal-cancel:hover { background: var(--surface2); }
.btn-modal-ok { flex: 1; padding: 8px; border-radius: 10px; border: none;
background: #388bfd; color: #fff; font-size: 13px;
font-weight: 600; cursor: pointer; font-family: inherit; }
.btn-modal-ok:hover { background: #58a6ff; }
.btn-modal-cancel { padding: 8px 16px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
background: #161b22; color: #c9d1d9; font-size: 13px; cursor: pointer; font-family: inherit; }
.btn-modal-cancel:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
width: 100%; border-right: none; border-bottom: 1px solid rgba(240,246,252,0.08);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; }
.sidebar-link { flex: 1; text-align: center; }
}
@media (max-width: 720px) {
.config-tabs { grid-template-columns: 1fr; }
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
.sidebar-link { flex: 1; text-align: center; min-width: 72px; }
.main { padding: 20px 12px 28px; }
.card { padding: 16px; }
.topbar { padding: 12px 16px; flex-wrap: wrap; }
.main { padding: 16px 12px 6px; }
.app-footer { padding: 4px 12px 10px; }
.card { padding: 18px; }
}
</style>
@@ -171,11 +166,12 @@
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
<a href="/dashboard" class="sidebar-logo">secrets</a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link active">MCP</a>
<a href="/entries" class="sidebar-link">条目</a>
<a href="/audit" class="sidebar-link">审计</a>
<a href="/dashboard" class="sidebar-link active" data-i18n="navMcp">MCP</a>
<a href="/entries" class="sidebar-link" data-i18n="navEntries">条目</a>
<a href="/trash" class="sidebar-link" data-i18n="navTrash">回收站</a>
<a href="/audit" class="sidebar-link" data-i18n="navAudit">审计</a>
</nav>
</aside>
@@ -293,13 +289,11 @@
<button class="btn-sm" onclick="confirmRegenerate()" data-i18n="btnRegen">重置 API Key</button>
</div>
</div>
</div>
</div>
<footer class="app-footer">{{ version }}</footer>
</div>
</div>
</div>
<footer class="app-footer">{{ version }} · <a href="/changelog" data-i18n="changelogLink">变更记录</a></footer>
</div><!-- /main -->
</div><!-- /content-shell -->
</div><!-- /layout -->
<!-- ── Change passphrase modal ──────────────────────────────────────────────── -->
<div class="modal-bd" id="change-modal">
@@ -351,6 +345,7 @@
const T = {
'zh-CN': {
navMcp: 'MCP', navEntries: '条目', navTrash: '回收站', navAudit: '审计',
signOut: '退出',
lockedTitle: '获取 MCP 配置',
labelPassphrase: '加密密码',
@@ -386,8 +381,10 @@ const T = {
regenFailed: '重置失败,请刷新页面重试。',
ariaShowPw: '显示密码',
ariaHidePw: '隐藏密码',
changelogLink: '变更记录',
},
'zh-TW': {
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
signOut: '登出',
lockedTitle: '取得 MCP 設定',
labelPassphrase: '加密密碼',
@@ -423,8 +420,10 @@ const T = {
regenFailed: '重置失敗,請重新整理頁面再試。',
ariaShowPw: '顯示密碼',
ariaHidePw: '隱藏密碼',
changelogLink: '變更記錄',
},
'en': {
navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit',
signOut: 'Sign out',
lockedTitle: 'Get MCP Config',
labelPassphrase: 'Encryption password',
@@ -460,6 +459,7 @@ const T = {
regenFailed: 'Reset failed. Please refresh and try again.',
ariaShowPw: 'Show password',
ariaHidePw: 'Hide password',
changelogLink: 'Changelog',
}
};

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -3,6 +3,7 @@ var I18N_SHARED = {
pageTitleBase: 'Secrets',
navMcp: 'MCP',
navEntries: '条目',
navTrash: '回收站',
navAudit: '审计',
signOut: '退出',
mobileLabelTime: '时间',
@@ -14,6 +15,7 @@ var I18N_SHARED = {
pageTitleBase: 'Secrets',
navMcp: 'MCP',
navEntries: '條目',
navTrash: '回收站',
navAudit: '審計',
signOut: '登出',
mobileLabelTime: '時間',
@@ -25,6 +27,7 @@ var I18N_SHARED = {
pageTitleBase: 'Secrets',
navMcp: 'MCP',
navEntries: 'Entries',
navTrash: 'Trash',
navAudit: 'Audit',
signOut: 'Sign out',
mobileLabelTime: 'Time',
@@ -62,6 +65,10 @@ function applyLang() {
var key = el.getAttribute('data-i18n-ph');
el.placeholder = t(key);
});
document.querySelectorAll('[data-i18n-title]').forEach(function (el) {
var key = el.getAttribute('data-i18n-title');
el.title = t(key);
});
document.querySelectorAll('.lang-btn').forEach(function (btn) {
var map = { 'zh-CN': '简', 'zh-TW': '繁', en: 'EN' };
btn.classList.toggle('active', btn.textContent === map[currentLang]);

View File

@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
<title data-i18n="pageTitle">Secrets — 回收站</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #21262d;
--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; }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 200px; flex-shrink: 0; background: #0b1220; border-right: 1px solid rgba(240,246,252,0.08);
padding: 20px 12px; display: flex; flex-direction: column; gap: 20px;
}
.sidebar-logo { font-family: 'Inter', sans-serif; font-size: 16px; font-weight: 700;
color: #fff; text-decoration: none; padding: 0 10px; }
.sidebar-menu { display: grid; gap: 6px; }
.sidebar-link {
padding: 10px 12px; border-radius: 10px; color: #8b949e; text-decoration: none;
font-size: 13px; font-weight: 500;
}
.sidebar-link:hover { background: rgba(56,139,253,0.14); color: #fff; }
.sidebar-link.active { background: rgba(56,139,253,0.14); color: #fff; }
.content-shell { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.topbar {
background: transparent; border-bottom: none; padding: 0 24px;
display: flex; align-items: center; gap: 12px; min-height: 44px;
}
.topbar-spacer { flex: 1; }
.nav-user { font-size: 14px; color: #8b949e; }
.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; }
.btn-sign-out {
padding: 6px 14px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
background: #161b22; color: #c9d1d9; font-size: 13px; text-decoration: none; cursor: pointer;
}
.btn-sign-out:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.main { padding: 16px 16px 24px; flex: 1; }
.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: 8px; color: #fff; }
.card-subtitle { color: #8b949e; font-size: 14px; margin-bottom: 18px; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 14px 12px; border-top: 1px solid rgba(240,246,252,0.08); vertical-align: top; }
th { color: #8b949e; font-size: 12px; font-weight: 600; }
td { font-size: 13px; color: #c9d1d9; }
.mono { font-family: 'JetBrains Mono', monospace; }
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.btn { border: 1px solid rgba(240,246,252,0.12); background: #161b22; color: #c9d1d9; border-radius: 10px; padding: 8px 12px; cursor: pointer; font-size: 13px; font-family: inherit; }
.btn:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.btn-danger { color: #f85149; }
.empty { color: #8b949e; font-size: 14px; padding: 20px 0; }
.pagination {
display: flex; align-items: center; gap: 12px; margin-top: 18px;
justify-content: center; padding: 12px 0;
}
.page-btn {
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
background: #161b22; color: #c9d1d9; text-decoration: none;
font-size: 13px; cursor: pointer;
}
.page-btn:hover { border-color: rgba(56,139,253,0.45); color: #fff; }
.page-btn.disabled {
padding: 8px 12px; border-radius: 10px; border: 1px solid rgba(240,246,252,0.12);
background: #161b22; color: #6e7681; font-size: 13px;
opacity: 0.5; cursor: not-allowed;
}
.page-info {
color: #8b949e; font-size: 13px; font-family: 'JetBrains Mono', monospace;
}
@media (max-width: 900px) {
.layout { flex-direction: column; }
.sidebar {
width: 100%; border-right: none; border-bottom: 1px solid rgba(240,246,252,0.08);
padding: 16px; gap: 14px;
}
.sidebar-menu { flex-direction: row; flex-wrap: wrap; }
.sidebar-link { flex: 1; text-align: center; min-width: 72px; }
.main { padding: 20px 12px 28px; }
.card { padding: 16px; }
.topbar { padding: 12px 16px; flex-wrap: wrap; }
table, thead, tbody, th, td, tr { display: block; }
thead { display: none; }
tr { border-top: 1px solid rgba(240,246,252,0.08); padding: 12px 0; }
td { border-top: none; padding: 6px 0; }
td::before {
display: block; color: #8b949e; font-size: 11px;
margin-bottom: 4px; text-transform: uppercase;
content: attr(data-label);
}
}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<a href="/dashboard" class="sidebar-logo">secrets</a>
<nav class="sidebar-menu">
<a href="/dashboard" class="sidebar-link" data-i18n="navMcp">MCP</a>
<a href="/entries" class="sidebar-link" data-i18n="navEntries">条目</a>
<a href="/trash" class="sidebar-link active" data-i18n="navTrash">回收站</a>
<a href="/audit" class="sidebar-link" data-i18n="navAudit">审计</a>
</nav>
</aside>
<div class="content-shell">
<div class="topbar">
<span class="topbar-spacer"></span>
<span class="nav-user">{{ user_name }}{% if !user_email.is_empty() %} · {{ user_email }}{% endif %}</span>
<div class="lang-bar">
<button class="lang-btn" onclick="setLang('zh-CN')"></button>
<button class="lang-btn" onclick="setLang('zh-TW')"></button>
<button class="lang-btn" onclick="setLang('en')">EN</button>
</div>
<form action="/auth/logout" method="post" style="display:inline">
<button type="submit" class="btn-sign-out" data-i18n="signOut">退出</button>
</form>
</div>
<main class="main">
<section class="card">
<div class="card-title" data-i18n="trashTitle">回收站</div>
<div class="card-subtitle" data-i18n="trashSubtitle">已删除条目会保留 3 个月,可在此恢复或永久删除。</div>
{% if entries.is_empty() %}
<div class="empty" data-i18n="emptyTrash">回收站为空。</div>
{% else %}
<table>
<thead>
<tr>
<th data-i18n="colName">名称</th>
<th data-i18n="colType">类型</th>
<th data-i18n="colFolder">文件夹</th>
<th data-i18n="colDeletedAt">删除时间</th>
<th data-i18n="colActions">操作</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr data-trash-entry-id="{{ entry.id }}">
<td class="mono" data-label="名称">{{ entry.name }}</td>
<td class="mono" data-label="类型">{{ entry.entry_type }}</td>
<td class="mono" data-label="文件夹">{{ entry.folder }}</td>
<td class="mono" data-label="删除时间" title="{{ entry.deleted_at_iso }}">{{ entry.deleted_at_label }}</td>
<td data-label="操作">
<div class="row-actions">
<button type="button" class="btn btn-restore" data-i18n="btnRestore">恢复</button>
<button type="button" class="btn btn-danger btn-purge" data-i18n="btnPurge">永久删除</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if total_count > 0 %}
<div class="pagination">
{% if current_page > 1 %}
<a class="page-btn" href="/trash?page={{ current_page - 1 }}" data-i18n="prevPage">上一页</a>
{% else %}
<span class="page-btn disabled" data-i18n="prevPage">上一页</span>
{% endif %}
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
{% if current_page < total_pages %}
<a class="page-btn" href="/trash?page={{ current_page + 1 }}" data-i18n="nextPage">下一页</a>
{% else %}
<span class="page-btn disabled" data-i18n="nextPage">下一页</span>
{% endif %}
</div>
{% endif %}
</section>
</main>
</div>
</div>
<script src="/static/i18n.js?v={{ version }}"></script>
<script>
(function () {
I18N_PAGE = {
'zh-CN': {
pageTitle: 'Secrets — 回收站',
navMcp: 'MCP', navEntries: '条目', navTrash: '回收站', navAudit: '审计',
signOut: '退出', trashTitle: '回收站', trashSubtitle: '已删除条目会保留 3 个月,可在此恢复或永久删除。',
emptyTrash: '回收站为空。', colName: '名称', colType: '类型', colFolder: '文件夹',
colDeletedAt: '删除时间', colActions: '操作', btnRestore: '恢复', btnPurge: '永久删除',
prevPage: '上一页', nextPage: '下一页',
mobileLabelName: '名称', mobileLabelType: '类型', mobileLabelFolder: '文件夹',
mobileLabelDeletedAt: '删除时间', mobileLabelActions: '操作'
},
'zh-TW': {
pageTitle: 'Secrets — 回收站',
navMcp: 'MCP', navEntries: '條目', navTrash: '回收站', navAudit: '審計',
signOut: '退出', trashTitle: '回收站', trashSubtitle: '已刪除條目會保留 3 個月,可在此恢復或永久刪除。',
emptyTrash: '回收站為空。', colName: '名稱', colType: '類型', colFolder: '文件夾',
colDeletedAt: '刪除時間', colActions: '操作', btnRestore: '恢復', btnPurge: '永久刪除',
prevPage: '上一頁', nextPage: '下一頁',
mobileLabelName: '名稱', mobileLabelType: '類型', mobileLabelFolder: '文件夾',
mobileLabelDeletedAt: '刪除時間', mobileLabelActions: '操作'
},
en: {
pageTitle: 'Secrets — Trash',
navMcp: 'MCP', navEntries: 'Entries', navTrash: 'Trash', navAudit: 'Audit',
signOut: 'Sign out', trashTitle: 'Trash', trashSubtitle: 'Deleted entries are kept for 3 months. Restore or permanently delete them here.',
emptyTrash: 'Trash is empty.', colName: 'Name', colType: 'Type', colFolder: 'Folder',
colDeletedAt: 'Deleted at', colActions: 'Actions', btnRestore: 'Restore', btnPurge: 'Purge',
prevPage: 'Previous', nextPage: 'Next',
mobileLabelName: 'Name', mobileLabelType: 'Type', mobileLabelFolder: 'Folder',
mobileLabelDeletedAt: 'Deleted at', mobileLabelActions: 'Actions'
}
};
window.applyPageLang = function () {
document.querySelectorAll('tbody tr').forEach(function (tr) {
['Name', 'Type', 'Folder', 'DeletedAt', 'Actions'].forEach(function (col) {
var td = tr.querySelector('[data-label]');
});
});
};
applyLang();
})();
document.querySelectorAll('tr[data-trash-entry-id]').forEach(function (row) {
var entryId = row.getAttribute('data-trash-entry-id');
var restoreButton = row.querySelector('.btn-restore');
var purgeButton = row.querySelector('.btn-purge');
restoreButton.addEventListener('click', function () {
fetch('/api/trash/' + encodeURIComponent(entryId) + '/restore', {
method: 'POST',
credentials: 'same-origin'
}).then(function (response) {
return response.json().then(function (body) {
if (!response.ok) throw new Error(body.error || ('HTTP ' + response.status));
return body;
});
}).then(function () {
row.remove();
if (!document.querySelector('tr[data-trash-entry-id]')) window.location.reload();
}).catch(function (error) {
window.alert(error.message || String(error));
});
});
purgeButton.addEventListener('click', function () {
if (!window.confirm(t('confirmPurge') || '确定永久删除该条目?此操作不可撤销。')) return;
fetch('/api/trash/' + encodeURIComponent(entryId), {
method: 'DELETE',
credentials: 'same-origin'
}).then(function (response) {
return response.json().then(function (body) {
if (!response.ok) throw new Error(body.error || ('HTTP ' + response.status));
return body;
});
}).then(function () {
row.remove();
if (!document.querySelector('tr[data-trash-entry-id]')) window.location.reload();
}).catch(function (error) {
window.alert(error.message || String(error));
});
});
});
</script>
</body>
</html>

View File

@@ -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 的机器须能访问 Googleoauth2.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=

View File

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

View File

@@ -0,0 +1,141 @@
# 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
```
1. 跑发布前检查:
```bash
./scripts/release-check.sh
```
1. 确认版本和 tag
- `crates/secrets-mcp/Cargo.toml` 已 bump合并执行时为 `0.5.21`,因 `crates/**` 有变更)
- `jj tag list`
---
## 备注
如果后续要做最终合并,建议以 `d7720662` 为基础继续补测试,而不是尝试把两份实现整合成第三套逻辑。这样改动面最小,风险也最低。

View File

@@ -0,0 +1,392 @@
# Metadata Value Search & Entry Relations (DAG)
## Overview
Two new features for secrets-mcp:
1. **Metadata Value Search** — fuzzy search across all JSON scalar values in `metadata`, excluding keys
2. **Entry Relations** — directional parent-child associations between entries (DAG, multiple parents allowed, cycle detection)
---
## Feature 1: Metadata Value Search
### Problem
The existing `query` parameter in `secrets_find`/`secrets_search` searches `metadata::text ILIKE`, which matches keys, JSON punctuation, and structural characters. Users want to search **only metadata values** (e.g. find entries where any metadata value contains "1.2.3.4", regardless of key name).
### Solution
Add a new `metadata_query` filter to `SearchParams` that uses PostgreSQL `jsonb_path_query` to iterate over only scalar values (strings, numbers, booleans), then applies ILIKE matching.
### Changes
#### secrets-core
`**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:
```sql
EXISTS (
SELECT 1 FROM jsonb_path_query(
entries.metadata,
'strict $.** ? (@.type() != "object" && @.type() != "array")'
) AS val
WHERE (val#>>'{}') ILIKE $N ESCAPE '\'
)
```
- Bind `ilike_pattern(metadata_query)` at the correct `$N` position in both `fetch_entries_paged` and `count_entries`
#### secrets-mcp (MCP tools)
`**crates/secrets-mcp/src/tools.rs**`
- Add `metadata_query` field to `FindInput`:
```rust
#[schemars(description = "Fuzzy search across metadata values only (keys excluded)")]
metadata_query: Option<String>,
```
- Add same field to `SearchInput`
- Pass `metadata_query` through to `SearchParams` in both `secrets_find` and `secrets_search` handlers
#### secrets-mcp (Web)
`**crates/secrets-mcp/src/web/entries.rs**`
- Add `metadata_query: Option<String>` to `EntriesQuery`
- Thread it into all `SearchParams` usages (count, list, folder counts)
- Pass it into template context
- Add `metadata_query` to `EntriesPageTemplate` and filter form hidden fields
- Include `metadata_query` in pagination `href` links
`**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
- The existing GIN index on `metadata jsonb_path_ops` supports `@>` containment queries but NOT this pattern
- For production datasets > 10k entries, consider a generated column or materialized search column in a future iteration
- First version prioritizes semantic correctness over index optimization
---
## Feature 2: Entry Relations (DAG)
### Data Model
New table `entry_relations`:
```sql
CREATE TABLE IF NOT EXISTS entry_relations (
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (parent_entry_id, child_entry_id),
CHECK (parent_entry_id <> child_entry_id)
);
CREATE INDEX idx_entry_relations_parent ON entry_relations(parent_entry_id);
CREATE INDEX idx_entry_relations_child ON entry_relations(child_entry_id);
-- Enforce multi-tenant isolation: parent and child must belong to same user
ALTER TABLE entry_relations ADD CONSTRAINT fk_parent_user
FOREIGN KEY (parent_entry_id) REFERENCES entries(id) ON DELETE CASCADE;
ALTER TABLE entry_relations ADD CONSTRAINT fk_child_user
FOREIGN KEY (child_entry_id) REFERENCES entries(id) ON DELETE CASCADE;
```
Shared secrets already use `entry_secrets` as an N:N relation, so this is consistent with the existing pattern.
### Cycle Detection
On every `INSERT INTO entry_relations(parent, child)`, check that no path exists from `child` back to `parent`:
```sql
-- Returns true if adding (parent, child) would create a cycle
SELECT EXISTS(
SELECT 1 FROM entry_relations
WHERE child_entry_id = $1 -- $1 = proposed parent
START WITH parent_entry_id = $2 -- $2 = proposed child
CONNECT BY PRIOR child_entry_id = parent_entry_id
);
```
Wait — PostgreSQL doesn't support `START WITH ... CONNECT BY`. Use recursive CTE instead:
```sql
WITH RECURSIVE chain AS (
SELECT parent_entry_id AS ancestor
FROM entry_relations
WHERE child_entry_id = $1 -- proposed child
UNION ALL
SELECT er.parent_entry_id
FROM entry_relations er
JOIN chain c ON c.ancestor = er.child_entry_id
)
SELECT EXISTS(SELECT 1 FROM chain WHERE ancestor = $2);
-- $1 = proposed child, $2 = proposed parent
```
If `EXISTS` returns true, reject with `AppError::Validation { message: "cycle detected" }`.
### secrets-core Changes
**New file: `crates/secrets-core/src/service/relations.rs`**
```rust
pub struct RelationSummary {
pub parent_id: Uuid,
pub parent_name: String,
pub parent_folder: String,
pub parent_type: String,
}
pub struct AddRelationParams<'a> {
pub parent_entry_id: Uuid,
pub child_entry_id: Uuid,
pub user_id: Option<Uuid>,
}
pub struct RemoveRelationParams<'a> {
pub parent_entry_id: Uuid,
pub child_entry_id: Uuid,
pub user_id: Option<Uuid>,
}
/// Add a parent→child relation. Validates:
/// - Both entries exist and belong to the same user
/// - No self-reference (enforced by CHECK constraint)
/// - No cycle (recursive CTE check)
pub async fn add_relation(pool: &PgPool, params: AddRelationParams<'_>) -> Result<()>
/// Remove a parent→child relation.
pub async fn remove_relation(pool: &PgPool, params: RemoveRelationParams<'_>) -> Result<()>
/// Get all parents of an entry (with summary info).
pub async fn get_parents(pool: &PgPool, entry_id: Uuid, user_id: Option<Uuid>) -> Result<Vec<RelationSummary>>
/// Get all children of an entry (with summary info).
pub async fn get_children(pool: &PgPool, entry_id: Uuid, user_id: Option<Uuid>) -> Result<Vec<RelationSummary>>
/// Get parents + children for a batch of entry IDs (for list pages).
pub async fn get_relations_for_entries(
pool: &PgPool,
entry_ids: &[Uuid],
user_id: Option<Uuid>,
) -> Result<HashMap<Uuid, Vec<RelationSummary>>>
```
`**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/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**`
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:
- `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:
```json
{
"id": "...",
"name": "...",
"parents": [{"id": "...", "name": "...", "folder": "...", "type": "..."}],
"children": [{"id": "...", "name": "...", "folder": "...", "type": "..."}]
}
```
- Fetch relations for all returned entry IDs in a single batch query
### Web Changes
`**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>>`
- 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**`
1. **List table**: add a "关联" (relations) column showing parent/child counts as clickable chips
2. **Edit dialog**: add "上级条目" (parent entries) section
- Show current parents as removable chips
- Add a search-as-you-type input that queries `/api/entries/options`
- Click a search result to add it as parent
- On save, send `parent_ids` in the PATCH body
3. **View dialog / detail**: show "下级条目" (children) list with clickable links that navigate to the child entry
4. **i18n**: add keys for all new UI elements
### i18n Keys (Entry Relations)
| Key | zh | zh-Hant | en |
| -------------------------- | ------------ | ------------ | -------------------------------- |
| `colRelations` | 关联 | 關聯 | Relations |
| `parentEntriesLabel` | 上级条目 | 上級條目 | Parent entries |
| `childrenEntriesLabel` | 下级条目 | 下級條目 | Child entries |
| `addParentLabel` | 添加上级 | 新增上級 | Add parent |
| `removeParentLabel` | 移除上级 | 移除上級 | Remove parent |
| `searchEntriesPlaceholder` | 搜索条目… | 搜尋條目… | Search entries… |
| `noParents` | 无上级 | 無上級 | No parents |
| `noChildren` | 无下级 | 無下級 | No children |
| `relationCycleError` | 无法添加:会形成循环引用 | 無法新增:會形成循環引用 | Cannot add: would create a cycle |
### Audit Logging
Log relation changes in the existing `audit::log_tx` system:
- Action: `"add_relation"` / `"remove_relation"`
- Detail JSON: `{ "parent_id": "...", "parent_name": "...", "child_id": "...", "child_name": "..." }`
### Export / Import
`**ExportEntry`** — add optional `parents: Vec<ParentRef>` where:
```rust
pub struct ParentRef {
pub folder: String,
pub name: String,
}
```
- On export, resolve each entry's parent IDs to `(folder, name)` pairs
- On import, two-phase:
1. Create all entries (skip parents)
2. For each entry with `parents`, resolve `(folder, name)` → `entry_id` and call `add_relation`
3. If a parent reference cannot be resolved, log a warning and skip it (don't fail the entire import)
### History / Rollback
- Relation changes are **not** versioned in `entries_history`. They are tracked only via `audit_log`.
- Rationale: relations are a cross-entry concern; rolling them back alongside entry fields would require complex multi-entry coordination. The audit log provides sufficient traceability.
- If the user explicitly requests rollback of relations in the future, it can be implemented as a separate feature.
---
## Implementation Order
### Phase 1: Metadata Value Search
1. `secrets-core/src/service/search.rs` — add `metadata_query` to `SearchParams`, implement SQL condition
2. `secrets-mcp/src/tools.rs` — add `metadata_query` to `FindInput` and `SearchInput`, wire through
3. `secrets-mcp/src/web/entries.rs` — add `metadata_query` to `EntriesQuery`, `SearchParams`, pagination, folder counts
4. `secrets-mcp/templates/entries.html` — add input field, i18n
5. Test: existing `query` still works; `metadata_query` only matches values
### Phase 2: Entry Relations (Core)
1. `secrets-core/src/db.rs` — add `entry_relations` table to `migrate()`
2. `secrets-core/src/service/relations.rs` — implement `add_relation`, `remove_relation`, `get_parents`, `get_children`, `get_relations_for_entries`, cycle detection
3. `secrets-core/src/service/mod.rs` — add `pub mod relations`
4. Test: add/remove/query relations, cycle detection, same-user validation
### Phase 3: Entry Relations (MCP)
1. `secrets-mcp/src/tools.rs` — extend `AddInput`, `UpdateInput` with parent IDs
2. `secrets-mcp/src/tools.rs` — extend `secrets_find`/`secrets_search` output with `parents`/`children`
3. Test: MCP tools work end-to-end
### Phase 4: Entry Relations (Web)
1. `secrets-mcp/src/web/entries.rs` — add API endpoints, extend `EntryPatchBody`, extend template data
2. `secrets-mcp/templates/entries.html` — add relations column, edit dialog parent selector, view dialog children list
3. Test: Web UI works end-to-end
### Phase 5: Export / Import (Optional)
1. `secrets-core/src/models.rs` — add `parents` to `ExportEntry`
2. `secrets-core/src/service/export.rs` — populate parents
3. `secrets-core/src/service/import.rs` — two-phase import with relation resolution
---
## Database Migration
Add to `secrets-core/src/db.rs` `migrate()`:
```sql
CREATE TABLE IF NOT EXISTS entry_relations (
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (parent_entry_id, child_entry_id),
CHECK (parent_entry_id <> child_entry_id)
);
CREATE INDEX IF NOT EXISTS idx_entry_relations_parent ON entry_relations(parent_entry_id);
CREATE INDEX IF NOT EXISTS idx_entry_relations_child ON entry_relations(child_entry_id);
```
This is idempotent (uses `IF NOT EXISTS`) and will run automatically on next startup.
---
## Security Considerations
- **Same-user isolation**: `add_relation` must verify both `parent_entry_id` and `child_entry_id` belong to the same `user_id` (or both are `NULL` for legacy single-user mode)
- **Cycle detection**: Recursive CTE query prevents any directed cycle, regardless of depth
- **CASCADE delete**: When an entry is deleted, all its relation edges are automatically removed via the `ON DELETE CASCADE` foreign key. This is the same pattern used by `entry_secrets`.
---
## 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
### 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

View File

@@ -20,6 +20,7 @@
### 2. 查看密文弹窗 — 增加管理功能
在每个解密字段行中增加:
- **重命名输入框**inline edit带 debounce 校验)
- **类型下拉选择**
- **解绑按钮**
@@ -51,4 +52,4 @@
## 涉及文件
- `crates/secrets-mcp/templates/entries.html`HTML + JS + CSS
- `crates/secrets-mcp/src/web/entries.rs`(无需修改,复用现有 API
- `crates/secrets-mcp/src/web/entries.rs`(无需修改,复用现有 API

178
plans/web-tags-filter.md Normal file
View 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 时,要求条目同时包含这些 tagsAND 语义)
### 状态保持
- 筛选提交后,输入框保留原值
- 分页上一页/下一页链接保留 `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
```