release(secrets-mcp): 0.5.3 — 审计日志分页与 Web;CONTRIBUTING;文档与模板修正
This commit is contained in:
48
AGENTS.md
48
AGENTS.md
@@ -2,12 +2,37 @@
|
|||||||
|
|
||||||
本仓库为 **MCP SaaS**:`secrets-core`(业务与持久化)+ `secrets-mcp`(Streamable HTTP MCP、Web、OAuth、API Key)。对外入口见 `crates/secrets-mcp`。
|
本仓库为 **MCP SaaS**:`secrets-core`(业务与持久化)+ `secrets-mcp`(Streamable HTTP MCP、Web、OAuth、API Key)。对外入口见 `crates/secrets-mcp`。
|
||||||
|
|
||||||
|
## 版本控制
|
||||||
|
|
||||||
|
本仓库使用 **[Jujutsu (jj)](https://jj-vcs.dev/)** 作为版本控制系统(纯 jj 模式,无 `.git` 目录)。
|
||||||
|
|
||||||
|
### 常用 jj 命令对照
|
||||||
|
|
||||||
|
| 操作 | jj 命令 |
|
||||||
|
|------|---------|
|
||||||
|
| 查看历史 | `jj log` / `jj log 'all()'` |
|
||||||
|
| 查看状态 | `jj status` |
|
||||||
|
| 新建提交 | `jj commit` |
|
||||||
|
| 创建新变更 | `jj new` |
|
||||||
|
| 变基 | `jj rebase` |
|
||||||
|
| 合并提交 | `jj squash` |
|
||||||
|
| 撤销操作 | `jj undo` |
|
||||||
|
| 查看标签 | `jj tag list` |
|
||||||
|
| 查看分支 | `jj bookmark list` |
|
||||||
|
| 推送远端 | `jj git push` |
|
||||||
|
| 拉取远端 | `jj git fetch` |
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
- 本仓库为**纯 jj 模式**,无 `.git` 目录;本地不要使用 `git` 命令
|
||||||
|
- CI/CD(Gitea Actions)仍通过 Git 协议拉取代码,Runner 侧自动使用 `git`,无需修改
|
||||||
|
- 检查标签是否存在时使用 `jj log --no-graph --revisions "tag(${tag})"` 而非 `git rev-parse`
|
||||||
|
|
||||||
## 提交 / 推送硬规则(优先于下文)
|
## 提交 / 推送硬规则(优先于下文)
|
||||||
|
|
||||||
**每次提交和推送前必须执行以下检查,无论是否明确「发版」:**
|
**每次提交和推送前必须执行以下检查,无论是否明确「发版」:**
|
||||||
|
|
||||||
1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock`、`secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。
|
1. 涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock`、`secrets-mcp` 行为变更的提交,默认视为**需要发版**,除非明确说明「本次不发版」。
|
||||||
2. 提交前检查 `crates/secrets-mcp/Cargo.toml` 的 `version`,再查 tag:`git tag -l 'secrets-mcp-*'`。若当前版本对应 tag 已存在且有代码变更,**必须 bump 版本号**并 `cargo build` 同步 `Cargo.lock`。
|
2. 提交前检查 `crates/secrets-mcp/Cargo.toml` 的 `version`,再查 tag:`jj tag list`。若当前版本对应 tag 已存在且有代码变更,**必须 bump 版本号**并 `cargo build` 同步 `Cargo.lock`。
|
||||||
3. 提交前运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。若脚本不存在或不可用,至少运行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`。
|
3. 提交前运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。若脚本不存在或不可用,至少运行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
@@ -112,7 +137,9 @@ oauth_accounts (
|
|||||||
|
|
||||||
### MCP 消歧(AI 调用)
|
### MCP 消歧(AI 调用)
|
||||||
|
|
||||||
按 `name` 定位条目的工具(`get` / `update` / 单条 `delete` / `history` / `rollback`):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder`。`secrets_delete` 的 `dry_run=true` 与真实删除使用相同消歧规则。
|
按 `name` 定位条目的工具(`secrets_update` / `secrets_history` / `secrets_rollback` / `secrets_delete` 单条模式):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder`。也可直接传 `id`(UUID)跳过消歧。
|
||||||
|
|
||||||
|
注意:`secrets_get` 只接受 UUID `id`(来自 `secrets_find` 结果),不支持按 `name` 定位。
|
||||||
|
|
||||||
### 字段职责
|
### 字段职责
|
||||||
|
|
||||||
@@ -144,6 +171,14 @@ oauth_accounts (
|
|||||||
- 加密:密钥由用户密码短语通过 **PBKDF2-SHA256(600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`,不持有原始密钥。Web 客户端在浏览器本地完成加解密;MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。
|
- 加密:密钥由用户密码短语通过 **PBKDF2-SHA256(600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`,不持有原始密钥。Web 客户端在浏览器本地完成加解密;MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。
|
||||||
- MCP:tools 参数与 JSON Schema(`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。
|
- MCP:tools 参数与 JSON Schema(`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。
|
||||||
|
|
||||||
|
## 生产 CORS
|
||||||
|
|
||||||
|
生产环境 CORS 使用显式请求头白名单(`build_cors_layer`),而非 `allow_headers(Any)`,
|
||||||
|
因为 `tower-http` 禁止 `allow_credentials(true)` 与 `allow_headers(Any)` 同时使用。
|
||||||
|
|
||||||
|
**维护约束**:若 MCP 协议或客户端新增自定义请求头,必须同步更新 `production_allowed_headers()`。
|
||||||
|
当前允许的请求头:`Authorization`、`Content-Type`、`X-Encryption-Key`、`mcp-session-id`、`x-mcp-session`。
|
||||||
|
|
||||||
## 提交前检查
|
## 提交前检查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -162,7 +197,7 @@ cargo test --locked
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep '^version' crates/secrets-mcp/Cargo.toml
|
grep '^version' crates/secrets-mcp/Cargo.toml
|
||||||
git tag -l 'secrets-mcp-*'
|
jj tag list
|
||||||
```
|
```
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
@@ -182,10 +217,17 @@ git tag -l 'secrets-mcp-*'
|
|||||||
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 |
|
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 |
|
||||||
| `SECRETS_DATABASE_SSL_MODE` | 可选但强烈建议生产必填。推荐 `verify-full`(至少 `verify-ca`)。 |
|
| `SECRETS_DATABASE_SSL_MODE` | 可选但强烈建议生产必填。推荐 `verify-full`(至少 `verify-ca`)。 |
|
||||||
| `SECRETS_DATABASE_SSL_ROOT_CERT` | 可选。私有 CA 或自签链路时指定 CA 根证书路径。 |
|
| `SECRETS_DATABASE_SSL_ROOT_CERT` | 可选。私有 CA 或自签链路时指定 CA 根证书路径。 |
|
||||||
|
| `SECRETS_DATABASE_POOL_SIZE` | 可选。连接池最大连接数,默认 `10`。 |
|
||||||
|
| `SECRETS_DATABASE_ACQUIRE_TIMEOUT` | 可选。获取连接超时秒数,默认 `5`。 |
|
||||||
| `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式。 |
|
| `SECRETS_ENV` | 可选。设为 `prod` / `production` 时会拒绝弱 PostgreSQL TLS 模式。 |
|
||||||
| `BASE_URL` | 对外基址;OAuth 回调 `${BASE_URL}/auth/google/callback`。 |
|
| `BASE_URL` | 对外基址;OAuth 回调 `${BASE_URL}/auth/google/callback`。 |
|
||||||
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`)。 |
|
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`)。 |
|
||||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 |
|
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 |
|
||||||
| `RUST_LOG` | 如 `secrets_mcp=debug`。 |
|
| `RUST_LOG` | 如 `secrets_mcp=debug`。 |
|
||||||
|
| `RATE_LIMIT_GLOBAL_PER_SECOND` | 可选。全局限流速率,默认 `100` req/s。 |
|
||||||
|
| `RATE_LIMIT_GLOBAL_BURST` | 可选。全局限流突发量,默认 `200`。 |
|
||||||
|
| `RATE_LIMIT_IP_PER_SECOND` | 可选。单 IP 限流速率,默认 `20` req/s。 |
|
||||||
|
| `RATE_LIMIT_IP_BURST` | 可选。单 IP 限流突发量,默认 `40`。 |
|
||||||
|
| `TRUST_PROXY` | 可选。设为 `1`/`true`/`yes` 时从 `X-Forwarded-For` / `X-Real-IP` 提取客户端 IP。 |
|
||||||
|
|
||||||
> `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。
|
> `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。
|
||||||
|
|||||||
55
CONTRIBUTING.md
Normal file
55
CONTRIBUTING.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## 版本控制
|
||||||
|
|
||||||
|
本仓库使用 **[Jujutsu (jj)](https://jj-vcs.dev/)**。请勿使用 `git` 命令。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jj log # 查看历史
|
||||||
|
jj status # 查看状态
|
||||||
|
jj new # 创建新变更
|
||||||
|
jj commit # 提交
|
||||||
|
jj rebase # 变基
|
||||||
|
jj squash # 合并提交
|
||||||
|
jj git push # 推送到远端
|
||||||
|
```
|
||||||
|
|
||||||
|
详见 [AGENTS.md](AGENTS.md) 的「版本控制」章节。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 复制环境变量
|
||||||
|
cp deploy/.env.example .env
|
||||||
|
|
||||||
|
# 填写数据库连接等配置后
|
||||||
|
cargo build
|
||||||
|
cargo test --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
## 提交前检查
|
||||||
|
|
||||||
|
每次提交前必须通过:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt -- --check
|
||||||
|
cargo clippy --locked -- -D warnings
|
||||||
|
cargo test --locked
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/release-check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 发版规则
|
||||||
|
|
||||||
|
涉及 `crates/**`、根目录 `Cargo.toml`/`Cargo.lock`、`secrets-mcp` 行为变更的提交,默认需要发版。
|
||||||
|
|
||||||
|
1. 检查 `crates/secrets-mcp/Cargo.toml` 的 `version`
|
||||||
|
2. 运行 `jj tag list` 确认对应 tag 是否已存在
|
||||||
|
3. 若 tag 已存在且有代码变更,**必须 bump 版本**并 `cargo build` 同步 `Cargo.lock`
|
||||||
|
4. 通过 release-check 后再提交
|
||||||
|
|
||||||
|
详见 [AGENTS.md](AGENTS.md) 的「提交 / 推送硬规则」章节。
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2065,7 +2065,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"askama",
|
"askama",
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::models::AuditLogEntry;
|
use crate::models::AuditLogEntry;
|
||||||
|
|
||||||
pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result<Vec<AuditLogEntry>> {
|
pub async fn list_for_user(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<AuditLogEntry>> {
|
||||||
let limit = limit.clamp(1, 200);
|
let limit = limit.clamp(1, 200);
|
||||||
|
|
||||||
let rows = sqlx::query_as(
|
let rows = sqlx::query_as(
|
||||||
@@ -12,12 +17,22 @@ pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result<V
|
|||||||
FROM audit_log \
|
FROM audit_log \
|
||||||
WHERE user_id = $1 \
|
WHERE user_id = $1 \
|
||||||
ORDER BY created_at DESC, id DESC \
|
ORDER BY created_at DESC, id DESC \
|
||||||
LIMIT $2",
|
LIMIT $2 OFFSET $3",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn count_for_user(pool: &PgPool, user_id: Uuid) -> Result<i64> {
|
||||||
|
let count: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*)::bigint FROM audit_log WHERE user_id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.5.2"
|
version = "0.5.3"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -165,30 +165,7 @@ async fn main() -> Result<()> {
|
|||||||
Some("prod" | "production")
|
Some("prod" | "production")
|
||||||
);
|
);
|
||||||
|
|
||||||
let cors = if is_production {
|
let cors = build_cors_layer(&base_url, is_production);
|
||||||
// Only use the origin part (scheme://host:port) of BASE_URL for CORS.
|
|
||||||
// Browsers send Origin without path, so including a path would cause mismatches.
|
|
||||||
let allowed_origin = if let Ok(parsed) = base_url.parse::<url::Url>() {
|
|
||||||
let origin = parsed.origin().ascii_serialization();
|
|
||||||
origin
|
|
||||||
.parse::<axum::http::HeaderValue>()
|
|
||||||
.unwrap_or_else(|_| panic!("invalid BASE_URL origin: {}", origin))
|
|
||||||
} else {
|
|
||||||
base_url
|
|
||||||
.parse::<axum::http::HeaderValue>()
|
|
||||||
.unwrap_or_else(|_| panic!("invalid BASE_URL: {}", base_url))
|
|
||||||
};
|
|
||||||
CorsLayer::new()
|
|
||||||
.allow_origin(allowed_origin)
|
|
||||||
.allow_methods(Any)
|
|
||||||
.allow_headers(Any)
|
|
||||||
.allow_credentials(true)
|
|
||||||
} else {
|
|
||||||
CorsLayer::new()
|
|
||||||
.allow_origin(Any)
|
|
||||||
.allow_methods(Any)
|
|
||||||
.allow_headers(Any)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
let rate_limit_state = rate_limit::RateLimitState::new();
|
let rate_limit_state = rate_limit::RateLimitState::new();
|
||||||
@@ -257,3 +234,86 @@ async fn shutdown_signal() {
|
|||||||
|
|
||||||
tracing::info!("Shutting down gracefully...");
|
tracing::info!("Shutting down gracefully...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Production CORS allowed headers.
|
||||||
|
///
|
||||||
|
/// When adding a new custom header to the MCP or Web API, this list must be
|
||||||
|
/// updated accordingly — otherwise browsers will block the request during
|
||||||
|
/// the CORS preflight check.
|
||||||
|
fn production_allowed_headers() -> [axum::http::HeaderName; 5] {
|
||||||
|
[
|
||||||
|
axum::http::header::AUTHORIZATION,
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
axum::http::HeaderName::from_static("x-encryption-key"),
|
||||||
|
axum::http::HeaderName::from_static("mcp-session-id"),
|
||||||
|
axum::http::HeaderName::from_static("x-mcp-session"),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the CORS layer for the application.
|
||||||
|
///
|
||||||
|
/// In production mode the origin is restricted to the BASE_URL origin
|
||||||
|
/// (scheme://host:port, path stripped) and credentials are allowed.
|
||||||
|
/// `allow_headers` uses an explicit whitelist to avoid the tower-http
|
||||||
|
/// restriction on `allow_credentials(true)` + `allow_headers(Any)`.
|
||||||
|
///
|
||||||
|
/// In development mode all origins, methods and headers are allowed.
|
||||||
|
fn build_cors_layer(base_url: &str, is_production: bool) -> CorsLayer {
|
||||||
|
if is_production {
|
||||||
|
let allowed_origin = if let Ok(parsed) = base_url.parse::<url::Url>() {
|
||||||
|
let origin = parsed.origin().ascii_serialization();
|
||||||
|
origin
|
||||||
|
.parse::<axum::http::HeaderValue>()
|
||||||
|
.unwrap_or_else(|_| panic!("invalid BASE_URL origin: {}", origin))
|
||||||
|
} else {
|
||||||
|
base_url
|
||||||
|
.parse::<axum::http::HeaderValue>()
|
||||||
|
.unwrap_or_else(|_| panic!("invalid BASE_URL: {}", base_url))
|
||||||
|
};
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(allowed_origin)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(production_allowed_headers())
|
||||||
|
.allow_credentials(true)
|
||||||
|
} else {
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_cors_does_not_panic() {
|
||||||
|
let layer = build_cors_layer("https://secrets.example.com/app", true);
|
||||||
|
let _ = layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_cors_headers_include_all_required() {
|
||||||
|
let headers = production_allowed_headers();
|
||||||
|
let names: Vec<&str> = headers.iter().map(|h| h.as_str()).collect();
|
||||||
|
assert!(names.contains(&"authorization"));
|
||||||
|
assert!(names.contains(&"content-type"));
|
||||||
|
assert!(names.contains(&"x-encryption-key"));
|
||||||
|
assert!(names.contains(&"mcp-session-id"));
|
||||||
|
assert!(names.contains(&"x-mcp-session"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_cors_normalizes_base_url_with_path() {
|
||||||
|
let url = url::Url::parse("https://secrets.example.com/secrets/app").unwrap();
|
||||||
|
let origin = url.origin().ascii_serialization();
|
||||||
|
assert_eq!(origin, "https://secrets.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn development_cors_allows_everything() {
|
||||||
|
let layer = build_cors_layer("http://localhost:9315", false);
|
||||||
|
let _ = layer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -335,6 +335,9 @@ struct FindInput {
|
|||||||
#[schemars(description = "Max results (default 20)")]
|
#[schemars(description = "Max results (default 20)")]
|
||||||
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
|
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
|
||||||
limit: Option<u32>,
|
limit: Option<u32>,
|
||||||
|
#[schemars(description = "Offset for pagination (default 0)")]
|
||||||
|
#[serde(default, deserialize_with = "deser::option_u32_from_string")]
|
||||||
|
offset: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
@@ -680,13 +683,33 @@ impl SecretsService {
|
|||||||
query: input.query.as_deref(),
|
query: input.query.as_deref(),
|
||||||
sort: "name",
|
sort: "name",
|
||||||
limit: input.limit.unwrap_or(20),
|
limit: input.limit.unwrap_or(20),
|
||||||
offset: 0,
|
offset: input.offset.unwrap_or(0),
|
||||||
user_id: Some(user_id),
|
user_id: Some(user_id),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
|
.map_err(|e| mcp_err_internal_logged("secrets_find", Some(user_id), e))?;
|
||||||
|
|
||||||
|
let count_params = SearchParams {
|
||||||
|
folder: input.folder.as_deref(),
|
||||||
|
entry_type: input.entry_type.as_deref(),
|
||||||
|
name: input.name.as_deref(),
|
||||||
|
name_query: input.name_query.as_deref(),
|
||||||
|
tags: &tags,
|
||||||
|
query: input.query.as_deref(),
|
||||||
|
sort: "name",
|
||||||
|
limit: 0,
|
||||||
|
offset: 0,
|
||||||
|
user_id: Some(user_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
let entries: Vec<serde_json::Value> = result
|
let entries: Vec<serde_json::Value> = result
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -719,14 +742,20 @@ impl SecretsService {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let output = serde_json::json!({
|
||||||
|
"total_count": total_count,
|
||||||
|
"entries": entries,
|
||||||
|
});
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
tool = "secrets_find",
|
tool = "secrets_find",
|
||||||
?user_id,
|
?user_id,
|
||||||
result_count = entries.len(),
|
result_count = entries.len(),
|
||||||
|
total_count,
|
||||||
elapsed_ms = t.elapsed().as_millis(),
|
elapsed_ms = t.elapsed().as_millis(),
|
||||||
"tool call ok",
|
"tool call ok",
|
||||||
);
|
);
|
||||||
let json = serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string());
|
let json = serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string());
|
||||||
Ok(CallToolResult::success(vec![Content::text(json)]))
|
Ok(CallToolResult::success(vec![Content::text(json)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ use secrets_core::crypto::hex;
|
|||||||
use secrets_core::error::AppError;
|
use secrets_core::error::AppError;
|
||||||
use secrets_core::service::{
|
use secrets_core::service::{
|
||||||
api_key::{ensure_api_key, regenerate_api_key},
|
api_key::{ensure_api_key, regenerate_api_key},
|
||||||
audit_log::list_for_user,
|
audit_log::{count_for_user, list_for_user},
|
||||||
delete::delete_by_id,
|
delete::delete_by_id,
|
||||||
search::{SearchParams, fetch_secret_schemas, ilike_pattern, list_entries},
|
search::{SearchParams, count_entries, fetch_secret_schemas, ilike_pattern, list_entries},
|
||||||
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
|
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
|
||||||
user::{
|
user::{
|
||||||
OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id,
|
OAuthProfile, bind_oauth_account, find_or_create_user, get_user_by_id,
|
||||||
@@ -72,6 +72,9 @@ struct AuditPageTemplate {
|
|||||||
user_name: String,
|
user_name: String,
|
||||||
user_email: String,
|
user_email: String,
|
||||||
entries: Vec<AuditEntryView>,
|
entries: Vec<AuditEntryView>,
|
||||||
|
current_page: u32,
|
||||||
|
total_pages: u32,
|
||||||
|
total_count: i64,
|
||||||
version: &'static str,
|
version: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +98,9 @@ struct EntriesPageTemplate {
|
|||||||
filter_folder: String,
|
filter_folder: String,
|
||||||
filter_name: String,
|
filter_name: String,
|
||||||
filter_type: String,
|
filter_type: String,
|
||||||
|
current_page: u32,
|
||||||
|
total_pages: u32,
|
||||||
|
total_count: i64,
|
||||||
version: &'static str,
|
version: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +137,8 @@ struct FolderTabView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Cap for HTML list (avoids loading unbounded rows into memory).
|
/// Cap for HTML list (avoids loading unbounded rows into memory).
|
||||||
const ENTRIES_PAGE_LIMIT: u32 = 5_000;
|
const ENTRIES_PAGE_LIMIT: u32 = 50;
|
||||||
|
const AUDIT_PAGE_LIMIT: i64 = 10;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct EntriesQuery {
|
struct EntriesQuery {
|
||||||
@@ -140,6 +147,7 @@ struct EntriesQuery {
|
|||||||
/// URL query key is `type` (maps to DB column `entries.type`).
|
/// URL query key is `type` (maps to DB column `entries.type`).
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
entry_type: Option<String>,
|
entry_type: Option<String>,
|
||||||
|
page: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── App state helpers ─────────────────────────────────────────────────────────
|
// ── App state helpers ─────────────────────────────────────────────────────────
|
||||||
@@ -596,6 +604,8 @@ async fn entries_page(
|
|||||||
.map(|s| s.trim())
|
.map(|s| s.trim())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
let page = q.page.unwrap_or(1).max(1);
|
||||||
|
let offset = (page - 1) * ENTRIES_PAGE_LIMIT;
|
||||||
let params = SearchParams {
|
let params = SearchParams {
|
||||||
folder: folder_filter.as_deref(),
|
folder: folder_filter.as_deref(),
|
||||||
entry_type: type_filter.as_deref(),
|
entry_type: type_filter.as_deref(),
|
||||||
@@ -605,10 +615,17 @@ async fn entries_page(
|
|||||||
query: None,
|
query: None,
|
||||||
sort: "updated",
|
sort: "updated",
|
||||||
limit: ENTRIES_PAGE_LIMIT,
|
limit: ENTRIES_PAGE_LIMIT,
|
||||||
offset: 0,
|
offset,
|
||||||
user_id: Some(user_id),
|
user_id: Some(user_id),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let total_count = count_entries(&state.pool, ¶ms)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::warn!(error = %e, "count_entries failed for web entries page"))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let total_pages = (total_count as u32).div_ceil(ENTRIES_PAGE_LIMIT).max(1);
|
||||||
|
let current_page = page.min(total_pages);
|
||||||
|
|
||||||
let rows = list_entries(&state.pool, params).await.map_err(|e| {
|
let rows = list_entries(&state.pool, params).await.map_err(|e| {
|
||||||
tracing::error!(error = %e, "failed to load entries list for web");
|
tracing::error!(error = %e, "failed to load entries list for web");
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
@@ -681,7 +698,12 @@ async fn entries_page(
|
|||||||
type_options.sort_unstable();
|
type_options.sort_unstable();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entries_href(folder: Option<&str>, entry_type: Option<&str>, name: Option<&str>) -> String {
|
fn entries_href(
|
||||||
|
folder: Option<&str>,
|
||||||
|
entry_type: Option<&str>,
|
||||||
|
name: Option<&str>,
|
||||||
|
page: Option<u32>,
|
||||||
|
) -> String {
|
||||||
let mut pairs: Vec<String> = Vec::new();
|
let mut pairs: Vec<String> = Vec::new();
|
||||||
if let Some(f) = folder
|
if let Some(f) = folder
|
||||||
&& !f.is_empty()
|
&& !f.is_empty()
|
||||||
@@ -698,6 +720,9 @@ async fn entries_page(
|
|||||||
{
|
{
|
||||||
pairs.push(format!("name={}", urlencoding::encode(n)));
|
pairs.push(format!("name={}", urlencoding::encode(n)));
|
||||||
}
|
}
|
||||||
|
if let Some(p) = page {
|
||||||
|
pairs.push(format!("page={}", p));
|
||||||
|
}
|
||||||
if pairs.is_empty() {
|
if pairs.is_empty() {
|
||||||
"/entries".to_string()
|
"/entries".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -710,13 +735,23 @@ async fn entries_page(
|
|||||||
folder_tabs.push(FolderTabView {
|
folder_tabs.push(FolderTabView {
|
||||||
name: "全部".to_string(),
|
name: "全部".to_string(),
|
||||||
count: all_count,
|
count: all_count,
|
||||||
href: entries_href(None, type_filter.as_deref(), name_filter.as_deref()),
|
href: entries_href(
|
||||||
|
None,
|
||||||
|
type_filter.as_deref(),
|
||||||
|
name_filter.as_deref(),
|
||||||
|
Some(1),
|
||||||
|
),
|
||||||
active: folder_filter.is_none(),
|
active: folder_filter.is_none(),
|
||||||
});
|
});
|
||||||
for r in folder_rows {
|
for r in folder_rows {
|
||||||
let name = r.folder;
|
let name = r.folder;
|
||||||
folder_tabs.push(FolderTabView {
|
folder_tabs.push(FolderTabView {
|
||||||
href: entries_href(Some(&name), type_filter.as_deref(), name_filter.as_deref()),
|
href: entries_href(
|
||||||
|
Some(&name),
|
||||||
|
type_filter.as_deref(),
|
||||||
|
name_filter.as_deref(),
|
||||||
|
Some(1),
|
||||||
|
),
|
||||||
active: folder_filter.as_deref() == Some(name.as_str()),
|
active: folder_filter.as_deref() == Some(name.as_str()),
|
||||||
name,
|
name,
|
||||||
count: r.count,
|
count: r.count,
|
||||||
@@ -773,15 +808,24 @@ async fn entries_page(
|
|||||||
filter_folder: folder_filter.unwrap_or_default(),
|
filter_folder: folder_filter.unwrap_or_default(),
|
||||||
filter_name: name_filter.unwrap_or_default(),
|
filter_name: name_filter.unwrap_or_default(),
|
||||||
filter_type: type_filter.unwrap_or_default(),
|
filter_type: type_filter.unwrap_or_default(),
|
||||||
|
current_page,
|
||||||
|
total_pages,
|
||||||
|
total_count,
|
||||||
version: env!("CARGO_PKG_VERSION"),
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
};
|
};
|
||||||
|
|
||||||
render_template(tmpl)
|
render_template(tmpl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AuditQuery {
|
||||||
|
page: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
async fn audit_page(
|
async fn audit_page(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
session: Session,
|
session: Session,
|
||||||
|
Query(aq): Query<AuditQuery>,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let Some(user_id) = current_user_id(&session).await else {
|
let Some(user_id) = current_user_id(&session).await else {
|
||||||
return Ok(Redirect::to("/login").into_response());
|
return Ok(Redirect::to("/login").into_response());
|
||||||
@@ -795,7 +839,20 @@ async fn audit_page(
|
|||||||
None => return Ok(Redirect::to("/login").into_response()),
|
None => return Ok(Redirect::to("/login").into_response()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let rows = list_for_user(&state.pool, user_id, 100)
|
let page = aq.page.unwrap_or(1).max(1);
|
||||||
|
|
||||||
|
let total_count = count_for_user(&state.pool, user_id).await.map_err(|e| {
|
||||||
|
tracing::error!(error = %e, "failed to count audit log for user");
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let total_pages = (total_count as u32)
|
||||||
|
.div_ceil(AUDIT_PAGE_LIMIT as u32)
|
||||||
|
.max(1);
|
||||||
|
let current_page = page.min(total_pages);
|
||||||
|
let actual_offset = ((current_page - 1) as i64) * AUDIT_PAGE_LIMIT;
|
||||||
|
|
||||||
|
let rows = list_for_user(&state.pool, user_id, AUDIT_PAGE_LIMIT, actual_offset)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!(error = %e, "failed to load audit log for user");
|
tracing::error!(error = %e, "failed to load audit log for user");
|
||||||
@@ -816,6 +873,9 @@ async fn audit_page(
|
|||||||
user_name: user.name.clone(),
|
user_name: user.name.clone(),
|
||||||
user_email: user.email.clone().unwrap_or_default(),
|
user_email: user.email.clone().unwrap_or_default(),
|
||||||
entries,
|
entries,
|
||||||
|
current_page,
|
||||||
|
total_pages,
|
||||||
|
total_count,
|
||||||
version: env!("CARGO_PKG_VERSION"),
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,25 @@
|
|||||||
.main { padding: 32px 24px 40px; flex: 1; }
|
.main { padding: 32px 24px 40px; flex: 1; }
|
||||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||||
padding: 24px; width: 100%; max-width: 1180px; margin: 0 auto; }
|
padding: 24px; width: 100%; max-width: 1180px; margin: 0 auto; }
|
||||||
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 20px; }
|
.card-title-row {
|
||||||
|
display: flex; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.card-title { font-size: 20px; font-weight: 600; margin: 0; }
|
||||||
|
.card-title-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-muted);
|
||||||
|
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: var(--text-muted); font-size: 14px; padding: 20px 0; }
|
||||||
table { width: 100%; border-collapse: collapse; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
|
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
|
||||||
@@ -84,6 +102,24 @@
|
|||||||
}
|
}
|
||||||
.detail { max-width: none; }
|
.detail { max-width: none; }
|
||||||
}
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex; align-items: center; gap: 8px; margin-top: 20px;
|
||||||
|
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;
|
||||||
|
font-size: 13px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.page-btn:hover { background: var(--surface2); }
|
||||||
|
.page-btn-disabled {
|
||||||
|
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
||||||
|
background: var(--surface); color: var(--text-muted); font-size: 13px;
|
||||||
|
opacity: 0.5; cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-info {
|
||||||
|
color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -113,7 +149,10 @@
|
|||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card-title" data-i18n="auditTitle">我的审计</div>
|
<div class="card-title-row">
|
||||||
|
<div class="card-title" data-i18n="auditTitle">我的审计</div>
|
||||||
|
<span class="card-title-count">{{ total_count }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if entries.is_empty() %}
|
{% if entries.is_empty() %}
|
||||||
<div class="empty" data-i18n="emptyAudit">暂无审计记录。</div>
|
<div class="empty" data-i18n="emptyAudit">暂无审计记录。</div>
|
||||||
@@ -138,6 +177,22 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{% if total_count > 0 %}
|
||||||
|
<div class="pagination">
|
||||||
|
{% if current_page > 1 %}
|
||||||
|
<a href="?page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="page-btn page-btn-disabled" data-i18n="prevPage">上一页</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
|
||||||
|
{% if current_page < total_pages %}
|
||||||
|
<a href="?page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="page-btn page-btn-disabled" data-i18n="nextPage">下一页</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -147,9 +202,9 @@
|
|||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
I18N_PAGE = {
|
I18N_PAGE = {
|
||||||
'zh-CN': { pageTitle: 'Secrets — 审计', auditTitle: '我的审计', emptyAudit: '暂无审计记录。', colTime: '时间', colAction: '动作', colTarget: '目标', colDetail: '详情' },
|
'zh-CN': { pageTitle: 'Secrets — 审计', auditTitle: '我的审计', emptyAudit: '暂无审计记录。', colTime: '时间', colAction: '动作', colTarget: '目标', colDetail: '详情', prevPage: '上一页', nextPage: '下一页' },
|
||||||
'zh-TW': { pageTitle: 'Secrets — 審計', auditTitle: '我的審計', emptyAudit: '暫無審計記錄。', colTime: '時間', colAction: '動作', colTarget: '目標', colDetail: '詳情' },
|
'zh-TW': { pageTitle: 'Secrets — 審計', auditTitle: '我的審計', emptyAudit: '暫無審計記錄。', colTime: '時間', colAction: '動作', colTarget: '目標', colDetail: '詳情', prevPage: '上一頁', nextPage: '下一頁' },
|
||||||
en: { pageTitle: 'Secrets — Audit', auditTitle: 'My audit', emptyAudit: 'No audit records.', colTime: 'Time', colAction: 'Action', colTarget: 'Target', colDetail: 'Detail' }
|
en: { pageTitle: 'Secrets — Audit', auditTitle: 'My audit', emptyAudit: 'No audit records.', colTime: 'Time', colAction: 'Action', colTarget: 'Target', colDetail: 'Detail', prevPage: 'Previous', nextPage: 'Next' }
|
||||||
};
|
};
|
||||||
|
|
||||||
window.applyPageLang = function () {
|
window.applyPageLang = function () {
|
||||||
|
|||||||
@@ -350,6 +350,24 @@
|
|||||||
}
|
}
|
||||||
.detail, .notes-scroll, .secret-list { max-width: none; }
|
.detail, .notes-scroll, .secret-list { max-width: none; }
|
||||||
}
|
}
|
||||||
|
.pagination {
|
||||||
|
display: flex; align-items: center; gap: 8px; margin-top: 20px;
|
||||||
|
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;
|
||||||
|
font-size: 13px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.page-btn:hover { background: var(--surface2); }
|
||||||
|
.page-btn-disabled {
|
||||||
|
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
||||||
|
background: var(--surface); color: var(--text-muted); font-size: 13px;
|
||||||
|
opacity: 0.5; cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.page-info {
|
||||||
|
color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -456,6 +474,22 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if total_count > 0 %}
|
||||||
|
<div class="pagination">
|
||||||
|
{% if current_page > 1 %}
|
||||||
|
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}page={{ current_page - 1 }}" class="page-btn" data-i18n="prevPage">上一页</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="page-btn page-btn-disabled" data-i18n="prevPage">上一页</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="page-info">{{ current_page }} / {{ total_pages }}</span>
|
||||||
|
{% if current_page < total_pages %}
|
||||||
|
<a href="?{% if !filter_folder.is_empty() %}folder={{ filter_folder | urlencode }}&{% endif %}{% if !filter_type.is_empty() %}type={{ filter_type | urlencode }}&{% endif %}{% if !filter_name.is_empty() %}name={{ filter_name | urlencode }}&{% endif %}page={{ current_page + 1 }}" class="page-btn" data-i18n="nextPage">下一页</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="page-btn page-btn-disabled" data-i18n="nextPage">下一页</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -550,11 +584,8 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
checkingSecretName: '检查中...',
|
checkingSecretName: '检查中...',
|
||||||
secretNameAvailable: '名称可用',
|
secretNameAvailable: '名称可用',
|
||||||
secretNameTaken: '该名称已被使用',
|
secretNameTaken: '该名称已被使用',
|
||||||
secretNameInvalid: '名称不合法',
|
prevPage: '上一页',
|
||||||
secretNameCheckError: '校验失败,请重试',
|
nextPage: '下一页',
|
||||||
secretNameFixBeforeSave: '请先修复密文名称校验问题后再保存',
|
|
||||||
secretTypePlaceholder: '选择类型',
|
|
||||||
secretTypeInvalid: '类型不能为空'
|
|
||||||
},
|
},
|
||||||
'zh-TW': {
|
'zh-TW': {
|
||||||
pageTitle: 'Secrets — 條目',
|
pageTitle: 'Secrets — 條目',
|
||||||
@@ -610,7 +641,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
secretNameCheckError: '校驗失敗,請重試',
|
secretNameCheckError: '校驗失敗,請重試',
|
||||||
secretNameFixBeforeSave: '請先修復密文名稱校驗問題後再儲存',
|
secretNameFixBeforeSave: '請先修復密文名稱校驗問題後再儲存',
|
||||||
secretTypePlaceholder: '選擇類型',
|
secretTypePlaceholder: '選擇類型',
|
||||||
secretTypeInvalid: '類型不能為空'
|
secretTypeInvalid: '類型不能為空',
|
||||||
|
prevPage: '上一頁',
|
||||||
|
nextPage: '下一頁',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
pageTitle: 'Secrets — Entries',
|
pageTitle: 'Secrets — Entries',
|
||||||
@@ -666,7 +699,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
secretNameCheckError: 'Validation failed, please retry',
|
secretNameCheckError: 'Validation failed, please retry',
|
||||||
secretNameFixBeforeSave: 'Fix secret name validation errors before saving',
|
secretNameFixBeforeSave: 'Fix secret name validation errors before saving',
|
||||||
secretTypePlaceholder: 'Select type',
|
secretTypePlaceholder: 'Select type',
|
||||||
secretTypeInvalid: 'Type cannot be empty'
|
secretTypeInvalid: 'Type cannot be empty',
|
||||||
|
prevPage: 'Previous',
|
||||||
|
nextPage: 'Next'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,3 @@ GOOGLE_CLIENT_SECRET=
|
|||||||
|
|
||||||
# ─── 日志(可选)──────────────────────────────────────────────────────
|
# ─── 日志(可选)──────────────────────────────────────────────────────
|
||||||
# RUST_LOG=secrets_mcp=debug
|
# RUST_LOG=secrets_mcp=debug
|
||||||
|
|
||||||
# ─── 注意 ─────────────────────────────────────────────────────────────
|
|
||||||
# SERVER_MASTER_KEY 已不再需要。
|
|
||||||
# 新架构(E2EE)中,加密密钥由用户密码短语在客户端本地派生,服务端不持有原始密钥。
|
|
||||||
# 仅在需要迁移旧版 wrapped_key 数据时临时启用。
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ tag="secrets-mcp-${version}"
|
|||||||
echo "==> 当前 secrets-mcp 版本: ${version}"
|
echo "==> 当前 secrets-mcp 版本: ${version}"
|
||||||
echo "==> 检查是否已存在 tag: ${tag}"
|
echo "==> 检查是否已存在 tag: ${tag}"
|
||||||
|
|
||||||
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
|
if jj log --no-graph --revisions "tag(${tag})" --limit 1 >/dev/null 2>&1; then
|
||||||
echo "提示: 已存在 tag ${tag},将按重复构建处理,不阻断检查。"
|
echo "提示: 已存在 tag ${tag},将按重复构建处理,不阻断检查。"
|
||||||
echo "如需创建新的发布版本,请先 bump crates/secrets-mcp/Cargo.toml 中的 version。"
|
echo "如需创建新的发布版本,请先 bump crates/secrets-mcp/Cargo.toml 中的 version。"
|
||||||
else
|
else
|
||||||
|
|||||||
Reference in New Issue
Block a user