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
This commit is contained in:
voson
2026-04-11 20:49:21 +08:00
parent ab1e3329b9
commit 1b2fbdae4d
10 changed files with 316 additions and 12 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
@@ -166,6 +166,10 @@ 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` 版本更新该文件即可。
### Web JSON API 与会话
除页面路由使用的 `require_valid_user`(未登录或 `key_version` 与库不一致时重定向 `/login`JSON API`/api/...`)使用等价校验:会话中的 `key_version` 须与 `users.key_version` 一致,否则返回 **401** JSON避免仅校验 `user_id` 时与页面行为不一致。

43
Cargo.lock generated
View File

@@ -740,6 +740,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"
@@ -1578,6 +1587,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 +2093,7 @@ dependencies = [
[[package]]
name = "secrets-mcp"
version = "0.5.21"
version = "0.5.24"
dependencies = [
"anyhow",
"askama",
@@ -2075,6 +2103,7 @@ dependencies = [
"dotenvy",
"governor",
"http",
"pulldown-cmark",
"rand 0.10.0",
"reqwest",
"rmcp",
@@ -2985,6 +3014,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 +3047,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"

View File

@@ -46,7 +46,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 加固
@@ -226,7 +226,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,20 @@
本文档在构建时嵌入 Web 的 `/changelog` 页面,并由服务端渲染为 HTML。
## [0.5.24] - 2026-04-11
### Changed
- 首页页脚将原「登录」入口改为「变更记录」(`/changelog`);顶部导航仍保留登录 / 进入控制台。
## [0.5.23] - 2026-04-11
### Added
- Changelog 页使用 **Markdown** 渲染(`pulldown-cmark`:表格、~~删除线~~、任务列表等)。
## [0.5.22] - 2026-04-11
### Added
- DashboardMCP页脚版本旁增加「变更记录」链接打开本变更说明页。

View File

@@ -1,6 +1,6 @@
[package]
name = "secrets-mcp"
version = "0.5.21"
version = "0.5.24"
edition.workspace = true
[[bin]]
@@ -45,3 +45,4 @@ urlencoding = "2"
schemars = "1"
http = "1"
url = "2"
pulldown-cmark = "0.13.3"

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',
}
};