Compare commits

...

3 Commits

Author SHA1 Message Date
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
13 changed files with 460 additions and 30 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,10 +166,26 @@ 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 视图下区分条目所属隔离空间。
### 导出 / 导入文件
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.21"
version = "0.5.26"
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。**条目**页 `/entries` 支持 folder 标签与条件筛选;表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。
- **Web**`BASE_URL`登录、Dashboard、设置密码短语、创建 API Key。**变更记录**页 **`/changelog`**:内容来自 `crates/secrets-mcp/CHANGELOG.md`(构建时嵌入并以 Markdown 渲染);首页页脚与 DashboardMCP页脚均提供入口。**条目**页 `/entries` 支持 folder 标签与条件筛选;表格列可在「显示列」中开关(名称与操作固定),**文件夹**列为可选列且默认显示。列可见性持久化见 [AGENTS.md](AGENTS.md)「Web 条目页表格列」。
- **MCP**Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)。
## PostgreSQL TLS 加固
@@ -72,9 +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

@@ -0,0 +1,32 @@
本文档在构建时嵌入 Web 的 `/changelog` 页面,并由服务端渲染为 HTML。
## [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.21"
version = "0.5.26"
edition.workspace = true
[[bin]]
@@ -45,3 +45,4 @@ urlencoding = "2"
schemars = "1"
http = "1"
url = "2"
pulldown-cmark = "0.13.3"

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

@@ -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

@@ -16,6 +16,7 @@ mod account;
mod assets;
mod audit;
mod auth;
mod changelog;
mod entries;
// ── Session keys ──────────────────────────────────────────────────────────────
@@ -253,6 +254,7 @@ pub fn web_router() -> Router<AppState> {
get(assets::oauth_protected_resource_metadata),
)
.route("/", get(auth::home_page))
.route("/changelog", get(changelog::changelog_page))
.route("/login", get(auth::login_page))
.route("/auth/google", get(auth::auth_google))
.route("/auth/google/callback", get(auth::auth_google_callback))

View File

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

View File

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

View File

@@ -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

@@ -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=