Compare commits
45 Commits
secrets-mc
...
v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c3efb70e | ||
|
|
e6bd2225cd | ||
|
|
328962706b | ||
|
|
763d99b15e | ||
|
|
0374899dab | ||
|
|
cb5865b958 | ||
|
|
34093b0e23 | ||
|
|
0bf06bbc73 | ||
|
|
f86d12b80e | ||
|
|
43d6164a15 | ||
|
|
1b2fbdae4d | ||
|
|
ab1e3329b9 | ||
|
|
c3b1a0df1a | ||
|
|
d772066210 | ||
|
|
2c7dbf890b | ||
|
|
8c49316923 | ||
|
|
cf93488c6a | ||
| 137a4d42b0 | |||
|
|
ff2ea91e72 | ||
|
|
574c1c9967 | ||
|
|
98d69f5f12 | ||
|
|
089d0b4b58 | ||
|
|
10da51c203 | ||
|
|
bc8995cf71 | ||
|
|
5333b863c5 | ||
|
|
6fde982c20 | ||
|
|
a2a80a1744 | ||
| dfe282095c | |||
|
|
59084a409d | ||
|
|
b0fcb83592 | ||
|
|
8942718641 | ||
|
|
53d53ff96a | ||
|
|
cab234cfcb | ||
|
|
e0fee639c1 | ||
|
|
7c53bfb782 | ||
|
|
63cb3a8216 | ||
|
|
2b994141b8 | ||
|
|
9d6ac5c13a | ||
|
|
1860cce86c | ||
| dd24f7cc44 | |||
|
|
aefad33870 | ||
|
|
0ffb81e57f | ||
|
|
4a1654c820 | ||
|
|
a15e2eaf4a | ||
|
|
1518388374 |
@@ -1,5 +1,4 @@
|
|||||||
# MCP 分支:仅构建/发布 secrets-mcp(CLI 在 main 分支维护)
|
name: Secrets v3 CI
|
||||||
name: Secrets MCP — Build & Release
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -18,7 +17,6 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
MCP_BINARY: secrets-mcp
|
|
||||||
RUST_TOOLCHAIN: 1.94.0
|
RUST_TOOLCHAIN: 1.94.0
|
||||||
CARGO_INCREMENTAL: 0
|
CARGO_INCREMENTAL: 0
|
||||||
CARGO_NET_RETRY: 10
|
CARGO_NET_RETRY: 10
|
||||||
@@ -28,34 +26,14 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
name: 检查 / 构建 / 发版
|
name: 检查
|
||||||
runs-on: debian
|
runs-on: debian
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
outputs:
|
|
||||||
tag: ${{ steps.ver.outputs.tag }}
|
|
||||||
version: ${{ steps.ver.outputs.version }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
# ── 版本解析 ────────────────────────────────────────────────────────
|
|
||||||
- name: 解析版本
|
|
||||||
id: ver
|
|
||||||
run: |
|
|
||||||
version=$(grep -m1 '^version' crates/secrets-mcp/Cargo.toml | sed 's/.*"\(.*\)".*/\1/')
|
|
||||||
tag="secrets-mcp-${version}"
|
|
||||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
|
|
||||||
echo "⚠ 版本 ${tag} 已存在,将覆盖重新发版。"
|
|
||||||
echo "tag_exists=true" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "将创建新版本 ${tag}"
|
|
||||||
echo "tag_exists=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Rust 工具链 ──────────────────────────────────────────────────────
|
# ── Rust 工具链 ──────────────────────────────────────────────────────
|
||||||
- name: 安装 Rust 与 musl 工具链
|
- name: 安装 Rust 与 musl 工具链
|
||||||
run: |
|
run: |
|
||||||
@@ -95,76 +73,13 @@ jobs:
|
|||||||
- name: test
|
- name: test
|
||||||
run: cargo test --locked
|
run: cargo test --locked
|
||||||
|
|
||||||
# ── 构建(质量检查通过后才执行)────────────────────────────────────
|
- name: 构建 secrets-api
|
||||||
- name: 构建 secrets-mcp (musl)
|
|
||||||
run: |
|
run: |
|
||||||
cargo build --release --locked --target "${MUSL_TARGET}" -p secrets-mcp
|
cargo build --release --locked -p secrets-api
|
||||||
strip "target/${MUSL_TARGET}/release/${MCP_BINARY}"
|
|
||||||
|
|
||||||
- name: 上传构建产物
|
- name: 构建 secrets-desktop-daemon
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: ${{ env.MCP_BINARY }}-linux-musl
|
|
||||||
path: target/${{ env.MUSL_TARGET }}/release/${{ env.MCP_BINARY }}
|
|
||||||
retention-days: 3
|
|
||||||
|
|
||||||
# ── 创建 / 覆盖 Tag(构建成功后才打)───────────────────────────────
|
|
||||||
- name: 创建 Tag
|
|
||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions[bot]"
|
cargo build --release --locked -p secrets-desktop-daemon
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
tag="${{ steps.ver.outputs.tag }}"
|
|
||||||
if [ "${{ steps.ver.outputs.tag_exists }}" = "true" ]; then
|
|
||||||
git tag -d "$tag" 2>/dev/null || true
|
|
||||||
git push origin ":refs/tags/$tag" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
git tag -a "$tag" -m "Release $tag"
|
|
||||||
git push origin "$tag"
|
|
||||||
|
|
||||||
# ── Release(可选,需配置 RELEASE_TOKEN)───────────────────────────
|
|
||||||
- name: Upsert Release
|
|
||||||
if: env.RELEASE_TOKEN != ''
|
|
||||||
env:
|
|
||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
run: |
|
|
||||||
tag="${{ steps.ver.outputs.tag }}"
|
|
||||||
version="${{ steps.ver.outputs.version }}"
|
|
||||||
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
|
||||||
auth="Authorization: token $RELEASE_TOKEN"
|
|
||||||
|
|
||||||
previous_tag=$(git tag --list 'secrets-mcp-*' --sort=-v:refname | awk -v t="$tag" '$0 != t { print; exit }')
|
|
||||||
if [ -n "$previous_tag" ]; then
|
|
||||||
changes=$(git log --pretty=format:'- %s (%h)' "${previous_tag}..HEAD")
|
|
||||||
else
|
|
||||||
changes=$(git log --pretty=format:'- %s (%h)')
|
|
||||||
fi
|
|
||||||
[ -z "$changes" ] && changes="- 首次发布"
|
|
||||||
body=$(printf '## 变更日志\n\n%s' "$changes")
|
|
||||||
|
|
||||||
# Upsert: 存在 → PATCH + 清旧 assets;不存在 → POST
|
|
||||||
release_id=$(curl -sS -H "$auth" "${api}/tags/${tag}" 2>/dev/null | jq -r '.id // empty')
|
|
||||||
if [ -n "$release_id" ]; then
|
|
||||||
curl -sS -o /dev/null -H "$auth" -H "Content-Type: application/json" \
|
|
||||||
-X PATCH "${api}/${release_id}" \
|
|
||||||
-d "$(jq -n --arg n "secrets-mcp ${version}" --arg b "$body" '{name:$n,body:$b,draft:false}')"
|
|
||||||
curl -sS -H "$auth" "${api}/${release_id}/assets" | \
|
|
||||||
jq -r '.[].id' | xargs -I{} curl -sS -o /dev/null -H "$auth" -X DELETE "${api}/${release_id}/assets/{}"
|
|
||||||
echo "已更新 Release ${release_id}"
|
|
||||||
else
|
|
||||||
release_id=$(curl -fsS -H "$auth" -H "Content-Type: application/json" \
|
|
||||||
-X POST "$api" \
|
|
||||||
-d "$(jq -n --arg t "$tag" --arg n "secrets-mcp ${version}" --arg b "$body" \
|
|
||||||
'{tag_name:$t,name:$n,body:$b,draft:false}')" | jq -r '.id')
|
|
||||||
echo "已创建 Release ${release_id}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
bin="target/${MUSL_TARGET}/release/${MCP_BINARY}"
|
|
||||||
archive="${MCP_BINARY}-${tag}-x86_64-linux-musl.tar.gz"
|
|
||||||
tar -czf "$archive" -C "$(dirname "$bin")" "$(basename "$bin")"
|
|
||||||
sha256sum "$archive" > "${archive}.sha256"
|
|
||||||
curl -fsS -H "$auth" -F "attachment=@${archive}" "${api}/${release_id}/assets"
|
|
||||||
curl -fsS -H "$auth" -F "attachment=@${archive}.sha256" "${api}/${release_id}/assets"
|
|
||||||
echo "Release ${tag} 已发布"
|
|
||||||
|
|
||||||
# ── 飞书汇总通知 ─────────────────────────────────────────────────────
|
# ── 飞书汇总通知 ─────────────────────────────────────────────────────
|
||||||
- name: 飞书通知
|
- name: 飞书通知
|
||||||
@@ -173,76 +88,14 @@ jobs:
|
|||||||
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
|
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
|
||||||
run: |
|
run: |
|
||||||
[ -z "$WEBHOOK_URL" ] && exit 0
|
[ -z "$WEBHOOK_URL" ] && exit 0
|
||||||
tag="${{ steps.ver.outputs.tag }}"
|
|
||||||
commit="${{ github.event.head_commit.message }}"
|
commit="${{ github.event.head_commit.message }}"
|
||||||
[ -z "$commit" ] && commit="${{ github.sha }}"
|
[ -z "$commit" ] && commit="${{ github.sha }}"
|
||||||
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
|
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
|
||||||
result="${{ job.status }}"
|
result="${{ job.status }}"
|
||||||
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
|
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
|
||||||
msg="secrets-mcp 构建&发版 ${icon}
|
msg="secrets v3 CI ${icon}
|
||||||
版本:${tag}
|
|
||||||
提交:${commit}
|
提交:${commit}
|
||||||
作者:${{ github.actor }}
|
作者:${{ github.actor }}
|
||||||
详情:${url}"
|
详情:${url}"
|
||||||
payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}')
|
payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}')
|
||||||
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"
|
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: 部署 secrets-mcp
|
|
||||||
needs: [ci]
|
|
||||||
if: |
|
|
||||||
github.ref == 'refs/heads/main' ||
|
|
||||||
github.ref == 'refs/heads/feat/mcp' ||
|
|
||||||
github.ref == 'refs/heads/mcp'
|
|
||||||
runs-on: debian
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: 下载构建产物
|
|
||||||
uses: actions/download-artifact@v3
|
|
||||||
with:
|
|
||||||
name: ${{ env.MCP_BINARY }}-linux-musl
|
|
||||||
path: /tmp/artifact
|
|
||||||
|
|
||||||
- name: 部署到阿里云 ECS
|
|
||||||
env:
|
|
||||||
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
|
|
||||||
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
|
|
||||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$DEPLOY_HOST" ] || [ -z "$DEPLOY_USER" ] || [ -z "$DEPLOY_SSH_KEY" ]; then
|
|
||||||
echo "部署跳过:请配置 vars.DEPLOY_HOST、vars.DEPLOY_USER 与 secrets.DEPLOY_SSH_KEY"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$DEPLOY_SSH_KEY" > /tmp/deploy_key
|
|
||||||
chmod 600 /tmp/deploy_key
|
|
||||||
|
|
||||||
scp -i /tmp/deploy_key -o StrictHostKeyChecking=no \
|
|
||||||
"/tmp/artifact/${MCP_BINARY}" \
|
|
||||||
"${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/secrets-mcp.new"
|
|
||||||
|
|
||||||
ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" "
|
|
||||||
sudo mv /tmp/secrets-mcp.new /opt/secrets-mcp/secrets-mcp
|
|
||||||
sudo chmod +x /opt/secrets-mcp/secrets-mcp
|
|
||||||
sudo systemctl restart secrets-mcp
|
|
||||||
sleep 2
|
|
||||||
sudo systemctl is-active secrets-mcp && echo '服务启动成功' || (sudo journalctl -u secrets-mcp -n 20 && exit 1)
|
|
||||||
"
|
|
||||||
rm -f /tmp/deploy_key
|
|
||||||
|
|
||||||
- name: 飞书通知
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
WEBHOOK_URL: ${{ vars.WEBHOOK_URL }}
|
|
||||||
run: |
|
|
||||||
[ -z "$WEBHOOK_URL" ] && exit 0
|
|
||||||
tag="${{ needs.ci.outputs.tag }}"
|
|
||||||
url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_number }}"
|
|
||||||
result="${{ job.status }}"
|
|
||||||
if [ "$result" = "success" ]; then icon="✅"; else icon="❌"; fi
|
|
||||||
msg="secrets-mcp 部署 ${icon}
|
|
||||||
版本:${tag}
|
|
||||||
作者:${{ github.actor }}
|
|
||||||
详情:${url}"
|
|
||||||
payload=$(jq -n --arg text "$msg" '{msg_type: "text", content: {text: $text}}')
|
|
||||||
curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$WEBHOOK_URL"
|
|
||||||
|
|||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -2,7 +2,14 @@
|
|||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.cursor/
|
.cursor/
|
||||||
# Google OAuth 下载的 JSON 凭据文件
|
|
||||||
client_secret_*.apps.googleusercontent.com.json
|
|
||||||
*.pem
|
*.pem
|
||||||
tmp/
|
tmp/
|
||||||
|
client_secret_*.apps.googleusercontent.com.json
|
||||||
|
node_modules/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Tauri app icon pack: generated by `cargo tauri icon apps/desktop/src-tauri/icons/icon.png`
|
||||||
|
# Version control only the 1024×1024 master; regenerate the rest locally or in release builds.
|
||||||
|
apps/desktop/src-tauri/icons/**
|
||||||
|
!apps/desktop/src-tauri/icons/
|
||||||
|
!apps/desktop/src-tauri/icons/icon.png
|
||||||
47
.vscode/tasks.json
vendored
47
.vscode/tasks.json
vendored
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "2.0.0",
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"label": "mcp: build",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "cargo build --locked -p secrets-mcp",
|
|
||||||
"group": "build",
|
|
||||||
"options": {
|
|
||||||
"envFile": "${workspaceFolder}/.env"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "mcp: run",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "cargo run --locked -p secrets-mcp",
|
|
||||||
"options": {
|
|
||||||
"envFile": "${workspaceFolder}/.env"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "test: workspace",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "cargo test --workspace --locked",
|
|
||||||
"dependsOn": "build",
|
|
||||||
"group": { "kind": "test", "isDefault": true }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "fmt: check",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "cargo fmt -- --check",
|
|
||||||
"problemMatcher": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "clippy: workspace",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "cargo clippy --workspace --locked -- -D warnings",
|
|
||||||
"dependsOn": "build"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "ci: release-check",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "./scripts/release-check.sh",
|
|
||||||
"problemMatcher": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
360
AGENTS.md
360
AGENTS.md
@@ -1,142 +1,43 @@
|
|||||||
# Secrets MCP — AGENTS.md
|
# Secrets — AGENTS.md
|
||||||
|
|
||||||
本仓库为 **MCP SaaS**:`secrets-core`(业务与持久化)+ `secrets-mcp`(Streamable HTTP MCP、Web、OAuth、API Key)。对外入口见 `crates/secrets-mcp`。
|
本仓库当前为 **v3 桌面端架构**:
|
||||||
|
|
||||||
## 提交 / 推送硬规则(优先于下文)
|
- `apps/api`:远端 JSON API
|
||||||
|
- `apps/desktop/src-tauri`:桌面客户端
|
||||||
|
- `crates/desktop-daemon`:本地 MCP daemon
|
||||||
|
- `crates/application` / `domain` / `infrastructure-db`:v3 业务与数据层
|
||||||
|
|
||||||
**每次提交和推送前必须执行以下检查,无论是否明确「发版」:**
|
旧 `secrets-core` / `secrets-mcp` / `secrets-mcp-local` 已移除,不再作为开发入口。
|
||||||
|
|
||||||
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`。
|
|
||||||
3. 提交前运行 `./scripts/release-check.sh`(版本/tag + `fmt` + `clippy --locked` + `test --locked`)。若脚本不存在或不可用,至少运行 `cargo fmt -- --check && cargo clippy --locked -- -D warnings && cargo test --locked`。
|
|
||||||
|
|
||||||
## 项目结构
|
本仓库使用 **[Jujutsu (jj)](https://jj-vcs.dev/)** 作为版本控制系统(纯 jj 模式,无 `.git` 目录)。
|
||||||
|
|
||||||
```
|
### 常用 jj 命令对照
|
||||||
secrets/
|
|
||||||
Cargo.toml
|
|
||||||
crates/
|
|
||||||
secrets-core/ # db / crypto / models / audit / service
|
|
||||||
secrets-mcp/ # rmcp tools、axum、OAuth、Dashboard
|
|
||||||
scripts/
|
|
||||||
release-check.sh
|
|
||||||
setup-gitea-actions.sh
|
|
||||||
.gitea/workflows/secrets.yml
|
|
||||||
.vscode/tasks.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## 数据库
|
| 操作 | 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` |
|
||||||
|
|
||||||
- **建议库名**:`secrets-mcp`(专用实例,与历史库名区分)。
|
### 注意事项
|
||||||
- **连接**:环境变量 **`SECRETS_DATABASE_URL`**(本分支无本地配置文件路径)。
|
|
||||||
- **表**:`entries`(含 `user_id`)、`secrets`、`entries_history`、`secrets_history`、`audit_log`、`users`、`oauth_accounts`,首次连接 **auto-migrate**(`secrets-core` 的 `migrate`)。
|
|
||||||
- **Web 会话**:与上项 **同一数据库 URL**;`secrets-mcp` 启动时对 tower-sessions 的 PostgreSQL 存储 **auto-migrate**(会话表与业务表共存于该实例,无需第二套连接串)。
|
|
||||||
|
|
||||||
### 表结构(摘录)
|
- 本仓库为纯 `jj` 模式,本地不要使用 `git` 命令。
|
||||||
|
- CI Runner 侧仍可能使用 `git` 拉代码,这不影响本地开发。
|
||||||
```sql
|
- 检查 tag 是否存在时,使用 `jj log --no-graph --revisions "tag(${tag})"`。
|
||||||
entries (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
||||||
user_id UUID, -- 多租户:NULL=遗留行;非空=归属用户
|
|
||||||
folder VARCHAR(128) NOT NULL DEFAULT '',
|
|
||||||
type VARCHAR(64) NOT NULL DEFAULT '',
|
|
||||||
name VARCHAR(256) NOT NULL,
|
|
||||||
notes TEXT NOT NULL DEFAULT '',
|
|
||||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
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()
|
|
||||||
)
|
|
||||||
-- 唯一:UNIQUE(user_id, folder, name) WHERE user_id IS NOT NULL;
|
|
||||||
-- UNIQUE(folder, name) WHERE user_id IS NULL(单租户遗留)
|
|
||||||
```
|
|
||||||
|
|
||||||
```sql
|
|
||||||
secrets (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
||||||
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
|
||||||
field_name VARCHAR(256) NOT NULL,
|
|
||||||
encrypted BYTEA NOT NULL DEFAULT '\x',
|
|
||||||
version BIGINT NOT NULL DEFAULT 1,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE(entry_id, field_name)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### users / oauth_accounts
|
|
||||||
|
|
||||||
```sql
|
|
||||||
users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
||||||
email VARCHAR(256),
|
|
||||||
name VARCHAR(256) NOT NULL DEFAULT '',
|
|
||||||
avatar_url TEXT,
|
|
||||||
key_salt BYTEA, -- PBKDF2 salt(32B),首次设置密码短语时写入
|
|
||||||
key_check BYTEA, -- 派生密钥加密已知常量,用于验证密码短语
|
|
||||||
key_params JSONB, -- 算法参数,如 {"alg":"pbkdf2-sha256","iterations":600000}
|
|
||||||
api_key TEXT UNIQUE, -- MCP Bearer token(当前实现为明文存储)
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
)
|
|
||||||
|
|
||||||
oauth_accounts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
provider VARCHAR(32) NOT NULL,
|
|
||||||
provider_id VARCHAR(256) NOT NULL,
|
|
||||||
email VARCHAR(256),
|
|
||||||
name VARCHAR(256),
|
|
||||||
avatar_url TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE(provider, provider_id)
|
|
||||||
)
|
|
||||||
-- 另有唯一索引 UNIQUE(user_id, provider)(迁移中 idx_oauth_accounts_user_provider):同一用户每种 provider 至多一条关联。
|
|
||||||
```
|
|
||||||
|
|
||||||
### audit_log / history
|
|
||||||
|
|
||||||
与迁移脚本一致:`audit_log`、`entries_history`、`secrets_history` 用于审计与时间旅行恢复;字段定义见 `crates/secrets-core/src/db.rs` 内 `migrate` SQL。`audit_log` 含可选 **`user_id`**(多租户下标识操作者;可空以兼容遗留数据)。`audit_log` 中普通业务事件使用 **`folder` / `type` / `name`** 对应 entry 坐标;登录类事件固定使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标而非 entry 身份。
|
|
||||||
|
|
||||||
### MCP 消歧(AI 调用)
|
|
||||||
|
|
||||||
按 `name` 定位条目的工具(`get` / `update` / 单条 `delete` / `history` / `rollback`):若该用户下仅一条匹配则直接执行;若多条(同 `name`、不同 `folder`)则返回错误并提示补全 `folder`。`secrets_delete` 的 `dry_run=true` 与真实删除使用相同消歧规则。
|
|
||||||
|
|
||||||
### 字段职责
|
|
||||||
|
|
||||||
| 字段 | 含义 | 示例 |
|
|
||||||
|------|------|------|
|
|
||||||
| `folder` | 隔离空间(参与唯一键) | `refining` |
|
|
||||||
| `type` | 软分类(不参与唯一键) | `server`, `service`, `key`, `person` |
|
|
||||||
| `name` | 标识名 | `gitea`, `aliyun` |
|
|
||||||
| `notes` | 非敏感说明 | 自由文本 |
|
|
||||||
| `tags` | 标签 | `["aliyun","prod"]` |
|
|
||||||
| `metadata` | 明文描述 | `ip`、`url`、`key_ref` |
|
|
||||||
| `secrets.field_name` | 加密字段名(明文) | `token`, `ssh_key` |
|
|
||||||
| `secrets.encrypted` | 密文 | AES-GCM |
|
|
||||||
|
|
||||||
### PEM 共享(`key_ref`)
|
|
||||||
|
|
||||||
建议将共享 PEM 存为 **`type=key`** 的 entry;其它记录在 `metadata.key_ref` 指向目标 entry 的 `name`(支持 `folder/name` 格式消歧)。删除被引用 key 时,服务会自动迁移为单副本 + 重定向(复制到首个引用方,其余引用方改指向新 owner);解析逻辑见 `secrets_core::service::env_map`。
|
|
||||||
|
|
||||||
## 代码规范
|
|
||||||
|
|
||||||
- 错误:业务层 `anyhow::Result`,避免生产路径 `unwrap()`。
|
|
||||||
- 异步:`tokio` + `sqlx` async。
|
|
||||||
- SQL:`sqlx::query` / `query_as` 参数绑定;动态 WHERE 仍须用占位符绑定。
|
|
||||||
- 日志:运维用 `tracing`;面向用户的 Web 响应走 axum handler。tracing 字段风格:变量名即字段名时用简写(`%var`、`?var`、`var`),否则用显式形式(`field = %expr`)。
|
|
||||||
- 审计:写操作成功后尽量 `audit::log_tx`;失败可 `warn`,不掩盖主错误。
|
|
||||||
- 加密:密钥由用户密码短语通过 **PBKDF2-SHA256(600k 次)** 在客户端派生,服务端只存 `key_salt`/`key_check`/`key_params`,不持有原始密钥。Web 客户端在浏览器本地完成加解密;MCP 客户端通过 `X-Encryption-Key` 请求头传递密钥,服务端临时解密后返回明文。
|
|
||||||
- MCP:tools 参数与 JSON Schema(`schemars`)保持同步,鉴权以请求扩展中的用户上下文为准。
|
|
||||||
|
|
||||||
## 提交前检查
|
## 提交前检查
|
||||||
|
|
||||||
```bash
|
每次提交前至少运行:
|
||||||
./scripts/release-check.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
或手动:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt -- --check
|
cargo fmt -- --check
|
||||||
@@ -144,31 +45,196 @@ cargo clippy --locked -- -D warnings
|
|||||||
cargo test --locked
|
cargo test --locked
|
||||||
```
|
```
|
||||||
|
|
||||||
发版前确认未重复 tag:
|
也可以直接运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
grep '^version' crates/secrets-mcp/Cargo.toml
|
./scripts/release-check.sh
|
||||||
git tag -l 'secrets-mcp-*'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## CI/CD
|
## 项目结构
|
||||||
|
|
||||||
- **触发**:任意分支 `push`,且路径含 `crates/**`、`deploy/**`、根目录 `Cargo.toml`、`Cargo.lock`、`.gitea/workflows/**`(见 `.gitea/workflows/secrets.yml`)。
|
```text
|
||||||
- **版本与 tag**:从 `crates/secrets-mcp/Cargo.toml` 读版本;构建成功后打 `secrets-mcp-<version>`:若远端已存在同名 tag,CI 会先删后于**当前提交**重建并推送(覆盖式发版)。
|
secrets/
|
||||||
- **质量与构建**:`fmt` / `clippy --locked` / `test --locked` → `x86_64-unknown-linux-musl` 发布构建 `secrets-mcp`。
|
Cargo.toml
|
||||||
- **Release(可选)**:`secrets.RELEASE_TOKEN`(Gitea PAT)用于通过 API **创建或更新**该 tag 的 Release(非 draft)、上传 `tar.gz` + `.sha256`;未配置则跳过 API Release,仅 tag + 构建。
|
apps/
|
||||||
- **部署(可选)**:仅 `main`、`feat/mcp`、`mcp` 分支在构建成功时跑 `deploy-mcp`;需 `vars.DEPLOY_HOST`、`vars.DEPLOY_USER`、`secrets.DEPLOY_SSH_KEY`。勿把 OAuth/DB 等写进 workflow,用 `deploy/.env.example` 在目标机配置。
|
api/ # 远端 JSON API
|
||||||
- **Secrets 写法**:Actions **secrets 须为原始值**(PEM、PAT 明文),**勿** base64;否则 SSH/Release 会失败。**勿**在 CI 中保存 `GOOGLE_CLIENT_SECRET`、DB 密码。
|
desktop/src-tauri/ # 桌面端
|
||||||
- **通知**:`vars.WEBHOOK_URL`(可选,飞书)。
|
crates/
|
||||||
|
application/ # v3 应用服务
|
||||||
|
client-integrations/ # Cursor / Claude Code 配置注入
|
||||||
|
crypto/ # 通用加密辅助
|
||||||
|
desktop-daemon/ # 本地 MCP daemon
|
||||||
|
device-auth/ # 设备登录 / Desktop OAuth 辅助
|
||||||
|
domain/ # v3 领域模型
|
||||||
|
infrastructure-db/ # 数据库与迁移
|
||||||
|
deploy/
|
||||||
|
scripts/
|
||||||
|
.gitea/workflows/
|
||||||
|
.vscode/tasks.json
|
||||||
|
```
|
||||||
|
|
||||||
## 环境变量(secrets-mcp)
|
## 数据库
|
||||||
|
|
||||||
| 变量 | 说明 |
|
- 建议数据库名:`secrets-v3`
|
||||||
|------|------|
|
- 连接串:`SECRETS_DATABASE_URL`
|
||||||
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL URL。 |
|
- 首次连接会自动运行 `secrets-infrastructure-db::migrate_current_schema`
|
||||||
| `BASE_URL` | 对外基址;OAuth 回调 `${BASE_URL}/auth/google/callback`。 |
|
|
||||||
| `SECRETS_MCP_BIND` | 监听地址,默认 `127.0.0.1:9315`(容器/远程直接暴露时需改为 `0.0.0.0:9315`)。 |
|
|
||||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | 可选;仅运行时配置。 |
|
|
||||||
| `RUST_LOG` | 如 `secrets_mcp=debug`。 |
|
|
||||||
|
|
||||||
> `SERVER_MASTER_KEY` 已不再需要。新架构下密钥由用户密码短语在客户端派生,服务端不持有。
|
当前 v3 主要表:
|
||||||
|
|
||||||
|
- `users`
|
||||||
|
- `oauth_accounts`
|
||||||
|
- `devices`
|
||||||
|
- `device_login_tokens`
|
||||||
|
- `auth_events`
|
||||||
|
- `vault_objects`
|
||||||
|
- `vault_object_revisions`
|
||||||
|
|
||||||
|
### 当前模型约束
|
||||||
|
|
||||||
|
- 服务端只保存同步所需的密文对象与版本信息
|
||||||
|
- 搜索、详情、reveal、history 主要在 desktop 本地 vault 中完成
|
||||||
|
- 删除通过对象级 `deleted_at` / tombstone 传播
|
||||||
|
- 历史服务端保留在 `vault_object_revisions`,本地另有 `vault_object_history`
|
||||||
|
|
||||||
|
### 字段职责
|
||||||
|
|
||||||
|
| 字段 | 含义 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| `object_id` | 同步对象标识 | `UUID` |
|
||||||
|
| `object_kind` | 当前对象类别 | `cipher` |
|
||||||
|
| `revision` | 对象版本号 | `12` |
|
||||||
|
| `cipher_version` | 密文封装版本 | `1` |
|
||||||
|
| `ciphertext` | 密文对象载荷 | AES-GCM 密文 |
|
||||||
|
| `content_hash` | 密文内容摘要 | `sha256:...` |
|
||||||
|
| `deleted_at` | 对象删除时间 | `2026-04-14T12:00:00Z` |
|
||||||
|
|
||||||
|
## Google 登录
|
||||||
|
|
||||||
|
当前登录流为 **Google Desktop OAuth**:
|
||||||
|
|
||||||
|
- 桌面端使用系统浏览器拉起 Google 授权
|
||||||
|
- API 服务端持有 Google OAuth client 配置并处理 callback / token exchange
|
||||||
|
- desktop 创建一次性 login session,打开托管登录页后轮询状态
|
||||||
|
- API 校验 Google userinfo 后发放本地 device token
|
||||||
|
|
||||||
|
官网 DMG 正式分发时,服务端至少需要配置:
|
||||||
|
|
||||||
|
- `SECRETS_PUBLIC_BASE_URL`
|
||||||
|
- `GOOGLE_OAUTH_CLIENT_ID`
|
||||||
|
- `GOOGLE_OAUTH_CLIENT_SECRET`
|
||||||
|
- `GOOGLE_OAUTH_REDIRECT_URI`
|
||||||
|
|
||||||
|
推荐约束:
|
||||||
|
|
||||||
|
- `SECRETS_PUBLIC_BASE_URL` 使用用户浏览器实际访问的 HTTPS 官网地址
|
||||||
|
- `GOOGLE_OAUTH_REDIRECT_URI` 配置为 `${SECRETS_PUBLIC_BASE_URL}/auth/google/callback`
|
||||||
|
- `GOOGLE_OAUTH_CLIENT_SECRET` 只保留在服务端环境变量或密钥管理系统中,不入库
|
||||||
|
- Google Cloud Console 中登记的 callback URL 必须与 `GOOGLE_OAUTH_REDIRECT_URI` 完全一致
|
||||||
|
|
||||||
|
## MCP
|
||||||
|
|
||||||
|
本地 MCP 入口由 `crates/desktop-daemon` 提供,默认地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:9515/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
当前暴露的工具:
|
||||||
|
|
||||||
|
- `secrets_entry_find`
|
||||||
|
- `secrets_entry_get`
|
||||||
|
- `secrets_entry_add`
|
||||||
|
- `secrets_entry_update`
|
||||||
|
- `secrets_entry_delete`
|
||||||
|
- `secrets_entry_restore`
|
||||||
|
- `secrets_secret_add`
|
||||||
|
- `secrets_secret_update`
|
||||||
|
- `secrets_secret_delete`
|
||||||
|
- `secrets_secret_history`
|
||||||
|
- `secrets_secret_rollback`
|
||||||
|
- `target_exec`
|
||||||
|
|
||||||
|
当前不保留:
|
||||||
|
|
||||||
|
- `secrets_env_map`
|
||||||
|
|
||||||
|
### `target_exec`
|
||||||
|
|
||||||
|
`target_exec` 会显式读取 entry 当前 secrets 的真实值,并从 metadata / secrets 派生标准环境变量,例如:
|
||||||
|
|
||||||
|
- `TARGET_ENTRY_ID`
|
||||||
|
- `TARGET_NAME`
|
||||||
|
- `TARGET_FOLDER`
|
||||||
|
- `TARGET_TYPE`
|
||||||
|
- `TARGET_HOST`
|
||||||
|
- `TARGET_PORT`
|
||||||
|
- `TARGET_USER`
|
||||||
|
- `TARGET_BASE_URL`
|
||||||
|
- `TARGET_API_KEY`
|
||||||
|
- `TARGET_TOKEN`
|
||||||
|
- `TARGET_SSH_KEY`
|
||||||
|
|
||||||
|
## 桌面端
|
||||||
|
|
||||||
|
桌面端当前支持:
|
||||||
|
|
||||||
|
- Google 登录
|
||||||
|
- 自动写入 `Cursor` / `Claude Code` 的 `mcp.json`
|
||||||
|
- 新建条目
|
||||||
|
- 搜索、按 type 筛选
|
||||||
|
- 右侧原地编辑
|
||||||
|
- secret 新增、编辑、删除
|
||||||
|
- secret 明文显示 / 复制
|
||||||
|
- secret 历史查看与回滚
|
||||||
|
- 删除到最近删除与恢复
|
||||||
|
- 登录态仅在当前 desktop 进程内有效,不做自动恢复登录
|
||||||
|
- desktop 进程退出后,本地 daemon 所有工具不可用
|
||||||
|
|
||||||
|
### 配置注入
|
||||||
|
|
||||||
|
桌面端会把本地 daemon 配置写入:
|
||||||
|
|
||||||
|
- `~/.cursor/mcp.json`
|
||||||
|
- `~/.claude/mcp.json`
|
||||||
|
|
||||||
|
写入策略:
|
||||||
|
|
||||||
|
- 保留现有其它 `mcpServers`
|
||||||
|
- 仅覆盖同名 `secrets` 节点
|
||||||
|
|
||||||
|
### 图标与前端 dist(本地 / CI)
|
||||||
|
|
||||||
|
版本库为减小噪音,**不提交** Tauri 生成的多尺寸图标包;但 **`apps/desktop/dist/`** 现在作为桌面端前端静态资源目录,**需要提交到版本库**,以保证新机器 clone 后可直接运行 Tauri desktop。
|
||||||
|
|
||||||
|
- **图标**:仅跟踪 `apps/desktop/src-tauri/icons/icon.png` 作为源图(建议 **1024×1024** PNG)。检出代码后,若需要完整 `icons/`(例如打包、验证窗口/托盘图标),在 **`apps/desktop/src-tauri`** 下执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/desktop/src-tauri
|
||||||
|
cargo tauri icon icons/icon.png
|
||||||
|
```
|
||||||
|
|
||||||
|
需已安装 **Tauri CLI**(例如 `cargo install tauri-cli`,或与项目一致的 `cargo-tauri` 版本)。
|
||||||
|
|
||||||
|
- **前端 dist**:`tauri.conf.json` 中 `build.frontendDist` 指向 `../dist`。当前仓库直接跟踪 **`apps/desktop/dist/`** 下的静态页面资源,因此新机器 clone 后无需额外生成前端产物即可运行 `cargo run -p secrets-desktop`。若后续引入独立前端构建链,再单独把这部分切回构建产物管理。
|
||||||
|
|
||||||
|
## 代码规范
|
||||||
|
|
||||||
|
- 业务层优先使用 `anyhow::Result`
|
||||||
|
- 避免生产路径 `unwrap()`
|
||||||
|
- 使用 `tokio` + `sqlx` async
|
||||||
|
- SQL 使用参数绑定,不要手拼用户输入
|
||||||
|
- 运维日志使用 `tracing`
|
||||||
|
- 变更后优先跑最小必要验证,不要只改不测
|
||||||
|
|
||||||
|
## CI / 脚本
|
||||||
|
|
||||||
|
- `.gitea/workflows/secrets.yml` 现在是 v3 workspace 级 CI
|
||||||
|
- `scripts/release-check.sh` 只做 workspace 质量检查
|
||||||
|
- `deploy/.env.example` 反映当前 v3 API / daemon / desktop 登录配置
|
||||||
|
|
||||||
|
## 安全约束
|
||||||
|
|
||||||
|
- 不要把 Google `client_secret` 提交到受版本控制的配置文件中
|
||||||
|
- 不要把 device token、数据库密码、真实生产密钥提交入库
|
||||||
|
- 数据库生产环境优先使用 `verify-full`
|
||||||
|
- AI 审查时,不要把“随机高熵 token 明文存储”机械地当成密码学问题处理,必须结合当前架构和威胁模型判断
|
||||||
|
|||||||
56
CONTRIBUTING.md
Normal file
56
CONTRIBUTING.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 发版规则
|
||||||
|
|
||||||
|
当前仓库已切换到 v3 架构,不再围绕 `secrets-mcp` 做单独发版。
|
||||||
|
|
||||||
|
提交前请至少保证:
|
||||||
|
|
||||||
|
1. `cargo fmt -- --check`
|
||||||
|
2. `cargo clippy --locked -- -D warnings`
|
||||||
|
3. `cargo test --locked`
|
||||||
|
|
||||||
|
详见 [AGENTS.md](AGENTS.md) 中最新的仓库说明。
|
||||||
3609
Cargo.lock
generated
3609
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
26
Cargo.toml
@@ -1,7 +1,14 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"crates/secrets-core",
|
"apps/api",
|
||||||
"crates/secrets-mcp",
|
"apps/desktop/src-tauri",
|
||||||
|
"crates/application",
|
||||||
|
"crates/client-integrations",
|
||||||
|
"crates/crypto",
|
||||||
|
"crates/desktop-daemon",
|
||||||
|
"crates/device-auth",
|
||||||
|
"crates/domain",
|
||||||
|
"crates/infrastructure-db",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -13,7 +20,7 @@ edition = "2024"
|
|||||||
tokio = { version = "^1.50.0", features = ["rt-multi-thread", "macros", "fs", "io-util", "process", "signal"] }
|
tokio = { version = "^1.50.0", features = ["rt-multi-thread", "macros", "fs", "io-util", "process", "signal"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlx = { version = "^0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "json", "chrono"] }
|
sqlx = { version = "^0.8.6", features = ["runtime-tokio", "tls-rustls", "postgres", "sqlite", "uuid", "json", "chrono"] }
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "^1.0.228", features = ["derive"] }
|
serde = { version = "^1.0.228", features = ["derive"] }
|
||||||
@@ -25,14 +32,23 @@ toml = "^1.0.7"
|
|||||||
aes-gcm = "^0.10.3"
|
aes-gcm = "^0.10.3"
|
||||||
sha2 = "^0.10.9"
|
sha2 = "^0.10.9"
|
||||||
rand = "^0.10.0"
|
rand = "^0.10.0"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
# Utils
|
# Utils
|
||||||
anyhow = "^1.0.102"
|
anyhow = "^1.0.102"
|
||||||
|
thiserror = "^2"
|
||||||
chrono = { version = "^0.4.44", features = ["serde"] }
|
chrono = { version = "^0.4.44", features = ["serde"] }
|
||||||
uuid = { version = "^1.22.0", features = ["serde"] }
|
uuid = { version = "^1.22.0", features = ["serde", "v4"] }
|
||||||
tracing = "^0.1"
|
tracing = "^0.1"
|
||||||
tracing-subscriber = { version = "^0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "^0.3", features = ["env-filter"] }
|
||||||
dotenvy = "^0.15"
|
dotenvy = "^0.15"
|
||||||
|
|
||||||
# HTTP
|
# 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"] }
|
||||||
|
axum = "0.8"
|
||||||
|
http = "1"
|
||||||
|
url = "2"
|
||||||
|
rmcp = { version = "1", features = ["server", "macros", "transport-streamable-http-server", "schemars"] }
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
302
README.md
302
README.md
@@ -1,149 +1,144 @@
|
|||||||
# secrets-mcp
|
# Secrets
|
||||||
|
|
||||||
Workspace:**`secrets-core`** + **`secrets-mcp`**(HTTP Streamable MCP + Web)。多租户密钥与元数据存 PostgreSQL;用户通过 **Google OAuth** 登录,**API Key** 鉴权 MCP 请求;秘密数据用**用户密码短语派生的密钥**在客户端加密,服务端不持有原始密钥。
|
这是 v3 架构的仓库,当前主路径已经收敛为:
|
||||||
|
|
||||||
## 安装
|
- `apps/api`:远端 JSON API
|
||||||
|
- `apps/desktop/src-tauri`:桌面客户端
|
||||||
|
- `crates/desktop-daemon`:本地 MCP 入口
|
||||||
|
- `crates/application` / `domain` / `infrastructure-db`:业务与数据层
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --release -p secrets-mcp
|
cp deploy/.env.example .env
|
||||||
# 产物: target/release/secrets-mcp
|
|
||||||
|
# 远端 API
|
||||||
|
cargo run -p secrets-api --bin secrets-api
|
||||||
|
|
||||||
|
# 本地 daemon
|
||||||
|
cargo run -p secrets-desktop-daemon
|
||||||
|
|
||||||
|
# 桌面客户端
|
||||||
|
cargo run -p secrets-desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
发版产物见 Gitea Release(tag:`secrets-mcp-<version>`,Linux musl 预编译);其它平台本地 `cargo build`。
|
说明:
|
||||||
|
|
||||||
## 环境变量与本地运行
|
- `apps/desktop/src-tauri/tauri.conf.json` 中 `build.frontendDist` 指向 `apps/desktop/dist`
|
||||||
|
- 当前仓库会直接提交 `apps/desktop/dist/` 下的桌面端静态资源
|
||||||
|
- 因此新机器 clone 后,无需额外前端构建步骤即可启动 desktop
|
||||||
|
- 官网 DMG 正式分发不依赖本地 `client_secret_*.json`
|
||||||
|
- Google OAuth 凭据只配置在 API 服务端,desktop 通过浏览器完成托管登录
|
||||||
|
|
||||||
复制 `deploy/.env.example` 为项目根目录 `.env`(已在 `.gitignore`),或导出同名变量:
|
## 官网 DMG 的服务端 OAuth 配置
|
||||||
|
|
||||||
| 变量 | 说明 |
|
官网 DMG 正式分发时,**Google OAuth 只配置在 API 服务端**。桌面端不需要本地 `client_secret_*.json`,也不直接向 Google 换 token。
|
||||||
|------|------|
|
|
||||||
| `SECRETS_DATABASE_URL` | **必填**。PostgreSQL 连接串(推荐使用域名,例如 `db.refining.ltd`,避免直连 IP)。 |
|
建议先复制 `deploy/.env.example` 为 `.env`,然后至少配置以下变量:
|
||||||
| `SECRETS_DATABASE_SSL_MODE` | 可选但强烈建议生产必填。推荐 `verify-full`(至少 `verify-ca`),避免回退到弱 TLS 模式。 |
|
|
||||||
| `SECRETS_DATABASE_SSL_ROOT_CERT` | 可选。私有 CA 或自签链路时指定 CA 根证书路径(如 `/etc/secrets/pg-ca.crt`)。 |
|
|
||||||
| `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、勿打入二进制。 |
|
|
||||||
| `RUST_LOG` | 可选;日志级别,如 `secrets_mcp=debug`。 |
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p secrets-mcp
|
SECRETS_PUBLIC_BASE_URL=https://secrets.example.com
|
||||||
|
GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
|
||||||
|
GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret
|
||||||
|
GOOGLE_OAUTH_REDIRECT_URI=https://secrets.example.com/auth/google/callback
|
||||||
```
|
```
|
||||||
|
|
||||||
生产推荐示例(PostgreSQL TLS):
|
变量含义:
|
||||||
|
|
||||||
|
- `SECRETS_PUBLIC_BASE_URL`:桌面端打开浏览器时访问的 API 外网基地址,必须是用户浏览器能访问到的公开地址
|
||||||
|
- `GOOGLE_OAUTH_CLIENT_ID`:Google Cloud Console 中为服务端登录流程配置的 OAuth Client ID
|
||||||
|
- `GOOGLE_OAUTH_CLIENT_SECRET`:对应的 Client Secret,只能保留在服务端
|
||||||
|
- `GOOGLE_OAUTH_REDIRECT_URI`:Google 登录完成后回调到 API 的地址,必须与 Google Console 中登记的回调地址完全一致
|
||||||
|
|
||||||
|
配置步骤建议:
|
||||||
|
|
||||||
|
1. 在 Google Cloud Console 创建或选择 OAuth Client
|
||||||
|
2. 把授权回调地址加入允许列表,例如 `https://secrets.example.com/auth/google/callback`
|
||||||
|
3. 把上面的 4 个变量配置到 API 服务的运行环境中
|
||||||
|
4. 确认 `SECRETS_PUBLIC_BASE_URL` 与 `GOOGLE_OAUTH_REDIRECT_URI` 使用同一公开域名
|
||||||
|
5. 重启 API 服务后,再用 desktop / DMG 验证浏览器登录流程
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- `GOOGLE_OAUTH_CLIENT_SECRET` 不要提交到仓库
|
||||||
|
- `GOOGLE_OAUTH_REDIRECT_URI` 不要写成 `localhost`,正式分发应使用官网可访问域名
|
||||||
|
- 如果 API 部署在反向代理后面,`SECRETS_PUBLIC_BASE_URL` 应填写用户实际访问的 HTTPS 地址,而不是内网监听地址
|
||||||
|
|
||||||
|
## 当前能力
|
||||||
|
|
||||||
|
- 桌面端使用系统浏览器完成 Google Desktop OAuth 登录
|
||||||
|
- 登录成功后向 API 注册设备,并在当前桌面进程内维护登录会话
|
||||||
|
- 本地 daemon 提供显式拆分的 MCP 工具:
|
||||||
|
- `secrets_entry_find` / `secrets_entry_get`
|
||||||
|
- `secrets_entry_add` / `secrets_entry_update` / `secrets_entry_delete` / `secrets_entry_restore`
|
||||||
|
- `secrets_secret_add` / `secrets_secret_update` / `secrets_secret_delete`
|
||||||
|
- `secrets_secret_history` / `secrets_secret_rollback`
|
||||||
|
- `target_exec`
|
||||||
|
- 桌面端会自动把本地 daemon MCP 配置写入 `Cursor` 与 `Claude Code`
|
||||||
|
- 桌面端支持条目新建、搜索、按 type 筛选、元数据编辑、最近删除与恢复
|
||||||
|
- 桌面端支持 secret 新增、编辑、删除、明文显示、真实复制、历史查看与回滚
|
||||||
|
- 不保留 `secrets_env_map`
|
||||||
|
- 不做自动恢复登录;重启 app 后必须重新登录
|
||||||
|
|
||||||
|
## 提交前检查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SECRETS_DATABASE_URL=postgres://postgres:***@db.refining.ltd:5432/secrets-mcp
|
cargo fmt -- --check
|
||||||
SECRETS_DATABASE_SSL_MODE=verify-full
|
cargo clippy --locked -- -D warnings
|
||||||
SECRETS_DATABASE_SSL_ROOT_CERT=/etc/secrets/pg-ca.crt
|
cargo test --locked
|
||||||
SECRETS_ENV=production
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Web**:`BASE_URL`(登录、Dashboard、设置密码短语、创建 API Key)。
|
|
||||||
- **MCP**:Streamable HTTP 基址 `{BASE_URL}/mcp`,需 `Authorization: Bearer <api_key>` + `X-Encryption-Key: <hex>` 请求头(读密文工具须带密钥)。
|
|
||||||
|
|
||||||
## PostgreSQL TLS 加固
|
## PostgreSQL TLS 加固
|
||||||
|
|
||||||
- 推荐将数据库域名单独设置为 `db.refining.ltd`,服务域名保持 `secrets.refining.app`。
|
- 推荐将数据库域名单独设置为 `db.refining.ltd`,服务域名保持 `secrets.refining.app`。
|
||||||
- 数据库证书建议使用可校验链路(如 Let's Encrypt 或私有 CA),并保证证书 `SAN` 包含 `db.refining.ltd`。
|
- 数据库证书建议使用可校验链路(如 Let's Encrypt 或私有 CA),并保证证书 `SAN` 包含 `db.refining.ltd`。
|
||||||
- PostgreSQL 侧建议使用 `hostssl` 规则限制应用来源(如 `47.238.146.244/32`),逐步移除公网明文 `host` 访问。
|
- PostgreSQL 侧建议使用 `hostssl` 规则限制应用来源(如 `47.238.146.244/32`),逐步移除公网明文 `host` 访问。
|
||||||
- 应用端推荐 `SECRETS_DATABASE_SSL_MODE=verify-full`;仅在过渡阶段可临时用 `verify-ca`。
|
- 应用端推荐 `SECRETS_DATABASE_SSL_MODE=verify-full`;仅在过渡阶段可临时用 `verify-ca`。
|
||||||
- 可执行运维步骤见 [`deploy/postgres-tls-hardening.md`](deploy/postgres-tls-hardening.md)。
|
- 可执行运维步骤见 `[deploy/postgres-tls-hardening.md](deploy/postgres-tls-hardening.md)`。
|
||||||
|
|
||||||
## MCP 与 AI 工作流(v0.3+)
|
## MCP 与 AI 工作流(v3)
|
||||||
|
|
||||||
条目在逻辑上以 **`(folder, name)`** 在用户内唯一(数据库唯一索引:`user_id + folder + name`)。同名可在不同 folder 下各存一条(例如 `refining/aliyun` 与 `ricnsmart/aliyun`)。
|
当前 v3 以 **桌面端 + 本地 daemon** 为主路径:
|
||||||
|
|
||||||
- **`secrets_search`**:发现条目(可按 query / folder / type / name 过滤);不要求加密头。
|
- 桌面端登录态仅在当前进程内有效,不持久化 `device token`
|
||||||
- **`secrets_get` / `secrets_update` / `secrets_delete`(按 name)/ `secrets_history` / `secrets_rollback`**:仅 `name` 且全局唯一则直接命中;若多条同名,返回消歧错误,需在参数中补 **`folder`**。
|
- 本地 daemon 默认监听 `http://127.0.0.1:9515/mcp`
|
||||||
- **`secrets_delete`**:`dry_run=true` 时与真实删除相同的消歧规则——唯一则预览一条,多条则报错并要求 `folder`。
|
- daemon 通过活跃 desktop 进程提供的本地会话转发访问 API;desktop 进程退出后所有工具不可用
|
||||||
- **共享 key 自动迁移删除**:删除仍被 `metadata.key_ref` 引用的 key 条目时,系统会自动迁移:把密文复制到首个引用方,并将其余引用方的 `key_ref` 重定向到新 owner,然后继续删除。
|
- `target_exec` 会显式读取真实 secret 值后再生成 `TARGET_`* 环境变量
|
||||||
|
- 不保留 `secrets_env_map`
|
||||||
|
|
||||||
## 加密架构(混合 E2EE)
|
### Canonical MCP 工具
|
||||||
|
|
||||||
### 密钥派生
|
|
||||||
|
|
||||||
用户在 Web Dashboard 设置**密码短语**,浏览器使用 **Web Crypto API(PBKDF2-SHA256,600k 次迭代)**在本地派生 256-bit AES 密钥。
|
| 工具 | 说明 |
|
||||||
|
| ------------------------- | --------------------------------------------------------- |
|
||||||
|
| `secrets_entry_find` | 从 desktop 已解锁本地 vault 搜索对象,支持 `query` / `folder` / `type` |
|
||||||
|
| `secrets_entry_get` | 读取单条本地对象,并返回当前 secrets 的真实值 |
|
||||||
|
| `secrets_entry_add` | 在本地 vault 创建对象,可选附带初始 secrets |
|
||||||
|
| `secrets_entry_update` | 更新本地对象的 folder / type / name / metadata |
|
||||||
|
| `secrets_entry_delete` | 将本地对象标记为删除 |
|
||||||
|
| `secrets_entry_restore` | 恢复本地已删除对象 |
|
||||||
|
| `secrets_secret_add` | 向已有本地对象新增 secret |
|
||||||
|
| `secrets_secret_update` | 更新本地 secret 名称、类型或内容 |
|
||||||
|
| `secrets_secret_delete` | 删除单个本地 secret |
|
||||||
|
| `secrets_secret_history` | 查看单个本地 secret 的历史版本 |
|
||||||
|
| `secrets_secret_rollback` | 将单个本地 secret 回滚到指定版本 |
|
||||||
|
| `target_exec` | 用本地对象的 metadata 和 secrets 生成 `TARGET_`* 环境变量并执行本地命令 |
|
||||||
|
|
||||||
- **Salt(32B)**:首次设置时在浏览器生成,存入服务端 `users.key_salt`
|
|
||||||
- **key_check**:派生密钥加密已知常量 `"secrets-mcp-key-check"`,存入 `users.key_check`,用于登录时验证密码短语
|
|
||||||
- **服务端不存储原始密钥**,只存 salt + key_check
|
|
||||||
|
|
||||||
跨设备同步:新设备登录 → 输入相同密码短语 → 从服务端取 salt → 同样的 PBKDF2 → 得到相同密钥。
|
|
||||||
|
|
||||||
### 写入与读取流程
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
subgraph Web["Web 浏览器(E2E)"]
|
|
||||||
P["密码短语"] --> K["PBKDF2 → 256-bit key"]
|
|
||||||
K --> Enc["AES-256-GCM 加密"]
|
|
||||||
K --> Dec["AES-256-GCM 解密"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph AI["AI 客户端(MCP)"]
|
|
||||||
HdrKey["X-Encryption-Key: hex"]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Server["secrets-mcp 服务端"]
|
|
||||||
Middleware["请求中临时持有 key\n请求结束即丢弃"]
|
|
||||||
DB[(PostgreSQL\nsecrets.encrypted = 密文\nentries.metadata = 明文)]
|
|
||||||
end
|
|
||||||
|
|
||||||
Enc -->|密文| Server
|
|
||||||
HdrKey -->|key + 请求| Middleware
|
|
||||||
Middleware <-->|加解密| DB
|
|
||||||
DB -->|密文| Dec
|
|
||||||
```
|
|
||||||
|
|
||||||
### 两种客户端对比
|
|
||||||
|
|
||||||
| | Web 浏览器 | AI 客户端(MCP) |
|
|
||||||
|---|---|---|
|
|
||||||
| 密钥位置 | 仅在浏览器内存 / sessionStorage | MCP 配置 headers 中 |
|
|
||||||
| 加解密位置 | 客户端(真正 E2E) | 服务端临时(请求级生命周期) |
|
|
||||||
| 安全边界 | 服务端零知识 | 依赖 TLS + 服务端内存隔离 |
|
|
||||||
|
|
||||||
### 敏感数据传输
|
|
||||||
|
|
||||||
- **OAuth `client_secret`** 只存服务端环境变量,不发给浏览器
|
|
||||||
- **API Key** 当前存放在 `users.api_key`,Dashboard 会明文展示并可重置
|
|
||||||
- **X-Encryption-Key** 随 MCP 请求经 TLS 传输,服务端仅在请求处理期间持有(不持久化)
|
|
||||||
- **生产环境必须走 HTTPS/TLS**
|
|
||||||
|
|
||||||
## AI 客户端配置
|
## AI 客户端配置
|
||||||
|
|
||||||
在 Web Dashboard 设置密码短语后,解锁页面会按客户端格式生成配置。常见客户端示例如下:
|
桌面端会自动把本地 daemon 写入以下配置:
|
||||||
|
|
||||||
`Cursor / Claude Desktop` 风格:
|
- `~/.cursor/mcp.json`
|
||||||
|
- `~/.claude/mcp.json`
|
||||||
|
|
||||||
|
写入示例:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"secrets": {
|
"secrets": {
|
||||||
"url": "https://secrets.example.com/mcp",
|
"url": "http://127.0.0.1:9515/mcp"
|
||||||
"headers": {
|
|
||||||
"Authorization": "Bearer sk_abc123...",
|
|
||||||
"X-Encryption-Key": "a1b2c3...(64位hex)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`OpenCode` 风格:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcp": {
|
|
||||||
"secrets": {
|
|
||||||
"type": "remote",
|
|
||||||
"enabled": true,
|
|
||||||
"url": "https://secrets.example.com/mcp",
|
|
||||||
"headers": {
|
|
||||||
"Authorization": "Bearer sk_abc123...",
|
|
||||||
"X-Encryption-Key": "a1b2c3...(64位hex)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,61 +146,78 @@ flowchart LR
|
|||||||
|
|
||||||
## 数据模型
|
## 数据模型
|
||||||
|
|
||||||
主表 **`entries`**(`folder`、`type`、`name`、`notes`、`tags`、`metadata`,多租户时带 `user_id`)+ 子表 **`secrets`**(每行一个加密字段:`field_name`、`encrypted`)。**唯一性**:`UNIQUE(user_id, folder, name)`(`user_id` 为空时为遗留行唯一 `(folder, name)`)。另有 `entries_history`、`secrets_history`、`audit_log`,以及 **`users`**(含 `key_salt`、`key_check`、`key_params`、`api_key`)、**`oauth_accounts`**。首次连库自动迁移建表(`secrets-core` 的 `migrate`);已有库可对照 [`scripts/migrate-v0.3.0.sql`](scripts/migrate-v0.3.0.sql) 做列重命名与索引重建。**Web 登录会话**(tower-sessions)使用同一 `SECRETS_DATABASE_URL`,进程启动时对会话存储执行迁移(见 `secrets-mcp` 中 `PostgresStore::migrate`),无需额外环境变量。
|
当前 v3 已切到**零知识同步模型**:
|
||||||
|
|
||||||
| 位置 | 字段 | 说明 |
|
- 服务端保存 `vault_objects` 与 `vault_object_revisions`
|
||||||
|------|------|------|
|
- desktop 本地保存 `vault_objects`、`vault_object_history`、`pending_changes`、`sync_state`
|
||||||
| entries | folder | 组织/隔离空间,如 `refining`、`ricnsmart`;参与唯一键 |
|
- 搜索、详情、reveal、history 主要在本地已解锁 vault 上完成
|
||||||
| entries | type | 软分类,如 `server`、`service`、`key`、`person`(可扩展,不参与唯一键) |
|
- 服务端负责 `auth/device` 与 `/sync/`*,不再承担明文搜索与明文 reveal
|
||||||
| entries | name | 人类可读标识;与 `folder` 一起在用户内唯一 |
|
|
||||||
| entries | notes | 非敏感说明文本 |
|
|
||||||
| entries | metadata | 明文 JSON(ip、url、`key_ref` 等) |
|
|
||||||
| secrets | field_name | 明文字段名,便于 schema 展示 |
|
|
||||||
| secrets | encrypted | AES-GCM 密文(含 nonce) |
|
|
||||||
| users | key_salt | PBKDF2 salt(32B),首次设置密码短语时写入 |
|
|
||||||
| users | key_check | 派生密钥加密已知常量,用于验证密码短语 |
|
|
||||||
| users | key_params | 派生算法参数,如 `{"alg":"pbkdf2-sha256","iterations":600000}` |
|
|
||||||
|
|
||||||
### PEM 共享(`key_ref`)
|
主要表:
|
||||||
|
|
||||||
同一 PEM 可被多条 `server` 等记录引用:建议将 PEM 存为 **`type=key`** 的 entry,在其它条目的 `metadata.key_ref` 中写目标 entry 的 `name`(支持 `folder/name` 格式消歧);轮换时只更新该目标记录即可。
|
- `users`
|
||||||
删除共享 key 时,系统会自动迁移引用:将密文复制到首个引用方(单副本),其余引用方的 `key_ref` 自动重定向到该新 owner,再删除原 key 记录。
|
- `oauth_accounts`
|
||||||
|
- `devices`
|
||||||
|
- `device_login_tokens`
|
||||||
|
- `auth_events`
|
||||||
|
- `vault_objects`
|
||||||
|
- `vault_object_revisions`
|
||||||
|
|
||||||
## 审计日志
|
字段职责:
|
||||||
|
|
||||||
`add`、`update`、`delete` 等写操作写入 **`audit_log`**(操作类型、对象、摘要,不含 secret 明文)。多租户场景下可写 **`user_id`**(可空,兼容遗留行)。
|
|
||||||
业务条目事件使用 **`folder` / `type` / `name`**;登录类事件使用 **`folder='auth'`**,此时 `type`/`name` 表示认证目标(例如 `oauth` / `google`),不表示某条 secrets entry。
|
|
||||||
|
|
||||||
```sql
|
| 位置 | 字段 | 说明 |
|
||||||
SELECT action, folder, type, name, detail, user_id, created_at
|
| ------------------------ | ------------------------- | --------------------- |
|
||||||
FROM audit_log
|
| `vault_objects` | `object_id` | 同步对象标识 |
|
||||||
ORDER BY created_at DESC
|
| `vault_objects` | `object_kind` | 当前对象类别,当前主要为 `cipher` |
|
||||||
LIMIT 20;
|
| `vault_objects` | `revision` | 服务端对象版本 |
|
||||||
```
|
| `vault_objects` | `ciphertext` | 密文对象载荷 |
|
||||||
|
| `vault_objects` | `content_hash` | 密文摘要 |
|
||||||
|
| `vault_objects` | `deleted_at` | 对象级删除标记 |
|
||||||
|
| `vault_object_revisions` | `revision` / `ciphertext` | 服务端对象历史版本 |
|
||||||
|
|
||||||
|
|
||||||
|
## 认证与事件
|
||||||
|
|
||||||
|
当前登录流为 Google Desktop OAuth:
|
||||||
|
|
||||||
|
- 桌面端使用系统浏览器拉起 Google 授权
|
||||||
|
- API 服务端负责发起 OAuth、处理 callback、校验 Google userinfo
|
||||||
|
- desktop 通过创建一次性 login session 并轮询状态获取 `device token`
|
||||||
|
- 登录与设备活动写入 `auth_events`
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```text
|
||||||
Cargo.toml
|
Cargo.toml
|
||||||
crates/secrets-core/ # db / crypto / models / audit / service
|
apps/
|
||||||
crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key
|
api/ # 远端 JSON API
|
||||||
|
desktop/src-tauri/ # Tauri 桌面端
|
||||||
|
crates/
|
||||||
|
application/ # v3 应用服务
|
||||||
|
client-integrations/ # Cursor / Claude Code mcp.json 注入
|
||||||
|
crypto/ # 通用加密辅助
|
||||||
|
desktop-daemon/ # 本地 MCP daemon
|
||||||
|
device-auth/ # Desktop OAuth / device token 辅助
|
||||||
|
domain/ # 领域模型
|
||||||
|
infrastructure-db/ # PostgreSQL 连接与迁移
|
||||||
|
deploy/
|
||||||
|
.env.example
|
||||||
|
secrets-mcp.service
|
||||||
|
postgres-tls-hardening.md
|
||||||
scripts/
|
scripts/
|
||||||
migrate-v0.3.0.sql # 可选:手动 SQL 迁移(namespace/kind → folder/type、唯一键含 folder)
|
release-check.sh
|
||||||
deploy/ # systemd、.env 示例
|
setup-gitea-actions.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## CI/CD(Gitea Actions)
|
## CI/CD(Gitea Actions)
|
||||||
|
|
||||||
见 [`.gitea/workflows/secrets.yml`](.gitea/workflows/secrets.yml)。
|
当前以 workspace 级检查为主,见 `[.gitea/workflows/secrets.yml](.gitea/workflows/secrets.yml)`。
|
||||||
|
|
||||||
- **触发**:任意分支 `push`,且变更路径包含 `crates/**`、`deploy/**`、根目录 `Cargo.toml` / `Cargo.lock`、`.gitea/workflows/**`。
|
提交前建议直接运行:
|
||||||
- **流水线**:解析 `crates/secrets-mcp/Cargo.toml` 版本 → `cargo fmt` / `clippy --locked` / `test --locked` → 交叉编译 `x86_64-unknown-linux-musl` 的 `secrets-mcp` → 构建成功后打 tag `secrets-mcp-<version>`(若远端已存在同名 tag,会先删除再于**当前提交**重建并推送,覆盖式发版)。
|
|
||||||
- **Release(可选)**:配置仓库 Secret `RELEASE_TOKEN`(Gitea PAT,明文勿 base64)时,会通过 API **创建或更新**已指向该 tag 的 Release(非 draft)、上传 `tar.gz` 与 `.sha256`;未配置则跳过 API Release,仅 tag + 构建结果。
|
|
||||||
- **部署(可选)**:仅在 `main`、`feat/mcp` 或 `mcp` 分支且构建成功时,若已配置 `vars.DEPLOY_HOST`、`vars.DEPLOY_USER` 与 `secrets.DEPLOY_SSH_KEY`,则 `deploy-mcp` 通过 SCP/SSH 更新目标机二进制并 `systemctl restart secrets-mcp`。
|
|
||||||
- **通知(可选)**:`vars.WEBHOOK_URL` 为飞书 Webhook 时,构建/部署/发布节点会推送简要状态。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/setup-gitea-actions.sh # 通过 Gitea API 写入 RELEASE_TOKEN、WEBHOOK_URL、部署相关变量等
|
./scripts/release-check.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
详见 [AGENTS.md](AGENTS.md)(发版规则、代码规范)。
|
详见 [AGENTS.md](AGENTS.md)(发版规则、代码规范)。
|
||||||
30
apps/api/Cargo.toml
Normal file
30
apps/api/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "secrets-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "secrets-api"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
dotenvy.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
base64 = "0.22.1"
|
||||||
|
|
||||||
|
secrets-application = { path = "../../crates/application" }
|
||||||
|
secrets-device-auth = { path = "../../crates/device-auth" }
|
||||||
|
secrets-domain = { path = "../../crates/domain" }
|
||||||
|
secrets-infrastructure-db = { path = "../../crates/infrastructure-db" }
|
||||||
15
apps/api/src/bin/secrets-api-migrate.rs
Normal file
15
apps/api/src/bin/secrets-api-migrate.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
|
let database_url = secrets_infrastructure_db::load_database_url()?;
|
||||||
|
let pool = secrets_infrastructure_db::create_pool(&database_url).await?;
|
||||||
|
secrets_infrastructure_db::migrate_current_schema(&pool)
|
||||||
|
.await
|
||||||
|
.context("failed to initialize current database schema")?;
|
||||||
|
|
||||||
|
println!("current database schema initialized");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
1099
apps/api/src/main.rs
Normal file
1099
apps/api/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
6
apps/desktop/README.md
Normal file
6
apps/desktop/README.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# apps/desktop
|
||||||
|
|
||||||
|
This directory is reserved for the v3 Tauri desktop shell.
|
||||||
|
|
||||||
|
The desktop UI is intentionally kept separate from `crates/desktop-daemon` so
|
||||||
|
that closing the main window does not terminate the local MCP process.
|
||||||
208
apps/desktop/design/DESIGN.md
Normal file
208
apps/desktop/design/DESIGN.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Secrets Design System
|
||||||
|
|
||||||
|
## 1. Visual Theme & Atmosphere
|
||||||
|
|
||||||
|
- Primary inspiration: Raycast desktop UI.
|
||||||
|
- Secondary influence: Linear information density and list discipline.
|
||||||
|
- Product personality: secure, local-first, developer-facing, restrained, trustworthy.
|
||||||
|
- Default mood: dark utility app, not a marketing site and not a glossy consumer app.
|
||||||
|
- The interface should feel like a native desktop control surface for secrets and MCP integrations.
|
||||||
|
- Use calm contrast, clean edges, compact spacing, and intentional empty space.
|
||||||
|
- Prefer precision over decoration. Visual polish should come from alignment, spacing, and hierarchy.
|
||||||
|
|
||||||
|
## 2. Color Palette & Roles
|
||||||
|
|
||||||
|
### Core Surfaces
|
||||||
|
|
||||||
|
- `bg.app`: `#0A0A0B` - app background, deepest canvas.
|
||||||
|
- `bg.panel`: `#111113` - main panel and modal background.
|
||||||
|
- `bg.panelElevated`: `#17171A` - cards, selected rows, input shells.
|
||||||
|
- `bg.panelHover`: `#1D1D22` - hover state for rows and controls.
|
||||||
|
- `bg.input`: `#141418` - text inputs, code blocks, secret fields.
|
||||||
|
- `border.subtle`: `#26262C` - default panel borders.
|
||||||
|
- `border.strong`: `#34343D` - active borders and high-emphasis outlines.
|
||||||
|
|
||||||
|
### Text
|
||||||
|
|
||||||
|
- `text.primary`: `#F5F5F7` - primary labels and values.
|
||||||
|
- `text.secondary`: `#B3B3BD` - supporting metadata.
|
||||||
|
- `text.tertiary`: `#7C7C88` - placeholders and low-emphasis copy.
|
||||||
|
- `text.inverse`: `#0B0B0D` - text on bright accents.
|
||||||
|
|
||||||
|
### Accents
|
||||||
|
|
||||||
|
- `accent.blue`: `#3B82F6` - login CTA, toggles, focus ring, trust signals.
|
||||||
|
- `accent.blueHover`: `#4C8DFF` - hover state for primary interactions.
|
||||||
|
- `accent.purple`: `#8B5CF6` - secondary accent for selected count pills or light emphasis.
|
||||||
|
- `accent.amber`: `#D97706` - local warnings or pending states.
|
||||||
|
- `accent.red`: `#EF4444` - destructive actions.
|
||||||
|
- `accent.green`: `#22C55E` - success or enabled state when stronger signal is required.
|
||||||
|
|
||||||
|
### Semantic Use
|
||||||
|
|
||||||
|
- Blue is the main action color. Keep it rare and meaningful.
|
||||||
|
- Purple can appear in subtle badges or selected-count chips, never as a second primary CTA.
|
||||||
|
- Red is reserved for delete, revoke, sign-out danger, and destructive confirmations.
|
||||||
|
- Avoid bright gradients as a dominant surface treatment.
|
||||||
|
|
||||||
|
## 3. Typography Rules
|
||||||
|
|
||||||
|
- Font stack: `Inter`, `SF Pro Text`, `SF Pro Display`, `Segoe UI`, system sans-serif.
|
||||||
|
- Use system-friendly text rendering. This is a desktop tool, not a display-heavy website.
|
||||||
|
- Chinese UI copy is allowed and should feel natural beside English identifiers like `host`, `token`, `MCP`.
|
||||||
|
- Keep tracking neutral. Avoid wide uppercase spacing except tiny overline labels.
|
||||||
|
|
||||||
|
### Type Scale
|
||||||
|
|
||||||
|
- App title / page title: 30-34px, weight 700.
|
||||||
|
- Section title: 18-22px, weight 650-700.
|
||||||
|
- Card title / row title: 15-17px, weight 600.
|
||||||
|
- Body text: 13-14px, weight 400-500.
|
||||||
|
- Caption / metadata label: 11-12px, weight 500, uppercase allowed with modest tracking.
|
||||||
|
- Monospace values: `SF Mono`, `JetBrains Mono`, `Menlo`, monospace; 12-13px.
|
||||||
|
|
||||||
|
## 4. Component Stylings
|
||||||
|
|
||||||
|
### App Shell
|
||||||
|
|
||||||
|
- Use a three-pane desktop layout for the main screen: left navigation, middle list, right detail pane.
|
||||||
|
- Pane separation should rely on subtle borders, not strong shadows.
|
||||||
|
- Sidebar should feel slightly darker than the center list pane.
|
||||||
|
- The detail pane can be the most open surface, with larger top padding and calmer spacing.
|
||||||
|
|
||||||
|
### Login Card
|
||||||
|
|
||||||
|
- Centered card on a dark canvas.
|
||||||
|
- Width: compact, roughly 420-520px.
|
||||||
|
- Rounded corners: 24-28px.
|
||||||
|
- Include one lock/trust mark, one clear product title, one short support sentence, one primary Google login button.
|
||||||
|
- Login should feel calm and premium, never busy.
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
- Primary button: dark app shell with blue fill, white text, medium radius.
|
||||||
|
- Secondary button: dark raised surface with subtle border.
|
||||||
|
- Destructive button: same structure as secondary, with red text or red-emphasis border only when needed.
|
||||||
|
- Button height should feel desktop-like, not mobile oversized.
|
||||||
|
- Avoid flashy gradients and oversized glows.
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
|
||||||
|
- Inputs use dark filled surfaces, subtle inset feel, 12-14px radius.
|
||||||
|
- Border should be nearly invisible at rest and stronger on hover/focus.
|
||||||
|
- Placeholders should be quiet and low-contrast.
|
||||||
|
- Search and filter inputs should visually align and share the same height.
|
||||||
|
|
||||||
|
### Lists and Rows
|
||||||
|
|
||||||
|
- Entry rows should be compact, crisp, and easy to scan.
|
||||||
|
- Selected row: slightly brighter dark card, subtle border, no heavy glow.
|
||||||
|
- Support a two-line rhythm: primary name and smaller type/folder metadata.
|
||||||
|
- Counts in the sidebar should use muted rounded chips.
|
||||||
|
|
||||||
|
### Detail Pane
|
||||||
|
|
||||||
|
- Use strong top title hierarchy with restrained action buttons on the right.
|
||||||
|
- Metadata should be presented in structured blocks or columns, not loose paragraphs.
|
||||||
|
- Secret values should live inside dedicated protected field cards.
|
||||||
|
- Secret field rows should include icon, masked value, reveal action, and copy action.
|
||||||
|
- Sensitive content must look controlled and deliberate, not playful.
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
|
||||||
|
- Modal cards should feel like elevated control panels.
|
||||||
|
- MCP integration modal should support stacked integration rows with trailing toggles.
|
||||||
|
- Embedded JSON/config blocks should use a darker, code-oriented surface with monospace text.
|
||||||
|
- Large modal width is acceptable for configuration-heavy content.
|
||||||
|
|
||||||
|
### Toggles
|
||||||
|
|
||||||
|
- Use blue enabled state by default.
|
||||||
|
- Toggle track should be compact and clean, avoiding iOS-like softness.
|
||||||
|
- Align toggles flush right in integration lists.
|
||||||
|
|
||||||
|
### Badges and Status Pills
|
||||||
|
|
||||||
|
- Use small rounded pills for folder counts, archived state, or recent-delete state.
|
||||||
|
- Prefer muted purple, gray, or amber fills over saturated color blocks.
|
||||||
|
|
||||||
|
## 5. Layout Principles
|
||||||
|
|
||||||
|
- Use an 8px spacing system.
|
||||||
|
- Typical paddings:
|
||||||
|
- Sidebars: 16-20px.
|
||||||
|
- List and toolbar: 12-18px.
|
||||||
|
- Detail pane: 24-32px.
|
||||||
|
- Modals: 20-28px.
|
||||||
|
- Favor even vertical rhythm over decorative separators.
|
||||||
|
- Keep left edges aligned aggressively across sections.
|
||||||
|
- Avoid oversized hero spacing inside application surfaces.
|
||||||
|
- The main app should feel dense enough for productivity but never cramped.
|
||||||
|
|
||||||
|
## 6. Depth & Elevation
|
||||||
|
|
||||||
|
- Most separation should come from tone shifts and borders.
|
||||||
|
- Base panels: no shadow or extremely soft shadow.
|
||||||
|
- Elevated cards and modals: subtle shadow only, with low blur and low opacity.
|
||||||
|
- Do not use neon bloom, oversized backdrop blur, or glassmorphism.
|
||||||
|
- Focus states should use border color and a faint blue outer ring.
|
||||||
|
|
||||||
|
## 7. Do's and Don'ts
|
||||||
|
|
||||||
|
### Do
|
||||||
|
|
||||||
|
- Keep the UI dark, crisp, and desktop-native.
|
||||||
|
- Preserve strong information hierarchy in the detail pane.
|
||||||
|
- Make security-sensitive actions feel explicit and carefully gated.
|
||||||
|
- Use compact controls and disciplined spacing.
|
||||||
|
- Let alignment and typography carry most of the visual quality.
|
||||||
|
- Keep MCP integration screens structured like settings panels.
|
||||||
|
|
||||||
|
### Don't
|
||||||
|
|
||||||
|
- Do not turn the app into a landing page aesthetic.
|
||||||
|
- Do not use giant gradients, colorful illustrations, or soft SaaS cards.
|
||||||
|
- Do not over-round every surface.
|
||||||
|
- Do not mix many accent colors in one screen.
|
||||||
|
- Do not make secret fields look like casual form inputs.
|
||||||
|
- Do not use bright white backgrounds in the desktop app.
|
||||||
|
|
||||||
|
## 8. Responsive Behavior
|
||||||
|
|
||||||
|
- Primary target is desktop widths from 1280px upward.
|
||||||
|
- The three-pane shell should remain stable on desktop.
|
||||||
|
- At narrower widths, collapse from three panes to two panes before using stacked mobile behavior.
|
||||||
|
- The MCP modal can reduce width but should keep readable row spacing and code block legibility.
|
||||||
|
- Buttons and toggles should remain mouse-first, with minimum 32px touch-friendly height where practical.
|
||||||
|
|
||||||
|
## 9. Screen-Specific Guidance
|
||||||
|
|
||||||
|
### Login Screen
|
||||||
|
|
||||||
|
- Centered trust card.
|
||||||
|
- One focal icon or emblem above the title.
|
||||||
|
- Keep copy short.
|
||||||
|
- The Google login button should be the visual anchor.
|
||||||
|
|
||||||
|
### Main Secrets Screen
|
||||||
|
|
||||||
|
- Left sidebar: user card, folder navigation, utility actions near the bottom.
|
||||||
|
- Middle pane: search, type filter, result list.
|
||||||
|
- Right pane: selected entry title, metadata grid, secret cards, edit actions.
|
||||||
|
- The selected item should be immediately obvious but understated.
|
||||||
|
|
||||||
|
### MCP Integration Screen
|
||||||
|
|
||||||
|
- Treat as a settings modal.
|
||||||
|
- Integration rows should read like desktop preferences, not marketing feature cards.
|
||||||
|
- JSON config block should feel developer-native and copy-friendly.
|
||||||
|
|
||||||
|
## 10. Agent Prompt Guide
|
||||||
|
|
||||||
|
- Keywords: `dark desktop utility`, `Raycast-inspired`, `Linear-density`, `secure control panel`, `developer tool`, `restrained premium`, `MCP settings modal`.
|
||||||
|
- When generating screens, preserve: dark surfaces, subtle borders, compact controls, right-aligned actions, clean typography, muted status pills.
|
||||||
|
- If unsure, bias toward less decoration and tighter structure.
|
||||||
|
|
||||||
|
## 11. Quick Summary for Agents
|
||||||
|
|
||||||
|
Build Secrets like a polished desktop utility: mostly Raycast in atmosphere, a little Linear in density, with dark layered panels, precise typography, subtle borders, blue-only primary actions, and security-sensitive detail cards that feel calm, serious, and highly usable.
|
||||||
6300
apps/desktop/design/secrets-client.pen
Normal file
6300
apps/desktop/design/secrets-client.pen
Normal file
File diff suppressed because it is too large
Load Diff
41
apps/desktop/dist/disable-features.js
vendored
Normal file
41
apps/desktop/dist/disable-features.js
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
(() => {
|
||||||
|
const tauriInvoke = window.__TAURI_INTERNALS__?.invoke;
|
||||||
|
|
||||||
|
// Disable text selection globally, but keep inputs editable.
|
||||||
|
document.addEventListener("selectstart", (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function applyProductionGuards() {
|
||||||
|
if (!tauriInvoke) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isDebugBuild = false;
|
||||||
|
try {
|
||||||
|
isDebugBuild = await tauriInvoke("is_debug_build");
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDebugBuild) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "F12") {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.shiftKey && ["I", "C", "J"].includes(event.key.toUpperCase())) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyProductionGuards();
|
||||||
|
})();
|
||||||
BIN
apps/desktop/dist/favicon.png
vendored
Normal file
BIN
apps/desktop/dist/favicon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
279
apps/desktop/dist/index.html
vendored
Normal file
279
apps/desktop/dist/index.html
vendored
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Secrets</title>
|
||||||
|
<link rel="stylesheet" href="./styles.css" />
|
||||||
|
<script src="./disable-features.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="login-view" class="login-screen hidden">
|
||||||
|
<div class="window-titlebar login-titlebar" data-tauri-drag-region aria-hidden="true"></div>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-main">
|
||||||
|
<div class="login-emblem" aria-hidden="true">
|
||||||
|
<svg class="login-lock-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="16" r="1"></circle>
|
||||||
|
<rect x="3" y="10" width="18" height="12" rx="2"></rect>
|
||||||
|
<path d="M7 10V7a5 5 0 0 1 10 0v3"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="login-title-block">
|
||||||
|
<h1>Secrets</h1>
|
||||||
|
<p class="login-subtle">用 AI 安全地管理和使用密钥</p>
|
||||||
|
</div>
|
||||||
|
<div class="login-actions">
|
||||||
|
<button id="login-button" class="primary login-google-button">
|
||||||
|
<svg class="login-google-mark" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||||
|
</svg>
|
||||||
|
<span>前往浏览器登录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="login-error" class="error-text hidden"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="vault-modal" class="modal hidden">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="vault-modal-title">解锁本地 Vault</h3>
|
||||||
|
</div>
|
||||||
|
<p id="vault-modal-copy" class="subtle modal-copy">请输入本地 vault 主密码。</p>
|
||||||
|
<div class="modal-form">
|
||||||
|
<label class="field-label">
|
||||||
|
<span>主密码</span>
|
||||||
|
<input id="vault-password-input" type="password" class="detail-input" placeholder="输入主密码" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p id="vault-modal-error" class="error-text hidden"></p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="vault-modal-save" class="primary small">继续</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="app-shell" class="shell hidden">
|
||||||
|
<div class="window-titlebar shell-titlebar" data-tauri-drag-region aria-hidden="true"></div>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="user-block">
|
||||||
|
<button id="user-trigger" class="user-trigger">
|
||||||
|
<div class="avatar">V</div>
|
||||||
|
<div class="user-copy">
|
||||||
|
<div id="user-name" class="user-name">-</div>
|
||||||
|
<div id="user-email" class="user-email">-</div>
|
||||||
|
</div>
|
||||||
|
<span class="caret">▾</span>
|
||||||
|
</button>
|
||||||
|
<div id="user-menu" class="user-menu hidden">
|
||||||
|
<button id="manage-devices" class="menu-item">管理设备</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="folder-list" class="folder-list"></div>
|
||||||
|
|
||||||
|
<div class="sidebar-spacer"></div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button id="open-mcp-modal" class="sidebar-utility">
|
||||||
|
<span class="sidebar-utility-icon" aria-hidden="true">⌁</span>
|
||||||
|
<span>MCP</span>
|
||||||
|
</button>
|
||||||
|
<button id="logout-button" class="sidebar-utility">
|
||||||
|
<span class="sidebar-utility-icon" aria-hidden="true">↩</span>
|
||||||
|
<span>退出登录</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main-shell">
|
||||||
|
<section class="list-column">
|
||||||
|
<div class="searchbar-shell">
|
||||||
|
<input id="search-input" class="search-input global-search" placeholder="按名称模糊搜索" />
|
||||||
|
</div>
|
||||||
|
<section class="list-pane">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="new-entry-button" class="secondary-button small">
|
||||||
|
<span class="button-icon" aria-hidden="true">+</span>
|
||||||
|
<span class="button-label">新建条目</span>
|
||||||
|
</button>
|
||||||
|
<select id="type-filter" class="filter-select">
|
||||||
|
<option value="">全部类型</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="entry-list" class="entry-list"></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-pane">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div class="detail-title-stack">
|
||||||
|
<div id="detail-folder-label" class="detail-folder-label">-</div>
|
||||||
|
<div class="detail-title-block">
|
||||||
|
<h2 id="entry-title">-</h2>
|
||||||
|
<div id="detail-badge" class="detail-badge hidden">最近删除</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button id="edit-entry-button" class="secondary-button small action-button">
|
||||||
|
<span class="button-icon" aria-hidden="true">✎</span>
|
||||||
|
<span class="button-label">编辑</span>
|
||||||
|
</button>
|
||||||
|
<button id="delete-entry-button" class="secondary-button small danger action-button hidden">
|
||||||
|
<span class="button-icon" aria-hidden="true">⌫</span>
|
||||||
|
<span class="button-label">删除</span>
|
||||||
|
</button>
|
||||||
|
<button id="restore-entry-button" class="secondary-button small action-button hidden">
|
||||||
|
<span class="button-icon" aria-hidden="true">↺</span>
|
||||||
|
<span class="button-label">恢复</span>
|
||||||
|
</button>
|
||||||
|
<button id="save-entry-button" class="primary small action-button hidden">
|
||||||
|
<span class="button-icon" aria-hidden="true">✓</span>
|
||||||
|
<span class="button-label">保存</span>
|
||||||
|
</button>
|
||||||
|
<button id="cancel-edit-button" class="secondary-button small action-button hidden">
|
||||||
|
<span class="button-icon" aria-hidden="true">×</span>
|
||||||
|
<span class="button-label">取消</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="name-section" class="detail-section detail-edit-section hidden">
|
||||||
|
<h3>名称</h3>
|
||||||
|
<div id="name-view" class="detail-inline-value">-</div>
|
||||||
|
<input id="name-input" class="detail-input hidden" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>元数据</h3>
|
||||||
|
<div id="metadata-list" class="detail-fields"></div>
|
||||||
|
<div id="metadata-editor" class="metadata-editor hidden"></div>
|
||||||
|
<button id="add-metadata-button" class="secondary-button small hidden">新增元数据</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-section">
|
||||||
|
<div class="section-header-row">
|
||||||
|
<h3>密钥</h3>
|
||||||
|
<button id="add-secret-button" class="secondary-button small hidden">
|
||||||
|
<span class="button-icon" aria-hidden="true">+</span>
|
||||||
|
<span class="button-label">新增密钥</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="secret-list" class="secret-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="device-modal" class="modal hidden">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>设备在线列表</h3>
|
||||||
|
<button id="close-device-modal" class="icon-button">×</button>
|
||||||
|
</div>
|
||||||
|
<p class="subtle modal-copy">查看已登录设备的在线情况与最近活动。</p>
|
||||||
|
<div id="device-list" class="device-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mcp-modal" class="modal hidden">
|
||||||
|
<div class="modal-card wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>MCP 集成</h3>
|
||||||
|
<button id="close-mcp-modal" class="icon-button">×</button>
|
||||||
|
</div>
|
||||||
|
<p class="subtle modal-copy">查看当前 AI 工具的 MCP 集成情况,并一键写入本地 daemon 配置。</p>
|
||||||
|
<section class="modal-section">
|
||||||
|
<div id="mcp-integration-list" class="integration-list"></div>
|
||||||
|
<p class="modal-footnote">启动 Secrets 桌面端时,可按选择自动为上述工具写入 MCP 配置。</p>
|
||||||
|
</section>
|
||||||
|
<section class="detail-section compact modal-section">
|
||||||
|
<div class="mcp-json-header">
|
||||||
|
<h4>自定义 MCP 配置</h4>
|
||||||
|
<button id="copy-mcp-config" class="secondary-button small">
|
||||||
|
<span class="button-icon" aria-hidden="true">⧉</span>
|
||||||
|
<span class="button-label">复制</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre id="mcp-config" class="mcp-config"></pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="entry-modal" class="modal hidden">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>新建条目</h3>
|
||||||
|
<button id="close-entry-modal" class="icon-button">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-form">
|
||||||
|
<label class="field-label">
|
||||||
|
<span>项目</span>
|
||||||
|
<input id="entry-modal-folder" class="detail-input" placeholder="例如:Refining" />
|
||||||
|
</label>
|
||||||
|
<label class="field-label">
|
||||||
|
<span>名称</span>
|
||||||
|
<input id="entry-modal-title" class="detail-input" placeholder="例如:secrets-local" />
|
||||||
|
</label>
|
||||||
|
<label class="field-label">
|
||||||
|
<span>类型</span>
|
||||||
|
<input id="entry-modal-type" class="detail-input" placeholder="例如:service" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="entry-modal-cancel" class="secondary-button small">取消</button>
|
||||||
|
<button id="entry-modal-save" class="primary small">创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="secret-modal" class="modal hidden">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="secret-modal-title">新增密钥</h3>
|
||||||
|
<button id="close-secret-modal" class="icon-button">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-form">
|
||||||
|
<label class="field-label">
|
||||||
|
<span>名称</span>
|
||||||
|
<input id="secret-name-input" class="detail-input" placeholder="例如:token" />
|
||||||
|
</label>
|
||||||
|
<label class="field-label">
|
||||||
|
<span>类型</span>
|
||||||
|
<select id="secret-type-input" class="filter-select">
|
||||||
|
<option value="text">text</option>
|
||||||
|
<option value="password">password</option>
|
||||||
|
<option value="key">key</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field-label">
|
||||||
|
<span>内容</span>
|
||||||
|
<textarea id="secret-value-input" class="detail-input detail-textarea" placeholder="输入密钥内容"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="secret-modal-cancel" class="secondary-button small">取消</button>
|
||||||
|
<button id="secret-modal-save" class="primary small">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="history-modal" class="modal hidden">
|
||||||
|
<div class="modal-card wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>密钥历史</h3>
|
||||||
|
<button id="close-history-modal" class="icon-button">×</button>
|
||||||
|
</div>
|
||||||
|
<p id="history-modal-copy" class="subtle modal-copy">查看版本历史并回滚到指定版本。</p>
|
||||||
|
<div id="history-list" class="history-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="./main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1020
apps/desktop/dist/main.js
vendored
Normal file
1020
apps/desktop/dist/main.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1072
apps/desktop/dist/styles.css
vendored
Normal file
1072
apps/desktop/dist/styles.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
32
apps/desktop/src-tauri/Cargo.toml
Normal file
32
apps/desktop/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "secrets-desktop"
|
||||||
|
version = "3.0.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tauri.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
base64 = "0.22.1"
|
||||||
|
|
||||||
|
secrets-client-integrations = { path = "../../../crates/client-integrations" }
|
||||||
|
secrets-crypto = { path = "../../../crates/crypto" }
|
||||||
|
secrets-device-auth = { path = "../../../crates/device-auth" }
|
||||||
|
secrets-domain = { path = "../../../crates/domain" }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "Secrets"
|
||||||
|
path = "src/main.rs"
|
||||||
3
apps/desktop/src-tauri/build.rs
Normal file
3
apps/desktop/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
2
apps/desktop/src-tauri/check_png_center.js
Normal file
2
apps/desktop/src-tauri/check_png_center.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
// Very simple check: read the first few bytes, maybe we can use an image library to find the bounding box
|
||||||
1
apps/desktop/src-tauri/gen/schemas/acl-manifests.json
Normal file
1
apps/desktop/src-tauri/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
apps/desktop/src-tauri/gen/schemas/capabilities.json
Normal file
1
apps/desktop/src-tauri/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
2244
apps/desktop/src-tauri/gen/schemas/desktop-schema.json
Normal file
2244
apps/desktop/src-tauri/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2244
apps/desktop/src-tauri/gen/schemas/macOS-schema.json
Normal file
2244
apps/desktop/src-tauri/gen/schemas/macOS-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
apps/desktop/src-tauri/icons/icon.png
Normal file
BIN
apps/desktop/src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
1427
apps/desktop/src-tauri/src/local_vault.rs
Normal file
1427
apps/desktop/src-tauri/src/local_vault.rs
Normal file
File diff suppressed because it is too large
Load Diff
1080
apps/desktop/src-tauri/src/main.rs
Normal file
1080
apps/desktop/src-tauri/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
356
apps/desktop/src-tauri/src/session_api.rs
Normal file
356
apps/desktop/src-tauri/src/session_api.rs
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
use anyhow::{Context, Result as AnyResult};
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
body::{Body, to_bytes},
|
||||||
|
extract::{Request, State as AxumState},
|
||||||
|
http::{StatusCode as AxumStatusCode, header},
|
||||||
|
response::Response,
|
||||||
|
routing::{any, get, post},
|
||||||
|
};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::local_vault::{
|
||||||
|
LocalEntryQuery, bootstrap as vault_bootstrap, create_entry as vault_create_entry,
|
||||||
|
create_secret as vault_create_secret, delete_entry as vault_delete_entry,
|
||||||
|
delete_secret as vault_delete_secret, entry_detail as vault_entry_detail,
|
||||||
|
list_entries as vault_list_entries, restore_entry as vault_restore_entry,
|
||||||
|
reveal_secret_value as vault_reveal_secret_value, rollback_secret as vault_rollback_secret,
|
||||||
|
secret_history as vault_secret_history, update_entry as vault_update_entry,
|
||||||
|
update_secret as vault_update_secret,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
DesktopState, EntryDetail, EntryDraft, EntryListItem, EntryListQuery, SecretDraft,
|
||||||
|
SecretUpdateDraft, current_device_token, map_entry_detail_to_local, map_entry_draft_to_local,
|
||||||
|
map_local_entry_detail, map_local_history_item, map_local_secret_value,
|
||||||
|
map_secret_draft_to_local, map_secret_update_to_local, split_secret_ref_for_ui,
|
||||||
|
sync_local_vault,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn desktop_session_health(
|
||||||
|
AxumState(state): AxumState<DesktopState>,
|
||||||
|
) -> Result<&'static str, AxumStatusCode> {
|
||||||
|
current_device_token(&state)
|
||||||
|
.map(|_| "ok")
|
||||||
|
.map_err(|_| AxumStatusCode::UNAUTHORIZED)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn desktop_session_api(
|
||||||
|
AxumState(state): AxumState<DesktopState>,
|
||||||
|
request: Request<Body>,
|
||||||
|
) -> Response {
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
let path_and_query = parts
|
||||||
|
.uri
|
||||||
|
.path_and_query()
|
||||||
|
.map(|value| value.as_str())
|
||||||
|
.unwrap_or("/");
|
||||||
|
|
||||||
|
let body_bytes = match to_bytes(body, 1024 * 1024).await {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(_) => {
|
||||||
|
return Response::builder()
|
||||||
|
.status(AxumStatusCode::BAD_REQUEST)
|
||||||
|
.body(Body::from("failed to read relay request body"))
|
||||||
|
.expect("build relay bad request");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handle_local_session_request(&state, parts.method.as_str(), path_and_query, &body_bytes)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
Response::builder()
|
||||||
|
.status(AxumStatusCode::NOT_FOUND)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"error":"desktop local vault route not found"}"#,
|
||||||
|
))
|
||||||
|
.expect("build local session not found response")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_local_session_request(
|
||||||
|
state: &DesktopState,
|
||||||
|
method: &str,
|
||||||
|
path_and_query: &str,
|
||||||
|
body_bytes: &[u8],
|
||||||
|
) -> Option<Response> {
|
||||||
|
let path = path_and_query.split('?').next().unwrap_or(path_and_query);
|
||||||
|
let make_json = |status: AxumStatusCode, value: serde_json::Value| {
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||||
|
.body(Body::from(value.to_string()))
|
||||||
|
.expect("build local session response")
|
||||||
|
};
|
||||||
|
|
||||||
|
match (method, path) {
|
||||||
|
("GET", "/vault/status") => {
|
||||||
|
let status = vault_bootstrap(&state.local_vault).await.ok()?;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::json!({
|
||||||
|
"unlocked": status.unlocked,
|
||||||
|
"has_master_password": status.has_master_password
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
("GET", "/vault/entries") => {
|
||||||
|
let url = format!("http://localhost{path_and_query}");
|
||||||
|
let parsed = Url::parse(&url).ok()?;
|
||||||
|
let mut query = EntryListQuery {
|
||||||
|
folder: None,
|
||||||
|
entry_type: None,
|
||||||
|
query: None,
|
||||||
|
deleted_only: false,
|
||||||
|
};
|
||||||
|
for (key, value) in parsed.query_pairs() {
|
||||||
|
match key.as_ref() {
|
||||||
|
"folder" => query.folder = Some(value.into_owned()),
|
||||||
|
"entry_type" => query.entry_type = Some(value.into_owned()),
|
||||||
|
"query" => query.query = Some(value.into_owned()),
|
||||||
|
"deleted_only" => query.deleted_only = value == "true",
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let entries = vault_list_entries(
|
||||||
|
&state.local_vault,
|
||||||
|
&LocalEntryQuery {
|
||||||
|
folder: query.folder,
|
||||||
|
cipher_type: query.entry_type,
|
||||||
|
query: query.query,
|
||||||
|
deleted_only: query.deleted_only,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::to_value(
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| EntryListItem {
|
||||||
|
id: entry.id,
|
||||||
|
title: entry.name,
|
||||||
|
subtitle: entry.cipher_type,
|
||||||
|
folder: entry.folder,
|
||||||
|
deleted: entry.deleted,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.ok()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ if method == "GET" && path.starts_with("/vault/entries/") => {
|
||||||
|
let entry_id = path.trim_start_matches("/vault/entries/");
|
||||||
|
let detail = vault_entry_detail(&state.local_vault, entry_id)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::to_value(map_local_entry_detail(detail)).ok()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
("POST", "/vault/entries") => {
|
||||||
|
let draft: EntryDraft = serde_json::from_slice(body_bytes).ok()?;
|
||||||
|
let created = vault_create_entry(&state.local_vault, map_entry_draft_to_local(draft))
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
let _ = sync_local_vault(state).await;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::to_value(map_local_entry_detail(created)).ok()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ if method == "PATCH" && path.starts_with("/vault/entries/") => {
|
||||||
|
let entry_id = path.trim_start_matches("/vault/entries/").to_string();
|
||||||
|
let mut detail: EntryDetail = serde_json::from_slice(body_bytes).ok()?;
|
||||||
|
detail.id = entry_id;
|
||||||
|
let updated = vault_update_entry(&state.local_vault, map_entry_detail_to_local(detail))
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
let _ = sync_local_vault(state).await;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ if method == "POST"
|
||||||
|
&& path.starts_with("/vault/entries/")
|
||||||
|
&& path.ends_with("/delete") =>
|
||||||
|
{
|
||||||
|
let entry_id = path
|
||||||
|
.trim_start_matches("/vault/entries/")
|
||||||
|
.trim_end_matches("/delete")
|
||||||
|
.trim_end_matches('/');
|
||||||
|
vault_delete_entry(&state.local_vault, entry_id)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
let _ = sync_local_vault(state).await;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::json!({ "ok": true }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ if method == "POST"
|
||||||
|
&& path.starts_with("/vault/entries/")
|
||||||
|
&& path.ends_with("/restore") =>
|
||||||
|
{
|
||||||
|
let entry_id = path
|
||||||
|
.trim_start_matches("/vault/entries/")
|
||||||
|
.trim_end_matches("/restore")
|
||||||
|
.trim_end_matches('/');
|
||||||
|
vault_restore_entry(&state.local_vault, entry_id)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
let _ = sync_local_vault(state).await;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::json!({ "ok": true }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ if method == "POST"
|
||||||
|
&& path.starts_with("/vault/entries/")
|
||||||
|
&& path.ends_with("/secrets") =>
|
||||||
|
{
|
||||||
|
let entry_id = path
|
||||||
|
.trim_start_matches("/vault/entries/")
|
||||||
|
.trim_end_matches("/secrets")
|
||||||
|
.trim_end_matches('/');
|
||||||
|
let secret: SecretDraft = serde_json::from_slice(body_bytes).ok()?;
|
||||||
|
let updated = vault_create_secret(
|
||||||
|
&state.local_vault,
|
||||||
|
entry_id,
|
||||||
|
map_secret_draft_to_local(secret),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
let _ = sync_local_vault(state).await;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ if method == "GET" && path.starts_with("/vault/secrets/") && path.ends_with("/value") => {
|
||||||
|
let secret_id = path
|
||||||
|
.trim_start_matches("/vault/secrets/")
|
||||||
|
.trim_end_matches("/value")
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string();
|
||||||
|
let (entry_id, secret_name) = split_secret_ref_for_ui(&secret_id).ok()?;
|
||||||
|
let value = vault_reveal_secret_value(&state.local_vault, &entry_id, &secret_name)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::to_value(map_local_secret_value(value)).ok()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ if method == "GET"
|
||||||
|
&& path.starts_with("/vault/secrets/")
|
||||||
|
&& path.ends_with("/history") =>
|
||||||
|
{
|
||||||
|
let secret_id = path
|
||||||
|
.trim_start_matches("/vault/secrets/")
|
||||||
|
.trim_end_matches("/history")
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string();
|
||||||
|
let (entry_id, secret_name) = split_secret_ref_for_ui(&secret_id).ok()?;
|
||||||
|
let history = vault_secret_history(&state.local_vault, &entry_id, &secret_name)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::to_value(
|
||||||
|
history
|
||||||
|
.into_iter()
|
||||||
|
.map(map_local_history_item)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.ok()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ if method == "PATCH" && path.starts_with("/vault/secrets/") => {
|
||||||
|
let secret_id = path.trim_start_matches("/vault/secrets/").to_string();
|
||||||
|
let mut update: SecretUpdateDraft = serde_json::from_slice(body_bytes).ok()?;
|
||||||
|
update.id = secret_id;
|
||||||
|
let updated =
|
||||||
|
vault_update_secret(&state.local_vault, map_secret_update_to_local(update))
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
let _ = sync_local_vault(state).await;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ if method == "POST"
|
||||||
|
&& path.starts_with("/vault/secrets/")
|
||||||
|
&& path.ends_with("/delete") =>
|
||||||
|
{
|
||||||
|
let secret_id = path
|
||||||
|
.trim_start_matches("/vault/secrets/")
|
||||||
|
.trim_end_matches("/delete")
|
||||||
|
.trim_end_matches('/');
|
||||||
|
vault_delete_secret(&state.local_vault, secret_id)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
let _ = sync_local_vault(state).await;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::json!({ "ok": true }),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ if method == "POST"
|
||||||
|
&& path.starts_with("/vault/secrets/")
|
||||||
|
&& path.ends_with("/rollback") =>
|
||||||
|
{
|
||||||
|
let secret_id = path
|
||||||
|
.trim_start_matches("/vault/secrets/")
|
||||||
|
.trim_end_matches("/rollback")
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_string();
|
||||||
|
let payload: serde_json::Value = serde_json::from_slice(body_bytes).ok()?;
|
||||||
|
let updated = vault_rollback_secret(
|
||||||
|
&state.local_vault,
|
||||||
|
&secret_id,
|
||||||
|
payload.get("history_id").and_then(|value| value.as_i64()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
let _ = sync_local_vault(state).await;
|
||||||
|
Some(make_json(
|
||||||
|
AxumStatusCode::OK,
|
||||||
|
serde_json::to_value(map_local_entry_detail(updated)).ok()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_desktop_session_server(state: DesktopState) -> AnyResult<()> {
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/healthz", get(desktop_session_health))
|
||||||
|
.route("/vault/status", get(desktop_session_api))
|
||||||
|
.route("/vault/entries", any(desktop_session_api))
|
||||||
|
.route("/vault/entries/{id}", any(desktop_session_api))
|
||||||
|
.route("/vault/entries/{id}/delete", post(desktop_session_api))
|
||||||
|
.route("/vault/entries/{id}/restore", post(desktop_session_api))
|
||||||
|
.route("/vault/entries/{id}/secrets", post(desktop_session_api))
|
||||||
|
.route("/vault/secrets/{id}", any(desktop_session_api))
|
||||||
|
.route("/vault/secrets/{id}/value", get(desktop_session_api))
|
||||||
|
.route("/vault/secrets/{id}/history", get(desktop_session_api))
|
||||||
|
.route("/vault/secrets/{id}/delete", post(desktop_session_api))
|
||||||
|
.route("/vault/secrets/{id}/rollback", post(desktop_session_api))
|
||||||
|
.with_state(state.clone());
|
||||||
|
let listener = tokio::net::TcpListener::bind(&state.session_bind)
|
||||||
|
.await
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"failed to bind desktop session relay {}",
|
||||||
|
state.session_bind
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.context("desktop session relay server error")
|
||||||
|
}
|
||||||
31
apps/desktop/src-tauri/tauri.conf.json
Normal file
31
apps/desktop/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Secrets",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"identifier": "dev.refining.secrets",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "",
|
||||||
|
"beforeBuildCommand": "",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Secrets",
|
||||||
|
"width": 420,
|
||||||
|
"height": 400,
|
||||||
|
"minWidth": 420,
|
||||||
|
"minHeight": 400,
|
||||||
|
"resizable": true,
|
||||||
|
"titleBarStyle": "overlay",
|
||||||
|
"hiddenTitle": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": false
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/application/Cargo.toml
Normal file
18
crates/application/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "secrets-application"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "secrets_application"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
chrono.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
|
secrets-domain = { path = "../domain" }
|
||||||
9
crates/application/src/conflict.rs
Normal file
9
crates/application/src/conflict.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use secrets_domain::VaultObjectEnvelope;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RevisionConflict {
|
||||||
|
pub change_id: Uuid,
|
||||||
|
pub object_id: Uuid,
|
||||||
|
pub server_object: Option<VaultObjectEnvelope>,
|
||||||
|
}
|
||||||
3
crates/application/src/lib.rs
Normal file
3
crates/application/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod conflict;
|
||||||
|
pub mod sync;
|
||||||
|
pub mod vault_store;
|
||||||
252
crates/application/src/sync.rs
Normal file
252
crates/application/src/sync.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use secrets_domain::{
|
||||||
|
SyncAcceptedChange, SyncConflict, SyncPullRequest, SyncPullResponse, SyncPushRequest,
|
||||||
|
SyncPushResponse, VaultObjectChange, VaultObjectEnvelope,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::vault_store::{
|
||||||
|
get_object, list_objects_since, list_tombstones_since, max_server_revision,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn detect_conflict(
|
||||||
|
change: &VaultObjectChange,
|
||||||
|
existing: Option<&VaultObjectEnvelope>,
|
||||||
|
) -> Option<SyncConflict> {
|
||||||
|
match (change.base_revision, existing) {
|
||||||
|
(Some(base_revision), Some(server_object)) if server_object.revision != base_revision => {
|
||||||
|
Some(SyncConflict {
|
||||||
|
change_id: change.change_id,
|
||||||
|
object_id: change.object_id,
|
||||||
|
reason: "revision_conflict".to_string(),
|
||||||
|
server_object: Some(server_object.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ if !matches!(change.operation.as_str(), "upsert" | "delete") => Some(SyncConflict {
|
||||||
|
change_id: change.change_id,
|
||||||
|
object_id: change.object_id,
|
||||||
|
reason: "unsupported_operation".to_string(),
|
||||||
|
server_object: existing.cloned(),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync_pull(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
request: SyncPullRequest,
|
||||||
|
) -> Result<SyncPullResponse> {
|
||||||
|
let cursor = request.cursor.unwrap_or(0).max(0);
|
||||||
|
let limit = request.limit.unwrap_or(200).clamp(1, 500);
|
||||||
|
let objects = list_objects_since(pool, user_id, cursor, limit).await?;
|
||||||
|
let tombstones = if request.include_deleted {
|
||||||
|
list_tombstones_since(pool, user_id, cursor, limit).await?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
let server_revision = max_server_revision(pool, user_id).await?;
|
||||||
|
let next_cursor = objects
|
||||||
|
.last()
|
||||||
|
.map(|object| object.revision)
|
||||||
|
.unwrap_or(cursor);
|
||||||
|
|
||||||
|
Ok(SyncPullResponse {
|
||||||
|
server_revision,
|
||||||
|
next_cursor,
|
||||||
|
has_more: (objects.len() as i64) >= limit,
|
||||||
|
objects,
|
||||||
|
tombstones,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync_push(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
request: SyncPushRequest,
|
||||||
|
) -> Result<SyncPushResponse> {
|
||||||
|
let mut accepted = Vec::new();
|
||||||
|
let mut conflicts = Vec::new();
|
||||||
|
|
||||||
|
for change in request.changes {
|
||||||
|
let existing = get_object(pool, user_id, change.object_id).await?;
|
||||||
|
if let Some(conflict) = detect_conflict(&change, existing.as_ref()) {
|
||||||
|
conflicts.push(conflict);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next_revision = existing
|
||||||
|
.as_ref()
|
||||||
|
.map(|object| object.revision + 1)
|
||||||
|
.unwrap_or(1);
|
||||||
|
let next_cipher_version = change.cipher_version.unwrap_or(1);
|
||||||
|
let next_ciphertext = change.ciphertext.clone().unwrap_or_default();
|
||||||
|
let next_content_hash = change.content_hash.clone().unwrap_or_default();
|
||||||
|
let next_deleted_at = if change.operation == "delete" {
|
||||||
|
Some(chrono::Utc::now())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
match change.operation.as_str() {
|
||||||
|
"upsert" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO vault_objects (
|
||||||
|
object_id, user_id, object_kind, revision, cipher_version, ciphertext, content_hash, deleted_at, updated_at, created_by_device
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NULL, NOW(), NULL)
|
||||||
|
ON CONFLICT (object_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
revision = EXCLUDED.revision,
|
||||||
|
cipher_version = EXCLUDED.cipher_version,
|
||||||
|
ciphertext = EXCLUDED.ciphertext,
|
||||||
|
content_hash = EXCLUDED.content_hash,
|
||||||
|
deleted_at = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(change.object_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(change.object_kind.as_str())
|
||||||
|
.bind(next_revision)
|
||||||
|
.bind(next_cipher_version)
|
||||||
|
.bind(next_ciphertext.clone())
|
||||||
|
.bind(next_content_hash.clone())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
"delete" => {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE vault_objects
|
||||||
|
SET revision = $1, deleted_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE object_id = $2
|
||||||
|
AND user_id = $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(next_revision)
|
||||||
|
.bind(change.object_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
_ => unreachable!("unsupported operations are filtered by detect_conflict"),
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO vault_object_revisions (
|
||||||
|
object_id, user_id, revision, cipher_version, ciphertext, content_hash, deleted_at, created_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(change.object_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(next_revision)
|
||||||
|
.bind(next_cipher_version)
|
||||||
|
.bind(next_ciphertext)
|
||||||
|
.bind(next_content_hash)
|
||||||
|
.bind(next_deleted_at)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
accepted.push(SyncAcceptedChange {
|
||||||
|
change_id: change.change_id,
|
||||||
|
object_id: change.object_id,
|
||||||
|
revision: next_revision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let server_revision = max_server_revision(pool, user_id).await?;
|
||||||
|
Ok(SyncPushResponse {
|
||||||
|
server_revision,
|
||||||
|
accepted,
|
||||||
|
conflicts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_object(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
object_id: Uuid,
|
||||||
|
) -> Result<Option<VaultObjectEnvelope>> {
|
||||||
|
get_object(pool, user_id, object_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use secrets_domain::{VaultObjectChange, VaultObjectKind};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn sample_change(operation: &str, base_revision: Option<i64>) -> VaultObjectChange {
|
||||||
|
VaultObjectChange {
|
||||||
|
change_id: Uuid::nil(),
|
||||||
|
object_id: Uuid::max(),
|
||||||
|
object_kind: VaultObjectKind::Cipher,
|
||||||
|
operation: operation.to_string(),
|
||||||
|
base_revision,
|
||||||
|
cipher_version: Some(1),
|
||||||
|
ciphertext: Some(vec![1, 2, 3]),
|
||||||
|
content_hash: Some("sha256:test".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_object(revision: i64) -> VaultObjectEnvelope {
|
||||||
|
VaultObjectEnvelope {
|
||||||
|
object_id: Uuid::max(),
|
||||||
|
object_kind: VaultObjectKind::Cipher,
|
||||||
|
revision,
|
||||||
|
cipher_version: 1,
|
||||||
|
ciphertext: vec![9, 9, 9],
|
||||||
|
content_hash: "sha256:server".to_string(),
|
||||||
|
deleted_at: None,
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conflict_when_base_revision_is_stale() {
|
||||||
|
let mut change = sample_change("upsert", Some(3));
|
||||||
|
let server = sample_object(5);
|
||||||
|
change.object_id = server.object_id;
|
||||||
|
|
||||||
|
let conflict = detect_conflict(&change, Some(&server)).expect("expected conflict");
|
||||||
|
|
||||||
|
assert_eq!(conflict.reason, "revision_conflict");
|
||||||
|
assert_eq!(conflict.object_id, server.object_id);
|
||||||
|
assert_eq!(
|
||||||
|
conflict
|
||||||
|
.server_object
|
||||||
|
.as_ref()
|
||||||
|
.map(|object| object.revision),
|
||||||
|
Some(5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_conflict_when_revision_matches() {
|
||||||
|
let mut change = sample_change("upsert", Some(5));
|
||||||
|
let server = sample_object(5);
|
||||||
|
change.object_id = server.object_id;
|
||||||
|
|
||||||
|
let conflict = detect_conflict(&change, Some(&server));
|
||||||
|
|
||||||
|
assert!(conflict.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_operation_is_conflict() {
|
||||||
|
let change = sample_change("merge", None);
|
||||||
|
|
||||||
|
let conflict = detect_conflict(&change, None).expect("expected unsupported operation");
|
||||||
|
|
||||||
|
assert_eq!(conflict.reason, "unsupported_operation");
|
||||||
|
assert!(conflict.server_object.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
147
crates/application/src/vault_store.rs
Normal file
147
crates/application/src/vault_store.rs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use secrets_domain::{VaultObjectEnvelope, VaultObjectKind, VaultTombstone};
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct VaultObjectRow {
|
||||||
|
object_id: Uuid,
|
||||||
|
_object_kind: String,
|
||||||
|
revision: i64,
|
||||||
|
cipher_version: i32,
|
||||||
|
ciphertext: Vec<u8>,
|
||||||
|
content_hash: String,
|
||||||
|
deleted_at: Option<DateTime<Utc>>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<VaultObjectRow> for VaultObjectEnvelope {
|
||||||
|
fn from(row: VaultObjectRow) -> Self {
|
||||||
|
Self {
|
||||||
|
object_id: row.object_id,
|
||||||
|
object_kind: VaultObjectKind::Cipher,
|
||||||
|
revision: row.revision,
|
||||||
|
cipher_version: row.cipher_version,
|
||||||
|
ciphertext: row.ciphertext,
|
||||||
|
content_hash: row.content_hash,
|
||||||
|
deleted_at: row.deleted_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_objects_since(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
cursor: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<VaultObjectEnvelope>> {
|
||||||
|
let rows = sqlx::query_as::<_, VaultObjectRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
object_id,
|
||||||
|
object_kind AS _object_kind,
|
||||||
|
revision,
|
||||||
|
cipher_version,
|
||||||
|
ciphertext,
|
||||||
|
content_hash,
|
||||||
|
deleted_at,
|
||||||
|
updated_at
|
||||||
|
FROM vault_objects
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND revision > $2
|
||||||
|
ORDER BY revision ASC
|
||||||
|
LIMIT $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(cursor)
|
||||||
|
.bind(limit.max(1))
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.context("failed to list vault objects")?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_object(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
object_id: Uuid,
|
||||||
|
) -> Result<Option<VaultObjectEnvelope>> {
|
||||||
|
let row = sqlx::query_as::<_, VaultObjectRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
object_id,
|
||||||
|
object_kind AS _object_kind,
|
||||||
|
revision,
|
||||||
|
cipher_version,
|
||||||
|
ciphertext,
|
||||||
|
content_hash,
|
||||||
|
deleted_at,
|
||||||
|
updated_at
|
||||||
|
FROM vault_objects
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND object_id = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(object_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.context("failed to load vault object")?;
|
||||||
|
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_tombstones_since(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
cursor: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<VaultTombstone>> {
|
||||||
|
let rows = sqlx::query_as::<_, (Uuid, i64, DateTime<Utc>)>(
|
||||||
|
r#"
|
||||||
|
SELECT object_id, revision, deleted_at
|
||||||
|
FROM vault_objects
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND revision > $2
|
||||||
|
AND deleted_at IS NOT NULL
|
||||||
|
ORDER BY revision ASC
|
||||||
|
LIMIT $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(cursor)
|
||||||
|
.bind(limit.max(1))
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.context("failed to list tombstones")?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|(object_id, revision, deleted_at)| VaultTombstone {
|
||||||
|
object_id,
|
||||||
|
revision,
|
||||||
|
deleted_at,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn max_server_revision(pool: &PgPool, user_id: Uuid) -> Result<i64> {
|
||||||
|
let revision = sqlx::query_scalar::<_, Option<i64>>(
|
||||||
|
r#"
|
||||||
|
SELECT MAX(revision)
|
||||||
|
FROM vault_objects
|
||||||
|
WHERE user_id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.context("failed to load max server revision")?;
|
||||||
|
|
||||||
|
Ok(revision.unwrap_or(0))
|
||||||
|
}
|
||||||
13
crates/client-integrations/Cargo.toml
Normal file
13
crates/client-integrations/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "secrets-client-integrations"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "secrets_client_integrations"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
162
crates/client-integrations/src/lib.rs
Normal file
162
crates/client-integrations/src/lib.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait ClientAdapter {
|
||||||
|
fn client_name(&self) -> &'static str;
|
||||||
|
fn config_path(&self) -> PathBuf;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CursorAdapter;
|
||||||
|
|
||||||
|
impl ClientAdapter for CursorAdapter {
|
||||||
|
fn client_name(&self) -> &'static str {
|
||||||
|
"cursor"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path(&self) -> PathBuf {
|
||||||
|
default_home().join(".cursor").join("mcp.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ClaudeCodeAdapter;
|
||||||
|
|
||||||
|
impl ClientAdapter for ClaudeCodeAdapter {
|
||||||
|
fn client_name(&self) -> &'static str {
|
||||||
|
"claude-code"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path(&self) -> PathBuf {
|
||||||
|
default_home().join(".claude").join("mcp.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_home() -> PathBuf {
|
||||||
|
std::env::var_os("HOME")
|
||||||
|
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_managed_server(adapter: &dyn ClientAdapter, server_name: &str) -> Result<bool> {
|
||||||
|
let path = adapter.config_path();
|
||||||
|
let root = read_config_or_default(&path)?;
|
||||||
|
Ok(root
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.is_some_and(|servers| servers.contains_key(server_name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_managed_server(
|
||||||
|
adapter: &dyn ClientAdapter,
|
||||||
|
server_name: &str,
|
||||||
|
server_config: Value,
|
||||||
|
) -> Result<()> {
|
||||||
|
let path = adapter.config_path();
|
||||||
|
let mut root = read_config_or_default(&path)?;
|
||||||
|
let root_object = ensure_object(&mut root);
|
||||||
|
let mcp_servers = root_object
|
||||||
|
.entry("mcpServers".to_string())
|
||||||
|
.or_insert_with(|| Value::Object(Map::new()));
|
||||||
|
let servers_object = ensure_object(mcp_servers);
|
||||||
|
servers_object.insert(server_name.to_string(), server_config);
|
||||||
|
write_config_atomically(&path, &root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_config_or_default(path: &Path) -> Result<Value> {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(Value::Object(Map::new()));
|
||||||
|
}
|
||||||
|
let raw =
|
||||||
|
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
|
||||||
|
serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_config_atomically(path: &Path, value: &Value) -> Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||||
|
}
|
||||||
|
let tmp_path = path.with_extension("json.tmp");
|
||||||
|
let body = serde_json::to_string_pretty(value).context("failed to serialize mcp config")?;
|
||||||
|
fs::write(&tmp_path, body)
|
||||||
|
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
|
||||||
|
fs::rename(&tmp_path, path).with_context(|| format!("failed to replace {}", path.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_object(value: &mut Value) -> &mut Map<String, Value> {
|
||||||
|
if !value.is_object() {
|
||||||
|
*value = Value::Object(Map::new());
|
||||||
|
}
|
||||||
|
value.as_object_mut().expect("object just ensured")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
struct TestAdapter {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientAdapter for TestAdapter {
|
||||||
|
fn client_name(&self) -> &'static str {
|
||||||
|
"test"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path(&self) -> PathBuf {
|
||||||
|
self.path.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_preserves_other_servers() {
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.expect("clock")
|
||||||
|
.as_nanos();
|
||||||
|
let base = std::env::temp_dir().join(format!("secrets-client-integrations-{unique}"));
|
||||||
|
let adapter = TestAdapter {
|
||||||
|
path: base.join("mcp.json"),
|
||||||
|
};
|
||||||
|
fs::create_dir_all(adapter.path.parent().expect("parent")).expect("mkdir");
|
||||||
|
fs::write(
|
||||||
|
&adapter.path,
|
||||||
|
r#"{"mcpServers":{"postgres":{"command":"npx"},"secrets":{"url":"http://old"}}}"#,
|
||||||
|
)
|
||||||
|
.expect("seed config");
|
||||||
|
|
||||||
|
upsert_managed_server(
|
||||||
|
&adapter,
|
||||||
|
"secrets",
|
||||||
|
serde_json::json!({
|
||||||
|
"url": "http://127.0.0.1:9515/mcp"
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.expect("upsert config");
|
||||||
|
|
||||||
|
let root: Value =
|
||||||
|
serde_json::from_str(&fs::read_to_string(&adapter.path).expect("read back"))
|
||||||
|
.expect("parse back");
|
||||||
|
let servers = root
|
||||||
|
.get("mcpServers")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.expect("mcpServers object");
|
||||||
|
assert!(servers.contains_key("postgres"));
|
||||||
|
assert_eq!(
|
||||||
|
servers
|
||||||
|
.get("secrets")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|value| value.get("url"))
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("http://127.0.0.1:9515/mcp")
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(base);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
crates/crypto/Cargo.toml
Normal file
14
crates/crypto/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "secrets-crypto"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "secrets_crypto"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
aes-gcm.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
47
crates/crypto/src/lib.rs
Normal file
47
crates/crypto/src/lib.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use aes_gcm::aead::{Aead, KeyInit};
|
||||||
|
use aes_gcm::{Aes256Gcm, Nonce};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
pub const KEY_CHECK_PLAINTEXT: &[u8] = b"secrets-v3-key-check";
|
||||||
|
|
||||||
|
pub fn decode_hex(input: &str) -> Result<Vec<u8>> {
|
||||||
|
hex::decode(input.trim()).context("invalid hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_hex(input: &[u8]) -> String {
|
||||||
|
hex::encode(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_key_32(input: &str) -> Result<[u8; 32]> {
|
||||||
|
let bytes = decode_hex(input)?;
|
||||||
|
let key: [u8; 32] = bytes
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("expected 32-byte key"))?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(key).context("invalid AES-256 key")?;
|
||||||
|
let mut nonce_bytes = [0_u8; 12];
|
||||||
|
rand::rng().fill_bytes(&mut nonce_bytes);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
let mut out = nonce_bytes.to_vec();
|
||||||
|
out.extend(
|
||||||
|
cipher
|
||||||
|
.encrypt(nonce, plaintext)
|
||||||
|
.map_err(|_| anyhow::anyhow!("encryption failed"))?,
|
||||||
|
);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt(key: &[u8; 32], ciphertext: &[u8]) -> Result<Vec<u8>> {
|
||||||
|
if ciphertext.len() < 12 {
|
||||||
|
anyhow::bail!("ciphertext too short");
|
||||||
|
}
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(key).context("invalid AES-256 key")?;
|
||||||
|
let (nonce, body) = ciphertext.split_at(12);
|
||||||
|
cipher
|
||||||
|
.decrypt(Nonce::from_slice(nonce), body)
|
||||||
|
.map_err(|_| anyhow::anyhow!("decryption failed"))
|
||||||
|
}
|
||||||
26
crates/desktop-daemon/Cargo.toml
Normal file
26
crates/desktop-daemon/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "secrets-desktop-daemon"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "secrets_desktop_daemon"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "secrets-desktop-daemon"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
dotenvy.workspace = true
|
||||||
|
reqwest = { workspace = true, features = ["stream"] }
|
||||||
|
rmcp.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
secrets-device-auth = { path = "../device-auth" }
|
||||||
23
crates/desktop-daemon/src/config.rs
Normal file
23
crates/desktop-daemon/src/config.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DaemonConfig {
|
||||||
|
pub bind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_config() -> Result<DaemonConfig> {
|
||||||
|
let bind =
|
||||||
|
std::env::var("SECRETS_DAEMON_BIND").unwrap_or_else(|_| "127.0.0.1:9515".to_string());
|
||||||
|
if bind.trim().is_empty() {
|
||||||
|
anyhow::bail!("SECRETS_DAEMON_BIND must not be empty");
|
||||||
|
}
|
||||||
|
Ok(DaemonConfig { bind })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_persisted_device_token() -> Result<Option<String>> {
|
||||||
|
let token = std::env::var("SECRETS_DEVICE_LOGIN_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
139
crates/desktop-daemon/src/exec.rs
Normal file
139
crates/desktop-daemon/src/exec.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use crate::target::{ExecutionTarget, ResolvedTarget};
|
||||||
|
|
||||||
|
const MAX_OUTPUT_CHARS: usize = 64 * 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub struct TargetExecInput {
|
||||||
|
pub target_ref: Option<String>,
|
||||||
|
pub command: String,
|
||||||
|
pub timeout_secs: Option<u64>,
|
||||||
|
pub working_dir: Option<String>,
|
||||||
|
pub env_overrides: Option<Map<String, Value>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct ExecResult {
|
||||||
|
pub resolved_target: ResolvedTarget,
|
||||||
|
pub resolved_env_keys: Vec<String>,
|
||||||
|
pub command: String,
|
||||||
|
pub exit_code: Option<i32>,
|
||||||
|
pub stdout: String,
|
||||||
|
pub stderr: String,
|
||||||
|
pub timed_out: bool,
|
||||||
|
pub duration_ms: u128,
|
||||||
|
pub stdout_truncated: bool,
|
||||||
|
pub stderr_truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_output(text: String) -> (String, bool) {
|
||||||
|
if text.chars().count() <= MAX_OUTPUT_CHARS {
|
||||||
|
return (text, false);
|
||||||
|
}
|
||||||
|
let truncated = text.chars().take(MAX_OUTPUT_CHARS).collect::<String>();
|
||||||
|
(truncated, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stringify_env_override(value: &Value) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Value::Null => None,
|
||||||
|
Value::String(s) => Some(s.clone()),
|
||||||
|
Value::Bool(v) => Some(v.to_string()),
|
||||||
|
Value::Number(v) => Some(v.to_string()),
|
||||||
|
other => serde_json::to_string(other).ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_env_overrides(
|
||||||
|
env: &mut BTreeMap<String, String>,
|
||||||
|
overrides: Option<&Map<String, Value>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let Some(overrides) = overrides else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
for (key, value) in overrides {
|
||||||
|
if key.is_empty() || key.contains('=') {
|
||||||
|
return Err(anyhow!("invalid env override key: {key}"));
|
||||||
|
}
|
||||||
|
if key.starts_with("TARGET_") {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"env override `{key}` cannot override reserved TARGET_* variables"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(value) = stringify_env_override(value) {
|
||||||
|
env.insert(key.clone(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_command(
|
||||||
|
input: &TargetExecInput,
|
||||||
|
target: &ExecutionTarget,
|
||||||
|
timeout_secs: u64,
|
||||||
|
) -> Result<ExecResult> {
|
||||||
|
let mut env = target.env.clone();
|
||||||
|
apply_env_overrides(&mut env, input.env_overrides.as_ref())?;
|
||||||
|
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let mut command = Command::new("/bin/sh");
|
||||||
|
command
|
||||||
|
.arg("-lc")
|
||||||
|
.arg(&input.command)
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
|
||||||
|
if let Some(dir) = input.working_dir.as_ref().filter(|dir| !dir.is_empty()) {
|
||||||
|
command.current_dir(dir);
|
||||||
|
}
|
||||||
|
for (key, value) in &env {
|
||||||
|
command.env(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let child = command
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("failed to spawn command: {}", input.command))?;
|
||||||
|
|
||||||
|
let timed = tokio::time::timeout(
|
||||||
|
Duration::from_secs(timeout_secs.clamp(1, 86400)),
|
||||||
|
child.wait_with_output(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let (exit_code, stdout, stderr, timed_out) = match timed {
|
||||||
|
Ok(output) => {
|
||||||
|
let output = output.context("failed waiting for command output")?;
|
||||||
|
(
|
||||||
|
output.status.code(),
|
||||||
|
String::from_utf8_lossy(&output.stdout).to_string(),
|
||||||
|
String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(_) => (None, String::new(), "command timed out".to_string(), true),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (stdout, stdout_truncated) = truncate_output(stdout);
|
||||||
|
let (stderr, stderr_truncated) = truncate_output(stderr);
|
||||||
|
|
||||||
|
Ok(ExecResult {
|
||||||
|
resolved_target: target.resolved.clone(),
|
||||||
|
resolved_env_keys: target.resolved_env_keys(),
|
||||||
|
command: input.command.clone(),
|
||||||
|
exit_code,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
timed_out,
|
||||||
|
duration_ms: started.elapsed().as_millis(),
|
||||||
|
stdout_truncated,
|
||||||
|
stderr_truncated,
|
||||||
|
})
|
||||||
|
}
|
||||||
642
crates/desktop-daemon/src/lib.rs
Normal file
642
crates/desktop-daemon/src/lib.rs
Normal file
@@ -0,0 +1,642 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod exec;
|
||||||
|
pub mod target;
|
||||||
|
pub mod vault_client;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
body::Body,
|
||||||
|
extract::State,
|
||||||
|
http::{StatusCode, header},
|
||||||
|
response::Response,
|
||||||
|
routing::{any, get},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
exec::{TargetExecInput, execute_command},
|
||||||
|
target::{TargetSnapshot, build_execution_target},
|
||||||
|
vault_client::{
|
||||||
|
EntryDetail, EntrySummary, SecretHistoryItem, SecretValueField, authorized_get,
|
||||||
|
authorized_patch, authorized_post, entry_detail_payload, fetch_entry_detail,
|
||||||
|
fetch_revealed_entry_secrets,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
session_base: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct JsonRpcRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
id: Value,
|
||||||
|
method: String,
|
||||||
|
#[serde(default)]
|
||||||
|
params: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_response(status: StatusCode, value: Value) -> Response {
|
||||||
|
Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||||
|
.body(Body::from(value.to_string()))
|
||||||
|
.expect("build response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn jsonrpc_result_response(id: Value, result: Value) -> Response {
|
||||||
|
json_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": id,
|
||||||
|
"result": result,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_success_response(id: Value, value: Value) -> Response {
|
||||||
|
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
|
||||||
|
jsonrpc_result_response(
|
||||||
|
id,
|
||||||
|
json!({
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": pretty
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isError": false
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_error_response(id: Value, message: impl Into<String>) -> Response {
|
||||||
|
jsonrpc_result_response(
|
||||||
|
id,
|
||||||
|
json!({
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": message.into()
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isError": true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize_response(id: Value) -> Response {
|
||||||
|
let session_id = format!(
|
||||||
|
"desktop-daemon-{}",
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_nanos())
|
||||||
|
.unwrap_or(0)
|
||||||
|
);
|
||||||
|
let payload = json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": id,
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": "2025-06-18",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": {}
|
||||||
|
},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "secrets-desktop-daemon",
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
"title": "Secrets Desktop Daemon"
|
||||||
|
},
|
||||||
|
"instructions": "Preferred tools: secrets_entry_find, secrets_entry_get, secrets_entry_add, secrets_entry_update, secrets_entry_delete, secrets_entry_restore, secrets_secret_add, secrets_secret_update, secrets_secret_delete, secrets_secret_history, secrets_secret_rollback, and target_exec. All data is resolved from the desktop app's unlocked local vault session."
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
|
||||||
|
.header("mcp-session-id", session_id)
|
||||||
|
.body(Body::from(payload.to_string()))
|
||||||
|
.expect("build response")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_definitions() -> Vec<Value> {
|
||||||
|
vec![
|
||||||
|
json!({
|
||||||
|
"name": "secrets_entry_find",
|
||||||
|
"description": "Find entries from the user's secrets vault.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": { "type": ["string", "null"] },
|
||||||
|
"folder": { "type": ["string", "null"] },
|
||||||
|
"type": { "type": ["string", "null"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "secrets_entry_get",
|
||||||
|
"description": "Get one entry from the unlocked local vault by entry id.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "secrets_entry_add",
|
||||||
|
"description": "Create a new entry and optionally include initial secrets.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"folder": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"type": { "type": ["string", "null"] },
|
||||||
|
"metadata": { "type": ["object", "null"] },
|
||||||
|
"secrets": {
|
||||||
|
"type": ["array", "null"],
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"secret_type": { "type": ["string", "null"] },
|
||||||
|
"value": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["name", "value"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["folder", "name"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "secrets_entry_update",
|
||||||
|
"description": "Update an existing entry by id.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"folder": { "type": ["string", "null"] },
|
||||||
|
"name": { "type": ["string", "null"] },
|
||||||
|
"type": { "type": ["string", "null"] },
|
||||||
|
"metadata": { "type": ["object", "null"] }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "secrets_entry_delete",
|
||||||
|
"description": "Move an entry into recycle bin by id.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "secrets_entry_restore",
|
||||||
|
"description": "Restore a deleted entry from recycle bin by id.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "secrets_secret_add",
|
||||||
|
"description": "Create one secret under an existing entry.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"entry_id": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"secret_type": { "type": ["string", "null"] },
|
||||||
|
"value": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["entry_id", "name", "value"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "secrets_secret_update",
|
||||||
|
"description": "Update one secret by id.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": { "type": ["string", "null"] },
|
||||||
|
"secret_type": { "type": ["string", "null"] },
|
||||||
|
"value": { "type": ["string", "null"] }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "secrets_secret_delete",
|
||||||
|
"description": "Delete one secret by id.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "secrets_secret_history",
|
||||||
|
"description": "List history snapshots for one secret by id.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "secrets_secret_rollback",
|
||||||
|
"description": "Rollback one secret by id to a previous version or history id.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"version": { "type": ["integer", "null"] },
|
||||||
|
"history_id": { "type": ["integer", "null"] }
|
||||||
|
},
|
||||||
|
"required": ["id"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"name": "target_exec",
|
||||||
|
"description": "Execute a local shell command with resolved TARGET_* environment variables from one entry.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"target_ref": { "type": ["string", "null"] },
|
||||||
|
"command": { "type": "string" },
|
||||||
|
"timeout_secs": { "type": ["integer", "null"] },
|
||||||
|
"working_dir": { "type": ["string", "null"] },
|
||||||
|
"env_overrides": { "type": ["object", "null"] }
|
||||||
|
},
|
||||||
|
"required": ["target_ref", "command"]
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_detail_to_snapshot(detail: &EntryDetail) -> TargetSnapshot {
|
||||||
|
let metadata = detail
|
||||||
|
.metadata
|
||||||
|
.iter()
|
||||||
|
.map(|field| (field.label.clone(), Value::String(field.value.clone())))
|
||||||
|
.collect();
|
||||||
|
let secret_fields = detail
|
||||||
|
.secrets
|
||||||
|
.iter()
|
||||||
|
.map(|secret| crate::target::SecretFieldRef {
|
||||||
|
name: secret.name.clone(),
|
||||||
|
secret_type: Some(secret.secret_type.clone()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
TargetSnapshot {
|
||||||
|
id: detail.id.clone(),
|
||||||
|
folder: detail.folder.clone(),
|
||||||
|
name: detail.name.clone(),
|
||||||
|
entry_type: Some(detail.cipher_type.clone()),
|
||||||
|
metadata,
|
||||||
|
secret_fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn revealed_secrets_to_env(secrets: &[SecretValueField]) -> HashMap<String, Value> {
|
||||||
|
secrets
|
||||||
|
.iter()
|
||||||
|
.map(|secret| (secret.name.clone(), Value::String(secret.value.clone())))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_tool(state: &AppState, name: &str, arguments: Value) -> Result<Value> {
|
||||||
|
match name {
|
||||||
|
"secrets_entry_find" => {
|
||||||
|
let folder = arguments
|
||||||
|
.get("folder")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
let query = arguments
|
||||||
|
.get("query")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
let entry_type = arguments
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
let mut params = Vec::new();
|
||||||
|
if let Some(folder) = folder {
|
||||||
|
params.push(("folder", folder));
|
||||||
|
}
|
||||||
|
if let Some(query) = query {
|
||||||
|
params.push(("query", query));
|
||||||
|
}
|
||||||
|
if let Some(entry_type) = entry_type {
|
||||||
|
params.push(("entry_type", entry_type));
|
||||||
|
}
|
||||||
|
params.push(("deleted_only", "false".to_string()));
|
||||||
|
let entries = authorized_get(state, "/vault/entries", ¶ms)
|
||||||
|
.await?
|
||||||
|
.json::<Vec<EntrySummary>>()
|
||||||
|
.await
|
||||||
|
.context("failed to decode entries list")?;
|
||||||
|
Ok(json!({
|
||||||
|
"entries": entries.into_iter().map(|entry| {
|
||||||
|
json!({
|
||||||
|
"id": entry.id,
|
||||||
|
"folder": entry.folder,
|
||||||
|
"name": entry.name,
|
||||||
|
"type": entry.cipher_type
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"secrets_entry_get" => {
|
||||||
|
let id = arguments
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("id is required")?;
|
||||||
|
let detail = fetch_entry_detail(state, id).await?;
|
||||||
|
let secrets = fetch_revealed_entry_secrets(state, id).await?;
|
||||||
|
Ok(entry_detail_payload(&detail, Some(&secrets)))
|
||||||
|
}
|
||||||
|
"secrets_entry_add" => {
|
||||||
|
let folder = arguments
|
||||||
|
.get("folder")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("folder is required")?;
|
||||||
|
let name = arguments
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("name is required")?;
|
||||||
|
let entry_type = arguments
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("entry");
|
||||||
|
let metadata = arguments
|
||||||
|
.get("metadata")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!({}));
|
||||||
|
let res = authorized_post(
|
||||||
|
state,
|
||||||
|
"/vault/entries",
|
||||||
|
&json!({
|
||||||
|
"folder": folder,
|
||||||
|
"name": name,
|
||||||
|
"entry_type": entry_type,
|
||||||
|
"metadata": metadata,
|
||||||
|
"secrets": arguments.get("secrets").cloned().unwrap_or(Value::Null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(res
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.context("failed to decode create result")?)
|
||||||
|
}
|
||||||
|
"secrets_entry_update" => {
|
||||||
|
let id = arguments
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("id is required")?;
|
||||||
|
let body = json!({
|
||||||
|
"folder": arguments.get("folder").cloned().unwrap_or(Value::Null),
|
||||||
|
"entry_type": arguments.get("type").cloned().unwrap_or(Value::Null),
|
||||||
|
"title": arguments.get("name").cloned().unwrap_or(Value::Null),
|
||||||
|
"metadata": arguments.get("metadata").cloned().unwrap_or(Value::Null)
|
||||||
|
});
|
||||||
|
let res = authorized_patch(state, &format!("/vault/entries/{id}"), &body).await?;
|
||||||
|
Ok(res
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.context("failed to decode update result")?)
|
||||||
|
}
|
||||||
|
"secrets_entry_delete" => {
|
||||||
|
let id = arguments
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("id is required")?;
|
||||||
|
let res =
|
||||||
|
authorized_post(state, &format!("/vault/entries/{id}/delete"), &json!({})).await?;
|
||||||
|
Ok(res
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.context("failed to decode delete result")?)
|
||||||
|
}
|
||||||
|
"secrets_entry_restore" => {
|
||||||
|
let id = arguments
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("id is required")?;
|
||||||
|
let res =
|
||||||
|
authorized_post(state, &format!("/vault/entries/{id}/restore"), &json!({})).await?;
|
||||||
|
Ok(res
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.context("failed to decode restore result")?)
|
||||||
|
}
|
||||||
|
"secrets_secret_add" => {
|
||||||
|
let entry_id = arguments
|
||||||
|
.get("entry_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("entry_id is required")?;
|
||||||
|
let name = arguments
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("name is required")?;
|
||||||
|
let value = arguments
|
||||||
|
.get("value")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("value is required")?;
|
||||||
|
let res = authorized_post(
|
||||||
|
state,
|
||||||
|
&format!("/vault/entries/{entry_id}/secrets"),
|
||||||
|
&json!({
|
||||||
|
"name": name,
|
||||||
|
"secret_type": arguments.get("secret_type").cloned().unwrap_or(Value::Null),
|
||||||
|
"value": value
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(res
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.context("failed to decode secret create result")?)
|
||||||
|
}
|
||||||
|
"secrets_secret_update" => {
|
||||||
|
let id = arguments
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("id is required")?;
|
||||||
|
let res = authorized_patch(
|
||||||
|
state,
|
||||||
|
&format!("/vault/secrets/{id}"),
|
||||||
|
&json!({
|
||||||
|
"name": arguments.get("name").cloned().unwrap_or(Value::Null),
|
||||||
|
"secret_type": arguments.get("secret_type").cloned().unwrap_or(Value::Null),
|
||||||
|
"value": arguments.get("value").cloned().unwrap_or(Value::Null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(res
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.context("failed to decode secret update result")?)
|
||||||
|
}
|
||||||
|
"secrets_secret_delete" => {
|
||||||
|
let id = arguments
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("id is required")?;
|
||||||
|
let res =
|
||||||
|
authorized_post(state, &format!("/vault/secrets/{id}/delete"), &json!({})).await?;
|
||||||
|
Ok(res
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.context("failed to decode secret delete result")?)
|
||||||
|
}
|
||||||
|
"secrets_secret_history" => {
|
||||||
|
let id = arguments
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("id is required")?;
|
||||||
|
let history = authorized_get(state, &format!("/vault/secrets/{id}/history"), &[])
|
||||||
|
.await?
|
||||||
|
.json::<Vec<SecretHistoryItem>>()
|
||||||
|
.await
|
||||||
|
.context("failed to decode secret history")?;
|
||||||
|
Ok(json!({
|
||||||
|
"history": history.into_iter().map(|item| {
|
||||||
|
json!({
|
||||||
|
"history_id": item.history_id,
|
||||||
|
"secret_id": item.secret_id,
|
||||||
|
"name": item.name,
|
||||||
|
"type": item.secret_type,
|
||||||
|
"masked_value": item.masked_value,
|
||||||
|
"value": item.value,
|
||||||
|
"version": item.version,
|
||||||
|
"action": item.action,
|
||||||
|
"created_at": item.created_at
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
"secrets_secret_rollback" => {
|
||||||
|
let id = arguments
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.context("id is required")?;
|
||||||
|
let res = authorized_post(
|
||||||
|
state,
|
||||||
|
&format!("/vault/secrets/{id}/rollback"),
|
||||||
|
&json!({
|
||||||
|
"version": arguments.get("version").cloned().unwrap_or(Value::Null),
|
||||||
|
"history_id": arguments.get("history_id").cloned().unwrap_or(Value::Null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(res
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.context("failed to decode secret rollback result")?)
|
||||||
|
}
|
||||||
|
"target_exec" => {
|
||||||
|
let input: TargetExecInput =
|
||||||
|
serde_json::from_value(arguments).context("invalid target_exec arguments")?;
|
||||||
|
let target_ref = input
|
||||||
|
.target_ref
|
||||||
|
.as_ref()
|
||||||
|
.context("target_ref is required")?;
|
||||||
|
let detail = fetch_entry_detail(state, target_ref).await?;
|
||||||
|
let secrets = fetch_revealed_entry_secrets(state, target_ref).await?;
|
||||||
|
let execution_target = build_execution_target(
|
||||||
|
&entry_detail_to_snapshot(&detail),
|
||||||
|
&revealed_secrets_to_env(&secrets),
|
||||||
|
)?;
|
||||||
|
let result =
|
||||||
|
execute_command(&input, &execution_target, input.timeout_secs.unwrap_or(30))
|
||||||
|
.await?;
|
||||||
|
Ok(serde_json::to_value(result).context("failed to encode exec result")?)
|
||||||
|
}
|
||||||
|
other => Err(anyhow!("unsupported tool: {other}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_mcp(State(state): State<AppState>, body: String) -> Response {
|
||||||
|
let request: JsonRpcRequest = match serde_json::from_str(&body) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(err) => {
|
||||||
|
return json_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": null,
|
||||||
|
"error": {
|
||||||
|
"code": -32600,
|
||||||
|
"message": format!("invalid request: {err}")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match request.method.as_str() {
|
||||||
|
"initialize" => initialize_response(request.id),
|
||||||
|
"tools/list" => jsonrpc_result_response(request.id, json!({ "tools": tool_definitions() })),
|
||||||
|
"tools/call" => {
|
||||||
|
let name = request
|
||||||
|
.params
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let arguments = request
|
||||||
|
.params
|
||||||
|
.get("arguments")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!({}));
|
||||||
|
match call_tool(&state, name, arguments).await {
|
||||||
|
Ok(value) => tool_success_response(request.id, value),
|
||||||
|
Err(err) => tool_error_response(request.id, err.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => json_response(
|
||||||
|
StatusCode::OK,
|
||||||
|
json!({
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request.id,
|
||||||
|
"error": {
|
||||||
|
"code": -32601,
|
||||||
|
"message": format!("method `{other}` not supported by secrets-desktop-daemon")
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn build_router() -> Result<Router> {
|
||||||
|
let session_base = std::env::var("SECRETS_DESKTOP_SESSION_URL")
|
||||||
|
.unwrap_or_else(|_| "http://127.0.0.1:9520".to_string());
|
||||||
|
let state = AppState {
|
||||||
|
session_base,
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
};
|
||||||
|
Ok(Router::new()
|
||||||
|
.route("/healthz", get(|| async { "ok" }))
|
||||||
|
.route("/mcp", any(handle_mcp))
|
||||||
|
.with_state(state))
|
||||||
|
}
|
||||||
26
crates/desktop-daemon/src/main.rs
Normal file
26
crates/desktop-daemon/src/main.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "secrets_desktop_daemon=info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = secrets_desktop_daemon::config::load_config()?;
|
||||||
|
let app = secrets_desktop_daemon::build_router().await?;
|
||||||
|
let listener = tokio::net::TcpListener::bind(&config.bind)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("failed to bind {}", config.bind))?;
|
||||||
|
|
||||||
|
tracing::info!(bind = %config.bind, "secrets-desktop-daemon listening");
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.await
|
||||||
|
.context("daemon server error")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
332
crates/desktop-daemon/src/target.rs
Normal file
332
crates/desktop-daemon/src/target.rs
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct SecretFieldRef {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub secret_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct TargetSnapshot {
|
||||||
|
pub id: String,
|
||||||
|
pub folder: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub entry_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub metadata: Map<String, Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secret_fields: Vec<SecretFieldRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
pub struct ResolvedTarget {
|
||||||
|
pub id: String,
|
||||||
|
pub folder: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub entry_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ExecutionTarget {
|
||||||
|
pub resolved: ResolvedTarget,
|
||||||
|
pub env: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExecutionTarget {
|
||||||
|
pub fn resolved_env_keys(&self) -> Vec<String> {
|
||||||
|
self.env.keys().cloned().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stringify_value(value: &Value) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Value::Null => None,
|
||||||
|
Value::String(s) => Some(s.clone()),
|
||||||
|
Value::Bool(v) => Some(v.to_string()),
|
||||||
|
Value::Number(v) => Some(v.to_string()),
|
||||||
|
other => serde_json::to_string(other).ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_env_key(key: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(key.len());
|
||||||
|
for ch in key.chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
out.push(ch.to_ascii_uppercase());
|
||||||
|
} else {
|
||||||
|
out.push('_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while out.contains("__") {
|
||||||
|
out = out.replace("__", "_");
|
||||||
|
}
|
||||||
|
out.trim_matches('_').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_if_missing(env: &mut BTreeMap<String, String>, key: &str, value: Option<String>) {
|
||||||
|
if let Some(value) = value.filter(|v| !v.is_empty()) {
|
||||||
|
env.entry(key.to_string()).or_insert(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_alias(metadata: &Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| metadata.get(*key))
|
||||||
|
.and_then(stringify_value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secret_alias(
|
||||||
|
secrets: &HashMap<String, Value>,
|
||||||
|
secret_types: &HashMap<&str, Option<&str>>,
|
||||||
|
name_match: impl Fn(&str) -> bool,
|
||||||
|
type_match: impl Fn(Option<&str>) -> bool,
|
||||||
|
) -> Option<String> {
|
||||||
|
secrets.iter().find_map(|(name, value)| {
|
||||||
|
let normalized = sanitize_env_key(name);
|
||||||
|
let ty = secret_types.get(name.as_str()).copied().flatten();
|
||||||
|
if name_match(&normalized) || type_match(ty) {
|
||||||
|
stringify_value(value)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_execution_target(
|
||||||
|
snapshot: &TargetSnapshot,
|
||||||
|
secrets: &HashMap<String, Value>,
|
||||||
|
) -> Result<ExecutionTarget> {
|
||||||
|
if snapshot.id.trim().is_empty() {
|
||||||
|
return Err(anyhow!("target snapshot missing id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut env = BTreeMap::new();
|
||||||
|
env.insert("TARGET_ENTRY_ID".to_string(), snapshot.id.clone());
|
||||||
|
env.insert("TARGET_NAME".to_string(), snapshot.name.clone());
|
||||||
|
env.insert("TARGET_FOLDER".to_string(), snapshot.folder.clone());
|
||||||
|
if let Some(entry_type) = snapshot.entry_type.as_ref().filter(|v| !v.is_empty()) {
|
||||||
|
env.insert("TARGET_TYPE".to_string(), entry_type.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, value) in &snapshot.metadata {
|
||||||
|
if let Some(value) = stringify_value(value) {
|
||||||
|
let name = sanitize_env_key(key);
|
||||||
|
if !name.is_empty() {
|
||||||
|
env.insert(format!("TARGET_META_{name}"), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let secret_type_map: HashMap<&str, Option<&str>> = snapshot
|
||||||
|
.secret_fields
|
||||||
|
.iter()
|
||||||
|
.map(|field| (field.name.as_str(), field.secret_type.as_deref()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (key, value) in secrets {
|
||||||
|
if let Some(value) = stringify_value(value) {
|
||||||
|
let name = sanitize_env_key(key);
|
||||||
|
if !name.is_empty() {
|
||||||
|
env.insert(format!("TARGET_SECRET_{name}"), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set_if_missing(
|
||||||
|
&mut env,
|
||||||
|
"TARGET_HOST",
|
||||||
|
metadata_alias(
|
||||||
|
&snapshot.metadata,
|
||||||
|
&["public_ip", "ipv4", "private_ip", "host", "hostname"],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
set_if_missing(
|
||||||
|
&mut env,
|
||||||
|
"TARGET_PORT",
|
||||||
|
metadata_alias(&snapshot.metadata, &["ssh_port", "port"]),
|
||||||
|
);
|
||||||
|
set_if_missing(
|
||||||
|
&mut env,
|
||||||
|
"TARGET_USER",
|
||||||
|
metadata_alias(&snapshot.metadata, &["username", "ssh_user", "user"]),
|
||||||
|
);
|
||||||
|
set_if_missing(
|
||||||
|
&mut env,
|
||||||
|
"TARGET_BASE_URL",
|
||||||
|
metadata_alias(&snapshot.metadata, &["base_url", "url", "endpoint"]),
|
||||||
|
);
|
||||||
|
set_if_missing(
|
||||||
|
&mut env,
|
||||||
|
"TARGET_API_KEY",
|
||||||
|
secret_alias(
|
||||||
|
secrets,
|
||||||
|
&secret_type_map,
|
||||||
|
|name| matches!(name, "API_KEY" | "APIKEY" | "ACCESS_KEY" | "ACCESS_KEY_ID"),
|
||||||
|
|_| false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
set_if_missing(
|
||||||
|
&mut env,
|
||||||
|
"TARGET_TOKEN",
|
||||||
|
secret_alias(
|
||||||
|
secrets,
|
||||||
|
&secret_type_map,
|
||||||
|
|name| name.contains("TOKEN"),
|
||||||
|
|_| false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
set_if_missing(
|
||||||
|
&mut env,
|
||||||
|
"TARGET_SSH_KEY",
|
||||||
|
secret_alias(
|
||||||
|
secrets,
|
||||||
|
&secret_type_map,
|
||||||
|
|name| name.contains("SSH") || name.ends_with("PEM"),
|
||||||
|
|ty| ty.is_some_and(|v| v.eq_ignore_ascii_case("ssh-key")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(ExecutionTarget {
|
||||||
|
resolved: ResolvedTarget {
|
||||||
|
id: snapshot.id.clone(),
|
||||||
|
folder: snapshot.folder.clone(),
|
||||||
|
name: snapshot.name.clone(),
|
||||||
|
entry_type: snapshot.entry_type.clone(),
|
||||||
|
},
|
||||||
|
env,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn build_snapshot() -> TargetSnapshot {
|
||||||
|
let mut metadata = Map::new();
|
||||||
|
metadata.insert(
|
||||||
|
"host".to_string(),
|
||||||
|
Value::String("git.example.com".to_string()),
|
||||||
|
);
|
||||||
|
metadata.insert("port".to_string(), Value::String("22".to_string()));
|
||||||
|
metadata.insert("username".to_string(), Value::String("deploy".to_string()));
|
||||||
|
metadata.insert(
|
||||||
|
"base_url".to_string(),
|
||||||
|
Value::String("https://api.example.com".to_string()),
|
||||||
|
);
|
||||||
|
TargetSnapshot {
|
||||||
|
id: "entry-1".to_string(),
|
||||||
|
folder: "infra".to_string(),
|
||||||
|
name: "production".to_string(),
|
||||||
|
entry_type: Some("ssh_key".to_string()),
|
||||||
|
metadata,
|
||||||
|
secret_fields: vec![
|
||||||
|
SecretFieldRef {
|
||||||
|
name: "api_key".to_string(),
|
||||||
|
secret_type: Some("text".to_string()),
|
||||||
|
},
|
||||||
|
SecretFieldRef {
|
||||||
|
name: "token".to_string(),
|
||||||
|
secret_type: Some("text".to_string()),
|
||||||
|
},
|
||||||
|
SecretFieldRef {
|
||||||
|
name: "ssh_key".to_string(),
|
||||||
|
secret_type: Some("ssh-key".to_string()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derives_standard_target_env_keys() {
|
||||||
|
let snapshot = build_snapshot();
|
||||||
|
let secrets = HashMap::from([
|
||||||
|
("api_key".to_string(), Value::String("ak-123".to_string())),
|
||||||
|
("token".to_string(), Value::String("tok-456".to_string())),
|
||||||
|
(
|
||||||
|
"ssh_key".to_string(),
|
||||||
|
Value::String("-----BEGIN KEY-----".to_string()),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let target = build_execution_target(&snapshot, &secrets).expect("build execution target");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
target.env.get("TARGET_ENTRY_ID").map(String::as_str),
|
||||||
|
Some("entry-1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.env.get("TARGET_NAME").map(String::as_str),
|
||||||
|
Some("production")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.env.get("TARGET_FOLDER").map(String::as_str),
|
||||||
|
Some("infra")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.env.get("TARGET_TYPE").map(String::as_str),
|
||||||
|
Some("ssh_key")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.env.get("TARGET_HOST").map(String::as_str),
|
||||||
|
Some("git.example.com")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.env.get("TARGET_PORT").map(String::as_str),
|
||||||
|
Some("22")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.env.get("TARGET_USER").map(String::as_str),
|
||||||
|
Some("deploy")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.env.get("TARGET_BASE_URL").map(String::as_str),
|
||||||
|
Some("https://api.example.com")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.env.get("TARGET_API_KEY").map(String::as_str),
|
||||||
|
Some("ak-123")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.env.get("TARGET_TOKEN").map(String::as_str),
|
||||||
|
Some("tok-456")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target.env.get("TARGET_SSH_KEY").map(String::as_str),
|
||||||
|
Some("-----BEGIN KEY-----")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exports_sanitized_meta_and_secret_keys() {
|
||||||
|
let mut snapshot = build_snapshot();
|
||||||
|
snapshot.metadata.insert(
|
||||||
|
"private-ip".to_string(),
|
||||||
|
Value::String("10.0.0.8".to_string()),
|
||||||
|
);
|
||||||
|
let secrets = HashMap::from([(
|
||||||
|
"access key id".to_string(),
|
||||||
|
Value::String("access-1".to_string()),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let target = build_execution_target(&snapshot, &secrets).expect("build execution target");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
target.env.get("TARGET_META_PRIVATE_IP").map(String::as_str),
|
||||||
|
Some("10.0.0.8")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
target
|
||||||
|
.env
|
||||||
|
.get("TARGET_SECRET_ACCESS_KEY_ID")
|
||||||
|
.map(String::as_str),
|
||||||
|
Some("access-1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
168
crates/desktop-daemon/src/vault_client.rs
Normal file
168
crates/desktop-daemon/src/vault_client.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct EntrySummary {
|
||||||
|
pub id: String,
|
||||||
|
pub folder: String,
|
||||||
|
#[serde(rename = "title")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "subtitle")]
|
||||||
|
pub cipher_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct EntryDetail {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "title")]
|
||||||
|
pub name: String,
|
||||||
|
pub folder: String,
|
||||||
|
#[serde(rename = "entry_type")]
|
||||||
|
pub cipher_type: String,
|
||||||
|
pub metadata: Vec<DetailField>,
|
||||||
|
pub secrets: Vec<SecretField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct DetailField {
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SecretField {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub secret_type: String,
|
||||||
|
pub masked_value: String,
|
||||||
|
pub version: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SecretValueField {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SecretHistoryItem {
|
||||||
|
pub history_id: i64,
|
||||||
|
pub secret_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub secret_type: String,
|
||||||
|
pub masked_value: String,
|
||||||
|
pub value: String,
|
||||||
|
pub version: i64,
|
||||||
|
pub action: String,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authorized_get(
|
||||||
|
state: &AppState,
|
||||||
|
path: &str,
|
||||||
|
query: &[(&str, String)],
|
||||||
|
) -> Result<reqwest::Response> {
|
||||||
|
state
|
||||||
|
.client
|
||||||
|
.get(format!("{}{}", state.session_base, path))
|
||||||
|
.query(query)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("desktop local vault unavailable: {path}"))?
|
||||||
|
.error_for_status()
|
||||||
|
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authorized_patch(
|
||||||
|
state: &AppState,
|
||||||
|
path: &str,
|
||||||
|
body: &Value,
|
||||||
|
) -> Result<reqwest::Response> {
|
||||||
|
state
|
||||||
|
.client
|
||||||
|
.patch(format!("{}{}", state.session_base, path))
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("desktop local vault unavailable: {path}"))?
|
||||||
|
.error_for_status()
|
||||||
|
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn authorized_post(
|
||||||
|
state: &AppState,
|
||||||
|
path: &str,
|
||||||
|
body: &Value,
|
||||||
|
) -> Result<reqwest::Response> {
|
||||||
|
state
|
||||||
|
.client
|
||||||
|
.post(format!("{}{}", state.session_base, path))
|
||||||
|
.json(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("desktop local vault unavailable: {path}"))?
|
||||||
|
.error_for_status()
|
||||||
|
.with_context(|| format!("desktop local vault requires sign-in and unlock: {path}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_entry_detail(state: &AppState, entry_id: &str) -> Result<EntryDetail> {
|
||||||
|
authorized_get(state, &format!("/vault/entries/{entry_id}"), &[])
|
||||||
|
.await?
|
||||||
|
.json::<EntryDetail>()
|
||||||
|
.await
|
||||||
|
.context("failed to decode entry detail")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_revealed_entry_secrets(
|
||||||
|
state: &AppState,
|
||||||
|
entry_id: &str,
|
||||||
|
) -> Result<Vec<SecretValueField>> {
|
||||||
|
let detail = fetch_entry_detail(state, entry_id).await?;
|
||||||
|
let mut secrets = Vec::new();
|
||||||
|
for secret in detail.secrets {
|
||||||
|
let item = authorized_get(state, &format!("/vault/secrets/{}/value", secret.id), &[])
|
||||||
|
.await?
|
||||||
|
.json::<SecretValueField>()
|
||||||
|
.await
|
||||||
|
.context("failed to decode revealed secret value")?;
|
||||||
|
secrets.push(item);
|
||||||
|
}
|
||||||
|
Ok(secrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entry_detail_payload(detail: &EntryDetail, revealed: Option<&[SecretValueField]>) -> Value {
|
||||||
|
let revealed_by_id: HashMap<&str, &SecretValueField> = revealed
|
||||||
|
.unwrap_or(&[])
|
||||||
|
.iter()
|
||||||
|
.map(|secret| (secret.id.as_str(), secret))
|
||||||
|
.collect();
|
||||||
|
json!({
|
||||||
|
"id": detail.id,
|
||||||
|
"folder": detail.folder,
|
||||||
|
"name": detail.name,
|
||||||
|
"type": detail.cipher_type,
|
||||||
|
"metadata": detail.metadata.iter().map(|field| {
|
||||||
|
json!({
|
||||||
|
"label": field.label,
|
||||||
|
"value": field.value
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
"secrets": detail.secrets.iter().map(|secret| {
|
||||||
|
let revealed = revealed_by_id.get(secret.id.as_str());
|
||||||
|
json!({
|
||||||
|
"id": secret.id,
|
||||||
|
"name": secret.name,
|
||||||
|
"type": secret.secret_type,
|
||||||
|
"masked_value": secret.masked_value,
|
||||||
|
"value": revealed.map(|item| item.value.clone()),
|
||||||
|
"version": secret.version
|
||||||
|
})
|
||||||
|
}).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
}
|
||||||
16
crates/device-auth/Cargo.toml
Normal file
16
crates/device-auth/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "secrets-device-auth"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "secrets_device_auth"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
27
crates/device-auth/src/lib.rs
Normal file
27
crates/device-auth/src/lib.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use anyhow::{Context, Result};
|
||||||
|
use rand::{Rng, RngExt};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
pub fn loopback_redirect_uri(port: u16) -> Result<Url> {
|
||||||
|
Url::parse(&format!("http://127.0.0.1:{port}/oauth/callback"))
|
||||||
|
.context("failed to build loopback redirect URI")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_device_fingerprint() -> String {
|
||||||
|
let mut bytes = [0_u8; 16];
|
||||||
|
rand::rng().fill(&mut bytes);
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_device_login_token() -> String {
|
||||||
|
let mut bytes = [0_u8; 32];
|
||||||
|
rand::rng().fill_bytes(&mut bytes);
|
||||||
|
hex::encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hash_device_login_token(token: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(token.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
16
crates/domain/Cargo.toml
Normal file
16
crates/domain/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "secrets-domain"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "secrets_domain"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
argon2 = "0.5.3"
|
||||||
|
chrono.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
68
crates/domain/src/auth.rs
Normal file
68
crates/domain/src/auth.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub key_salt: Option<Vec<u8>>,
|
||||||
|
pub key_check: Option<Vec<u8>>,
|
||||||
|
pub key_params: Option<Value>,
|
||||||
|
pub key_version: i64,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Device {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub display_name: String,
|
||||||
|
pub platform: String,
|
||||||
|
pub client_version: String,
|
||||||
|
pub device_fingerprint: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_seen_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DeviceLoginToken {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub device_id: Uuid,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub last_seen_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum LoginMethod {
|
||||||
|
GoogleOauth,
|
||||||
|
DeviceToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum LoginResult {
|
||||||
|
Success,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ClientLoginEvent {
|
||||||
|
pub id: i64,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub device_id: Uuid,
|
||||||
|
pub device_name: String,
|
||||||
|
pub platform: String,
|
||||||
|
pub client_version: String,
|
||||||
|
pub ip_addr: Option<String>,
|
||||||
|
pub forwarded_ip: Option<String>,
|
||||||
|
pub login_method: LoginMethod,
|
||||||
|
pub login_result: LoginResult,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
138
crates/domain/src/cipher.rs
Normal file
138
crates/domain/src/cipher.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CipherType {
|
||||||
|
Login,
|
||||||
|
ApiKey,
|
||||||
|
SecureNote,
|
||||||
|
SshKey,
|
||||||
|
Identity,
|
||||||
|
Card,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CipherType {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Login => "login",
|
||||||
|
Self::ApiKey => "api_key",
|
||||||
|
Self::SecureNote => "secure_note",
|
||||||
|
Self::SshKey => "ssh_key",
|
||||||
|
Self::Identity => "identity",
|
||||||
|
Self::Card => "card",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(input: &str) -> Self {
|
||||||
|
match input {
|
||||||
|
"login" => Self::Login,
|
||||||
|
"api_key" => Self::ApiKey,
|
||||||
|
"secure_note" => Self::SecureNote,
|
||||||
|
"ssh_key" => Self::SshKey,
|
||||||
|
"identity" => Self::Identity,
|
||||||
|
"card" => Self::Card,
|
||||||
|
_ => Self::SecureNote,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct CustomField {
|
||||||
|
pub name: String,
|
||||||
|
pub value: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sensitive: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
pub struct LoginPayload {
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub uris: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub totp: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
pub struct ApiKeyPayload {
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub secret: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub host: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
pub struct SecureNotePayload {
|
||||||
|
#[serde(default)]
|
||||||
|
pub text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
pub struct SshKeyPayload {
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub host: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub port: Option<u16>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub private_key: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub passphrase: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum ItemPayload {
|
||||||
|
Login(LoginPayload),
|
||||||
|
ApiKey(ApiKeyPayload),
|
||||||
|
SecureNote(SecureNotePayload),
|
||||||
|
SshKey(SshKeyPayload),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ItemPayload {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::SecureNote(SecureNotePayload::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct CipherView {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub cipher_type: CipherType,
|
||||||
|
pub name: String,
|
||||||
|
pub folder: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub notes: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_fields: Vec<CustomField>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
|
pub revision_date: DateTime<Utc>,
|
||||||
|
pub payload: ItemPayload,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct Cipher {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub object_kind: String,
|
||||||
|
pub cipher_type: CipherType,
|
||||||
|
pub revision: i64,
|
||||||
|
pub cipher_version: i32,
|
||||||
|
pub ciphertext: Vec<u8>,
|
||||||
|
pub content_hash: String,
|
||||||
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
15
crates/domain/src/error.rs
Normal file
15
crates/domain/src/error.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum DomainError {
|
||||||
|
#[error("resource not found")]
|
||||||
|
NotFound,
|
||||||
|
#[error("resource already exists")]
|
||||||
|
Conflict,
|
||||||
|
#[error("validation failed: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
#[error("authentication failed")]
|
||||||
|
AuthenticationFailed,
|
||||||
|
#[error("decryption failed")]
|
||||||
|
DecryptionFailed,
|
||||||
|
}
|
||||||
37
crates/domain/src/kdf.rs
Normal file
37
crates/domain/src/kdf.rs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::DomainError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum KdfType {
|
||||||
|
Argon2id,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct KdfConfig {
|
||||||
|
pub kdf_type: KdfType,
|
||||||
|
pub memory_kib: u32,
|
||||||
|
pub iterations: u32,
|
||||||
|
pub parallelism: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KdfConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
kdf_type: KdfType::Argon2id,
|
||||||
|
memory_kib: 64 * 1024,
|
||||||
|
iterations: 3,
|
||||||
|
parallelism: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KdfConfig {
|
||||||
|
pub fn build_argon2(&self) -> Result<Argon2<'static>, DomainError> {
|
||||||
|
let params = Params::new(self.memory_kib, self.iterations, self.parallelism, Some(32))
|
||||||
|
.map_err(|err| DomainError::Validation(err.to_string()))?;
|
||||||
|
Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
|
||||||
|
}
|
||||||
|
}
|
||||||
19
crates/domain/src/lib.rs
Normal file
19
crates/domain/src/lib.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod cipher;
|
||||||
|
pub mod error;
|
||||||
|
pub mod kdf;
|
||||||
|
pub mod sync;
|
||||||
|
pub mod vault_object;
|
||||||
|
|
||||||
|
pub use auth::{ClientLoginEvent, Device, DeviceLoginToken, LoginMethod, LoginResult, User};
|
||||||
|
pub use cipher::{
|
||||||
|
ApiKeyPayload, Cipher, CipherType, CipherView, CustomField, ItemPayload, LoginPayload,
|
||||||
|
SecureNotePayload, SshKeyPayload,
|
||||||
|
};
|
||||||
|
pub use error::DomainError;
|
||||||
|
pub use kdf::{KdfConfig, KdfType};
|
||||||
|
pub use sync::{
|
||||||
|
SyncAcceptedChange, SyncConflict, SyncPullRequest, SyncPullResponse, SyncPushRequest,
|
||||||
|
SyncPushResponse,
|
||||||
|
};
|
||||||
|
pub use vault_object::{VaultObjectChange, VaultObjectEnvelope, VaultObjectKind, VaultTombstone};
|
||||||
47
crates/domain/src/sync.rs
Normal file
47
crates/domain/src/sync.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::vault_object::{VaultObjectChange, VaultObjectEnvelope, VaultTombstone};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct SyncPullRequest {
|
||||||
|
pub cursor: Option<i64>,
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub include_deleted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct SyncPullResponse {
|
||||||
|
pub server_revision: i64,
|
||||||
|
pub next_cursor: i64,
|
||||||
|
pub has_more: bool,
|
||||||
|
pub objects: Vec<VaultObjectEnvelope>,
|
||||||
|
pub tombstones: Vec<VaultTombstone>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct SyncPushRequest {
|
||||||
|
pub changes: Vec<VaultObjectChange>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct SyncAcceptedChange {
|
||||||
|
pub change_id: uuid::Uuid,
|
||||||
|
pub object_id: uuid::Uuid,
|
||||||
|
pub revision: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct SyncConflict {
|
||||||
|
pub change_id: uuid::Uuid,
|
||||||
|
pub object_id: uuid::Uuid,
|
||||||
|
pub reason: String,
|
||||||
|
pub server_object: Option<VaultObjectEnvelope>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct SyncPushResponse {
|
||||||
|
pub server_revision: i64,
|
||||||
|
pub accepted: Vec<SyncAcceptedChange>,
|
||||||
|
pub conflicts: Vec<SyncConflict>,
|
||||||
|
}
|
||||||
48
crates/domain/src/vault_object.rs
Normal file
48
crates/domain/src/vault_object.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum VaultObjectKind {
|
||||||
|
Cipher,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VaultObjectKind {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Cipher => "cipher",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct VaultObjectEnvelope {
|
||||||
|
pub object_id: Uuid,
|
||||||
|
pub object_kind: VaultObjectKind,
|
||||||
|
pub revision: i64,
|
||||||
|
pub cipher_version: i32,
|
||||||
|
pub ciphertext: Vec<u8>,
|
||||||
|
pub content_hash: String,
|
||||||
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct VaultObjectChange {
|
||||||
|
pub change_id: Uuid,
|
||||||
|
pub object_id: Uuid,
|
||||||
|
pub object_kind: VaultObjectKind,
|
||||||
|
pub operation: String,
|
||||||
|
pub base_revision: Option<i64>,
|
||||||
|
pub cipher_version: Option<i32>,
|
||||||
|
pub ciphertext: Option<Vec<u8>>,
|
||||||
|
pub content_hash: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct VaultTombstone {
|
||||||
|
pub object_id: Uuid,
|
||||||
|
pub revision: i64,
|
||||||
|
pub deleted_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
15
crates/infrastructure-db/Cargo.toml
Normal file
15
crates/infrastructure-db/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "secrets-infrastructure-db"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "secrets_infrastructure_db"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
dotenvy.workspace = true
|
||||||
|
sqlx.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
29
crates/infrastructure-db/src/lib.rs
Normal file
29
crates/infrastructure-db/src/lib.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
mod migrate;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
pub use migrate::migrate_current_schema;
|
||||||
|
|
||||||
|
pub fn load_database_url() -> Result<String> {
|
||||||
|
std::env::var("SECRETS_DATABASE_URL")
|
||||||
|
.context("SECRETS_DATABASE_URL is required for current services")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_pool(database_url: &str) -> Result<PgPool> {
|
||||||
|
let options =
|
||||||
|
PgConnectOptions::from_str(database_url).context("failed to parse SECRETS_DATABASE_URL")?;
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.max_connections(
|
||||||
|
std::env::var("SECRETS_DATABASE_POOL_SIZE")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse::<u32>().ok())
|
||||||
|
.unwrap_or(10),
|
||||||
|
)
|
||||||
|
.connect_with(options)
|
||||||
|
.await
|
||||||
|
.context("failed to connect to PostgreSQL")?;
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
130
crates/infrastructure-db/src/migrate.rs
Normal file
130
crates/infrastructure-db/src/migrate.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
pub async fn migrate_current_schema(pool: &PgPool) -> Result<()> {
|
||||||
|
sqlx::raw_sql(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||||
|
email VARCHAR(256),
|
||||||
|
name VARCHAR(256) NOT NULL DEFAULT '',
|
||||||
|
avatar_url TEXT,
|
||||||
|
key_salt BYTEA,
|
||||||
|
key_check BYTEA,
|
||||||
|
key_params JSONB,
|
||||||
|
key_version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
provider VARCHAR(32) NOT NULL,
|
||||||
|
provider_id VARCHAR(256) NOT NULL,
|
||||||
|
email VARCHAR(256),
|
||||||
|
name VARCHAR(256),
|
||||||
|
avatar_url TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(provider, provider_id),
|
||||||
|
UNIQUE(user_id, provider)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
display_name VARCHAR(256) NOT NULL,
|
||||||
|
platform VARCHAR(64) NOT NULL,
|
||||||
|
client_version VARCHAR(64) NOT NULL,
|
||||||
|
device_fingerprint TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_user_id ON devices(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS device_login_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||||
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_device_login_tokens_device_id ON device_login_tokens(device_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_events (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
|
||||||
|
device_name VARCHAR(256) NOT NULL,
|
||||||
|
platform VARCHAR(64) NOT NULL,
|
||||||
|
client_version VARCHAR(64) NOT NULL,
|
||||||
|
ip_addr TEXT,
|
||||||
|
forwarded_ip TEXT,
|
||||||
|
login_method VARCHAR(32) NOT NULL,
|
||||||
|
login_result VARCHAR(32) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_events_user_id_created_at
|
||||||
|
ON auth_events(user_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_events_device_id_created_at
|
||||||
|
ON auth_events(device_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS desktop_login_sessions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
oauth_state TEXT NOT NULL UNIQUE,
|
||||||
|
pkce_verifier TEXT NOT NULL,
|
||||||
|
device_name VARCHAR(256) NOT NULL,
|
||||||
|
platform VARCHAR(64) NOT NULL,
|
||||||
|
client_version VARCHAR(64) NOT NULL,
|
||||||
|
device_fingerprint TEXT NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||||
|
error_message TEXT,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
device_id UUID REFERENCES devices(id) ON DELETE SET NULL,
|
||||||
|
device_token TEXT,
|
||||||
|
device_token_hash TEXT,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
consumed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_desktop_login_sessions_status_expires
|
||||||
|
ON desktop_login_sessions(status, expires_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vault_objects (
|
||||||
|
object_id UUID PRIMARY KEY,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
object_kind VARCHAR(32) NOT NULL,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
cipher_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ciphertext BYTEA NOT NULL DEFAULT '\x',
|
||||||
|
content_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_by_device UUID REFERENCES devices(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vault_objects_user_revision
|
||||||
|
ON vault_objects(user_id, revision ASC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vault_objects_user_deleted
|
||||||
|
ON vault_objects(user_id, deleted_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vault_object_revisions (
|
||||||
|
object_id UUID NOT NULL,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
revision BIGINT NOT NULL,
|
||||||
|
cipher_version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ciphertext BYTEA NOT NULL DEFAULT '\x',
|
||||||
|
content_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (object_id, revision)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vault_object_revisions_user_revision
|
||||||
|
ON vault_object_revisions(user_id, revision ASC);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "secrets-core"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition.workspace = true
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "secrets_core"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
aes-gcm.workspace = true
|
|
||||||
anyhow.workspace = true
|
|
||||||
chrono.workspace = true
|
|
||||||
rand.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
serde_yaml.workspace = true
|
|
||||||
sha2.workspace = true
|
|
||||||
sqlx.workspace = true
|
|
||||||
toml.workspace = true
|
|
||||||
tokio.workspace = true
|
|
||||||
tracing.workspace = true
|
|
||||||
uuid.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = "3"
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
use serde_json::{Value, json};
|
|
||||||
use sqlx::{PgPool, Postgres, Transaction};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
pub const ACTION_LOGIN: &str = "login";
|
|
||||||
pub const FOLDER_AUTH: &str = "auth";
|
|
||||||
|
|
||||||
fn login_detail(provider: &str, client_ip: Option<&str>, user_agent: Option<&str>) -> Value {
|
|
||||||
json!({
|
|
||||||
"provider": provider,
|
|
||||||
"client_ip": client_ip,
|
|
||||||
"user_agent": user_agent,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write a login audit entry without requiring an explicit transaction.
|
|
||||||
pub async fn log_login(
|
|
||||||
pool: &PgPool,
|
|
||||||
entry_type: &str,
|
|
||||||
provider: &str,
|
|
||||||
user_id: Uuid,
|
|
||||||
client_ip: Option<&str>,
|
|
||||||
user_agent: Option<&str>,
|
|
||||||
) {
|
|
||||||
let detail = login_detail(provider, client_ip, user_agent);
|
|
||||||
let result: Result<_, sqlx::Error> = sqlx::query(
|
|
||||||
"INSERT INTO audit_log (user_id, action, folder, type, name, detail) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(ACTION_LOGIN)
|
|
||||||
.bind(FOLDER_AUTH)
|
|
||||||
.bind(entry_type)
|
|
||||||
.bind(provider)
|
|
||||||
.bind(&detail)
|
|
||||||
.execute(pool)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
tracing::warn!(error = %e, entry_type, provider, "failed to write login audit log");
|
|
||||||
} else {
|
|
||||||
tracing::debug!(entry_type, provider, ?user_id, "login audit logged");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write an audit entry within an existing transaction.
|
|
||||||
pub async fn log_tx(
|
|
||||||
tx: &mut Transaction<'_, Postgres>,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
action: &str,
|
|
||||||
folder: &str,
|
|
||||||
entry_type: &str,
|
|
||||||
name: &str,
|
|
||||||
detail: Value,
|
|
||||||
) {
|
|
||||||
let result: Result<_, sqlx::Error> = sqlx::query(
|
|
||||||
"INSERT INTO audit_log (user_id, action, folder, type, name, detail) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(action)
|
|
||||||
.bind(folder)
|
|
||||||
.bind(entry_type)
|
|
||||||
.bind(name)
|
|
||||||
.bind(&detail)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
tracing::warn!(error = %e, "failed to write audit log");
|
|
||||||
} else {
|
|
||||||
tracing::debug!(action, folder, entry_type, name, "audit logged");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn login_detail_includes_expected_fields() {
|
|
||||||
let detail = login_detail("google", Some("127.0.0.1"), Some("Mozilla/5.0"));
|
|
||||||
|
|
||||||
assert_eq!(detail["provider"], "google");
|
|
||||||
assert_eq!(detail["client_ip"], "127.0.0.1");
|
|
||||||
assert_eq!(detail["user_agent"], "Mozilla/5.0");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use sqlx::postgres::PgSslMode;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
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.
|
|
||||||
/// Priority: `SECRETS_DATABASE_URL` env var → error.
|
|
||||||
pub fn resolve_db_url(override_url: &str) -> Result<String> {
|
|
||||||
if !override_url.is_empty() {
|
|
||||||
return Ok(override_url.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(url) = std::env::var("SECRETS_DATABASE_URL")
|
|
||||||
&& !url.is_empty()
|
|
||||||
{
|
|
||||||
return Ok(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
anyhow::bail!(
|
|
||||||
"Database not configured. Set the SECRETS_DATABASE_URL environment variable.\n\
|
|
||||||
Example: SECRETS_DATABASE_URL=postgres://user:pass@host:port/dbname"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_var_non_empty(name: &str) -> Option<String> {
|
|
||||||
std::env::var(name)
|
|
||||||
.ok()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_ssl_mode_from_env() -> Result<Option<PgSslMode>> {
|
|
||||||
let Some(mode) = env_var_non_empty("SECRETS_DATABASE_SSL_MODE") else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let parsed = mode.parse::<PgSslMode>().with_context(|| {
|
|
||||||
format!(
|
|
||||||
"Invalid SECRETS_DATABASE_SSL_MODE='{mode}'. Use one of: disable, allow, prefer, require, verify-ca, verify-full."
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(Some(parsed))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_ssl_root_cert_from_env() -> Result<Option<PathBuf>> {
|
|
||||||
let Some(path) = env_var_non_empty("SECRETS_DATABASE_SSL_ROOT_CERT") else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let path = PathBuf::from(path);
|
|
||||||
if !path.exists() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"SECRETS_DATABASE_SSL_ROOT_CERT points to a missing file: {}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
use aes_gcm::{
|
|
||||||
Aes256Gcm, Key, Nonce,
|
|
||||||
aead::{Aead, AeadCore, KeyInit, OsRng},
|
|
||||||
};
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
const NONCE_LEN: usize = 12;
|
|
||||||
|
|
||||||
// ─── AES-256-GCM encrypt / decrypt ───────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Encrypt plaintext bytes with AES-256-GCM.
|
|
||||||
/// Returns `nonce (12 B) || ciphertext+tag`.
|
|
||||||
pub fn encrypt(master_key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>> {
|
|
||||||
let key = Key::<Aes256Gcm>::from_slice(master_key);
|
|
||||||
let cipher = Aes256Gcm::new(key);
|
|
||||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
|
||||||
let ciphertext = cipher
|
|
||||||
.encrypt(&nonce, plaintext)
|
|
||||||
.map_err(|e| anyhow::anyhow!("AES-256-GCM encryption failed: {}", e))?;
|
|
||||||
let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
|
|
||||||
out.extend_from_slice(&nonce);
|
|
||||||
out.extend_from_slice(&ciphertext);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt `nonce (12 B) || ciphertext+tag` with AES-256-GCM.
|
|
||||||
pub fn decrypt(master_key: &[u8; 32], data: &[u8]) -> Result<Vec<u8>> {
|
|
||||||
if data.len() < NONCE_LEN {
|
|
||||||
bail!(
|
|
||||||
"encrypted data too short ({}B); possibly corrupted",
|
|
||||||
data.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let (nonce_bytes, ciphertext) = data.split_at(NONCE_LEN);
|
|
||||||
let key = Key::<Aes256Gcm>::from_slice(master_key);
|
|
||||||
let cipher = Aes256Gcm::new(key);
|
|
||||||
let nonce = Nonce::from_slice(nonce_bytes);
|
|
||||||
cipher
|
|
||||||
.decrypt(nonce, ciphertext)
|
|
||||||
.map_err(|_| anyhow::anyhow!("decryption failed — wrong master key or corrupted data"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── JSON helpers ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Serialize a JSON Value and encrypt it. Returns the encrypted blob.
|
|
||||||
pub fn encrypt_json(master_key: &[u8; 32], value: &Value) -> Result<Vec<u8>> {
|
|
||||||
let bytes = serde_json::to_vec(value).context("serialize JSON for encryption")?;
|
|
||||||
encrypt(master_key, &bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt an encrypted blob and deserialize it as a JSON Value.
|
|
||||||
pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result<Value> {
|
|
||||||
let bytes = decrypt(master_key, data)?;
|
|
||||||
serde_json::from_slice(&bytes).context("deserialize decrypted JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Client-supplied key extraction ──────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Parse a 64-char hex string (from X-Encryption-Key header) into a 32-byte key.
|
|
||||||
pub fn extract_key_from_hex(hex_str: &str) -> Result<[u8; 32]> {
|
|
||||||
let bytes = hex::decode_hex(hex_str.trim())?;
|
|
||||||
if bytes.len() != 32 {
|
|
||||||
bail!(
|
|
||||||
"X-Encryption-Key must be 64 hex chars (32 bytes), got {} bytes",
|
|
||||||
bytes.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let mut key = [0u8; 32];
|
|
||||||
key.copy_from_slice(&bytes);
|
|
||||||
Ok(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Public hex helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub mod hex {
|
|
||||||
use anyhow::{Result, bail};
|
|
||||||
|
|
||||||
pub fn encode_hex(bytes: &[u8]) -> String {
|
|
||||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
|
|
||||||
let s = s.trim();
|
|
||||||
if !s.len().is_multiple_of(2) {
|
|
||||||
bail!("hex string has odd length");
|
|
||||||
}
|
|
||||||
(0..s.len())
|
|
||||||
.step_by(2)
|
|
||||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!("{}", e)))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn roundtrip_encrypt_decrypt() {
|
|
||||||
let key = [0x42u8; 32];
|
|
||||||
let plaintext = b"hello world";
|
|
||||||
let enc = encrypt(&key, plaintext).unwrap();
|
|
||||||
let dec = decrypt(&key, &enc).unwrap();
|
|
||||||
assert_eq!(dec, plaintext);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encrypt_produces_different_ciphertexts() {
|
|
||||||
let key = [0x42u8; 32];
|
|
||||||
let plaintext = b"hello world";
|
|
||||||
let enc1 = encrypt(&key, plaintext).unwrap();
|
|
||||||
let enc2 = encrypt(&key, plaintext).unwrap();
|
|
||||||
assert_ne!(enc1, enc2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn wrong_key_fails_decryption() {
|
|
||||||
let key1 = [0x42u8; 32];
|
|
||||||
let key2 = [0x43u8; 32];
|
|
||||||
let enc = encrypt(&key1, b"secret").unwrap();
|
|
||||||
assert!(decrypt(&key2, &enc).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn json_roundtrip() {
|
|
||||||
let key = [0x42u8; 32];
|
|
||||||
let value = serde_json::json!({"token": "abc123", "password": "hunter2"});
|
|
||||||
let enc = encrypt_json(&key, &value).unwrap();
|
|
||||||
let dec = decrypt_json(&key, &enc).unwrap();
|
|
||||||
assert_eq!(dec, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode};
|
|
||||||
|
|
||||||
use crate::config::DatabaseConfig;
|
|
||||||
|
|
||||||
fn build_connect_options(config: &DatabaseConfig) -> Result<PgConnectOptions> {
|
|
||||||
let mut options = PgConnectOptions::from_str(&config.url)
|
|
||||||
.with_context(|| "failed to parse SECRETS_DATABASE_URL".to_string())?;
|
|
||||||
|
|
||||||
if let Some(mode) = config.ssl_mode {
|
|
||||||
options = options.ssl_mode(mode);
|
|
||||||
}
|
|
||||||
if let Some(path) = &config.ssl_root_cert {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_pool(config: &DatabaseConfig) -> Result<PgPool> {
|
|
||||||
tracing::debug!("connecting to database");
|
|
||||||
let connect_options = build_connect_options(config)?;
|
|
||||||
let pool = PgPoolOptions::new()
|
|
||||||
.max_connections(10)
|
|
||||||
.acquire_timeout(std::time::Duration::from_secs(5))
|
|
||||||
.connect_with(connect_options)
|
|
||||||
.await?;
|
|
||||||
tracing::debug!("database connection established");
|
|
||||||
Ok(pool)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn migrate(pool: &PgPool) -> Result<()> {
|
|
||||||
tracing::debug!("running migrations");
|
|
||||||
sqlx::raw_sql(
|
|
||||||
r#"
|
|
||||||
-- ── entries: top-level entities ─────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS entries (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
||||||
user_id UUID,
|
|
||||||
folder VARCHAR(128) NOT NULL DEFAULT '',
|
|
||||||
type VARCHAR(64) NOT NULL DEFAULT '',
|
|
||||||
name VARCHAR(256) NOT NULL,
|
|
||||||
notes TEXT NOT NULL DEFAULT '',
|
|
||||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
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()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Legacy unique constraint without user_id (single-user mode)
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
|
|
||||||
ON entries(folder, name)
|
|
||||||
WHERE user_id IS NULL;
|
|
||||||
|
|
||||||
-- Multi-user unique constraint
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
|
|
||||||
ON entries(user_id, folder, name)
|
|
||||||
WHERE user_id IS NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_folder 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_user_id ON entries(user_id) WHERE user_id IS NOT NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_tags ON entries USING GIN(tags);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_metadata ON entries USING GIN(metadata jsonb_path_ops);
|
|
||||||
|
|
||||||
-- ── secrets: one row per encrypted field ─────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS secrets (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
||||||
user_id UUID,
|
|
||||||
name VARCHAR(256) NOT NULL,
|
|
||||||
type VARCHAR(64) NOT NULL DEFAULT 'text',
|
|
||||||
encrypted BYTEA NOT NULL DEFAULT '\x',
|
|
||||||
version BIGINT NOT NULL DEFAULT 1,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_secrets_user_id ON secrets(user_id) WHERE user_id IS NOT NULL;
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_unique_user_name
|
|
||||||
ON secrets(user_id, name) WHERE user_id IS NOT NULL;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_secrets_type ON secrets(type);
|
|
||||||
|
|
||||||
-- ── entry_secrets: N:N relation ────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS entry_secrets (
|
|
||||||
entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
|
||||||
secret_id UUID NOT NULL REFERENCES secrets(id) ON DELETE CASCADE,
|
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
PRIMARY KEY(entry_id, secret_id)
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entry_secrets_secret_id ON entry_secrets(secret_id);
|
|
||||||
|
|
||||||
-- ── audit_log: append-only operation log ─────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
user_id UUID,
|
|
||||||
action VARCHAR(32) NOT NULL,
|
|
||||||
folder VARCHAR(128) NOT NULL DEFAULT '',
|
|
||||||
type VARCHAR(64) NOT NULL DEFAULT '',
|
|
||||||
name VARCHAR(256) NOT NULL,
|
|
||||||
detail JSONB NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_folder_type ON audit_log(folder, type);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON audit_log(user_id) WHERE user_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- ── entries_history ───────────────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS entries_history (
|
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
entry_id UUID NOT NULL,
|
|
||||||
folder VARCHAR(128) NOT NULL DEFAULT '',
|
|
||||||
type VARCHAR(64) NOT NULL DEFAULT '',
|
|
||||||
name VARCHAR(256) NOT NULL,
|
|
||||||
version BIGINT NOT NULL,
|
|
||||||
action VARCHAR(16) NOT NULL,
|
|
||||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_history_entry_id
|
|
||||||
ON entries_history(entry_id, version DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_history_folder_type_name
|
|
||||||
ON entries_history(folder, type, name, version DESC);
|
|
||||||
|
|
||||||
-- Backfill: add user_id to entries_history for multi-tenant isolation
|
|
||||||
ALTER TABLE entries_history ADD COLUMN IF NOT EXISTS user_id UUID;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_history_user_id
|
|
||||||
ON entries_history(user_id) WHERE user_id IS NOT NULL;
|
|
||||||
ALTER TABLE entries_history DROP COLUMN IF EXISTS actor;
|
|
||||||
|
|
||||||
-- 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 '';
|
|
||||||
|
|
||||||
-- ── secrets_history: field-level snapshot ────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS secrets_history (
|
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
secret_id UUID NOT NULL,
|
|
||||||
name VARCHAR(256) NOT NULL,
|
|
||||||
encrypted BYTEA NOT NULL DEFAULT '\x',
|
|
||||||
action VARCHAR(16) NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_secrets_history_secret_id
|
|
||||||
ON secrets_history(secret_id);
|
|
||||||
|
|
||||||
-- Drop redundant actor column (derivable via entries_history JOIN)
|
|
||||||
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
|
|
||||||
|
|
||||||
-- ── users ─────────────────────────────────────────────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
||||||
email VARCHAR(256),
|
|
||||||
name VARCHAR(256) NOT NULL DEFAULT '',
|
|
||||||
avatar_url TEXT,
|
|
||||||
key_salt BYTEA,
|
|
||||||
key_check BYTEA,
|
|
||||||
key_params JSONB,
|
|
||||||
api_key TEXT UNIQUE,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ── oauth_accounts: per-provider identity links ───────────────────────────
|
|
||||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
|
||||||
id UUID PRIMARY KEY DEFAULT uuidv7(),
|
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
provider VARCHAR(32) NOT NULL,
|
|
||||||
provider_id VARCHAR(256) NOT NULL,
|
|
||||||
email VARCHAR(256),
|
|
||||||
name VARCHAR(256),
|
|
||||||
avatar_url TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
UNIQUE(provider, provider_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts(user_id);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_accounts_user_provider
|
|
||||||
ON oauth_accounts(user_id, provider);
|
|
||||||
|
|
||||||
-- FK: user_id columns -> users(id) (nullable = legacy rows; ON DELETE SET NULL)
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_entries_user_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE entries
|
|
||||||
ADD CONSTRAINT fk_entries_user_id
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_entries_history_user_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE entries_history
|
|
||||||
ADD CONSTRAINT fk_entries_history_user_id
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_secrets_user_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE secrets
|
|
||||||
ADD CONSTRAINT fk_secrets_user_id
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM pg_constraint WHERE conname = 'fk_audit_log_user_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE audit_log
|
|
||||||
ADD CONSTRAINT fk_audit_log_user_id
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
migrate_schema(pool).await?;
|
|
||||||
restore_plaintext_api_keys(pool).await?;
|
|
||||||
|
|
||||||
tracing::debug!("migrations complete");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Idempotent schema migration: rename namespace→folder, kind→type in existing databases.
|
|
||||||
async fn migrate_schema(pool: &PgPool) -> Result<()> {
|
|
||||||
sqlx::raw_sql(
|
|
||||||
r#"
|
|
||||||
-- ── entries: rename namespace→folder, kind→type ──────────────────────────
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'entries' AND column_name = 'namespace'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE entries RENAME COLUMN namespace TO folder;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'entries' AND column_name = 'kind'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE entries RENAME COLUMN kind TO type;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ── audit_log: rename namespace→folder, kind→type ────────────────────────
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'audit_log' AND column_name = 'namespace'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE audit_log RENAME COLUMN namespace TO folder;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'audit_log' AND column_name = 'kind'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE audit_log RENAME COLUMN kind TO type;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ── entries_history: rename namespace→folder, kind→type ──────────────────
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'entries_history' AND column_name = 'namespace'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE entries_history RENAME COLUMN namespace TO folder;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'entries_history' AND column_name = 'kind'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE entries_history RENAME COLUMN kind TO type;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ── Set empty defaults for new folder/type columns ────────────────────────
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'entries' AND column_name = 'folder'
|
|
||||||
) THEN
|
|
||||||
UPDATE entries SET folder = '' WHERE folder IS NULL;
|
|
||||||
ALTER TABLE entries ALTER COLUMN folder SET NOT NULL;
|
|
||||||
ALTER TABLE entries ALTER COLUMN folder SET DEFAULT '';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'entries' AND column_name = 'type'
|
|
||||||
) THEN
|
|
||||||
UPDATE entries SET type = '' WHERE type IS NULL;
|
|
||||||
ALTER TABLE entries ALTER COLUMN type SET NOT NULL;
|
|
||||||
ALTER TABLE entries ALTER COLUMN type SET DEFAULT '';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'audit_log' AND column_name = 'folder'
|
|
||||||
) THEN
|
|
||||||
UPDATE audit_log SET folder = '' WHERE folder IS NULL;
|
|
||||||
ALTER TABLE audit_log ALTER COLUMN folder SET NOT NULL;
|
|
||||||
ALTER TABLE audit_log ALTER COLUMN folder SET DEFAULT '';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'audit_log' AND column_name = 'type'
|
|
||||||
) THEN
|
|
||||||
UPDATE audit_log SET type = '' WHERE type IS NULL;
|
|
||||||
ALTER TABLE audit_log ALTER COLUMN type SET NOT NULL;
|
|
||||||
ALTER TABLE audit_log ALTER COLUMN type SET DEFAULT '';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'entries_history' AND column_name = 'folder'
|
|
||||||
) THEN
|
|
||||||
UPDATE entries_history SET folder = '' WHERE folder IS NULL;
|
|
||||||
ALTER TABLE entries_history ALTER COLUMN folder SET NOT NULL;
|
|
||||||
ALTER TABLE entries_history ALTER COLUMN folder SET DEFAULT '';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
DO $$ BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name = 'entries_history' AND column_name = 'type'
|
|
||||||
) THEN
|
|
||||||
UPDATE entries_history SET type = '' WHERE type IS NULL;
|
|
||||||
ALTER TABLE entries_history ALTER COLUMN type SET NOT NULL;
|
|
||||||
ALTER TABLE entries_history ALTER COLUMN type SET DEFAULT '';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
-- ── Rebuild unique indexes on entries: folder is now part of the key ────────
|
|
||||||
-- (user_id, folder, name) allows same name in different folders.
|
|
||||||
DROP INDEX IF EXISTS idx_entries_unique_legacy;
|
|
||||||
DROP INDEX IF EXISTS idx_entries_unique_user;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_legacy
|
|
||||||
ON entries(folder, name)
|
|
||||||
WHERE user_id IS NULL;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_unique_user
|
|
||||||
ON entries(user_id, folder, name)
|
|
||||||
WHERE user_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- ── Replace old namespace/kind indexes ────────────────────────────────────
|
|
||||||
DROP INDEX IF EXISTS idx_entries_namespace;
|
|
||||||
DROP INDEX IF EXISTS idx_entries_kind;
|
|
||||||
DROP INDEX IF EXISTS idx_audit_log_ns_kind;
|
|
||||||
DROP INDEX IF EXISTS idx_entries_history_ns_kind_name;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_folder
|
|
||||||
ON entries(folder) WHERE folder <> '';
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_entries_type
|
|
||||||
ON entries(type) WHERE type <> '';
|
|
||||||
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
|
|
||||||
ON entries_history(folder, type, name, version DESC);
|
|
||||||
|
|
||||||
-- ── Drop legacy actor columns ─────────────────────────────────────────────
|
|
||||||
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
|
|
||||||
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn restore_plaintext_api_keys(pool: &PgPool) -> Result<()> {
|
|
||||||
let has_users_api_key: bool = sqlx::query_scalar(
|
|
||||||
"SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'users'
|
|
||||||
AND column_name = 'api_key'
|
|
||||||
)",
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !has_users_api_key {
|
|
||||||
sqlx::query("ALTER TABLE users ADD COLUMN api_key TEXT")
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
sqlx::query("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_key ON users(api_key) WHERE api_key IS NOT NULL")
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let has_api_keys_table: bool = sqlx::query_scalar(
|
|
||||||
"SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'api_keys'
|
|
||||||
)",
|
|
||||||
)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !has_api_keys_table {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct UserWithoutKey {
|
|
||||||
id: uuid::Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
let users_without_key: Vec<UserWithoutKey> =
|
|
||||||
sqlx::query_as("SELECT DISTINCT user_id AS id FROM api_keys WHERE user_id NOT IN (SELECT id FROM users WHERE api_key IS NOT NULL)")
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for user in users_without_key {
|
|
||||||
let new_key = crate::service::api_key::generate_api_key();
|
|
||||||
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
|
|
||||||
.bind(&new_key)
|
|
||||||
.bind(user.id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query("DROP TABLE IF EXISTS api_keys")
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Entry-level history snapshot ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub struct EntrySnapshotParams<'a> {
|
|
||||||
pub entry_id: uuid::Uuid,
|
|
||||||
pub user_id: Option<uuid::Uuid>,
|
|
||||||
pub folder: &'a str,
|
|
||||||
pub entry_type: &'a str,
|
|
||||||
pub name: &'a str,
|
|
||||||
pub version: i64,
|
|
||||||
pub action: &'a str,
|
|
||||||
pub tags: &'a [String],
|
|
||||||
pub metadata: &'a Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn snapshot_entry_history(
|
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
||||||
p: EntrySnapshotParams<'_>,
|
|
||||||
) -> Result<()> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO entries_history \
|
|
||||||
(entry_id, folder, type, name, version, action, tags, metadata, user_id) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
|
||||||
)
|
|
||||||
.bind(p.entry_id)
|
|
||||||
.bind(p.folder)
|
|
||||||
.bind(p.entry_type)
|
|
||||||
.bind(p.name)
|
|
||||||
.bind(p.version)
|
|
||||||
.bind(p.action)
|
|
||||||
.bind(p.tags)
|
|
||||||
.bind(p.metadata)
|
|
||||||
.bind(p.user_id)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Secret field-level history snapshot ──────────────────────────────────────
|
|
||||||
|
|
||||||
pub struct SecretSnapshotParams<'a> {
|
|
||||||
pub secret_id: uuid::Uuid,
|
|
||||||
pub name: &'a str,
|
|
||||||
pub encrypted: &'a [u8],
|
|
||||||
pub action: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn snapshot_secret_history(
|
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
||||||
p: SecretSnapshotParams<'_>,
|
|
||||||
) -> Result<()> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO secrets_history \
|
|
||||||
(secret_id, name, encrypted, action) \
|
|
||||||
VALUES ($1, $2, $3, $4)",
|
|
||||||
)
|
|
||||||
.bind(p.secret_id)
|
|
||||||
.bind(p.name)
|
|
||||||
.bind(p.encrypted)
|
|
||||||
.bind(p.action)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── DB helpers ────────────────────────────────────────────────────────────────
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
pub mod audit;
|
|
||||||
pub mod config;
|
|
||||||
pub mod crypto;
|
|
||||||
pub mod db;
|
|
||||||
pub mod models;
|
|
||||||
pub mod service;
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
/// A top-level entry (server, service, key, person, …).
|
|
||||||
/// Sensitive fields are stored separately in `secrets`.
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct Entry {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
pub folder: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
#[sqlx(rename = "type")]
|
|
||||||
pub entry_type: String,
|
|
||||||
pub name: String,
|
|
||||||
pub notes: String,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
pub metadata: Value,
|
|
||||||
pub version: i64,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single encrypted field belonging to an Entry.
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct SecretField {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
pub name: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
#[sqlx(rename = "type")]
|
|
||||||
pub secret_type: String,
|
|
||||||
/// AES-256-GCM ciphertext: nonce(12B) || ciphertext+tag
|
|
||||||
pub encrypted: Vec<u8>,
|
|
||||||
pub version: i64,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal query row types (shared across commands) ─────────────────────────
|
|
||||||
|
|
||||||
/// Minimal entry row fetched for write operations (add / update / delete / rollback).
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
pub struct EntryRow {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub version: i64,
|
|
||||||
pub folder: String,
|
|
||||||
#[sqlx(rename = "type")]
|
|
||||||
pub entry_type: String,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
pub metadata: Value,
|
|
||||||
pub notes: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Entry row including `name` (used for id-scoped web / service updates).
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
pub struct EntryWriteRow {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub version: i64,
|
|
||||||
pub folder: String,
|
|
||||||
#[sqlx(rename = "type")]
|
|
||||||
pub entry_type: String,
|
|
||||||
pub name: String,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
pub metadata: Value,
|
|
||||||
pub notes: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&EntryWriteRow> for EntryRow {
|
|
||||||
fn from(r: &EntryWriteRow) -> Self {
|
|
||||||
EntryRow {
|
|
||||||
id: r.id,
|
|
||||||
version: r.version,
|
|
||||||
folder: r.folder.clone(),
|
|
||||||
entry_type: r.entry_type.clone(),
|
|
||||||
tags: r.tags.clone(),
|
|
||||||
metadata: r.metadata.clone(),
|
|
||||||
notes: r.notes.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal secret field row fetched before snapshots or cascade deletes.
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
pub struct SecretFieldRow {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub name: String,
|
|
||||||
pub encrypted: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Export / Import types ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Supported file formats for export/import.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
||||||
pub enum ExportFormat {
|
|
||||||
Json,
|
|
||||||
Toml,
|
|
||||||
Yaml,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::str::FromStr for ExportFormat {
|
|
||||||
type Err = anyhow::Error;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
||||||
match s.to_lowercase().as_str() {
|
|
||||||
"json" => Ok(Self::Json),
|
|
||||||
"toml" => Ok(Self::Toml),
|
|
||||||
"yaml" | "yml" => Ok(Self::Yaml),
|
|
||||||
other => anyhow::bail!("Unknown format '{}'. Expected: json, toml, or yaml", other),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ExportFormat {
|
|
||||||
/// Infer format from file extension (.json / .toml / .yaml / .yml).
|
|
||||||
pub fn from_extension(path: &str) -> anyhow::Result<Self> {
|
|
||||||
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
|
||||||
ext.parse().map_err(|_| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"Cannot infer format from extension '.{}'. Use --format json|toml|yaml",
|
|
||||||
ext
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize ExportData to a string in this format.
|
|
||||||
pub fn serialize(&self, data: &ExportData) -> anyhow::Result<String> {
|
|
||||||
match self {
|
|
||||||
Self::Json => Ok(serde_json::to_string_pretty(data)?),
|
|
||||||
Self::Toml => {
|
|
||||||
let toml_val = json_to_toml_value(&serde_json::to_value(data)?)?;
|
|
||||||
toml::to_string_pretty(&toml_val)
|
|
||||||
.map_err(|e| anyhow::anyhow!("TOML serialization failed: {}", e))
|
|
||||||
}
|
|
||||||
Self::Yaml => serde_yaml::to_string(data)
|
|
||||||
.map_err(|e| anyhow::anyhow!("YAML serialization failed: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deserialize ExportData from a string in this format.
|
|
||||||
pub fn deserialize(&self, content: &str) -> anyhow::Result<ExportData> {
|
|
||||||
match self {
|
|
||||||
Self::Json => Ok(serde_json::from_str(content)?),
|
|
||||||
Self::Toml => {
|
|
||||||
let toml_val: toml::Value = toml::from_str(content)
|
|
||||||
.map_err(|e| anyhow::anyhow!("TOML parse error: {}", e))?;
|
|
||||||
let json_val = toml_to_json_value(&toml_val);
|
|
||||||
Ok(serde_json::from_value(json_val)?)
|
|
||||||
}
|
|
||||||
Self::Yaml => serde_yaml::from_str(content)
|
|
||||||
.map_err(|e| anyhow::anyhow!("YAML parse error: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Top-level structure for export/import files.
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct ExportData {
|
|
||||||
pub version: u32,
|
|
||||||
pub exported_at: String,
|
|
||||||
pub entries: Vec<ExportEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single entry with decrypted secrets for export/import.
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
pub struct ExportEntry {
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub folder: String,
|
|
||||||
#[serde(default, rename = "type")]
|
|
||||||
pub entry_type: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub notes: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub metadata: Value,
|
|
||||||
/// 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>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Multi-user models ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// A registered user (created on first OAuth login).
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct User {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub email: Option<String>,
|
|
||||||
pub name: String,
|
|
||||||
pub avatar_url: Option<String>,
|
|
||||||
/// PBKDF2 salt (32 B). NULL until user sets up passphrase.
|
|
||||||
pub key_salt: Option<Vec<u8>>,
|
|
||||||
/// AES-256-GCM encryption of the known constant "secrets-mcp-key-check".
|
|
||||||
/// Used to verify the passphrase without storing the key itself.
|
|
||||||
pub key_check: Option<Vec<u8>>,
|
|
||||||
/// Key derivation parameters, e.g. {"alg":"pbkdf2-sha256","iterations":600000}.
|
|
||||||
pub key_params: Option<serde_json::Value>,
|
|
||||||
/// Plaintext API key for MCP Bearer authentication. Auto-created on first login.
|
|
||||||
pub api_key: Option<String>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An OAuth account linked to a user.
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct OauthAccount {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: Uuid,
|
|
||||||
pub provider: String,
|
|
||||||
pub provider_id: String,
|
|
||||||
pub email: Option<String>,
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub avatar_url: Option<String>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single audit log row, optionally scoped to a business user.
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
|
||||||
pub struct AuditLogEntry {
|
|
||||||
pub id: i64,
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
pub action: String,
|
|
||||||
pub folder: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
#[sqlx(rename = "type")]
|
|
||||||
pub entry_type: String,
|
|
||||||
pub name: String,
|
|
||||||
pub detail: Value,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── TOML ↔ JSON value conversion ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// Convert a serde_json Value to a toml Value.
|
|
||||||
/// `null` values are filtered out (TOML does not support null).
|
|
||||||
/// Mixed-type arrays are serialised as JSON strings.
|
|
||||||
pub fn json_to_toml_value(v: &Value) -> anyhow::Result<toml::Value> {
|
|
||||||
match v {
|
|
||||||
Value::Null => anyhow::bail!("TOML does not support null values"),
|
|
||||||
Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
|
|
||||||
Value::Number(n) => {
|
|
||||||
if let Some(i) = n.as_i64() {
|
|
||||||
Ok(toml::Value::Integer(i))
|
|
||||||
} else if let Some(f) = n.as_f64() {
|
|
||||||
Ok(toml::Value::Float(f))
|
|
||||||
} else {
|
|
||||||
anyhow::bail!("unsupported number: {}", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Value::String(s) => Ok(toml::Value::String(s.clone())),
|
|
||||||
Value::Array(arr) => {
|
|
||||||
let items: anyhow::Result<Vec<toml::Value>> =
|
|
||||||
arr.iter().map(json_to_toml_value).collect();
|
|
||||||
match items {
|
|
||||||
Ok(vals) => Ok(toml::Value::Array(vals)),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!(error = %e, "mixed-type array; falling back to JSON string");
|
|
||||||
Ok(toml::Value::String(serde_json::to_string(v)?))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Value::Object(map) => {
|
|
||||||
let mut toml_map = toml::map::Map::new();
|
|
||||||
for (k, val) in map {
|
|
||||||
if val.is_null() {
|
|
||||||
// Skip null entries
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match json_to_toml_value(val) {
|
|
||||||
Ok(tv) => {
|
|
||||||
toml_map.insert(k.clone(), tv);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::debug!(key = %k, error = %e, "field not representable in TOML; falling back to JSON string");
|
|
||||||
toml_map
|
|
||||||
.insert(k.clone(), toml::Value::String(serde_json::to_string(val)?));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(toml::Value::Table(toml_map))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a toml Value back to a serde_json Value.
|
|
||||||
pub fn toml_to_json_value(v: &toml::Value) -> Value {
|
|
||||||
match v {
|
|
||||||
toml::Value::Boolean(b) => Value::Bool(*b),
|
|
||||||
toml::Value::Integer(i) => Value::Number((*i).into()),
|
|
||||||
toml::Value::Float(f) => serde_json::Number::from_f64(*f)
|
|
||||||
.map(Value::Number)
|
|
||||||
.unwrap_or(Value::Null),
|
|
||||||
toml::Value::String(s) => Value::String(s.clone()),
|
|
||||||
toml::Value::Datetime(dt) => Value::String(dt.to_string()),
|
|
||||||
toml::Value::Array(arr) => Value::Array(arr.iter().map(toml_to_json_value).collect()),
|
|
||||||
toml::Value::Table(map) => {
|
|
||||||
let obj: serde_json::Map<String, Value> = map
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), toml_to_json_value(v)))
|
|
||||||
.collect();
|
|
||||||
Value::Object(obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,712 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use serde_json::{Map, Value};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use std::collections::{BTreeSet, HashSet};
|
|
||||||
use std::fs;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::crypto;
|
|
||||||
use crate::db;
|
|
||||||
use crate::models::EntryRow;
|
|
||||||
|
|
||||||
// ── Key/value parsing helpers ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
pub fn parse_kv(entry: &str) -> Result<(Vec<String>, Value)> {
|
|
||||||
if let Some((key, json_str)) = entry.split_once(":=") {
|
|
||||||
let val: Value = serde_json::from_str(json_str).map_err(|e| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"Invalid JSON value for key '{}': {} (use key=value for plain strings)",
|
|
||||||
key,
|
|
||||||
e
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
return Ok((parse_key_path(key)?, val));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((key, raw_val)) = entry.split_once('=') {
|
|
||||||
let value = if let Some(path) = raw_val.strip_prefix('@') {
|
|
||||||
fs::read_to_string(path)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?
|
|
||||||
} else {
|
|
||||||
raw_val.to_string()
|
|
||||||
};
|
|
||||||
return Ok((parse_key_path(key)?, Value::String(value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((key, path)) = entry.split_once('@') {
|
|
||||||
let value = fs::read_to_string(path)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", path, e))?;
|
|
||||||
return Ok((parse_key_path(key)?, Value::String(value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
anyhow::bail!(
|
|
||||||
"Invalid format '{}'. Expected: key=value, key=@file, nested:key@file, or key:=<json>",
|
|
||||||
entry
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_json(entries: &[String]) -> Result<Value> {
|
|
||||||
let mut map = Map::new();
|
|
||||||
for entry in entries {
|
|
||||||
let (path, value) = parse_kv(entry)?;
|
|
||||||
insert_path(&mut map, &path, value)?;
|
|
||||||
}
|
|
||||||
Ok(Value::Object(map))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn key_path_to_string(path: &[String]) -> String {
|
|
||||||
path.join(":")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn collect_key_paths(entries: &[String]) -> Result<Vec<String>> {
|
|
||||||
entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| parse_kv(entry).map(|(path, _)| key_path_to_string(&path)))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn collect_field_paths(entries: &[String]) -> Result<Vec<String>> {
|
|
||||||
entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| parse_key_path(entry).map(|path| key_path_to_string(&path)))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_key_path(key: &str) -> Result<Vec<String>> {
|
|
||||||
let path: Vec<String> = key
|
|
||||||
.split(':')
|
|
||||||
.map(str::trim)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if path.is_empty() || path.iter().any(|part| part.is_empty()) {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Invalid key path '{}'. Use non-empty segments like 'credentials:content'.",
|
|
||||||
key
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn insert_path(map: &mut Map<String, Value>, path: &[String], value: Value) -> Result<()> {
|
|
||||||
if path.is_empty() {
|
|
||||||
anyhow::bail!("Key path cannot be empty");
|
|
||||||
}
|
|
||||||
if path.len() == 1 {
|
|
||||||
map.insert(path[0].clone(), value);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let head = path[0].clone();
|
|
||||||
let tail = &path[1..];
|
|
||||||
match map.entry(head.clone()) {
|
|
||||||
serde_json::map::Entry::Vacant(entry) => {
|
|
||||||
let mut child = Map::new();
|
|
||||||
insert_path(&mut child, tail, value)?;
|
|
||||||
entry.insert(Value::Object(child));
|
|
||||||
}
|
|
||||||
serde_json::map::Entry::Occupied(mut entry) => match entry.get_mut() {
|
|
||||||
Value::Object(child) => insert_path(child, tail, value)?,
|
|
||||||
_ => {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Cannot set nested key '{}' because '{}' is already a non-object value",
|
|
||||||
key_path_to_string(path),
|
|
||||||
head
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_path(map: &mut Map<String, Value>, path: &[String]) -> Result<bool> {
|
|
||||||
if path.is_empty() {
|
|
||||||
anyhow::bail!("Key path cannot be empty");
|
|
||||||
}
|
|
||||||
if path.len() == 1 {
|
|
||||||
return Ok(map.remove(&path[0]).is_some());
|
|
||||||
}
|
|
||||||
let Some(value) = map.get_mut(&path[0]) else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
let Value::Object(child) = value else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
let removed = remove_path(child, &path[1..])?;
|
|
||||||
if child.is_empty() {
|
|
||||||
map.remove(&path[0]);
|
|
||||||
}
|
|
||||||
Ok(removed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn flatten_json_fields(prefix: &str, value: &Value) -> Vec<(String, Value)> {
|
|
||||||
match value {
|
|
||||||
Value::Object(map) => {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for (k, v) in map {
|
|
||||||
let full_key = if prefix.is_empty() {
|
|
||||||
k.clone()
|
|
||||||
} else {
|
|
||||||
format!("{}.{}", prefix, k)
|
|
||||||
};
|
|
||||||
out.extend(flatten_json_fields(&full_key, v));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
other => vec![(prefix.to_string(), other.clone())],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── AddResult ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct AddResult {
|
|
||||||
pub name: String,
|
|
||||||
pub folder: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub entry_type: String,
|
|
||||||
pub tags: Vec<String>,
|
|
||||||
pub meta_keys: Vec<String>,
|
|
||||||
pub secret_keys: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AddParams<'a> {
|
|
||||||
pub name: &'a str,
|
|
||||||
pub folder: &'a str,
|
|
||||||
pub entry_type: &'a str,
|
|
||||||
pub notes: &'a str,
|
|
||||||
pub tags: &'a [String],
|
|
||||||
pub meta_entries: &'a [String],
|
|
||||||
pub secret_entries: &'a [String],
|
|
||||||
pub link_secret_names: &'a [String],
|
|
||||||
/// Optional user_id for multi-user isolation (None = single-user CLI mode)
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result<AddResult> {
|
|
||||||
let metadata = build_json(params.meta_entries)?;
|
|
||||||
let secret_json = build_json(params.secret_entries)?;
|
|
||||||
let meta_keys = collect_key_paths(params.meta_entries)?;
|
|
||||||
let secret_keys = collect_key_paths(params.secret_entries)?;
|
|
||||||
let flat_fields = flatten_json_fields("", &secret_json);
|
|
||||||
let new_secret_names: BTreeSet<String> =
|
|
||||||
flat_fields.iter().map(|(name, _)| name.clone()).collect();
|
|
||||||
let link_secret_names =
|
|
||||||
validate_link_secret_names(params.link_secret_names, &new_secret_names)?;
|
|
||||||
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
|
|
||||||
// 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",
|
|
||||||
)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(params.folder)
|
|
||||||
.bind(params.name)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.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",
|
|
||||||
)
|
|
||||||
.bind(params.folder)
|
|
||||||
.bind(params.name)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(ref ex) = existing
|
|
||||||
&& let Err(e) = db::snapshot_entry_history(
|
|
||||||
&mut tx,
|
|
||||||
db::EntrySnapshotParams {
|
|
||||||
entry_id: ex.id,
|
|
||||||
user_id: params.user_id,
|
|
||||||
folder: params.folder,
|
|
||||||
entry_type: params.entry_type,
|
|
||||||
name: params.name,
|
|
||||||
version: ex.version,
|
|
||||||
action: "add",
|
|
||||||
tags: &ex.tags,
|
|
||||||
metadata: &ex.metadata,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "failed to snapshot entry history before upsert");
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry_id: Uuid = if let Some(uid) = params.user_id {
|
|
||||||
sqlx::query_scalar(
|
|
||||||
r#"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata, version, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 1, NOW())
|
|
||||||
ON CONFLICT (user_id, folder, name) WHERE user_id IS NOT NULL
|
|
||||||
DO UPDATE SET
|
|
||||||
folder = EXCLUDED.folder,
|
|
||||||
type = EXCLUDED.type,
|
|
||||||
notes = EXCLUDED.notes,
|
|
||||||
tags = EXCLUDED.tags,
|
|
||||||
metadata = EXCLUDED.metadata,
|
|
||||||
version = entries.version + 1,
|
|
||||||
updated_at = NOW()
|
|
||||||
RETURNING id"#,
|
|
||||||
)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(params.folder)
|
|
||||||
.bind(params.entry_type)
|
|
||||||
.bind(params.name)
|
|
||||||
.bind(params.notes)
|
|
||||||
.bind(params.tags)
|
|
||||||
.bind(&metadata)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_scalar(
|
|
||||||
r#"INSERT INTO entries (folder, type, name, notes, tags, metadata, version, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 1, NOW())
|
|
||||||
ON CONFLICT (folder, name) WHERE user_id IS NULL
|
|
||||||
DO UPDATE SET
|
|
||||||
folder = EXCLUDED.folder,
|
|
||||||
type = EXCLUDED.type,
|
|
||||||
notes = EXCLUDED.notes,
|
|
||||||
tags = EXCLUDED.tags,
|
|
||||||
metadata = EXCLUDED.metadata,
|
|
||||||
version = entries.version + 1,
|
|
||||||
updated_at = NOW()
|
|
||||||
RETURNING id"#,
|
|
||||||
)
|
|
||||||
.bind(params.folder)
|
|
||||||
.bind(params.entry_type)
|
|
||||||
.bind(params.name)
|
|
||||||
.bind(params.notes)
|
|
||||||
.bind(params.tags)
|
|
||||||
.bind(&metadata)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
let current_entry_version: i64 =
|
|
||||||
sqlx::query_scalar("SELECT version FROM entries WHERE id = $1")
|
|
||||||
.bind(entry_id)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if existing.is_none()
|
|
||||||
&& let Err(e) = db::snapshot_entry_history(
|
|
||||||
&mut tx,
|
|
||||||
db::EntrySnapshotParams {
|
|
||||||
entry_id,
|
|
||||||
user_id: params.user_id,
|
|
||||||
folder: params.folder,
|
|
||||||
entry_type: params.entry_type,
|
|
||||||
name: params.name,
|
|
||||||
version: current_entry_version,
|
|
||||||
action: "create",
|
|
||||||
tags: params.tags,
|
|
||||||
metadata: &metadata,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "failed to snapshot entry history on create");
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing.is_some() {
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct ExistingField {
|
|
||||||
id: Uuid,
|
|
||||||
name: String,
|
|
||||||
encrypted: Vec<u8>,
|
|
||||||
}
|
|
||||||
let existing_fields: Vec<ExistingField> = 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?;
|
|
||||||
|
|
||||||
for f in &existing_fields {
|
|
||||||
if let Err(e) = db::snapshot_secret_history(
|
|
||||||
&mut tx,
|
|
||||||
db::SecretSnapshotParams {
|
|
||||||
secret_id: f.id,
|
|
||||||
name: &f.name,
|
|
||||||
encrypted: &f.encrypted,
|
|
||||||
action: "add",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "failed to snapshot secret field history");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1")
|
|
||||||
.bind(entry_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"DELETE FROM secrets s \
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
|
|
||||||
)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (field_name, field_value) in &flat_fields {
|
|
||||||
let encrypted = crypto::encrypt_json(master_key, field_value)?;
|
|
||||||
let secret_id: Uuid = sqlx::query_scalar(
|
|
||||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
|
|
||||||
)
|
|
||||||
.bind(params.user_id)
|
|
||||||
.bind(field_name)
|
|
||||||
.bind(infer_secret_type(field_name))
|
|
||||||
.bind(&encrypted)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
|
|
||||||
.bind(entry_id)
|
|
||||||
.bind(secret_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for link_name in &link_secret_names {
|
|
||||||
let secret_ids: Vec<Uuid> = if let Some(uid) = params.user_id {
|
|
||||||
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id = $1 AND name = $2")
|
|
||||||
.bind(uid)
|
|
||||||
.bind(link_name)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_scalar("SELECT id FROM secrets WHERE user_id IS NULL AND name = $1")
|
|
||||||
.bind(link_name)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
match secret_ids.len() {
|
|
||||||
0 => anyhow::bail!("Not found: secret named '{}'", link_name),
|
|
||||||
1 => {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
|
||||||
)
|
|
||||||
.bind(entry_id)
|
|
||||||
.bind(secret_ids[0])
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
n => anyhow::bail!(
|
|
||||||
"Ambiguous: {} secrets named '{}' found. Please deduplicate names first.",
|
|
||||||
n,
|
|
||||||
link_name
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
crate::audit::log_tx(
|
|
||||||
&mut tx,
|
|
||||||
params.user_id,
|
|
||||||
"add",
|
|
||||||
params.folder,
|
|
||||||
params.entry_type,
|
|
||||||
params.name,
|
|
||||||
serde_json::json!({
|
|
||||||
"tags": params.tags,
|
|
||||||
"meta_keys": meta_keys,
|
|
||||||
"secret_keys": secret_keys,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
Ok(AddResult {
|
|
||||||
name: params.name.to_string(),
|
|
||||||
folder: params.folder.to_string(),
|
|
||||||
entry_type: params.entry_type.to_string(),
|
|
||||||
tags: params.tags.to_vec(),
|
|
||||||
meta_keys,
|
|
||||||
secret_keys,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn infer_secret_type(name: &str) -> &'static str {
|
|
||||||
match name {
|
|
||||||
"ssh_key" => "pem",
|
|
||||||
"password" => "password",
|
|
||||||
"phone" | "phone_2" => "phone",
|
|
||||||
"webhook_url" | "address" => "url",
|
|
||||||
"access_key_id"
|
|
||||||
| "access_key_secret"
|
|
||||||
| "global_api_key"
|
|
||||||
| "api_key"
|
|
||||||
| "secret_key"
|
|
||||||
| "personal_access_token"
|
|
||||||
| "runner_token"
|
|
||||||
| "GOOGLE_CLIENT_ID"
|
|
||||||
| "GOOGLE_CLIENT_SECRET" => "token",
|
|
||||||
_ => "text",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_link_secret_names(
|
|
||||||
link_secret_names: &[String],
|
|
||||||
new_secret_names: &BTreeSet<String>,
|
|
||||||
) -> Result<Vec<String>> {
|
|
||||||
let mut deduped = Vec::new();
|
|
||||||
let mut seen = HashSet::new();
|
|
||||||
|
|
||||||
for raw in link_secret_names {
|
|
||||||
let trimmed = raw.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
anyhow::bail!("link_secret_names contains an empty name");
|
|
||||||
}
|
|
||||||
if new_secret_names.contains(trimmed) {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Conflict: secret '{}' is provided both in secrets/secrets_obj and link_secret_names",
|
|
||||||
trimmed
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if seen.insert(trimmed.to_string()) {
|
|
||||||
deduped.push(trimmed.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(deduped)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use std::collections::BTreeSet;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_nested_file_shorthand() {
|
|
||||||
use std::io::Write;
|
|
||||||
let mut f = tempfile::NamedTempFile::new().unwrap();
|
|
||||||
writeln!(f, "line1\nline2").unwrap();
|
|
||||||
let path = f.path().to_str().unwrap().to_string();
|
|
||||||
let entry = format!("credentials:content@{}", path);
|
|
||||||
let (path_parts, value) = parse_kv(&entry).unwrap();
|
|
||||||
assert_eq!(key_path_to_string(&path_parts), "credentials:content");
|
|
||||||
assert!(matches!(value, Value::String(_)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn flatten_json_fields_nested() {
|
|
||||||
let v = serde_json::json!({
|
|
||||||
"username": "root",
|
|
||||||
"credentials": {
|
|
||||||
"type": "ssh",
|
|
||||||
"content": "pem"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let mut fields = flatten_json_fields("", &v);
|
|
||||||
fields.sort_by(|a, b| a.0.cmp(&b.0));
|
|
||||||
assert_eq!(fields[0].0, "credentials.content");
|
|
||||||
assert_eq!(fields[1].0, "credentials.type");
|
|
||||||
assert_eq!(fields[2].0, "username");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_link_secret_names_conflict_with_new_secret() {
|
|
||||||
let mut new_names = BTreeSet::new();
|
|
||||||
new_names.insert("password".to_string());
|
|
||||||
let err = validate_link_secret_names(&[String::from("password")], &new_names)
|
|
||||||
.expect_err("must fail on overlap");
|
|
||||||
assert!(
|
|
||||||
err.to_string()
|
|
||||||
.contains("provided both in secrets/secrets_obj and link_secret_names")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validate_link_secret_names_dedup_and_trim() {
|
|
||||||
let names = vec![
|
|
||||||
" shared_key ".to_string(),
|
|
||||||
"shared_key".to_string(),
|
|
||||||
"runner_token".to_string(),
|
|
||||||
];
|
|
||||||
let deduped = validate_link_secret_names(&names, &BTreeSet::new()).unwrap();
|
|
||||||
assert_eq!(deduped, vec!["shared_key", "runner_token"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn maybe_test_pool() -> Option<PgPool> {
|
|
||||||
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
|
|
||||||
eprintln!("skip add linkage tests: SECRETS_DATABASE_URL is not set");
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let Ok(pool) = PgPool::connect(&url).await else {
|
|
||||||
eprintln!("skip add linkage tests: cannot connect to database");
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
if let Err(e) = crate::db::migrate(&pool).await {
|
|
||||||
eprintln!("skip add linkage tests: migrate failed: {e}");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(pool)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cleanup_test_rows(pool: &PgPool, marker: &str) -> Result<()> {
|
|
||||||
sqlx::query(
|
|
||||||
"DELETE FROM entries WHERE user_id IS NULL AND (name LIKE $1 OR folder LIKE $1)",
|
|
||||||
)
|
|
||||||
.bind(format!("%{marker}%"))
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
sqlx::query(
|
|
||||||
"DELETE FROM secrets WHERE user_id IS NULL AND name LIKE $1 \
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = secrets.id)",
|
|
||||||
)
|
|
||||||
.bind(format!("%{marker}%"))
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn add_links_existing_secret_by_unique_name() -> Result<()> {
|
|
||||||
let Some(pool) = maybe_test_pool().await else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let suffix = Uuid::from_u128(rand::random()).to_string();
|
|
||||||
let marker = format!("link_unique_{}", &suffix[..8]);
|
|
||||||
let secret_name = format!("{}_secret", marker);
|
|
||||||
let entry_name = format!("{}_entry", marker);
|
|
||||||
|
|
||||||
cleanup_test_rows(&pool, &marker).await?;
|
|
||||||
|
|
||||||
let secret_id: Uuid = sqlx::query_scalar(
|
|
||||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2) RETURNING id",
|
|
||||||
)
|
|
||||||
.bind(&secret_name)
|
|
||||||
.bind(vec![1_u8, 2, 3])
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
run(
|
|
||||||
&pool,
|
|
||||||
AddParams {
|
|
||||||
name: &entry_name,
|
|
||||||
folder: &marker,
|
|
||||||
entry_type: "service",
|
|
||||||
notes: "",
|
|
||||||
tags: &[],
|
|
||||||
meta_entries: &[],
|
|
||||||
secret_entries: &[],
|
|
||||||
link_secret_names: std::slice::from_ref(&secret_name),
|
|
||||||
user_id: None,
|
|
||||||
},
|
|
||||||
&[0_u8; 32],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let linked: bool = sqlx::query_scalar(
|
|
||||||
"SELECT EXISTS( \
|
|
||||||
SELECT 1 FROM entry_secrets es \
|
|
||||||
JOIN entries e ON e.id = es.entry_id \
|
|
||||||
WHERE e.user_id IS NULL AND e.name = $1 AND es.secret_id = $2 \
|
|
||||||
)",
|
|
||||||
)
|
|
||||||
.bind(&entry_name)
|
|
||||||
.bind(secret_id)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await?;
|
|
||||||
assert!(linked);
|
|
||||||
|
|
||||||
cleanup_test_rows(&pool, &marker).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn add_link_secret_name_not_found_fails() -> Result<()> {
|
|
||||||
let Some(pool) = maybe_test_pool().await else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let suffix = Uuid::from_u128(rand::random()).to_string();
|
|
||||||
let marker = format!("link_missing_{}", &suffix[..8]);
|
|
||||||
let secret_name = format!("{}_secret", marker);
|
|
||||||
let entry_name = format!("{}_entry", marker);
|
|
||||||
|
|
||||||
cleanup_test_rows(&pool, &marker).await?;
|
|
||||||
|
|
||||||
let err = run(
|
|
||||||
&pool,
|
|
||||||
AddParams {
|
|
||||||
name: &entry_name,
|
|
||||||
folder: &marker,
|
|
||||||
entry_type: "service",
|
|
||||||
notes: "",
|
|
||||||
tags: &[],
|
|
||||||
meta_entries: &[],
|
|
||||||
secret_entries: &[],
|
|
||||||
link_secret_names: std::slice::from_ref(&secret_name),
|
|
||||||
user_id: None,
|
|
||||||
},
|
|
||||||
&[0_u8; 32],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("must fail when linked secret is not found");
|
|
||||||
assert!(err.to_string().contains("Not found: secret named"));
|
|
||||||
|
|
||||||
cleanup_test_rows(&pool, &marker).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn add_link_secret_name_ambiguous_fails() -> Result<()> {
|
|
||||||
let Some(pool) = maybe_test_pool().await else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let suffix = Uuid::from_u128(rand::random()).to_string();
|
|
||||||
let marker = format!("link_amb_{}", &suffix[..8]);
|
|
||||||
let secret_name = format!("{}_dup_secret", marker);
|
|
||||||
let entry_name = format!("{}_entry", marker);
|
|
||||||
|
|
||||||
cleanup_test_rows(&pool, &marker).await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)",
|
|
||||||
)
|
|
||||||
.bind(&secret_name)
|
|
||||||
.bind(vec![1_u8])
|
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES (NULL, $1, 'text', $2)",
|
|
||||||
)
|
|
||||||
.bind(&secret_name)
|
|
||||||
.bind(vec![2_u8])
|
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let err = run(
|
|
||||||
&pool,
|
|
||||||
AddParams {
|
|
||||||
name: &entry_name,
|
|
||||||
folder: &marker,
|
|
||||||
entry_type: "service",
|
|
||||||
notes: "",
|
|
||||||
tags: &[],
|
|
||||||
meta_entries: &[],
|
|
||||||
secret_entries: &[],
|
|
||||||
link_secret_names: std::slice::from_ref(&secret_name),
|
|
||||||
user_id: None,
|
|
||||||
},
|
|
||||||
&[0_u8; 32],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("must fail on ambiguous linked secret name");
|
|
||||||
assert!(err.to_string().contains("Ambiguous:"));
|
|
||||||
|
|
||||||
cleanup_test_rows(&pool, &marker).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const KEY_PREFIX: &str = "sk_";
|
|
||||||
|
|
||||||
/// Generate a new API key: `sk_<64 hex chars>` = 67 characters total.
|
|
||||||
pub fn generate_api_key() -> String {
|
|
||||||
use rand::RngExt;
|
|
||||||
let mut bytes = [0u8; 32];
|
|
||||||
rand::rng().fill(&mut bytes);
|
|
||||||
let hex: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
|
|
||||||
format!("{}{}", KEY_PREFIX, hex)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the user's existing API key, or generate and store a new one if NULL.
|
|
||||||
pub async fn ensure_api_key(pool: &PgPool, user_id: Uuid) -> Result<String> {
|
|
||||||
let existing: Option<(Option<String>,)> =
|
|
||||||
sqlx::query_as("SELECT api_key FROM users WHERE id = $1")
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some((Some(key),)) = existing {
|
|
||||||
return Ok(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_key = generate_api_key();
|
|
||||||
sqlx::query("UPDATE users SET api_key = $1 WHERE id = $2")
|
|
||||||
.bind(&new_key)
|
|
||||||
.bind(user_id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(new_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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")
|
|
||||||
.bind(&new_key)
|
|
||||||
.bind(user_id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(new_key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validate a Bearer token. Returns the `user_id` if the key matches.
|
|
||||||
pub async fn validate_api_key(pool: &PgPool, raw_key: &str) -> Result<Option<Uuid>> {
|
|
||||||
let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE api_key = $1")
|
|
||||||
.bind(raw_key)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.map(|(id,)| id))
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::models::AuditLogEntry;
|
|
||||||
|
|
||||||
pub async fn list_for_user(pool: &PgPool, user_id: Uuid, limit: i64) -> Result<Vec<AuditLogEntry>> {
|
|
||||||
let limit = limit.clamp(1, 200);
|
|
||||||
|
|
||||||
let rows = sqlx::query_as(
|
|
||||||
"SELECT id, user_id, action, folder, type, name, detail, created_at \
|
|
||||||
FROM audit_log \
|
|
||||||
WHERE user_id = $1 \
|
|
||||||
ORDER BY created_at DESC, id DESC \
|
|
||||||
LIMIT $2",
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(limit)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(rows)
|
|
||||||
}
|
|
||||||
@@ -1,938 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use serde_json::json;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::db;
|
|
||||||
use crate::models::{EntryRow, EntryWriteRow, SecretFieldRow};
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct DeletedEntry {
|
|
||||||
pub name: String,
|
|
||||||
pub folder: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub entry_type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct DeleteResult {
|
|
||||||
pub deleted: Vec<DeletedEntry>,
|
|
||||||
pub migrated: Vec<String>,
|
|
||||||
pub dry_run: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DeleteParams<'a> {
|
|
||||||
/// If set, delete a single entry by name.
|
|
||||||
pub name: Option<&'a str>,
|
|
||||||
/// Folder filter for bulk delete.
|
|
||||||
pub folder: Option<&'a str>,
|
|
||||||
/// Type filter for bulk delete.
|
|
||||||
pub entry_type: Option<&'a str>,
|
|
||||||
pub dry_run: bool,
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct KeyReferrer {
|
|
||||||
id: Uuid,
|
|
||||||
folder: String,
|
|
||||||
#[sqlx(rename = "type")]
|
|
||||||
entry_type: String,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ref_label(r: &KeyReferrer) -> String {
|
|
||||||
format!("{}/{} ({})", r.folder, r.name, r.entry_type)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ref_path(r: &KeyReferrer) -> String {
|
|
||||||
format!("{}/{}", r.folder, r.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_key_referrers_pool(
|
|
||||||
pool: &PgPool,
|
|
||||||
key_entry_id: Uuid,
|
|
||||||
key_folder: &str,
|
|
||||||
key_name: &str,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<Vec<KeyReferrer>> {
|
|
||||||
let qualified = format!("{}/{}", key_folder, key_name);
|
|
||||||
let refs: Vec<KeyReferrer> = if let Some(uid) = user_id {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, folder, type, name FROM entries \
|
|
||||||
WHERE user_id = $1 AND id <> $2 \
|
|
||||||
AND (metadata->>'key_ref' = $3 OR metadata->>'key_ref' = $4) \
|
|
||||||
ORDER BY folder, type, name",
|
|
||||||
)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(key_entry_id)
|
|
||||||
.bind(key_name)
|
|
||||||
.bind(&qualified)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, folder, type, name FROM entries \
|
|
||||||
WHERE user_id IS NULL AND id <> $1 \
|
|
||||||
AND (metadata->>'key_ref' = $2 OR metadata->>'key_ref' = $3) \
|
|
||||||
ORDER BY folder, type, name",
|
|
||||||
)
|
|
||||||
.bind(key_entry_id)
|
|
||||||
.bind(key_name)
|
|
||||||
.bind(&qualified)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
Ok(refs)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn migrate_key_refs_if_needed(
|
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
||||||
key_row: &EntryRow,
|
|
||||||
key_name: &str,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
dry_run: bool,
|
|
||||||
) -> Result<Vec<String>> {
|
|
||||||
let qualified = format!("{}/{}", key_row.folder, key_name);
|
|
||||||
let refs: Vec<KeyReferrer> = if let Some(uid) = user_id {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, folder, type, name FROM entries \
|
|
||||||
WHERE user_id = $1 AND id <> $2 \
|
|
||||||
AND (metadata->>'key_ref' = $3 OR metadata->>'key_ref' = $4) \
|
|
||||||
ORDER BY folder, type, name",
|
|
||||||
)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(key_row.id)
|
|
||||||
.bind(key_name)
|
|
||||||
.bind(&qualified)
|
|
||||||
.fetch_all(&mut **tx)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, folder, type, name FROM entries \
|
|
||||||
WHERE user_id IS NULL AND id <> $1 \
|
|
||||||
AND (metadata->>'key_ref' = $2 OR metadata->>'key_ref' = $3) \
|
|
||||||
ORDER BY folder, type, name",
|
|
||||||
)
|
|
||||||
.bind(key_row.id)
|
|
||||||
.bind(key_name)
|
|
||||||
.bind(&qualified)
|
|
||||||
.fetch_all(&mut **tx)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
if refs.is_empty() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
if dry_run {
|
|
||||||
return Ok(refs.iter().map(ref_label).collect());
|
|
||||||
}
|
|
||||||
|
|
||||||
let owner = &refs[0];
|
|
||||||
let owner_path = ref_path(owner);
|
|
||||||
let key_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(key_row.id)
|
|
||||||
.fetch_all(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for f in &key_fields {
|
|
||||||
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2) ON CONFLICT DO NOTHING")
|
|
||||||
.bind(owner.id)
|
|
||||||
.bind(f.id)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE entries SET metadata = metadata - 'key_ref', \
|
|
||||||
version = version + 1, updated_at = NOW() WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(owner.id)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
crate::audit::log_tx(
|
|
||||||
tx,
|
|
||||||
user_id,
|
|
||||||
"key_migrate",
|
|
||||||
&owner.folder,
|
|
||||||
&owner.entry_type,
|
|
||||||
&owner.name,
|
|
||||||
json!({
|
|
||||||
"from_key": format!("{}/{}", key_row.folder, key_name),
|
|
||||||
"role": "new_owner",
|
|
||||||
"redirect_target": owner_path,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
for r in refs.iter().skip(1) {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE entries SET metadata = jsonb_set(metadata, '{key_ref}', to_jsonb($2::text), true), \
|
|
||||||
version = version + 1, updated_at = NOW() WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(r.id)
|
|
||||||
.bind(&owner_path)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
crate::audit::log_tx(
|
|
||||||
tx,
|
|
||||||
user_id,
|
|
||||||
"key_migrate",
|
|
||||||
&r.folder,
|
|
||||||
&r.entry_type,
|
|
||||||
&r.name,
|
|
||||||
json!({
|
|
||||||
"from_key": format!("{}/{}", key_row.folder, key_name),
|
|
||||||
"role": "redirected_ref",
|
|
||||||
"redirect_to": owner_path,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(refs.iter().map(ref_label).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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",
|
|
||||||
)
|
|
||||||
.bind(entry_id)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let row = match row {
|
|
||||||
Some(r) => r,
|
|
||||||
None => {
|
|
||||||
tx.rollback().await?;
|
|
||||||
anyhow::bail!("Entry not found");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let folder = row.folder.clone();
|
|
||||||
let entry_type = row.entry_type.clone();
|
|
||||||
let name = row.name.clone();
|
|
||||||
let entry_row: EntryRow = (&row).into();
|
|
||||||
let migrated =
|
|
||||||
migrate_key_refs_if_needed(&mut tx, &entry_row, &name, Some(user_id), false).await?;
|
|
||||||
|
|
||||||
snapshot_and_delete(
|
|
||||||
&mut tx,
|
|
||||||
&folder,
|
|
||||||
&entry_type,
|
|
||||||
&name,
|
|
||||||
&entry_row,
|
|
||||||
Some(user_id),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
crate::audit::log_tx(
|
|
||||||
&mut tx,
|
|
||||||
Some(user_id),
|
|
||||||
"delete",
|
|
||||||
&folder,
|
|
||||||
&entry_type,
|
|
||||||
&name,
|
|
||||||
json!({ "source": "web", "entry_id": entry_id }),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
Ok(DeleteResult {
|
|
||||||
deleted: vec![DeletedEntry {
|
|
||||||
name,
|
|
||||||
folder,
|
|
||||||
entry_type,
|
|
||||||
}],
|
|
||||||
migrated,
|
|
||||||
dry_run: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(pool: &PgPool, params: DeleteParams<'_>) -> Result<DeleteResult> {
|
|
||||||
match params.name {
|
|
||||||
Some(name) => delete_one(pool, name, params.folder, params.dry_run, params.user_id).await,
|
|
||||||
None => {
|
|
||||||
if params.folder.is_none() && params.entry_type.is_none() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Bulk delete requires at least one of: name, folder, or type filter."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
delete_bulk(
|
|
||||||
pool,
|
|
||||||
params.folder,
|
|
||||||
params.entry_type,
|
|
||||||
params.dry_run,
|
|
||||||
params.user_id,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_one(
|
|
||||||
pool: &PgPool,
|
|
||||||
name: &str,
|
|
||||||
folder: Option<&str>,
|
|
||||||
dry_run: bool,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<DeleteResult> {
|
|
||||||
if dry_run {
|
|
||||||
// Dry-run uses the same disambiguation logic as actual delete:
|
|
||||||
// - 0 matches → nothing to delete
|
|
||||||
// - 1 match → show what would be deleted (with correct folder/type)
|
|
||||||
// - 2+ matches → disambiguation error (same as non-dry-run)
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct DryRunRow {
|
|
||||||
id: Uuid,
|
|
||||||
folder: String,
|
|
||||||
#[sqlx(rename = "type")]
|
|
||||||
entry_type: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let rows: Vec<DryRunRow> = if let Some(uid) = user_id {
|
|
||||||
if let Some(f) = folder {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, folder, type FROM entries WHERE user_id = $1 AND folder = $2 AND name = $3",
|
|
||||||
)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(f)
|
|
||||||
.bind(name)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, folder, type FROM entries WHERE user_id = $1 AND name = $2",
|
|
||||||
)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(name)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
} else if let Some(f) = folder {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, folder, type FROM entries WHERE user_id IS NULL AND folder = $1 AND name = $2",
|
|
||||||
)
|
|
||||||
.bind(f)
|
|
||||||
.bind(name)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, folder, type FROM entries WHERE user_id IS NULL AND name = $1",
|
|
||||||
)
|
|
||||||
.bind(name)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
return match rows.len() {
|
|
||||||
0 => Ok(DeleteResult {
|
|
||||||
deleted: vec![],
|
|
||||||
migrated: vec![],
|
|
||||||
dry_run: true,
|
|
||||||
}),
|
|
||||||
1 => {
|
|
||||||
let row = rows.into_iter().next().unwrap();
|
|
||||||
let refs =
|
|
||||||
fetch_key_referrers_pool(pool, row.id, &row.folder, name, user_id).await?;
|
|
||||||
Ok(DeleteResult {
|
|
||||||
deleted: vec![DeletedEntry {
|
|
||||||
name: name.to_string(),
|
|
||||||
folder: row.folder,
|
|
||||||
entry_type: row.entry_type,
|
|
||||||
}],
|
|
||||||
migrated: refs.iter().map(ref_label).collect(),
|
|
||||||
dry_run: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
|
|
||||||
anyhow::bail!(
|
|
||||||
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
|
|
||||||
Specify 'folder' to disambiguate.",
|
|
||||||
rows.len(),
|
|
||||||
name,
|
|
||||||
folders.join(", ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
|
|
||||||
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
|
|
||||||
let rows: Vec<EntryRow> = if let Some(uid) = user_id {
|
|
||||||
if let Some(f) = folder {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
|
||||||
WHERE user_id = $1 AND folder = $2 AND name = $3 FOR UPDATE",
|
|
||||||
)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(f)
|
|
||||||
.bind(name)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
|
||||||
WHERE user_id = $1 AND name = $2 FOR UPDATE",
|
|
||||||
)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(name)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
} else if let Some(f) = folder {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
|
||||||
WHERE user_id IS NULL AND folder = $1 AND name = $2 FOR UPDATE",
|
|
||||||
)
|
|
||||||
.bind(f)
|
|
||||||
.bind(name)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
|
||||||
WHERE user_id IS NULL AND name = $1 FOR UPDATE",
|
|
||||||
)
|
|
||||||
.bind(name)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
let row = match rows.len() {
|
|
||||||
0 => {
|
|
||||||
tx.rollback().await?;
|
|
||||||
return Ok(DeleteResult {
|
|
||||||
deleted: vec![],
|
|
||||||
migrated: vec![],
|
|
||||||
dry_run: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
1 => rows.into_iter().next().unwrap(),
|
|
||||||
_ => {
|
|
||||||
tx.rollback().await?;
|
|
||||||
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
|
|
||||||
anyhow::bail!(
|
|
||||||
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
|
|
||||||
Specify 'folder' to disambiguate.",
|
|
||||||
rows.len(),
|
|
||||||
name,
|
|
||||||
folders.join(", ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let folder = row.folder.clone();
|
|
||||||
let entry_type = row.entry_type.clone();
|
|
||||||
let migrated = migrate_key_refs_if_needed(&mut tx, &row, name, user_id, false).await?;
|
|
||||||
snapshot_and_delete(&mut tx, &folder, &entry_type, name, &row, user_id).await?;
|
|
||||||
crate::audit::log_tx(
|
|
||||||
&mut tx,
|
|
||||||
user_id,
|
|
||||||
"delete",
|
|
||||||
&folder,
|
|
||||||
&entry_type,
|
|
||||||
name,
|
|
||||||
json!({}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
Ok(DeleteResult {
|
|
||||||
deleted: vec![DeletedEntry {
|
|
||||||
name: name.to_string(),
|
|
||||||
folder,
|
|
||||||
entry_type,
|
|
||||||
}],
|
|
||||||
migrated,
|
|
||||||
dry_run: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_bulk(
|
|
||||||
pool: &PgPool,
|
|
||||||
folder: Option<&str>,
|
|
||||||
entry_type: Option<&str>,
|
|
||||||
dry_run: bool,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<DeleteResult> {
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
|
||||||
struct FullEntryRow {
|
|
||||||
id: Uuid,
|
|
||||||
version: i64,
|
|
||||||
folder: String,
|
|
||||||
#[sqlx(rename = "type")]
|
|
||||||
entry_type: String,
|
|
||||||
name: String,
|
|
||||||
metadata: serde_json::Value,
|
|
||||||
tags: Vec<String>,
|
|
||||||
notes: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut conditions: Vec<String> = Vec::new();
|
|
||||||
let mut idx: i32 = 1;
|
|
||||||
|
|
||||||
if user_id.is_some() {
|
|
||||||
conditions.push(format!("user_id = ${}", idx));
|
|
||||||
idx += 1;
|
|
||||||
} else {
|
|
||||||
conditions.push("user_id IS NULL".to_string());
|
|
||||||
}
|
|
||||||
if folder.is_some() {
|
|
||||||
conditions.push(format!("folder = ${}", idx));
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
if entry_type.is_some() {
|
|
||||||
conditions.push(format!("type = ${}", idx));
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_clause = format!("WHERE {}", conditions.join(" AND "));
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT id, version, folder, type, name, metadata, tags, notes \
|
|
||||||
FROM entries {where_clause} ORDER BY type, name"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut q = sqlx::query_as::<_, FullEntryRow>(&sql);
|
|
||||||
if let Some(uid) = user_id {
|
|
||||||
q = q.bind(uid);
|
|
||||||
}
|
|
||||||
if let Some(f) = folder {
|
|
||||||
q = q.bind(f);
|
|
||||||
}
|
|
||||||
if let Some(t) = entry_type {
|
|
||||||
q = q.bind(t);
|
|
||||||
}
|
|
||||||
let rows = q.fetch_all(pool).await?;
|
|
||||||
|
|
||||||
if dry_run {
|
|
||||||
let mut migrated: Vec<String> = Vec::new();
|
|
||||||
for row in &rows {
|
|
||||||
let refs =
|
|
||||||
fetch_key_referrers_pool(pool, row.id, &row.folder, &row.name, user_id).await?;
|
|
||||||
migrated.extend(refs.iter().map(ref_label));
|
|
||||||
}
|
|
||||||
let deleted = rows
|
|
||||||
.iter()
|
|
||||||
.map(|r| DeletedEntry {
|
|
||||||
name: r.name.clone(),
|
|
||||||
folder: r.folder.clone(),
|
|
||||||
entry_type: r.entry_type.clone(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
return Ok(DeleteResult {
|
|
||||||
deleted,
|
|
||||||
migrated,
|
|
||||||
dry_run: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut deleted = Vec::with_capacity(rows.len());
|
|
||||||
let mut migrated: Vec<String> = Vec::new();
|
|
||||||
for row in &rows {
|
|
||||||
let entry_row = EntryRow {
|
|
||||||
id: row.id,
|
|
||||||
version: row.version,
|
|
||||||
folder: row.folder.clone(),
|
|
||||||
entry_type: row.entry_type.clone(),
|
|
||||||
tags: row.tags.clone(),
|
|
||||||
metadata: row.metadata.clone(),
|
|
||||||
notes: row.notes.clone(),
|
|
||||||
};
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
let m = migrate_key_refs_if_needed(&mut tx, &entry_row, &row.name, user_id, false).await?;
|
|
||||||
migrated.extend(m);
|
|
||||||
snapshot_and_delete(
|
|
||||||
&mut tx,
|
|
||||||
&row.folder,
|
|
||||||
&row.entry_type,
|
|
||||||
&row.name,
|
|
||||||
&entry_row,
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
crate::audit::log_tx(
|
|
||||||
&mut tx,
|
|
||||||
user_id,
|
|
||||||
"delete",
|
|
||||||
&row.folder,
|
|
||||||
&row.entry_type,
|
|
||||||
&row.name,
|
|
||||||
json!({"bulk": true}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
tx.commit().await?;
|
|
||||||
deleted.push(DeletedEntry {
|
|
||||||
name: row.name.clone(),
|
|
||||||
folder: row.folder.clone(),
|
|
||||||
entry_type: row.entry_type.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(DeleteResult {
|
|
||||||
deleted,
|
|
||||||
migrated,
|
|
||||||
dry_run: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn snapshot_and_delete(
|
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
||||||
folder: &str,
|
|
||||||
entry_type: &str,
|
|
||||||
name: &str,
|
|
||||||
row: &EntryRow,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<()> {
|
|
||||||
if let Err(e) = db::snapshot_entry_history(
|
|
||||||
tx,
|
|
||||||
db::EntrySnapshotParams {
|
|
||||||
entry_id: row.id,
|
|
||||||
user_id,
|
|
||||||
folder,
|
|
||||||
entry_type,
|
|
||||||
name,
|
|
||||||
version: row.version,
|
|
||||||
action: "delete",
|
|
||||||
tags: &row.tags,
|
|
||||||
metadata: &row.metadata,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "failed to snapshot entry history before delete");
|
|
||||||
}
|
|
||||||
|
|
||||||
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(row.id)
|
|
||||||
.fetch_all(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for f in &fields {
|
|
||||||
if let Err(e) = db::snapshot_secret_history(
|
|
||||||
tx,
|
|
||||||
db::SecretSnapshotParams {
|
|
||||||
secret_id: f.id,
|
|
||||||
name: &f.name,
|
|
||||||
encrypted: &f.encrypted,
|
|
||||||
action: "delete",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "failed to snapshot secret history before delete");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM entries WHERE id = $1")
|
|
||||||
.bind(row.id)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"DELETE FROM secrets s \
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
|
|
||||||
)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
async fn maybe_test_pool() -> Option<PgPool> {
|
|
||||||
let Ok(url) = std::env::var("SECRETS_DATABASE_URL") else {
|
|
||||||
eprintln!("skip delete migration tests: SECRETS_DATABASE_URL is not set");
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let Ok(pool) = PgPool::connect(&url).await else {
|
|
||||||
eprintln!("skip delete migration tests: cannot connect to database");
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
if let Err(e) = crate::db::migrate(&pool).await {
|
|
||||||
eprintln!("skip delete migration tests: migrate failed: {e}");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(pool)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn insert_entry(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
folder: &str,
|
|
||||||
entry_type: &str,
|
|
||||||
name: &str,
|
|
||||||
metadata: serde_json::Value,
|
|
||||||
) -> Result<()> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO entries (id, user_id, folder, type, name, notes, tags, metadata, version) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, '', ARRAY[]::text[], $6, 1)",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(folder)
|
|
||||||
.bind(entry_type)
|
|
||||||
.bind(name)
|
|
||||||
.bind(metadata)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn insert_secret_for_entry(
|
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Uuid,
|
|
||||||
entry_id: Uuid,
|
|
||||||
name: &str,
|
|
||||||
secret_type: &str,
|
|
||||||
encrypted: Vec<u8>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let secret_id: Uuid = sqlx::query_scalar(
|
|
||||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(name)
|
|
||||||
.bind(secret_type)
|
|
||||||
.bind(encrypted)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
|
|
||||||
.bind(entry_id)
|
|
||||||
.bind(secret_id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn delete_shared_key_dry_run_reports_migration_without_writes() -> Result<()> {
|
|
||||||
let Some(pool) = maybe_test_pool().await else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
let user_id = Uuid::from_u128(rand::random());
|
|
||||||
let key_id = Uuid::from_u128(rand::random());
|
|
||||||
let ref_a = Uuid::from_u128(rand::random());
|
|
||||||
let ref_b = Uuid::from_u128(rand::random());
|
|
||||||
|
|
||||||
insert_entry(
|
|
||||||
&pool,
|
|
||||||
key_id,
|
|
||||||
user_id,
|
|
||||||
"kfolder",
|
|
||||||
"key",
|
|
||||||
"shared-key",
|
|
||||||
json!({}),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
insert_secret_for_entry(&pool, user_id, key_id, "pem", "pem", vec![1_u8, 2, 3]).await?;
|
|
||||||
|
|
||||||
insert_entry(
|
|
||||||
&pool,
|
|
||||||
ref_a,
|
|
||||||
user_id,
|
|
||||||
"afolder",
|
|
||||||
"server",
|
|
||||||
"srv-a",
|
|
||||||
json!({"key_ref":"kfolder/shared-key"}),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
insert_entry(
|
|
||||||
&pool,
|
|
||||||
ref_b,
|
|
||||||
user_id,
|
|
||||||
"bfolder",
|
|
||||||
"server",
|
|
||||||
"srv-b",
|
|
||||||
json!({"key_ref":"shared-key"}),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let result = run(
|
|
||||||
&pool,
|
|
||||||
DeleteParams {
|
|
||||||
name: Some("shared-key"),
|
|
||||||
folder: Some("kfolder"),
|
|
||||||
entry_type: None,
|
|
||||||
dry_run: true,
|
|
||||||
user_id: Some(user_id),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
assert!(result.dry_run);
|
|
||||||
assert_eq!(result.deleted.len(), 1);
|
|
||||||
assert_eq!(result.migrated.len(), 2);
|
|
||||||
|
|
||||||
let key_exists: bool = sqlx::query_scalar(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM entries WHERE id = $1 AND user_id = $2)",
|
|
||||||
)
|
|
||||||
.bind(key_id)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await?;
|
|
||||||
assert!(key_exists);
|
|
||||||
|
|
||||||
let ref_a_key_ref: Option<String> =
|
|
||||||
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
|
|
||||||
.bind(ref_a)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await?;
|
|
||||||
let ref_b_key_ref: Option<String> =
|
|
||||||
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
|
|
||||||
.bind(ref_b)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(ref_a_key_ref.as_deref(), Some("kfolder/shared-key"));
|
|
||||||
assert_eq!(ref_b_key_ref.as_deref(), Some("shared-key"));
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM entries WHERE user_id = $1")
|
|
||||||
.bind(user_id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn delete_shared_key_auto_migrates_single_copy_and_redirects_refs() -> Result<()> {
|
|
||||||
let Some(pool) = maybe_test_pool().await else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
|
|
||||||
let user_id = Uuid::from_u128(rand::random());
|
|
||||||
let key_id = Uuid::from_u128(rand::random());
|
|
||||||
let ref_a = Uuid::from_u128(rand::random());
|
|
||||||
let ref_b = Uuid::from_u128(rand::random());
|
|
||||||
let ref_c = Uuid::from_u128(rand::random());
|
|
||||||
|
|
||||||
insert_entry(
|
|
||||||
&pool,
|
|
||||||
key_id,
|
|
||||||
user_id,
|
|
||||||
"kfolder",
|
|
||||||
"key",
|
|
||||||
"shared-key",
|
|
||||||
json!({}),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
insert_secret_for_entry(&pool, user_id, key_id, "pem", "pem", vec![7_u8, 8, 9]).await?;
|
|
||||||
|
|
||||||
// owner candidate (sorted first by folder)
|
|
||||||
insert_entry(
|
|
||||||
&pool,
|
|
||||||
ref_a,
|
|
||||||
user_id,
|
|
||||||
"afolder",
|
|
||||||
"server",
|
|
||||||
"srv-a",
|
|
||||||
json!({"key_ref":"kfolder/shared-key"}),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
insert_entry(
|
|
||||||
&pool,
|
|
||||||
ref_b,
|
|
||||||
user_id,
|
|
||||||
"bfolder",
|
|
||||||
"server",
|
|
||||||
"srv-b",
|
|
||||||
json!({"key_ref":"shared-key"}),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
insert_entry(
|
|
||||||
&pool,
|
|
||||||
ref_c,
|
|
||||||
user_id,
|
|
||||||
"cfolder",
|
|
||||||
"service",
|
|
||||||
"svc-c",
|
|
||||||
json!({"key_ref":"kfolder/shared-key"}),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let result = run(
|
|
||||||
&pool,
|
|
||||||
DeleteParams {
|
|
||||||
name: Some("shared-key"),
|
|
||||||
folder: Some("kfolder"),
|
|
||||||
entry_type: None,
|
|
||||||
dry_run: false,
|
|
||||||
user_id: Some(user_id),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
assert!(!result.dry_run);
|
|
||||||
assert_eq!(result.deleted.len(), 1);
|
|
||||||
assert_eq!(result.migrated.len(), 3);
|
|
||||||
|
|
||||||
let key_exists: bool = sqlx::query_scalar(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM entries WHERE id = $1 AND user_id = $2)",
|
|
||||||
)
|
|
||||||
.bind(key_id)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await?;
|
|
||||||
assert!(!key_exists);
|
|
||||||
|
|
||||||
let owner_key_ref: Option<String> =
|
|
||||||
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
|
|
||||||
.bind(ref_a)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await?;
|
|
||||||
let ref_b_key_ref: Option<String> =
|
|
||||||
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
|
|
||||||
.bind(ref_b)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await?;
|
|
||||||
let ref_c_key_ref: Option<String> =
|
|
||||||
sqlx::query_scalar("SELECT metadata->>'key_ref' FROM entries WHERE id = $1")
|
|
||||||
.bind(ref_c)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
assert_eq!(owner_key_ref, None);
|
|
||||||
assert_eq!(ref_b_key_ref.as_deref(), Some("afolder/srv-a"));
|
|
||||||
assert_eq!(ref_c_key_ref.as_deref(), Some("afolder/srv-a"));
|
|
||||||
|
|
||||||
let owner_has_copied: bool = sqlx::query_scalar(
|
|
||||||
"SELECT EXISTS( \
|
|
||||||
SELECT 1 \
|
|
||||||
FROM entry_secrets es \
|
|
||||||
JOIN secrets s ON s.id = es.secret_id \
|
|
||||||
WHERE es.entry_id = $1 AND s.name = 'pem' \
|
|
||||||
)",
|
|
||||||
)
|
|
||||||
.bind(ref_a)
|
|
||||||
.fetch_one(&pool)
|
|
||||||
.await?;
|
|
||||||
assert!(owner_has_copied);
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM entries WHERE user_id = $1")
|
|
||||||
.bind(user_id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::crypto;
|
|
||||||
use crate::models::Entry;
|
|
||||||
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
|
|
||||||
|
|
||||||
/// Build an env variable map from entry secrets (for dry-run preview or injection).
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn build_env_map(
|
|
||||||
pool: &PgPool,
|
|
||||||
folder: Option<&str>,
|
|
||||||
entry_type: Option<&str>,
|
|
||||||
name: Option<&str>,
|
|
||||||
tags: &[String],
|
|
||||||
only_fields: &[String],
|
|
||||||
prefix: &str,
|
|
||||||
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 mut combined: HashMap<String, String> = HashMap::new();
|
|
||||||
|
|
||||||
for entry in &entries {
|
|
||||||
let entry_map =
|
|
||||||
build_entry_env_map(pool, entry, only_fields, prefix, master_key, user_id).await?;
|
|
||||||
combined.extend(entry_map);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(combined)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn build_entry_env_map(
|
|
||||||
pool: &PgPool,
|
|
||||||
entry: &Entry,
|
|
||||||
only_fields: &[String],
|
|
||||||
prefix: &str,
|
|
||||||
master_key: &[u8; 32],
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<HashMap<String, String>> {
|
|
||||||
let entry_ids = vec![entry.id];
|
|
||||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
|
||||||
let all_fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
|
||||||
|
|
||||||
let fields: Vec<_> = if only_fields.is_empty() {
|
|
||||||
all_fields.iter().collect()
|
|
||||||
} else {
|
|
||||||
all_fields
|
|
||||||
.iter()
|
|
||||||
.filter(|f| only_fields.contains(&f.name))
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
let effective_prefix = env_prefix(entry, prefix);
|
|
||||||
let mut map = HashMap::new();
|
|
||||||
|
|
||||||
for f in fields {
|
|
||||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
|
||||||
let key = format!(
|
|
||||||
"{}_{}",
|
|
||||||
effective_prefix,
|
|
||||||
f.name.to_uppercase().replace(['-', '.'], "_")
|
|
||||||
);
|
|
||||||
map.insert(key, json_to_env_string(&decrypted));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve key_ref. Supported formats: "name" or "folder/name".
|
|
||||||
if let Some(key_ref) = entry.metadata.get("key_ref").and_then(|v| v.as_str()) {
|
|
||||||
let (ref_folder, ref_name) = if let Some((f, n)) = key_ref.split_once('/') {
|
|
||||||
(Some(f), n)
|
|
||||||
} else {
|
|
||||||
(None, key_ref)
|
|
||||||
};
|
|
||||||
let key_entries =
|
|
||||||
fetch_entries(pool, ref_folder, None, Some(ref_name), &[], None, user_id).await?;
|
|
||||||
|
|
||||||
if key_entries.len() > 1 {
|
|
||||||
anyhow::bail!(
|
|
||||||
"key_ref '{}' matched {} entries; qualify with folder/name to resolve the ambiguity",
|
|
||||||
key_ref,
|
|
||||||
key_entries.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(key_entry) = key_entries.first() {
|
|
||||||
let key_ids = vec![key_entry.id];
|
|
||||||
let key_fields_map = fetch_secrets_for_entries(pool, &key_ids).await?;
|
|
||||||
let empty = vec![];
|
|
||||||
let key_fields = key_fields_map.get(&key_entry.id).unwrap_or(&empty);
|
|
||||||
let key_prefix = env_prefix(key_entry, prefix);
|
|
||||||
for f in key_fields {
|
|
||||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
|
||||||
let key_var = format!(
|
|
||||||
"{}_{}",
|
|
||||||
key_prefix,
|
|
||||||
f.name.to_uppercase().replace(['-', '.'], "_")
|
|
||||||
);
|
|
||||||
map.insert(key_var, json_to_env_string(&decrypted));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tracing::warn!(key_ref, ?user_id, "key_ref target not found");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn env_prefix(entry: &Entry, prefix: &str) -> String {
|
|
||||||
let name_part = entry.name.to_uppercase().replace(['-', '.', ' '], "_");
|
|
||||||
if prefix.is_empty() {
|
|
||||||
name_part
|
|
||||||
} else {
|
|
||||||
let normalized = prefix.to_uppercase().replace(['-', '.', ' '], "_");
|
|
||||||
let normalized = normalized.trim_end_matches('_');
|
|
||||||
format!("{}_{}", normalized, name_part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_to_env_string(v: &Value) -> String {
|
|
||||||
match v {
|
|
||||||
Value::String(s) => s.clone(),
|
|
||||||
Value::Null => String::new(),
|
|
||||||
other => other.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::crypto;
|
|
||||||
use crate::models::{ExportData, ExportEntry, ExportFormat};
|
|
||||||
use crate::service::search::{fetch_entries, fetch_secrets_for_entries};
|
|
||||||
|
|
||||||
pub struct ExportParams<'a> {
|
|
||||||
pub folder: Option<&'a str>,
|
|
||||||
pub entry_type: Option<&'a str>,
|
|
||||||
pub name: Option<&'a str>,
|
|
||||||
pub tags: &'a [String],
|
|
||||||
pub query: Option<&'a str>,
|
|
||||||
pub no_secrets: bool,
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn export(
|
|
||||||
pool: &PgPool,
|
|
||||||
params: ExportParams<'_>,
|
|
||||||
master_key: Option<&[u8; 32]>,
|
|
||||||
) -> Result<ExportData> {
|
|
||||||
let entries = fetch_entries(
|
|
||||||
pool,
|
|
||||||
params.folder,
|
|
||||||
params.entry_type,
|
|
||||||
params.name,
|
|
||||||
params.tags,
|
|
||||||
params.query,
|
|
||||||
params.user_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
|
|
||||||
let secrets_map: HashMap<Uuid, Vec<_>> = if !params.no_secrets && !entry_ids.is_empty() {
|
|
||||||
fetch_secrets_for_entries(pool, &entry_ids).await?
|
|
||||||
} else {
|
|
||||||
HashMap::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut export_entries: Vec<ExportEntry> = Vec::with_capacity(entries.len());
|
|
||||||
for entry in &entries {
|
|
||||||
let secrets = if params.no_secrets {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
|
||||||
if fields.is_empty() {
|
|
||||||
Some(BTreeMap::new())
|
|
||||||
} else {
|
|
||||||
let mk = master_key
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("master key required to decrypt secrets"))?;
|
|
||||||
let mut map = BTreeMap::new();
|
|
||||||
for f in fields {
|
|
||||||
let decrypted = crypto::decrypt_json(mk, &f.encrypted)?;
|
|
||||||
map.insert(f.name.clone(), decrypted);
|
|
||||||
}
|
|
||||||
Some(map)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export_entries.push(ExportEntry {
|
|
||||||
name: entry.name.clone(),
|
|
||||||
folder: entry.folder.clone(),
|
|
||||||
entry_type: entry.entry_type.clone(),
|
|
||||||
notes: entry.notes.clone(),
|
|
||||||
tags: entry.tags.clone(),
|
|
||||||
metadata: entry.metadata.clone(),
|
|
||||||
secrets,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ExportData {
|
|
||||||
version: 1,
|
|
||||||
exported_at: chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
|
||||||
entries: export_entries,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn export_to_file(
|
|
||||||
pool: &PgPool,
|
|
||||||
params: ExportParams<'_>,
|
|
||||||
master_key: Option<&[u8; 32]>,
|
|
||||||
file_path: &str,
|
|
||||||
format_override: Option<&str>,
|
|
||||||
) -> Result<usize> {
|
|
||||||
let format = if let Some(f) = format_override {
|
|
||||||
f.parse::<ExportFormat>()?
|
|
||||||
} else {
|
|
||||||
ExportFormat::from_extension(file_path).unwrap_or(ExportFormat::Json)
|
|
||||||
};
|
|
||||||
|
|
||||||
let data = export(pool, params, master_key).await?;
|
|
||||||
let count = data.entries.len();
|
|
||||||
let serialized = format.serialize(&data)?;
|
|
||||||
std::fs::write(file_path, &serialized)?;
|
|
||||||
Ok(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn export_to_string(
|
|
||||||
pool: &PgPool,
|
|
||||||
params: ExportParams<'_>,
|
|
||||||
master_key: Option<&[u8; 32]>,
|
|
||||||
format: &str,
|
|
||||||
) -> Result<String> {
|
|
||||||
let fmt = format.parse::<ExportFormat>()?;
|
|
||||||
let data = export(pool, params, master_key).await?;
|
|
||||||
fmt.serialize(&data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Build helpers for re-encoding values as CLI-style entries ─────────────────
|
|
||||||
|
|
||||||
pub fn build_meta_entries(metadata: &Value) -> Vec<String> {
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
if let Some(obj) = metadata.as_object() {
|
|
||||||
for (k, v) in obj {
|
|
||||||
entries.push(value_to_kv_entry(k, v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entries
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn build_secret_entries(secrets: Option<&BTreeMap<String, Value>>) -> Vec<String> {
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
if let Some(map) = secrets {
|
|
||||||
for (k, v) in map {
|
|
||||||
entries.push(value_to_kv_entry(k, v));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entries
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn value_to_kv_entry(key: &str, value: &Value) -> String {
|
|
||||||
match value {
|
|
||||||
Value::String(s) => format!("{}={}", key, s),
|
|
||||||
other => format!("{}:={}", key, other),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::crypto;
|
|
||||||
use crate::service::search::{fetch_secrets_for_entries, resolve_entry, resolve_entry_by_id};
|
|
||||||
|
|
||||||
/// Decrypt a single named field from an entry.
|
|
||||||
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
|
|
||||||
pub async fn get_secret_field(
|
|
||||||
pool: &PgPool,
|
|
||||||
name: &str,
|
|
||||||
folder: Option<&str>,
|
|
||||||
field_name: &str,
|
|
||||||
master_key: &[u8; 32],
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<Value> {
|
|
||||||
let entry = resolve_entry(pool, name, folder, user_id).await?;
|
|
||||||
|
|
||||||
let entry_ids = vec![entry.id];
|
|
||||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
|
||||||
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
|
||||||
|
|
||||||
let field = fields
|
|
||||||
.iter()
|
|
||||||
.find(|f| f.name == field_name)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?;
|
|
||||||
|
|
||||||
crypto::decrypt_json(master_key, &field.encrypted)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt all secret fields from an entry. Returns a map field_name → decrypted Value.
|
|
||||||
/// `folder` is optional; if omitted and multiple entries share the name, an error is returned.
|
|
||||||
pub async fn get_all_secrets(
|
|
||||||
pool: &PgPool,
|
|
||||||
name: &str,
|
|
||||||
folder: Option<&str>,
|
|
||||||
master_key: &[u8; 32],
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<HashMap<String, Value>> {
|
|
||||||
let entry = resolve_entry(pool, name, folder, user_id).await?;
|
|
||||||
|
|
||||||
let entry_ids = vec![entry.id];
|
|
||||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
|
||||||
let fields = secrets_map.get(&entry.id).map(Vec::as_slice).unwrap_or(&[]);
|
|
||||||
|
|
||||||
let mut map = HashMap::new();
|
|
||||||
for f in fields {
|
|
||||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
|
||||||
map.insert(f.name.clone(), decrypted);
|
|
||||||
}
|
|
||||||
Ok(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt a single named field from an entry, located by its UUID.
|
|
||||||
pub async fn get_secret_field_by_id(
|
|
||||||
pool: &PgPool,
|
|
||||||
entry_id: Uuid,
|
|
||||||
field_name: &str,
|
|
||||||
master_key: &[u8; 32],
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<Value> {
|
|
||||||
resolve_entry_by_id(pool, entry_id, user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("Entry with id '{}' not found", entry_id))?;
|
|
||||||
|
|
||||||
let entry_ids = vec![entry_id];
|
|
||||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
|
||||||
let fields = secrets_map.get(&entry_id).map(Vec::as_slice).unwrap_or(&[]);
|
|
||||||
|
|
||||||
let field = fields
|
|
||||||
.iter()
|
|
||||||
.find(|f| f.name == field_name)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Secret field '{}' not found", field_name))?;
|
|
||||||
|
|
||||||
crypto::decrypt_json(master_key, &field.encrypted)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decrypt all secret fields from an entry, located by its UUID.
|
|
||||||
/// Returns a map field_name → decrypted Value.
|
|
||||||
pub async fn get_all_secrets_by_id(
|
|
||||||
pool: &PgPool,
|
|
||||||
entry_id: Uuid,
|
|
||||||
master_key: &[u8; 32],
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<HashMap<String, Value>> {
|
|
||||||
// Validate entry exists (and that it belongs to the requesting user)
|
|
||||||
resolve_entry_by_id(pool, entry_id, user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|_| anyhow::anyhow!("Entry with id '{}' not found", entry_id))?;
|
|
||||||
|
|
||||||
let entry_ids = vec![entry_id];
|
|
||||||
let secrets_map = fetch_secrets_for_entries(pool, &entry_ids).await?;
|
|
||||||
let fields = secrets_map.get(&entry_id).map(Vec::as_slice).unwrap_or(&[]);
|
|
||||||
|
|
||||||
let mut map = HashMap::new();
|
|
||||||
for f in fields {
|
|
||||||
let decrypted = crypto::decrypt_json(master_key, &f.encrypted)?;
|
|
||||||
map.insert(f.name.clone(), decrypted);
|
|
||||||
}
|
|
||||||
Ok(map)
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::service::search::resolve_entry;
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct HistoryEntry {
|
|
||||||
pub version: i64,
|
|
||||||
pub action: String,
|
|
||||||
pub created_at: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return version history for the entry identified by `name`.
|
|
||||||
/// `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>,
|
|
||||||
limit: u32,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<Vec<HistoryEntry>> {
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct Row {
|
|
||||||
version: i64,
|
|
||||||
action: String,
|
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry = resolve_entry(pool, name, folder, user_id).await?;
|
|
||||||
|
|
||||||
let rows: Vec<Row> = sqlx::query_as(
|
|
||||||
"SELECT version, action, created_at FROM entries_history \
|
|
||||||
WHERE entry_id = $1 ORDER BY id DESC LIMIT $2",
|
|
||||||
)
|
|
||||||
.bind(entry.id)
|
|
||||||
.bind(limit as i64)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| HistoryEntry {
|
|
||||||
version: r.version,
|
|
||||||
action: r.action,
|
|
||||||
created_at: r.created_at.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run_json(
|
|
||||||
pool: &PgPool,
|
|
||||||
name: &str,
|
|
||||||
folder: Option<&str>,
|
|
||||||
limit: u32,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<Value> {
|
|
||||||
let entries = run(pool, name, folder, limit, user_id).await?;
|
|
||||||
Ok(serde_json::to_value(entries)?)
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::models::ExportFormat;
|
|
||||||
use crate::service::add::{AddParams, run as add_run};
|
|
||||||
use crate::service::export::{build_meta_entries, build_secret_entries};
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct ImportSummary {
|
|
||||||
pub total: usize,
|
|
||||||
pub inserted: usize,
|
|
||||||
pub skipped: usize,
|
|
||||||
pub failed: usize,
|
|
||||||
pub dry_run: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ImportParams<'a> {
|
|
||||||
pub file: &'a str,
|
|
||||||
pub force: bool,
|
|
||||||
pub dry_run: bool,
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(
|
|
||||||
pool: &PgPool,
|
|
||||||
params: ImportParams<'_>,
|
|
||||||
master_key: &[u8; 32],
|
|
||||||
) -> Result<ImportSummary> {
|
|
||||||
let format = ExportFormat::from_extension(params.file)?;
|
|
||||||
let content = std::fs::read_to_string(params.file)
|
|
||||||
.map_err(|e| anyhow::anyhow!("Cannot read file '{}': {}", params.file, e))?;
|
|
||||||
let data = format.deserialize(&content)?;
|
|
||||||
|
|
||||||
if data.version != 1 {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Unsupported export version {}. Only version 1 is supported.",
|
|
||||||
data.version
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let total = data.entries.len();
|
|
||||||
let mut inserted = 0usize;
|
|
||||||
let mut skipped = 0usize;
|
|
||||||
let mut failed = 0usize;
|
|
||||||
|
|
||||||
for entry in &data.entries {
|
|
||||||
let exists: bool = sqlx::query_scalar(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM entries \
|
|
||||||
WHERE folder = $1 AND name = $2 AND user_id IS NOT DISTINCT FROM $3)",
|
|
||||||
)
|
|
||||||
.bind(&entry.folder)
|
|
||||||
.bind(&entry.name)
|
|
||||||
.bind(params.user_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if exists && !params.force {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"Import aborted: conflict on '{}'",
|
|
||||||
entry.name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if params.dry_run {
|
|
||||||
if exists {
|
|
||||||
skipped += 1;
|
|
||||||
} else {
|
|
||||||
inserted += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let secret_entries = build_secret_entries(entry.secrets.as_ref());
|
|
||||||
let meta_entries = build_meta_entries(&entry.metadata);
|
|
||||||
|
|
||||||
match add_run(
|
|
||||||
pool,
|
|
||||||
AddParams {
|
|
||||||
name: &entry.name,
|
|
||||||
folder: &entry.folder,
|
|
||||||
entry_type: &entry.entry_type,
|
|
||||||
notes: &entry.notes,
|
|
||||||
tags: &entry.tags,
|
|
||||||
meta_entries: &meta_entries,
|
|
||||||
secret_entries: &secret_entries,
|
|
||||||
link_secret_names: &[],
|
|
||||||
user_id: params.user_id,
|
|
||||||
},
|
|
||||||
master_key,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => {
|
|
||||||
inserted += 1;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
name = entry.name,
|
|
||||||
error = %e,
|
|
||||||
"failed to import entry"
|
|
||||||
);
|
|
||||||
failed += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if failed > 0 {
|
|
||||||
return Err(anyhow::anyhow!("{} record(s) failed to import", failed));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(ImportSummary {
|
|
||||||
total,
|
|
||||||
inserted,
|
|
||||||
skipped,
|
|
||||||
failed,
|
|
||||||
dry_run: params.dry_run,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
pub mod add;
|
|
||||||
pub mod api_key;
|
|
||||||
pub mod audit_log;
|
|
||||||
pub mod delete;
|
|
||||||
pub mod env_map;
|
|
||||||
pub mod export;
|
|
||||||
pub mod get_secret;
|
|
||||||
pub mod history;
|
|
||||||
pub mod import;
|
|
||||||
pub mod rollback;
|
|
||||||
pub mod search;
|
|
||||||
pub mod update;
|
|
||||||
pub mod user;
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::db;
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct RollbackResult {
|
|
||||||
pub name: String,
|
|
||||||
pub folder: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub entry_type: String,
|
|
||||||
pub restored_version: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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>,
|
|
||||||
to_version: Option<i64>,
|
|
||||||
master_key: &[u8; 32],
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<RollbackResult> {
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct EntryHistoryRow {
|
|
||||||
folder: String,
|
|
||||||
#[sqlx(rename = "type")]
|
|
||||||
entry_type: 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",
|
|
||||||
)
|
|
||||||
.bind(name)
|
|
||||||
.bind(f)
|
|
||||||
.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 IS NULL",
|
|
||||||
)
|
|
||||||
.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(", ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let entry_id = entry_id.ok_or_else(|| anyhow::anyhow!("No history found for '{}'", name))?;
|
|
||||||
|
|
||||||
let snap: Option<EntryHistoryRow> = if let Some(ver) = to_version {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT folder, type, version, action, tags, metadata \
|
|
||||||
FROM entries_history \
|
|
||||||
WHERE entry_id = $1 AND version = $2 ORDER BY id DESC LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(entry_id)
|
|
||||||
.bind(ver)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT folder, type, version, action, tags, metadata \
|
|
||||||
FROM entries_history \
|
|
||||||
WHERE entry_id = $1 ORDER BY id DESC LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(entry_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
let snap = snap.ok_or_else(|| {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"No history found for '{}'{}.",
|
|
||||||
name,
|
|
||||||
to_version
|
|
||||||
.map(|v| format!(" at version {}", v))
|
|
||||||
.unwrap_or_default()
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let _ = master_key;
|
|
||||||
|
|
||||||
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,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
notes: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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, notes 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 {
|
|
||||||
if let Err(e) = db::snapshot_entry_history(
|
|
||||||
&mut tx,
|
|
||||||
db::EntrySnapshotParams {
|
|
||||||
entry_id: lr.id,
|
|
||||||
user_id,
|
|
||||||
folder: &lr.folder,
|
|
||||||
entry_type: &lr.entry_type,
|
|
||||||
name,
|
|
||||||
version: lr.version,
|
|
||||||
action: "rollback",
|
|
||||||
tags: &lr.tags,
|
|
||||||
metadata: &lr.metadata,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "failed to snapshot entry before rollback");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct LiveField {
|
|
||||||
id: Uuid,
|
|
||||||
name: String,
|
|
||||||
encrypted: Vec<u8>,
|
|
||||||
}
|
|
||||||
let live_fields: Vec<LiveField> = 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(lr.id)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for f in &live_fields {
|
|
||||||
if let Err(e) = db::snapshot_secret_history(
|
|
||||||
&mut tx,
|
|
||||||
db::SecretSnapshotParams {
|
|
||||||
secret_id: f.id,
|
|
||||||
name: &f.name,
|
|
||||||
encrypted: &f.encrypted,
|
|
||||||
action: "rollback",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "failed to snapshot secret field before rollback");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE entries SET tags = $1, metadata = $2, version = version + 1, \
|
|
||||||
updated_at = NOW() WHERE id = $3",
|
|
||||||
)
|
|
||||||
.bind(&snap.tags)
|
|
||||||
.bind(&snap.metadata)
|
|
||||||
.bind(lr.id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.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?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// In N:N mode, rollback restores entry metadata/tags only.
|
|
||||||
// Secret snapshots are kept for audit but secret linkage/content is not rewritten here.
|
|
||||||
let _ = live_entry_id;
|
|
||||||
|
|
||||||
crate::audit::log_tx(
|
|
||||||
&mut tx,
|
|
||||||
user_id,
|
|
||||||
"rollback",
|
|
||||||
&snap.folder,
|
|
||||||
&snap.entry_type,
|
|
||||||
name,
|
|
||||||
serde_json::json!({
|
|
||||||
"restored_version": snap.version,
|
|
||||||
"original_action": snap.action,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
Ok(RollbackResult {
|
|
||||||
name: name.to_string(),
|
|
||||||
folder: snap.folder,
|
|
||||||
entry_type: snap.entry_type,
|
|
||||||
restored_version: snap.version,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::models::{Entry, SecretField};
|
|
||||||
|
|
||||||
pub const FETCH_ALL_LIMIT: u32 = 100_000;
|
|
||||||
|
|
||||||
pub struct SearchParams<'a> {
|
|
||||||
pub folder: Option<&'a str>,
|
|
||||||
pub entry_type: Option<&'a str>,
|
|
||||||
pub name: Option<&'a str>,
|
|
||||||
pub tags: &'a [String],
|
|
||||||
pub query: Option<&'a str>,
|
|
||||||
pub sort: &'a str,
|
|
||||||
pub limit: u32,
|
|
||||||
pub offset: u32,
|
|
||||||
/// Multi-user: filter by this user_id. None = single-user / no filter.
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct SearchResult {
|
|
||||||
pub entries: Vec<Entry>,
|
|
||||||
pub secret_schemas: HashMap<Uuid, Vec<SecretField>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List `entries` rows matching params (paged, ordered per `params.sort`).
|
|
||||||
/// Does not read the `secrets` table.
|
|
||||||
pub async fn list_entries(pool: &PgPool, params: SearchParams<'_>) -> Result<Vec<Entry>> {
|
|
||||||
fetch_entries_paged(pool, ¶ms).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Count `entries` rows matching the same filters as [`list_entries`] (ignores `sort` / `limit` / `offset`).
|
|
||||||
/// Does not read the `secrets` table.
|
|
||||||
pub async fn count_entries(pool: &PgPool, a: &SearchParams<'_>) -> Result<i64> {
|
|
||||||
let (where_clause, _) = entry_where_clause_and_next_idx(a);
|
|
||||||
let sql = format!("SELECT COUNT(*)::bigint FROM entries {where_clause}");
|
|
||||||
let mut q = sqlx::query_scalar::<_, i64>(&sql);
|
|
||||||
if let Some(uid) = a.user_id {
|
|
||||||
q = q.bind(uid);
|
|
||||||
}
|
|
||||||
if let Some(v) = a.folder {
|
|
||||||
q = q.bind(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = a.entry_type {
|
|
||||||
q = q.bind(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = a.name {
|
|
||||||
q = q.bind(v);
|
|
||||||
}
|
|
||||||
for tag in a.tags {
|
|
||||||
q = q.bind(tag);
|
|
||||||
}
|
|
||||||
if let Some(v) = a.query {
|
|
||||||
let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_"));
|
|
||||||
q = q.bind(pattern);
|
|
||||||
}
|
|
||||||
let n = q.fetch_one(pool).await?;
|
|
||||||
Ok(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared WHERE clause and the next `$n` index (for LIMIT/OFFSET in paged queries).
|
|
||||||
fn entry_where_clause_and_next_idx(a: &SearchParams<'_>) -> (String, i32) {
|
|
||||||
let mut conditions: Vec<String> = Vec::new();
|
|
||||||
let mut idx: i32 = 1;
|
|
||||||
|
|
||||||
if a.user_id.is_some() {
|
|
||||||
conditions.push(format!("user_id = ${}", idx));
|
|
||||||
idx += 1;
|
|
||||||
} else {
|
|
||||||
conditions.push("user_id IS NULL".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
if a.folder.is_some() {
|
|
||||||
conditions.push(format!("folder = ${}", idx));
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
if a.entry_type.is_some() {
|
|
||||||
conditions.push(format!("type = ${}", idx));
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
if a.name.is_some() {
|
|
||||||
conditions.push(format!("name = ${}", idx));
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
if !a.tags.is_empty() {
|
|
||||||
let placeholders: Vec<String> = a
|
|
||||||
.tags
|
|
||||||
.iter()
|
|
||||||
.map(|_| {
|
|
||||||
let p = format!("${}", idx);
|
|
||||||
idx += 1;
|
|
||||||
p
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
conditions.push(format!(
|
|
||||||
"tags @> ARRAY[{}]::text[]",
|
|
||||||
placeholders.join(", ")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if a.query.is_some() {
|
|
||||||
conditions.push(format!(
|
|
||||||
"(name ILIKE ${i} ESCAPE '\\' OR folder ILIKE ${i} ESCAPE '\\' \
|
|
||||||
OR type ILIKE ${i} ESCAPE '\\' OR notes ILIKE ${i} ESCAPE '\\' \
|
|
||||||
OR metadata::text ILIKE ${i} ESCAPE '\\' \
|
|
||||||
OR EXISTS (SELECT 1 FROM unnest(tags) t WHERE t ILIKE ${i} ESCAPE '\\'))",
|
|
||||||
i = idx
|
|
||||||
));
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let where_clause = if conditions.is_empty() {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
format!("WHERE {}", conditions.join(" AND "))
|
|
||||||
};
|
|
||||||
(where_clause, idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(pool: &PgPool, params: SearchParams<'_>) -> Result<SearchResult> {
|
|
||||||
let entries = fetch_entries_paged(pool, ¶ms).await?;
|
|
||||||
let entry_ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
|
|
||||||
let secret_schemas = if !entry_ids.is_empty() {
|
|
||||||
fetch_secret_schemas(pool, &entry_ids).await?
|
|
||||||
} else {
|
|
||||||
HashMap::new()
|
|
||||||
};
|
|
||||||
Ok(SearchResult {
|
|
||||||
entries,
|
|
||||||
secret_schemas,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch entries matching the given filters — returns all matching entries up to FETCH_ALL_LIMIT.
|
|
||||||
pub async fn fetch_entries(
|
|
||||||
pool: &PgPool,
|
|
||||||
folder: Option<&str>,
|
|
||||||
entry_type: Option<&str>,
|
|
||||||
name: Option<&str>,
|
|
||||||
tags: &[String],
|
|
||||||
query: Option<&str>,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<Vec<Entry>> {
|
|
||||||
let params = SearchParams {
|
|
||||||
folder,
|
|
||||||
entry_type,
|
|
||||||
name,
|
|
||||||
tags,
|
|
||||||
query,
|
|
||||||
sort: "name",
|
|
||||||
limit: FETCH_ALL_LIMIT,
|
|
||||||
offset: 0,
|
|
||||||
user_id,
|
|
||||||
};
|
|
||||||
list_entries(pool, params).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn fetch_entries_paged(pool: &PgPool, a: &SearchParams<'_>) -> Result<Vec<Entry>> {
|
|
||||||
let (where_clause, idx) = entry_where_clause_and_next_idx(a);
|
|
||||||
|
|
||||||
let order = match a.sort {
|
|
||||||
"updated" => "updated_at DESC",
|
|
||||||
"created" => "created_at DESC",
|
|
||||||
_ => "name ASC",
|
|
||||||
};
|
|
||||||
|
|
||||||
let limit_idx = idx;
|
|
||||||
let offset_idx = idx + 1;
|
|
||||||
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT id, user_id, folder, type, name, notes, tags, metadata, version, \
|
|
||||||
created_at, updated_at \
|
|
||||||
FROM entries {where_clause} ORDER BY {order} LIMIT ${limit_idx} OFFSET ${offset_idx}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut q = sqlx::query_as::<_, EntryRaw>(&sql);
|
|
||||||
if let Some(uid) = a.user_id {
|
|
||||||
q = q.bind(uid);
|
|
||||||
}
|
|
||||||
if let Some(v) = a.folder {
|
|
||||||
q = q.bind(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = a.entry_type {
|
|
||||||
q = q.bind(v);
|
|
||||||
}
|
|
||||||
if let Some(v) = a.name {
|
|
||||||
q = q.bind(v);
|
|
||||||
}
|
|
||||||
for tag in a.tags {
|
|
||||||
q = q.bind(tag);
|
|
||||||
}
|
|
||||||
if let Some(v) = a.query {
|
|
||||||
let pattern = format!("%{}%", v.replace('%', "\\%").replace('_', "\\_"));
|
|
||||||
q = q.bind(pattern);
|
|
||||||
}
|
|
||||||
q = q.bind(a.limit as i64).bind(a.offset as i64);
|
|
||||||
|
|
||||||
let rows = q.fetch_all(pool).await?;
|
|
||||||
Ok(rows.into_iter().map(Entry::from).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch secret field names for a set of entry ids (no decryption).
|
|
||||||
pub async fn fetch_secret_schemas(
|
|
||||||
pool: &PgPool,
|
|
||||||
entry_ids: &[Uuid],
|
|
||||||
) -> Result<HashMap<Uuid, Vec<SecretField>>> {
|
|
||||||
if entry_ids.is_empty() {
|
|
||||||
return Ok(HashMap::new());
|
|
||||||
}
|
|
||||||
let fields: Vec<EntrySecretRow> = sqlx::query_as(
|
|
||||||
"SELECT es.entry_id, s.id, s.user_id, s.name, s.type, s.encrypted, s.version, s.created_at, s.updated_at \
|
|
||||||
FROM entry_secrets es \
|
|
||||||
JOIN secrets s ON s.id = es.secret_id \
|
|
||||||
WHERE es.entry_id = ANY($1) \
|
|
||||||
ORDER BY es.entry_id, es.sort_order, s.name",
|
|
||||||
)
|
|
||||||
.bind(entry_ids)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
|
|
||||||
for f in fields {
|
|
||||||
let entry_id = f.entry_id;
|
|
||||||
map.entry(entry_id).or_default().push(f.secret());
|
|
||||||
}
|
|
||||||
Ok(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch all secret fields (including encrypted bytes) for a set of entry ids.
|
|
||||||
pub async fn fetch_secrets_for_entries(
|
|
||||||
pool: &PgPool,
|
|
||||||
entry_ids: &[Uuid],
|
|
||||||
) -> Result<HashMap<Uuid, Vec<SecretField>>> {
|
|
||||||
if entry_ids.is_empty() {
|
|
||||||
return Ok(HashMap::new());
|
|
||||||
}
|
|
||||||
let fields: Vec<EntrySecretRow> = sqlx::query_as(
|
|
||||||
"SELECT es.entry_id, s.id, s.user_id, s.name, s.type, s.encrypted, s.version, s.created_at, s.updated_at \
|
|
||||||
FROM entry_secrets es \
|
|
||||||
JOIN secrets s ON s.id = es.secret_id \
|
|
||||||
WHERE es.entry_id = ANY($1) \
|
|
||||||
ORDER BY es.entry_id, es.sort_order, s.name",
|
|
||||||
)
|
|
||||||
.bind(entry_ids)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut map: HashMap<Uuid, Vec<SecretField>> = HashMap::new();
|
|
||||||
for f in fields {
|
|
||||||
let entry_id = f.entry_id;
|
|
||||||
map.entry(entry_id).or_default().push(f.secret());
|
|
||||||
}
|
|
||||||
Ok(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve exactly one entry by its UUID primary key.
|
|
||||||
///
|
|
||||||
/// Returns an error if the entry does not exist or does not belong to the given user.
|
|
||||||
pub async fn resolve_entry_by_id(
|
|
||||||
pool: &PgPool,
|
|
||||||
id: Uuid,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<crate::models::Entry> {
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(uid)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
} 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",
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
row.map(Entry::from)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Entry with id '{}' not found", id))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve exactly one entry by name, with optional folder for disambiguation.
|
|
||||||
///
|
|
||||||
/// - If `folder` is provided: exact `(folder, name)` match.
|
|
||||||
/// - If `folder` is None and exactly one entry matches: returns it.
|
|
||||||
/// - If `folder` is None and multiple entries match: returns an error listing
|
|
||||||
/// the folders and asking the caller to specify one.
|
|
||||||
pub async fn resolve_entry(
|
|
||||||
pool: &PgPool,
|
|
||||||
name: &str,
|
|
||||||
folder: Option<&str>,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
) -> Result<crate::models::Entry> {
|
|
||||||
let entries = fetch_entries(pool, folder, None, Some(name), &[], None, user_id).await?;
|
|
||||||
match entries.len() {
|
|
||||||
0 => {
|
|
||||||
if let Some(f) = folder {
|
|
||||||
anyhow::bail!("Not found: '{}' in folder '{}'", name, f)
|
|
||||||
} else {
|
|
||||||
anyhow::bail!("Not found: '{}'", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1 => Ok(entries.into_iter().next().unwrap()),
|
|
||||||
_ => {
|
|
||||||
let folders: Vec<&str> = entries.iter().map(|e| e.folder.as_str()).collect();
|
|
||||||
anyhow::bail!(
|
|
||||||
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
|
|
||||||
Specify 'folder' to disambiguate.",
|
|
||||||
entries.len(),
|
|
||||||
name,
|
|
||||||
folders.join(", ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Internal raw row (because user_id is nullable in DB) ─────────────────────
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct EntryRaw {
|
|
||||||
id: Uuid,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
folder: String,
|
|
||||||
#[sqlx(rename = "type")]
|
|
||||||
entry_type: String,
|
|
||||||
name: String,
|
|
||||||
notes: String,
|
|
||||||
tags: Vec<String>,
|
|
||||||
metadata: Value,
|
|
||||||
version: i64,
|
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
updated_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<EntryRaw> for Entry {
|
|
||||||
fn from(r: EntryRaw) -> Self {
|
|
||||||
Entry {
|
|
||||||
id: r.id,
|
|
||||||
user_id: r.user_id,
|
|
||||||
folder: r.folder,
|
|
||||||
entry_type: r.entry_type,
|
|
||||||
name: r.name,
|
|
||||||
notes: r.notes,
|
|
||||||
tags: r.tags,
|
|
||||||
metadata: r.metadata,
|
|
||||||
version: r.version,
|
|
||||||
created_at: r.created_at,
|
|
||||||
updated_at: r.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct EntrySecretRow {
|
|
||||||
entry_id: Uuid,
|
|
||||||
id: Uuid,
|
|
||||||
user_id: Option<Uuid>,
|
|
||||||
name: String,
|
|
||||||
#[sqlx(rename = "type")]
|
|
||||||
secret_type: String,
|
|
||||||
encrypted: Vec<u8>,
|
|
||||||
version: i64,
|
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
updated_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EntrySecretRow {
|
|
||||||
fn secret(self) -> SecretField {
|
|
||||||
SecretField {
|
|
||||||
id: self.id,
|
|
||||||
user_id: self.user_id,
|
|
||||||
name: self.name,
|
|
||||||
secret_type: self.secret_type,
|
|
||||||
encrypted: self.encrypted,
|
|
||||||
version: self.version,
|
|
||||||
created_at: self.created_at,
|
|
||||||
updated_at: self.updated_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use serde_json::{Map, Value};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::crypto;
|
|
||||||
use crate::db;
|
|
||||||
use crate::models::{EntryRow, EntryWriteRow};
|
|
||||||
use crate::service::add::{
|
|
||||||
collect_field_paths, collect_key_paths, flatten_json_fields, infer_secret_type, insert_path,
|
|
||||||
parse_key_path, parse_kv, remove_path,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
|
||||||
pub struct UpdateResult {
|
|
||||||
pub name: String,
|
|
||||||
pub folder: String,
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub entry_type: String,
|
|
||||||
pub add_tags: Vec<String>,
|
|
||||||
pub remove_tags: Vec<String>,
|
|
||||||
pub meta_keys: Vec<String>,
|
|
||||||
pub remove_meta: Vec<String>,
|
|
||||||
pub secret_keys: Vec<String>,
|
|
||||||
pub remove_secrets: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct UpdateParams<'a> {
|
|
||||||
pub name: &'a str,
|
|
||||||
/// Optional folder for disambiguation when multiple entries share the same name.
|
|
||||||
pub folder: Option<&'a str>,
|
|
||||||
pub notes: Option<&'a str>,
|
|
||||||
pub add_tags: &'a [String],
|
|
||||||
pub remove_tags: &'a [String],
|
|
||||||
pub meta_entries: &'a [String],
|
|
||||||
pub remove_meta: &'a [String],
|
|
||||||
pub secret_entries: &'a [String],
|
|
||||||
pub remove_secrets: &'a [String],
|
|
||||||
pub user_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn run(
|
|
||||||
pool: &PgPool,
|
|
||||||
params: UpdateParams<'_>,
|
|
||||||
master_key: &[u8; 32],
|
|
||||||
) -> Result<UpdateResult> {
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
|
|
||||||
// Fetch matching rows with FOR UPDATE; use folder when provided to resolve ambiguity.
|
|
||||||
let rows: Vec<EntryRow> = if let Some(uid) = params.user_id {
|
|
||||||
if let Some(folder) = params.folder {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
|
||||||
WHERE user_id = $1 AND folder = $2 AND name = $3 FOR UPDATE",
|
|
||||||
)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(folder)
|
|
||||||
.bind(params.name)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
|
||||||
WHERE user_id = $1 AND name = $2 FOR UPDATE",
|
|
||||||
)
|
|
||||||
.bind(uid)
|
|
||||||
.bind(params.name)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
} else if let Some(folder) = params.folder {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
|
||||||
WHERE user_id IS NULL AND folder = $1 AND name = $2 FOR UPDATE",
|
|
||||||
)
|
|
||||||
.bind(folder)
|
|
||||||
.bind(params.name)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(
|
|
||||||
"SELECT id, version, folder, type, tags, metadata, notes FROM entries \
|
|
||||||
WHERE user_id IS NULL AND name = $1 FOR UPDATE",
|
|
||||||
)
|
|
||||||
.bind(params.name)
|
|
||||||
.fetch_all(&mut *tx)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
let row = match rows.len() {
|
|
||||||
0 => {
|
|
||||||
tx.rollback().await?;
|
|
||||||
anyhow::bail!(
|
|
||||||
"Not found: '{}'. Use `add` to create it first.",
|
|
||||||
params.name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
1 => rows.into_iter().next().unwrap(),
|
|
||||||
_ => {
|
|
||||||
tx.rollback().await?;
|
|
||||||
let folders: Vec<&str> = rows.iter().map(|r| r.folder.as_str()).collect();
|
|
||||||
anyhow::bail!(
|
|
||||||
"Ambiguous: {} entries named '{}' found in folders: [{}]. \
|
|
||||||
Specify 'folder' to disambiguate.",
|
|
||||||
rows.len(),
|
|
||||||
params.name,
|
|
||||||
folders.join(", ")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = db::snapshot_entry_history(
|
|
||||||
&mut tx,
|
|
||||||
db::EntrySnapshotParams {
|
|
||||||
entry_id: row.id,
|
|
||||||
user_id: params.user_id,
|
|
||||||
folder: &row.folder,
|
|
||||||
entry_type: &row.entry_type,
|
|
||||||
name: params.name,
|
|
||||||
version: row.version,
|
|
||||||
action: "update",
|
|
||||||
tags: &row.tags,
|
|
||||||
metadata: &row.metadata,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "failed to snapshot entry history before update");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tags: Vec<String> = row.tags.clone();
|
|
||||||
for t in params.add_tags {
|
|
||||||
if !tags.contains(t) {
|
|
||||||
tags.push(t.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tags.retain(|t| !params.remove_tags.contains(t));
|
|
||||||
|
|
||||||
let mut meta_map: Map<String, Value> = match row.metadata.clone() {
|
|
||||||
Value::Object(m) => m,
|
|
||||||
_ => Map::new(),
|
|
||||||
};
|
|
||||||
for entry in params.meta_entries {
|
|
||||||
let (path, value) = parse_kv(entry)?;
|
|
||||||
insert_path(&mut meta_map, &path, value)?;
|
|
||||||
}
|
|
||||||
for key in params.remove_meta {
|
|
||||||
let path = parse_key_path(key)?;
|
|
||||||
remove_path(&mut meta_map, &path)?;
|
|
||||||
}
|
|
||||||
let metadata = Value::Object(meta_map);
|
|
||||||
|
|
||||||
let new_notes = params.notes.unwrap_or(&row.notes);
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE entries SET tags = $1, metadata = $2, notes = $3, \
|
|
||||||
version = version + 1, updated_at = NOW() \
|
|
||||||
WHERE id = $4 AND version = $5",
|
|
||||||
)
|
|
||||||
.bind(&tags)
|
|
||||||
.bind(&metadata)
|
|
||||||
.bind(new_notes)
|
|
||||||
.bind(row.id)
|
|
||||||
.bind(row.version)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
|
||||||
tx.rollback().await?;
|
|
||||||
anyhow::bail!(
|
|
||||||
"Concurrent modification detected for '{}'. Please retry.",
|
|
||||||
params.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for entry in params.secret_entries {
|
|
||||||
let (path, field_value) = parse_kv(entry)?;
|
|
||||||
let flat = flatten_json_fields("", &{
|
|
||||||
let mut m = Map::new();
|
|
||||||
insert_path(&mut m, &path, field_value)?;
|
|
||||||
Value::Object(m)
|
|
||||||
});
|
|
||||||
|
|
||||||
for (field_name, fv) in &flat {
|
|
||||||
let encrypted = crypto::encrypt_json(master_key, fv)?;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct ExistingField {
|
|
||||||
id: Uuid,
|
|
||||||
encrypted: Vec<u8>,
|
|
||||||
}
|
|
||||||
let ef: Option<ExistingField> = sqlx::query_as(
|
|
||||||
"SELECT s.id, s.encrypted \
|
|
||||||
FROM entry_secrets es \
|
|
||||||
JOIN secrets s ON s.id = es.secret_id \
|
|
||||||
WHERE es.entry_id = $1 AND s.name = $2",
|
|
||||||
)
|
|
||||||
.bind(row.id)
|
|
||||||
.bind(field_name)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(ef) = &ef
|
|
||||||
&& let Err(e) = db::snapshot_secret_history(
|
|
||||||
&mut tx,
|
|
||||||
db::SecretSnapshotParams {
|
|
||||||
secret_id: ef.id,
|
|
||||||
name: field_name,
|
|
||||||
encrypted: &ef.encrypted,
|
|
||||||
action: "update",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "failed to snapshot secret field history");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ef) = ef {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE secrets SET encrypted = $1, version = version + 1, updated_at = NOW() WHERE id = $2",
|
|
||||||
)
|
|
||||||
.bind(&encrypted)
|
|
||||||
.bind(ef.id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
} else {
|
|
||||||
let secret_id: Uuid = sqlx::query_scalar(
|
|
||||||
"INSERT INTO secrets (user_id, name, type, encrypted) VALUES ($1, $2, $3, $4) RETURNING id",
|
|
||||||
)
|
|
||||||
.bind(params.user_id)
|
|
||||||
.bind(field_name)
|
|
||||||
.bind(infer_secret_type(field_name))
|
|
||||||
.bind(&encrypted)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
sqlx::query("INSERT INTO entry_secrets (entry_id, secret_id) VALUES ($1, $2)")
|
|
||||||
.bind(row.id)
|
|
||||||
.bind(secret_id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for key in params.remove_secrets {
|
|
||||||
let path = parse_key_path(key)?;
|
|
||||||
let field_name = path.join(".");
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct FieldToDelete {
|
|
||||||
id: Uuid,
|
|
||||||
encrypted: Vec<u8>,
|
|
||||||
}
|
|
||||||
let field: Option<FieldToDelete> = sqlx::query_as(
|
|
||||||
"SELECT s.id, s.encrypted \
|
|
||||||
FROM entry_secrets es \
|
|
||||||
JOIN secrets s ON s.id = es.secret_id \
|
|
||||||
WHERE es.entry_id = $1 AND s.name = $2",
|
|
||||||
)
|
|
||||||
.bind(row.id)
|
|
||||||
.bind(&field_name)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(f) = field {
|
|
||||||
if let Err(e) = db::snapshot_secret_history(
|
|
||||||
&mut tx,
|
|
||||||
db::SecretSnapshotParams {
|
|
||||||
secret_id: f.id,
|
|
||||||
name: &field_name,
|
|
||||||
encrypted: &f.encrypted,
|
|
||||||
action: "delete",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "failed to snapshot secret field history before delete");
|
|
||||||
}
|
|
||||||
sqlx::query("DELETE FROM entry_secrets WHERE entry_id = $1 AND secret_id = $2")
|
|
||||||
.bind(row.id)
|
|
||||||
.bind(f.id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
sqlx::query(
|
|
||||||
"DELETE FROM secrets s \
|
|
||||||
WHERE s.id = $1 \
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM entry_secrets es WHERE es.secret_id = s.id)",
|
|
||||||
)
|
|
||||||
.bind(f.id)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let meta_keys = collect_key_paths(params.meta_entries)?;
|
|
||||||
let remove_meta_keys = collect_field_paths(params.remove_meta)?;
|
|
||||||
let secret_keys = collect_key_paths(params.secret_entries)?;
|
|
||||||
let remove_secret_keys = collect_field_paths(params.remove_secrets)?;
|
|
||||||
|
|
||||||
crate::audit::log_tx(
|
|
||||||
&mut tx,
|
|
||||||
params.user_id,
|
|
||||||
"update",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
params.name,
|
|
||||||
serde_json::json!({
|
|
||||||
"add_tags": params.add_tags,
|
|
||||||
"remove_tags": params.remove_tags,
|
|
||||||
"meta_keys": meta_keys,
|
|
||||||
"remove_meta": remove_meta_keys,
|
|
||||||
"secret_keys": secret_keys,
|
|
||||||
"remove_secrets": remove_secret_keys,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
Ok(UpdateResult {
|
|
||||||
name: params.name.to_string(),
|
|
||||||
folder: row.folder.clone(),
|
|
||||||
entry_type: row.entry_type.clone(),
|
|
||||||
add_tags: params.add_tags.to_vec(),
|
|
||||||
remove_tags: params.remove_tags.to_vec(),
|
|
||||||
meta_keys,
|
|
||||||
remove_meta: remove_meta_keys,
|
|
||||||
secret_keys,
|
|
||||||
remove_secrets: remove_secret_keys,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update non-sensitive entry columns by primary key (multi-tenant: `user_id` must match).
|
|
||||||
/// Does not read or modify `secrets` rows.
|
|
||||||
pub struct UpdateEntryFieldsByIdParams<'a> {
|
|
||||||
pub folder: &'a str,
|
|
||||||
pub entry_type: &'a str,
|
|
||||||
pub name: &'a str,
|
|
||||||
pub notes: &'a str,
|
|
||||||
pub tags: &'a [String],
|
|
||||||
pub metadata: &'a serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn update_fields_by_id(
|
|
||||||
pool: &PgPool,
|
|
||||||
entry_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
params: UpdateEntryFieldsByIdParams<'_>,
|
|
||||||
) -> Result<()> {
|
|
||||||
if params.folder.len() > 128 {
|
|
||||||
anyhow::bail!("folder must be at most 128 characters");
|
|
||||||
}
|
|
||||||
if params.entry_type.len() > 64 {
|
|
||||||
anyhow::bail!("type must be at most 64 characters");
|
|
||||||
}
|
|
||||||
if params.name.len() > 256 {
|
|
||||||
anyhow::bail!("name must be at most 256 characters");
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
.bind(entry_id)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_optional(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let row = match row {
|
|
||||||
Some(r) => r,
|
|
||||||
None => {
|
|
||||||
tx.rollback().await?;
|
|
||||||
anyhow::bail!("Entry not found");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Err(e) = db::snapshot_entry_history(
|
|
||||||
&mut tx,
|
|
||||||
db::EntrySnapshotParams {
|
|
||||||
entry_id: row.id,
|
|
||||||
user_id: Some(user_id),
|
|
||||||
folder: &row.folder,
|
|
||||||
entry_type: &row.entry_type,
|
|
||||||
name: &row.name,
|
|
||||||
version: row.version,
|
|
||||||
action: "update",
|
|
||||||
tags: &row.tags,
|
|
||||||
metadata: &row.metadata,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "failed to snapshot entry history before web update");
|
|
||||||
}
|
|
||||||
|
|
||||||
let res = sqlx::query(
|
|
||||||
"UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \
|
|
||||||
version = version + 1, updated_at = NOW() \
|
|
||||||
WHERE id = $7 AND version = $8",
|
|
||||||
)
|
|
||||||
.bind(params.folder)
|
|
||||||
.bind(params.entry_type)
|
|
||||||
.bind(params.name)
|
|
||||||
.bind(params.notes)
|
|
||||||
.bind(params.tags)
|
|
||||||
.bind(params.metadata)
|
|
||||||
.bind(row.id)
|
|
||||||
.bind(row.version)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await
|
|
||||||
.map_err(|e| {
|
|
||||||
if let sqlx::Error::Database(ref d) = e
|
|
||||||
&& d.code().as_deref() == Some("23505")
|
|
||||||
{
|
|
||||||
return anyhow::anyhow!(
|
|
||||||
"An entry with this folder and name already exists for your account."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
e.into()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if res.rows_affected() == 0 {
|
|
||||||
tx.rollback().await?;
|
|
||||||
anyhow::bail!("Concurrent modification detected. Please refresh and try again.");
|
|
||||||
}
|
|
||||||
|
|
||||||
crate::audit::log_tx(
|
|
||||||
&mut tx,
|
|
||||||
Some(user_id),
|
|
||||||
"update",
|
|
||||||
params.folder,
|
|
||||||
params.entry_type,
|
|
||||||
params.name,
|
|
||||||
serde_json::json!({
|
|
||||||
"source": "web",
|
|
||||||
"entry_id": entry_id,
|
|
||||||
"fields": ["folder", "type", "name", "notes", "tags", "metadata"],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use serde_json::Value;
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::models::{OauthAccount, User};
|
|
||||||
|
|
||||||
pub struct OAuthProfile {
|
|
||||||
pub provider: String,
|
|
||||||
pub provider_id: String,
|
|
||||||
pub email: Option<String>,
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub avatar_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find or create a user from an OAuth profile.
|
|
||||||
/// Returns (user, is_new) where is_new indicates first-time registration.
|
|
||||||
pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result<(User, bool)> {
|
|
||||||
// Check if this OAuth account already exists
|
|
||||||
let existing: Option<OauthAccount> = sqlx::query_as(
|
|
||||||
"SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \
|
|
||||||
FROM oauth_accounts WHERE provider = $1 AND provider_id = $2",
|
|
||||||
)
|
|
||||||
.bind(&profile.provider)
|
|
||||||
.bind(&profile.provider_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(oa) = existing {
|
|
||||||
let user: User = sqlx::query_as(
|
|
||||||
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
|
|
||||||
FROM users WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(oa.user_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
return Ok((user, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
// New user — create records (no key yet; user sets passphrase on dashboard)
|
|
||||||
let display_name = profile
|
|
||||||
.name
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| profile.email.clone().unwrap_or_else(|| "User".to_string()));
|
|
||||||
|
|
||||||
let mut tx = pool.begin().await?;
|
|
||||||
|
|
||||||
let user: User = sqlx::query_as(
|
|
||||||
"INSERT INTO users (email, name, avatar_url) \
|
|
||||||
VALUES ($1, $2, $3) \
|
|
||||||
RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at",
|
|
||||||
)
|
|
||||||
.bind(&profile.email)
|
|
||||||
.bind(&display_name)
|
|
||||||
.bind(&profile.avatar_url)
|
|
||||||
.fetch_one(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
|
||||||
)
|
|
||||||
.bind(user.id)
|
|
||||||
.bind(&profile.provider)
|
|
||||||
.bind(&profile.provider_id)
|
|
||||||
.bind(&profile.email)
|
|
||||||
.bind(&profile.name)
|
|
||||||
.bind(&profile.avatar_url)
|
|
||||||
.execute(&mut *tx)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tx.commit().await?;
|
|
||||||
|
|
||||||
Ok((user, true))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store the PBKDF2 salt, key_check, and params for a user's passphrase setup.
|
|
||||||
pub async fn update_user_key_setup(
|
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Uuid,
|
|
||||||
key_salt: &[u8],
|
|
||||||
key_check: &[u8],
|
|
||||||
key_params: &Value,
|
|
||||||
) -> Result<()> {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, updated_at = NOW() \
|
|
||||||
WHERE id = $4",
|
|
||||||
)
|
|
||||||
.bind(key_salt)
|
|
||||||
.bind(key_check)
|
|
||||||
.bind(key_params)
|
|
||||||
.bind(user_id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch a user by ID.
|
|
||||||
pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<User>> {
|
|
||||||
let user = sqlx::query_as(
|
|
||||||
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
|
|
||||||
FROM users WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all OAuth accounts linked to a user.
|
|
||||||
pub async fn list_oauth_accounts(pool: &PgPool, user_id: Uuid) -> Result<Vec<OauthAccount>> {
|
|
||||||
let accounts = sqlx::query_as(
|
|
||||||
"SELECT id, user_id, provider, provider_id, email, name, avatar_url, created_at \
|
|
||||||
FROM oauth_accounts WHERE user_id = $1 ORDER BY created_at",
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
Ok(accounts)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bind an additional OAuth account to an existing user.
|
|
||||||
pub async fn bind_oauth_account(
|
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Uuid,
|
|
||||||
profile: OAuthProfile,
|
|
||||||
) -> Result<OauthAccount> {
|
|
||||||
// Check if this provider_id is already linked to someone else
|
|
||||||
let conflict: Option<(Uuid,)> = sqlx::query_as(
|
|
||||||
"SELECT user_id FROM oauth_accounts WHERE provider = $1 AND provider_id = $2",
|
|
||||||
)
|
|
||||||
.bind(&profile.provider)
|
|
||||||
.bind(&profile.provider_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some((existing_user_id,)) = conflict {
|
|
||||||
if existing_user_id != user_id {
|
|
||||||
anyhow::bail!(
|
|
||||||
"This {} account is already linked to a different user",
|
|
||||||
profile.provider
|
|
||||||
);
|
|
||||||
}
|
|
||||||
anyhow::bail!(
|
|
||||||
"This {} account is already linked to your account",
|
|
||||||
profile.provider
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let existing_provider_for_user: Option<(String,)> = sqlx::query_as(
|
|
||||||
"SELECT provider_id FROM oauth_accounts WHERE user_id = $1 AND provider = $2",
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(&profile.provider)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if existing_provider_for_user.is_some() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"You already linked a {} account. Unlink the other provider instead of binding multiple {} accounts.",
|
|
||||||
profile.provider,
|
|
||||||
profile.provider
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let account: OauthAccount = sqlx::query_as(
|
|
||||||
"INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name, avatar_url) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
|
||||||
RETURNING id, user_id, provider, provider_id, email, name, avatar_url, created_at",
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(&profile.provider)
|
|
||||||
.bind(&profile.provider_id)
|
|
||||||
.bind(&profile.email)
|
|
||||||
.bind(&profile.name)
|
|
||||||
.bind(&profile.avatar_url)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(account)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unbind an OAuth account. Ensures at least one remains and blocks unlinking the current login provider.
|
|
||||||
pub async fn unbind_oauth_account(
|
|
||||||
pool: &PgPool,
|
|
||||||
user_id: Uuid,
|
|
||||||
provider: &str,
|
|
||||||
current_login_provider: Option<&str>,
|
|
||||||
) -> Result<()> {
|
|
||||||
if current_login_provider == Some(provider) {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Cannot unlink the {} account you are currently using to sign in",
|
|
||||||
provider
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_accounts WHERE user_id = $1")
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if count <= 1 {
|
|
||||||
anyhow::bail!("Cannot unbind the last OAuth account. Please link another account first.");
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM oauth_accounts WHERE user_id = $1 AND provider = $2")
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(provider)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "secrets-mcp"
|
|
||||||
version = "0.3.9"
|
|
||||||
edition.workspace = true
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "secrets-mcp"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
secrets-core = { path = "../secrets-core" }
|
|
||||||
|
|
||||||
# MCP
|
|
||||||
rmcp = { version = "1", features = ["server", "macros", "transport-streamable-http-server", "schemars"] }
|
|
||||||
|
|
||||||
# Web framework
|
|
||||||
axum = "0.8"
|
|
||||||
axum-extra = { version = "0.10", features = ["typed-header"] }
|
|
||||||
tower = "0.5"
|
|
||||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
|
||||||
tower-sessions = "0.14"
|
|
||||||
tower-sessions-sqlx-store-chrono = { version = "0.14", features = ["postgres"] }
|
|
||||||
time = "0.3"
|
|
||||||
|
|
||||||
# OAuth (manual token exchange via reqwest)
|
|
||||||
reqwest.workspace = true
|
|
||||||
|
|
||||||
# Templating - render templates manually to avoid integration crate issues
|
|
||||||
askama = "0.13"
|
|
||||||
|
|
||||||
# Common
|
|
||||||
anyhow.workspace = true
|
|
||||||
chrono.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
sha2.workspace = true
|
|
||||||
rand.workspace = true
|
|
||||||
sqlx.workspace = true
|
|
||||||
tokio.workspace = true
|
|
||||||
tracing.workspace = true
|
|
||||||
tracing-subscriber.workspace = true
|
|
||||||
uuid.workspace = true
|
|
||||||
dotenvy.workspace = true
|
|
||||||
urlencoding = "2"
|
|
||||||
schemars = "1"
|
|
||||||
http = "1"
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
use std::net::SocketAddr;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
extract::{ConnectInfo, Request, State},
|
|
||||||
http::StatusCode,
|
|
||||||
middleware::Next,
|
|
||||||
response::Response,
|
|
||||||
};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use secrets_core::service::api_key::validate_api_key;
|
|
||||||
|
|
||||||
/// Injected into request extensions after Bearer token validation.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct AuthUser {
|
|
||||||
pub user_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_client_ip(req: &Request) -> Option<String> {
|
|
||||||
if let Some(first) = req
|
|
||||||
.headers()
|
|
||||||
.get("x-forwarded-for")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(|s| s.split(',').next())
|
|
||||||
{
|
|
||||||
let s = first.trim();
|
|
||||||
if !s.is_empty() {
|
|
||||||
return Some(s.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
req.extensions()
|
|
||||||
.get::<ConnectInfo<SocketAddr>>()
|
|
||||||
.map(|c| c.ip().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Axum middleware that validates Bearer API keys for the /mcp route.
|
|
||||||
/// Passes all non-MCP paths through without authentication.
|
|
||||||
pub async fn bearer_auth_middleware(
|
|
||||||
State(pool): State<PgPool>,
|
|
||||||
req: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Result<Response, StatusCode> {
|
|
||||||
let path = req.uri().path();
|
|
||||||
let method = req.method().as_str();
|
|
||||||
let client_ip = log_client_ip(&req);
|
|
||||||
|
|
||||||
// Only authenticate /mcp paths
|
|
||||||
if !path.starts_with("/mcp") {
|
|
||||||
return Ok(next.run(req).await);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow OPTIONS (CORS preflight) through
|
|
||||||
if req.method() == axum::http::Method::OPTIONS {
|
|
||||||
return Ok(next.run(req).await);
|
|
||||||
}
|
|
||||||
|
|
||||||
let auth_header = req
|
|
||||||
.headers()
|
|
||||||
.get(axum::http::header::AUTHORIZATION)
|
|
||||||
.and_then(|v| v.to_str().ok());
|
|
||||||
|
|
||||||
let raw_key = match auth_header {
|
|
||||||
Some(h) if h.starts_with("Bearer ") => h.trim_start_matches("Bearer ").trim(),
|
|
||||||
Some(_) => {
|
|
||||||
tracing::warn!(
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
client_ip = client_ip.as_deref(),
|
|
||||||
"invalid Authorization header format on /mcp (expected Bearer …)"
|
|
||||||
);
|
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
tracing::warn!(
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
client_ip = client_ip.as_deref(),
|
|
||||||
"missing Authorization header on /mcp"
|
|
||||||
);
|
|
||||||
return Err(StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match validate_api_key(&pool, raw_key).await {
|
|
||||||
Ok(Some(user_id)) => {
|
|
||||||
tracing::debug!(?user_id, "api key authenticated");
|
|
||||||
let mut req = req;
|
|
||||||
req.extensions_mut().insert(AuthUser { user_id });
|
|
||||||
Ok(next.run(req).await)
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
tracing::warn!(
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
client_ip = client_ip.as_deref(),
|
|
||||||
key_prefix = %&raw_key.chars().take(12).collect::<String>(),
|
|
||||||
key_len = raw_key.len(),
|
|
||||||
"invalid api key (not found in database — e.g. revoked key or DB was reset; update MCP client Bearer token)"
|
|
||||||
);
|
|
||||||
Err(StatusCode::UNAUTHORIZED)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(
|
|
||||||
method,
|
|
||||||
path,
|
|
||||||
client_ip = client_ip.as_deref(),
|
|
||||||
error = %e,
|
|
||||||
"api key validation error"
|
|
||||||
);
|
|
||||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
use std::net::SocketAddr;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
body::{Body, Bytes, to_bytes},
|
|
||||||
extract::{ConnectInfo, Request},
|
|
||||||
http::{
|
|
||||||
HeaderMap, Method, StatusCode,
|
|
||||||
header::{CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT},
|
|
||||||
},
|
|
||||||
middleware::Next,
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Axum middleware that logs structured info for every HTTP request.
|
|
||||||
///
|
|
||||||
/// All requests: method, path, status, latency_ms, client_ip, user_agent.
|
|
||||||
/// POST /mcp requests: additionally parses JSON-RPC body for jsonrpc_method,
|
|
||||||
/// tool_name, jsonrpc_id, mcp_session, batch_size.
|
|
||||||
///
|
|
||||||
/// Sensitive headers (Authorization, X-Encryption-Key) and secret values
|
|
||||||
/// are never logged.
|
|
||||||
pub async fn request_logging_middleware(req: Request, next: Next) -> Response {
|
|
||||||
let method = req.method().clone();
|
|
||||||
let path = req.uri().path().to_string();
|
|
||||||
let ip = client_ip(&req);
|
|
||||||
let ua = header_str(req.headers(), USER_AGENT);
|
|
||||||
let content_len = header_str(req.headers(), CONTENT_LENGTH).and_then(|v| v.parse::<u64>().ok());
|
|
||||||
let mcp_session = req
|
|
||||||
.headers()
|
|
||||||
.get("mcp-session-id")
|
|
||||||
.or_else(|| req.headers().get("x-mcp-session"))
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
let is_mcp_post = path.starts_with("/mcp") && method == Method::POST;
|
|
||||||
let is_json = header_str(req.headers(), CONTENT_TYPE)
|
|
||||||
.map(|ct| ct.contains("application/json"))
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
// For MCP JSON-RPC POST requests, buffer body to extract JSON-RPC metadata.
|
|
||||||
// We cap at 512 KiB to avoid buffering large payloads.
|
|
||||||
if is_mcp_post && is_json {
|
|
||||||
let cap = content_len.unwrap_or(0);
|
|
||||||
if cap <= 512 * 1024 {
|
|
||||||
let (parts, body) = req.into_parts();
|
|
||||||
match to_bytes(body, 512 * 1024).await {
|
|
||||||
Ok(bytes) => {
|
|
||||||
let rpc = parse_jsonrpc_meta(&bytes);
|
|
||||||
let req = Request::from_parts(parts, Body::from(bytes));
|
|
||||||
let resp = next.run(req).await;
|
|
||||||
let status = resp.status().as_u16();
|
|
||||||
let elapsed = start.elapsed().as_millis();
|
|
||||||
log_mcp_request(
|
|
||||||
&method,
|
|
||||||
&path,
|
|
||||||
status,
|
|
||||||
elapsed,
|
|
||||||
ip.as_deref(),
|
|
||||||
ua.as_deref(),
|
|
||||||
content_len,
|
|
||||||
mcp_session.as_deref(),
|
|
||||||
&rpc,
|
|
||||||
);
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(path, error = %e, "failed to buffer MCP request body for logging");
|
|
||||||
let elapsed = start.elapsed().as_millis();
|
|
||||||
tracing::info!(
|
|
||||||
method = method.as_str(),
|
|
||||||
path,
|
|
||||||
status = StatusCode::INTERNAL_SERVER_ERROR.as_u16(),
|
|
||||||
elapsed_ms = elapsed,
|
|
||||||
client_ip = ip.as_deref(),
|
|
||||||
ua = ua.as_deref(),
|
|
||||||
content_length = content_len,
|
|
||||||
mcp_session = mcp_session.as_deref(),
|
|
||||||
"mcp request",
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
|
||||||
"failed to read request body",
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let resp = next.run(req).await;
|
|
||||||
let status = resp.status().as_u16();
|
|
||||||
let elapsed = start.elapsed().as_millis();
|
|
||||||
|
|
||||||
// Known client probe patterns that legitimately 404 — downgrade to debug to
|
|
||||||
// avoid noise in production logs. These are:
|
|
||||||
// • GET /.well-known/* — OAuth/OIDC discovery by MCP clients (RFC 8414 / RFC 9728)
|
|
||||||
// • GET /mcp → 404 — old SSE-transport compatibility probe by clients
|
|
||||||
let is_expected_probe_404 = status == 404
|
|
||||||
&& (path.starts_with("/.well-known/")
|
|
||||||
|| (method == Method::GET && path.starts_with("/mcp")));
|
|
||||||
|
|
||||||
if is_expected_probe_404 {
|
|
||||||
tracing::debug!(
|
|
||||||
method = method.as_str(),
|
|
||||||
path,
|
|
||||||
status,
|
|
||||||
elapsed_ms = elapsed,
|
|
||||||
client_ip = ip.as_deref(),
|
|
||||||
ua = ua.as_deref(),
|
|
||||||
"probe request (not found — expected)",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log_http_request(
|
|
||||||
&method,
|
|
||||||
&path,
|
|
||||||
status,
|
|
||||||
elapsed,
|
|
||||||
ip.as_deref(),
|
|
||||||
ua.as_deref(),
|
|
||||||
content_len,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Logging helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn log_http_request(
|
|
||||||
method: &Method,
|
|
||||||
path: &str,
|
|
||||||
status: u16,
|
|
||||||
elapsed_ms: u128,
|
|
||||||
client_ip: Option<&str>,
|
|
||||||
ua: Option<&str>,
|
|
||||||
content_length: Option<u64>,
|
|
||||||
) {
|
|
||||||
tracing::info!(
|
|
||||||
method = method.as_str(),
|
|
||||||
path,
|
|
||||||
status,
|
|
||||||
elapsed_ms,
|
|
||||||
client_ip,
|
|
||||||
ua,
|
|
||||||
content_length,
|
|
||||||
"http request",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn log_mcp_request(
|
|
||||||
method: &Method,
|
|
||||||
path: &str,
|
|
||||||
status: u16,
|
|
||||||
elapsed_ms: u128,
|
|
||||||
client_ip: Option<&str>,
|
|
||||||
ua: Option<&str>,
|
|
||||||
content_length: Option<u64>,
|
|
||||||
mcp_session: Option<&str>,
|
|
||||||
rpc: &JsonRpcMeta,
|
|
||||||
) {
|
|
||||||
tracing::info!(
|
|
||||||
method = method.as_str(),
|
|
||||||
path,
|
|
||||||
status,
|
|
||||||
elapsed_ms,
|
|
||||||
client_ip,
|
|
||||||
ua,
|
|
||||||
content_length,
|
|
||||||
mcp_session,
|
|
||||||
jsonrpc = rpc.rpc_method.as_deref(),
|
|
||||||
tool = rpc.tool_name.as_deref(),
|
|
||||||
jsonrpc_id = rpc.request_id.as_deref(),
|
|
||||||
batch_size = rpc.batch_size,
|
|
||||||
"mcp request",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── JSON-RPC body parsing ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct JsonRpcMeta {
|
|
||||||
request_id: Option<String>,
|
|
||||||
rpc_method: Option<String>,
|
|
||||||
tool_name: Option<String>,
|
|
||||||
batch_size: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_jsonrpc_meta(bytes: &Bytes) -> JsonRpcMeta {
|
|
||||||
let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
|
|
||||||
return JsonRpcMeta::default();
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(arr) = value.as_array() {
|
|
||||||
// Batch request: summarise method(s) from first element only
|
|
||||||
let first = arr.first().map(parse_single).unwrap_or_default();
|
|
||||||
return JsonRpcMeta {
|
|
||||||
batch_size: Some(arr.len()),
|
|
||||||
..first
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
parse_single(&value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_single(value: &serde_json::Value) -> JsonRpcMeta {
|
|
||||||
let request_id = value.get("id").and_then(json_to_string);
|
|
||||||
let rpc_method = value
|
|
||||||
.get("method")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
let tool_name = value
|
|
||||||
.pointer("/params/name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
|
|
||||||
JsonRpcMeta {
|
|
||||||
request_id,
|
|
||||||
rpc_method,
|
|
||||||
tool_name,
|
|
||||||
batch_size: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_to_string(value: &serde_json::Value) -> Option<String> {
|
|
||||||
match value {
|
|
||||||
serde_json::Value::Null => None,
|
|
||||||
serde_json::Value::String(s) => Some(s.clone()),
|
|
||||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
|
||||||
serde_json::Value::Bool(b) => Some(b.to_string()),
|
|
||||||
other => Some(other.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Header helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fn header_str(headers: &HeaderMap, name: impl axum::http::header::AsHeaderName) -> Option<String> {
|
|
||||||
headers
|
|
||||||
.get(name)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn client_ip(req: &Request) -> Option<String> {
|
|
||||||
if let Some(first) = req
|
|
||||||
.headers()
|
|
||||||
.get("x-forwarded-for")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(|s| s.split(',').next())
|
|
||||||
{
|
|
||||||
let s = first.trim();
|
|
||||||
if !s.is_empty() {
|
|
||||||
return Some(s.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
req.extensions()
|
|
||||||
.get::<ConnectInfo<SocketAddr>>()
|
|
||||||
.map(|c| c.ip().to_string())
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
mod auth;
|
|
||||||
mod logging;
|
|
||||||
mod oauth;
|
|
||||||
mod tools;
|
|
||||||
mod web;
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use axum::Router;
|
|
||||||
use rmcp::transport::streamable_http_server::{
|
|
||||||
StreamableHttpService, session::local::LocalSessionManager,
|
|
||||||
};
|
|
||||||
use sqlx::PgPool;
|
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
|
||||||
use tower_sessions::cookie::SameSite;
|
|
||||||
use tower_sessions::session_store::ExpiredDeletion;
|
|
||||||
use tower_sessions::{Expiry, SessionManagerLayer};
|
|
||||||
use tower_sessions_sqlx_store_chrono::PostgresStore;
|
|
||||||
use tracing_subscriber::EnvFilter;
|
|
||||||
use tracing_subscriber::fmt::time::FormatTime;
|
|
||||||
|
|
||||||
use secrets_core::config::resolve_db_config;
|
|
||||||
use secrets_core::db::{create_pool, migrate};
|
|
||||||
|
|
||||||
use crate::oauth::OAuthConfig;
|
|
||||||
use crate::tools::SecretsService;
|
|
||||||
|
|
||||||
/// Shared application state injected into web routes and middleware.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AppState {
|
|
||||||
pub pool: PgPool,
|
|
||||||
pub google_config: Option<OAuthConfig>,
|
|
||||||
pub base_url: String,
|
|
||||||
pub http_client: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_env_var(name: &str) -> Option<String> {
|
|
||||||
std::env::var(name).ok().filter(|s| !s.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pretty-print bind address in logs (`127.0.0.1` → `localhost`); actual socket bind unchanged.
|
|
||||||
fn listen_addr_log_display(bind_addr: &str) -> String {
|
|
||||||
bind_addr
|
|
||||||
.strip_prefix("127.0.0.1:")
|
|
||||||
.map(|port| format!("localhost:{port}"))
|
|
||||||
.unwrap_or_else(|| bind_addr.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_oauth_config(prefix: &str, base_url: &str, path: &str) -> Option<OAuthConfig> {
|
|
||||||
let client_id = load_env_var(&format!("{}_CLIENT_ID", prefix))?;
|
|
||||||
let client_secret = load_env_var(&format!("{}_CLIENT_SECRET", prefix))?;
|
|
||||||
Some(OAuthConfig {
|
|
||||||
client_id,
|
|
||||||
client_secret,
|
|
||||||
redirect_uri: format!("{}{}", base_url, path),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log line timestamps in the process local timezone (honors `TZ` / system zone).
|
|
||||||
#[derive(Clone, Copy, Default)]
|
|
||||||
struct LocalRfc3339Time;
|
|
||||||
|
|
||||||
impl FormatTime for LocalRfc3339Time {
|
|
||||||
fn format_time(&self, w: &mut tracing_subscriber::fmt::format::Writer<'_>) -> std::fmt::Result {
|
|
||||||
write!(
|
|
||||||
w,
|
|
||||||
"{}",
|
|
||||||
chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, false)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
// Load .env if present
|
|
||||||
let _ = dotenvy::dotenv();
|
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_timer(LocalRfc3339Time)
|
|
||||||
.with_env_filter(
|
|
||||||
EnvFilter::try_from_default_env()
|
|
||||||
.unwrap_or_else(|_| "secrets_mcp=info,tower_http=info".into()),
|
|
||||||
)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
// ── Database ──────────────────────────────────────────────────────────────
|
|
||||||
let db_config = resolve_db_config("")
|
|
||||||
.context("Database not configured. Set SECRETS_DATABASE_URL environment variable.")?;
|
|
||||||
let pool = create_pool(&db_config)
|
|
||||||
.await
|
|
||||||
.context("failed to connect to database")?;
|
|
||||||
migrate(&pool)
|
|
||||||
.await
|
|
||||||
.context("failed to run database migrations")?;
|
|
||||||
tracing::info!("Database connected and migrated");
|
|
||||||
|
|
||||||
// ── Configuration ─────────────────────────────────────────────────────────
|
|
||||||
let base_url = load_env_var("BASE_URL").unwrap_or_else(|| "http://localhost:9315".to_string());
|
|
||||||
let bind_addr =
|
|
||||||
load_env_var("SECRETS_MCP_BIND").unwrap_or_else(|| "127.0.0.1:9315".to_string());
|
|
||||||
|
|
||||||
// ── OAuth providers ───────────────────────────────────────────────────────
|
|
||||||
let google_config = load_oauth_config("GOOGLE", &base_url, "/auth/google/callback");
|
|
||||||
|
|
||||||
if google_config.is_none() {
|
|
||||||
tracing::warn!(
|
|
||||||
"No OAuth providers configured. Set GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET to enable login."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Session store (PostgreSQL-backed) ─────────────────────────────────────
|
|
||||||
let session_store = PostgresStore::new(pool.clone());
|
|
||||||
session_store
|
|
||||||
.migrate()
|
|
||||||
.await
|
|
||||||
.context("failed to run session table migration")?;
|
|
||||||
// Prune expired rows every hour; task is aborted when the server shuts down.
|
|
||||||
let session_cleanup = tokio::spawn(
|
|
||||||
session_store
|
|
||||||
.clone()
|
|
||||||
.continuously_delete_expired(tokio::time::Duration::from_secs(3600)),
|
|
||||||
);
|
|
||||||
// Strict would drop the session cookie on redirect from Google → our origin (cross-site nav).
|
|
||||||
let session_layer = SessionManagerLayer::new(session_store)
|
|
||||||
.with_secure(base_url.starts_with("https://"))
|
|
||||||
.with_same_site(SameSite::Lax)
|
|
||||||
.with_expiry(Expiry::OnInactivity(time::Duration::days(14)));
|
|
||||||
|
|
||||||
// ── App state ─────────────────────────────────────────────────────────────
|
|
||||||
let app_state = AppState {
|
|
||||||
pool: pool.clone(),
|
|
||||||
google_config,
|
|
||||||
base_url: base_url.clone(),
|
|
||||||
http_client: reqwest::Client::builder()
|
|
||||||
.timeout(std::time::Duration::from_secs(15))
|
|
||||||
.build()
|
|
||||||
.context("failed to build HTTP client")?,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── MCP service ───────────────────────────────────────────────────────────
|
|
||||||
let pool_arc = Arc::new(pool.clone());
|
|
||||||
|
|
||||||
let mcp_service = StreamableHttpService::new(
|
|
||||||
move || {
|
|
||||||
let p = pool_arc.clone();
|
|
||||||
Ok(SecretsService::new(p))
|
|
||||||
},
|
|
||||||
LocalSessionManager::default().into(),
|
|
||||||
Default::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Router ────────────────────────────────────────────────────────────────
|
|
||||||
let cors = CorsLayer::new()
|
|
||||||
.allow_origin(Any)
|
|
||||||
.allow_methods(Any)
|
|
||||||
.allow_headers(Any);
|
|
||||||
|
|
||||||
let router = Router::new()
|
|
||||||
.merge(web::web_router())
|
|
||||||
.nest_service("/mcp", mcp_service)
|
|
||||||
.layer(axum::middleware::from_fn(
|
|
||||||
logging::request_logging_middleware,
|
|
||||||
))
|
|
||||||
.layer(axum::middleware::from_fn_with_state(
|
|
||||||
pool,
|
|
||||||
auth::bearer_auth_middleware,
|
|
||||||
))
|
|
||||||
.layer(session_layer)
|
|
||||||
.layer(cors)
|
|
||||||
.with_state(app_state);
|
|
||||||
|
|
||||||
// ── Start server ──────────────────────────────────────────────────────────
|
|
||||||
let listener = tokio::net::TcpListener::bind(&bind_addr)
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("failed to bind to {}", bind_addr))?;
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
"Secrets MCP Server listening on http://{}",
|
|
||||||
listen_addr_log_display(&bind_addr)
|
|
||||||
);
|
|
||||||
tracing::info!("MCP endpoint: {}/mcp", base_url);
|
|
||||||
|
|
||||||
axum::serve(
|
|
||||||
listener,
|
|
||||||
router.into_make_service_with_connect_info::<SocketAddr>(),
|
|
||||||
)
|
|
||||||
.with_graceful_shutdown(shutdown_signal())
|
|
||||||
.await
|
|
||||||
.context("server error")?;
|
|
||||||
|
|
||||||
session_cleanup.abort();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn shutdown_signal() {
|
|
||||||
tokio::signal::ctrl_c()
|
|
||||||
.await
|
|
||||||
.expect("failed to install CTRL+C signal handler");
|
|
||||||
tracing::info!("Shutting down gracefully...");
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
use anyhow::{Context, Result};
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use super::{OAuthConfig, OAuthUserInfo};
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct TokenResponse {
|
|
||||||
access_token: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
token_type: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
id_token: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct UserInfo {
|
|
||||||
sub: String,
|
|
||||||
email: Option<String>,
|
|
||||||
name: Option<String>,
|
|
||||||
picture: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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
|
|
||||||
.post("https://oauth2.googleapis.com/token")
|
|
||||||
.form(&[
|
|
||||||
("code", code),
|
|
||||||
("client_id", &config.client_id),
|
|
||||||
("client_secret", &config.client_secret),
|
|
||||||
("redirect_uri", &config.redirect_uri),
|
|
||||||
("grant_type", "authorization_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")?;
|
|
||||||
|
|
||||||
let user: UserInfo = client
|
|
||||||
.get("https://openidconnect.googleapis.com/v1/userinfo")
|
|
||||||
.bearer_auth(&token_resp.access_token)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("failed to fetch Google userinfo")?
|
|
||||||
.error_for_status()
|
|
||||||
.context("Google userinfo endpoint error")?
|
|
||||||
.json()
|
|
||||||
.await
|
|
||||||
.context("failed to parse Google userinfo")?;
|
|
||||||
|
|
||||||
Ok(OAuthUserInfo {
|
|
||||||
provider: "google".to_string(),
|
|
||||||
provider_id: user.sub,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
avatar_url: user.picture,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
pub mod google;
|
|
||||||
pub mod wechat; // not yet implemented — placeholder for future WeChat integration
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Normalized OAuth user profile from any provider.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct OAuthUserInfo {
|
|
||||||
pub provider: String,
|
|
||||||
pub provider_id: String,
|
|
||||||
pub email: Option<String>,
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub avatar_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OAuth provider configuration.
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct OAuthConfig {
|
|
||||||
pub client_id: String,
|
|
||||||
pub client_secret: String,
|
|
||||||
pub redirect_uri: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the Google authorization URL.
|
|
||||||
pub fn google_auth_url(config: &OAuthConfig, state: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"https://accounts.google.com/o/oauth2/v2/auth\
|
|
||||||
?client_id={}\
|
|
||||||
&redirect_uri={}\
|
|
||||||
&response_type=code\
|
|
||||||
&scope=openid%20email%20profile\
|
|
||||||
&state={}\
|
|
||||||
&access_type=offline",
|
|
||||||
urlencoding::encode(&config.client_id),
|
|
||||||
urlencoding::encode(&config.redirect_uri),
|
|
||||||
urlencoding::encode(state),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn random_state() -> String {
|
|
||||||
use rand::RngExt;
|
|
||||||
let mut bytes = [0u8; 16];
|
|
||||||
rand::rng().fill(&mut bytes);
|
|
||||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
use super::{OAuthConfig, OAuthUserInfo};
|
|
||||||
/// WeChat OAuth — not yet implemented.
|
|
||||||
///
|
|
||||||
/// This module is a placeholder for future WeChat Open Platform integration.
|
|
||||||
/// When ready, implement `exchange_code` following the non-standard WeChat OAuth 2.0 flow:
|
|
||||||
/// - Token exchange uses a GET request (not POST)
|
|
||||||
/// - Preferred user identifier is `unionid` (cross-app), falling back to `openid`
|
|
||||||
/// - Docs: https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html
|
|
||||||
use anyhow::{Result, bail};
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn exchange_code(
|
|
||||||
_client: &reqwest::Client,
|
|
||||||
_config: &OAuthConfig,
|
|
||||||
_code: &str,
|
|
||||||
) -> Result<OAuthUserInfo> {
|
|
||||||
bail!("WeChat login is not yet implemented")
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
||||||
<text x="16" y="23" text-anchor="middle" font-family="system-ui,Segoe UI,sans-serif" font-size="22" font-weight="700" fill="#58a6ff">S</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 212 B |
@@ -1,28 +0,0 @@
|
|||||||
# Secrets MCP
|
|
||||||
|
|
||||||
> 给 AI 与自动化工具的简要说明。本站是 **secrets-mcp**:Streamable HTTP **MCP**(Model Context Protocol)与 **Web 控制台** 的组合,用于在多租户场景下存储条目元数据与加密后的秘密字段;持久化在 PostgreSQL。用户通过 OAuth(如已配置)登录 Web;MCP 调用使用 API Key 与加密相关请求头。
|
|
||||||
|
|
||||||
## 公开页面
|
|
||||||
|
|
||||||
- **`/`**:公开首页,说明安全架构(客户端密钥派生、密文存储、多租户与审计等),无需登录。
|
|
||||||
|
|
||||||
## 不应抓取或索引的内容
|
|
||||||
|
|
||||||
- **`/mcp`**:MCP 流式 HTTP 端点(JSON-RPC 等),**不是** HTML 文档,也不适合作为公开知识库来源。
|
|
||||||
- **`/api/*`**:会话或 API Key 相关的 HTTP API。
|
|
||||||
- **`/login`**:登录入口页(`noindex` / robots 通常 disallow)。
|
|
||||||
- **`/dashboard`、`/audit`、`/auth/*`、`/account/*`**:需浏览器会话,属于用户私有界面与 OAuth 流程。
|
|
||||||
|
|
||||||
## 给 AI 助手的实用提示
|
|
||||||
|
|
||||||
- 向用户说明连接方式时:MCP 基址为 `{BASE_URL}/mcp`(`BASE_URL` 由部署方设置),通常需要 `Authorization: Bearer <api_key>`;读写加密秘密时还需按部署文档传递 `X-Encryption-Key` 等头(与客户端模式有关)。
|
|
||||||
- **不要编造**本实例的数据库 URL、OAuth 密钥、回调地址或任何凭据;一律以用户环境变量与运维文档为准。
|
|
||||||
- Web 端在浏览器内用密码短语派生密钥完成端到端加密;MCP 路径下服务端可能在请求周期内临时使用客户端提供的密钥处理密文(架构细节见项目 README「加密架构」)。
|
|
||||||
|
|
||||||
## 延伸阅读
|
|
||||||
|
|
||||||
- 源码仓库:<https://gitea.refining.dev/refining/secrets>(`README.md`、`AGENTS.md` 含环境变量、表结构与运维约定)。
|
|
||||||
|
|
||||||
## 关于本文件
|
|
||||||
|
|
||||||
- 遵循常见的 **`/llms.txt`** 约定,便于人类与 LLM 快速了解站点性质与抓取边界;同文可在 **`/ai.txt`** 获取。
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Secrets MCP — robots.txt
|
|
||||||
# 本站为需登录的私密控制台与 MCP API;以下路径请勿抓取,以免浪费配额并避免误索引敏感端点。
|
|
||||||
# This host serves an authenticated dashboard and machine APIs; please skip crawling the paths below.
|
|
||||||
|
|
||||||
User-agent: *
|
|
||||||
Disallow: /mcp
|
|
||||||
Disallow: /api/
|
|
||||||
Disallow: /dashboard
|
|
||||||
Disallow: /audit
|
|
||||||
Disallow: /auth/
|
|
||||||
Disallow: /login
|
|
||||||
Disallow: /account/
|
|
||||||
|
|
||||||
# 首页 `/` 为公开安全说明页,允许抓取。
|
|
||||||
|
|
||||||
# 面向 AI / LLM 的机器可读站点说明(Markdown):/llms.txt
|
|
||||||
# Human & AI-readable site summary: /llms.txt (also /ai.txt)
|
|
||||||
|
|
||||||
User-agent: GPTBot
|
|
||||||
User-agent: Google-Extended
|
|
||||||
User-agent: anthropic-ai
|
|
||||||
User-agent: Claude-Web
|
|
||||||
User-agent: PerplexityBot
|
|
||||||
User-agent: Bytespider
|
|
||||||
Disallow: /mcp
|
|
||||||
Disallow: /api/
|
|
||||||
Disallow: /dashboard
|
|
||||||
Disallow: /audit
|
|
||||||
Disallow: /auth/
|
|
||||||
Disallow: /login
|
|
||||||
Disallow: /account/
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
<!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>Secrets — Audit</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: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
|
|
||||||
padding: 24px 16px; 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-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);
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.topbar-spacer { flex: 1; }
|
|
||||||
.nav-user { font-size: 13px; color: var(--text-muted); }
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.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; }
|
|
||||||
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
|
|
||||||
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
|
|
||||||
.empty { color: var(--text-muted); 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; }
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.layout { flex-direction: column; }
|
|
||||||
.sidebar {
|
|
||||||
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
|
|
||||||
padding: 16px; gap: 14px;
|
|
||||||
}
|
|
||||||
.sidebar-menu { flex-direction: row; }
|
|
||||||
.sidebar-link { flex: 1; text-align: center; }
|
|
||||||
.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 var(--border); padding: 12px 0; }
|
|
||||||
td { border-top: none; padding: 6px 0; }
|
|
||||||
td::before {
|
|
||||||
display: block; color: var(--text-muted); font-size: 11px;
|
|
||||||
margin-bottom: 4px; text-transform: uppercase;
|
|
||||||
}
|
|
||||||
td.col-time::before { content: "Time"; }
|
|
||||||
td.col-action::before { content: "Action"; }
|
|
||||||
td.col-target::before { content: "Target"; }
|
|
||||||
td.col-detail::before { content: "Detail"; }
|
|
||||||
.detail { max-width: none; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="layout">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
|
|
||||||
<nav class="sidebar-menu">
|
|
||||||
<a href="/dashboard" class="sidebar-link">MCP</a>
|
|
||||||
<a href="/entries" class="sidebar-link">条目</a>
|
|
||||||
<a href="/audit" class="sidebar-link active">审计</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>
|
|
||||||
<form action="/auth/logout" method="post" style="display:inline">
|
|
||||||
<button type="submit" class="btn-sign-out">退出</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="main">
|
|
||||||
<section class="card">
|
|
||||||
<div class="card-title">我的审计</div>
|
|
||||||
<div class="card-subtitle">展示最近 100 条与当前用户相关的新审计记录。时间为浏览器本地时区。</div>
|
|
||||||
|
|
||||||
{% if entries.is_empty() %}
|
|
||||||
<div class="empty">暂无审计记录。</div>
|
|
||||||
{% else %}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>时间</th>
|
|
||||||
<th>动作</th>
|
|
||||||
<th>目标</th>
|
|
||||||
<th>详情</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for entry in entries %}
|
|
||||||
<tr>
|
|
||||||
<td class="col-time mono"><time class="audit-local-time" datetime="{{ entry.created_at_iso }}">{{ entry.created_at_iso }}</time></td>
|
|
||||||
<td class="col-action mono">{{ entry.action }}</td>
|
|
||||||
<td class="col-target mono">{{ entry.target }}</td>
|
|
||||||
<td class="col-detail"><pre class="detail">{{ entry.detail }}</pre></td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
document.querySelectorAll('time.audit-local-time[datetime]').forEach(function (el) {
|
|
||||||
var raw = el.getAttribute('datetime');
|
|
||||||
var d = raw ? new Date(raw) : null;
|
|
||||||
if (d && !isNaN(d.getTime())) {
|
|
||||||
el.textContent = d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' });
|
|
||||||
el.title = raw + ' (UTC)';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,940 +0,0 @@
|
|||||||
<!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>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;
|
|
||||||
--danger: #f85149; --success: #3fb950; --warn: #d29922;
|
|
||||||
}
|
|
||||||
body { background: var(--bg); color: var(--text); 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;
|
|
||||||
}
|
|
||||||
.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-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);
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
/* Main content column */
|
|
||||||
.main { display: flex; flex-direction: column; align-items: center;
|
|
||||||
padding: 24px 20px 8px; min-height: 0; }
|
|
||||||
.app-footer {
|
|
||||||
margin-top: auto;
|
|
||||||
text-align: center;
|
|
||||||
padding: 4px 20px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #9da7b3;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
}
|
|
||||||
.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; }
|
|
||||||
/* 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;
|
|
||||||
font-size: 13px; outline: none; }
|
|
||||||
.field input:focus { border-color: var(--accent); }
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.pw-toggle:hover { color: var(--text); background: var(--surface2); }
|
|
||||||
.pw-toggle:focus-visible { outline: 2px solid var(--accent); 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; }
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
|
|
||||||
.btn-primary:hover { background: var(--accent-hover); }
|
|
||||||
.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-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; }
|
|
||||||
.btn-copy:hover { background: rgba(63,185,80,0.2); }
|
|
||||||
.btn-copy.copied { background: var(--success); color: #0d1117; border-color: var(--success); }
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
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-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;
|
|
||||||
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;
|
|
||||||
pointer-events: none; }
|
|
||||||
.config-key { color: #79c0ff; }
|
|
||||||
.config-str { color: #a5d6ff; }
|
|
||||||
.config-val { color: var(--accent); }
|
|
||||||
|
|
||||||
/* Divider */
|
|
||||||
.divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
|
|
||||||
|
|
||||||
/* Actions row */
|
|
||||||
.actions-row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
|
||||||
|
|
||||||
/* Spinner */
|
|
||||||
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(13,17,23,0.3);
|
|
||||||
border-top-color: #0d1117; border-radius: 50%; animation: spin 0.7s linear infinite; }
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.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;
|
|
||||||
padding: 28px; width: 100%; max-width: 420px; }
|
|
||||||
.modal h3 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.layout { flex-direction: column; }
|
|
||||||
.sidebar {
|
|
||||||
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
|
|
||||||
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; }
|
|
||||||
.topbar { padding: 12px 16px; flex-wrap: wrap; }
|
|
||||||
.main { padding: 16px 12px 6px; }
|
|
||||||
.app-footer { padding: 4px 12px 10px; }
|
|
||||||
.card { padding: 18px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body data-has-passphrase="{{ has_passphrase }}" data-base-url="{{ base_url }}">
|
|
||||||
|
|
||||||
<div class="layout">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></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>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<div class="main">
|
|
||||||
<div class="card">
|
|
||||||
|
|
||||||
<!-- ── Locked state ──────────────────────────────────────────────────── -->
|
|
||||||
<div id="locked-view">
|
|
||||||
<div class="card-title" data-i18n="lockedTitle">获取 MCP 配置</div>
|
|
||||||
|
|
||||||
<!-- placeholder config -->
|
|
||||||
<div class="config-wrap">
|
|
||||||
<div class="config-box locked" id="placeholder-config"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Setup form (no passphrase yet) -->
|
|
||||||
<div id="setup-form" style="display:none">
|
|
||||||
<div class="field">
|
|
||||||
<label data-i18n="labelPassphrase">加密密码</label>
|
|
||||||
<div class="pw-field">
|
|
||||||
<input type="password" id="setup-pass1" data-i18n-ph="phPassphrase" autocomplete="new-password">
|
|
||||||
<button type="button" class="pw-toggle" data-target="setup-pass1" aria-pressed="false"
|
|
||||||
onclick="togglePwVisibility(this)" aria-label="">
|
|
||||||
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
|
|
||||||
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label data-i18n="labelConfirm">确认密码</label>
|
|
||||||
<div class="pw-field">
|
|
||||||
<input type="password" id="setup-pass2" data-i18n-ph="phConfirm" autocomplete="new-password">
|
|
||||||
<button type="button" class="pw-toggle" data-target="setup-pass2" aria-pressed="false"
|
|
||||||
onclick="togglePwVisibility(this)" aria-label="">
|
|
||||||
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
|
|
||||||
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="error-msg" id="setup-error"></div>
|
|
||||||
<button class="btn-primary" id="setup-btn" onclick="doSetup()">
|
|
||||||
<span data-i18n="btnSetup">设置并获取配置</span>
|
|
||||||
</button>
|
|
||||||
<p style="font-size:11px;color:var(--text-muted);text-align:center;margin-top:10px" data-i18n="setupNote">
|
|
||||||
密码不会上传服务器。遗忘后数据将无法恢复。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Unlock form (passphrase already set) -->
|
|
||||||
<div id="unlock-form" style="display:none">
|
|
||||||
<div class="field">
|
|
||||||
<label data-i18n="labelPassphrase">加密密码</label>
|
|
||||||
<div class="pw-field">
|
|
||||||
<input type="password" id="unlock-pass" data-i18n-ph="phPassphrase" autocomplete="current-password">
|
|
||||||
<button type="button" class="pw-toggle" data-target="unlock-pass" aria-pressed="false"
|
|
||||||
onclick="togglePwVisibility(this)" aria-label="">
|
|
||||||
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
|
|
||||||
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="error-msg" id="unlock-error"></div>
|
|
||||||
<button class="btn-primary" id="unlock-btn" onclick="doUnlock()">
|
|
||||||
<span data-i18n="btnUnlock">解锁并获取配置</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Unlocked state ────────────────────────────────────────────────── -->
|
|
||||||
<div id="unlocked-view" style="display:none">
|
|
||||||
<div class="card-title" data-i18n="unlockedTitle" style="margin-bottom:16px">MCP 配置</div>
|
|
||||||
|
|
||||||
<div class="config-tabs" role="tablist" aria-label="Config format">
|
|
||||||
<button type="button" class="config-tab active" role="tab" id="tab-mcp" aria-selected="true"
|
|
||||||
onclick="setConfigFormat('mcp')">
|
|
||||||
<span class="config-tab-title" data-i18n="tabMcp">Cursor、Claude Code、Codex、Gemini CLI</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="config-tab" role="tab" id="tab-opencode" aria-selected="false"
|
|
||||||
onclick="setConfigFormat('opencode')">
|
|
||||||
<span class="config-tab-title" data-i18n="tabOpencode">OpenCode</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="config-wrap">
|
|
||||||
<pre class="config-box" id="real-config"></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
|
||||||
<button class="btn-copy" id="copy-full-btn" onclick="copyFullConfig()" style="flex:1">
|
|
||||||
<span id="copy-full-text">复制完整 mcp.json</span>
|
|
||||||
</button>
|
|
||||||
<button class="btn-copy" id="copy-secrets-btn" onclick="copySecretsConfig()" style="flex:1">
|
|
||||||
<span id="copy-secrets-text">仅复制 secrets 节点</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="divider">
|
|
||||||
|
|
||||||
<div class="actions-row">
|
|
||||||
<button class="btn-sm" onclick="clearAndLock()" data-i18n="btnClear">清除密钥</button>
|
|
||||||
<button class="btn-sm" onclick="openChangeModal()" data-i18n="btnChangePass">更换密码</button>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- ── Change passphrase modal ──────────────────────────────────────────────── -->
|
|
||||||
<div class="modal-bd" id="change-modal">
|
|
||||||
<div class="modal">
|
|
||||||
<h3 data-i18n="changeTitle">更换密码</h3>
|
|
||||||
<div class="field">
|
|
||||||
<label data-i18n="labelNew">新密码</label>
|
|
||||||
<div class="pw-field">
|
|
||||||
<input type="password" id="change-pass1" data-i18n-ph="phPassphrase" autocomplete="new-password">
|
|
||||||
<button type="button" class="pw-toggle" data-target="change-pass1" aria-pressed="false"
|
|
||||||
onclick="togglePwVisibility(this)" aria-label="">
|
|
||||||
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
|
|
||||||
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label data-i18n="labelConfirm">确认</label>
|
|
||||||
<div class="pw-field">
|
|
||||||
<input type="password" id="change-pass2" data-i18n-ph="phConfirm" autocomplete="new-password">
|
|
||||||
<button type="button" class="pw-toggle" data-target="change-pass2" aria-pressed="false"
|
|
||||||
onclick="togglePwVisibility(this)" aria-label="">
|
|
||||||
<span class="pw-icon pw-icon-show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
|
|
||||||
<span class="pw-icon pw-icon-hide hidden" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="error-msg" id="change-error"></div>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button class="btn-modal-ok" id="change-btn" onclick="doChange()" data-i18n="btnChange">确认更换</button>
|
|
||||||
<button class="btn-modal-cancel" onclick="closeChangeModal()" data-i18n="btnCancel">取消</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// ── i18n ───────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const T = {
|
|
||||||
'zh-CN': {
|
|
||||||
signOut: '退出',
|
|
||||||
lockedTitle: '获取 MCP 配置',
|
|
||||||
labelPassphrase: '加密密码',
|
|
||||||
labelConfirm: '确认密码',
|
|
||||||
labelNew: '新密码',
|
|
||||||
phPassphrase: '输入密码…',
|
|
||||||
phConfirm: '再次输入…',
|
|
||||||
btnSetup: '设置并获取配置',
|
|
||||||
btnUnlock: '解锁并获取配置',
|
|
||||||
setupNote: '密码不会上传服务器。遗忘后数据将无法恢复。',
|
|
||||||
errEmpty: '密码不能为空。',
|
|
||||||
errShort: '密码至少需要 8 个字符。',
|
|
||||||
errMismatch: '两次输入不一致。',
|
|
||||||
errWrong: '密码错误,请重试。',
|
|
||||||
unlockedTitle: 'MCP 配置',
|
|
||||||
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
|
|
||||||
tabOpencode: 'OpenCode',
|
|
||||||
btnCopyFull: '复制完整 mcp.json',
|
|
||||||
btnCopySecrets: '仅复制 secrets 节点',
|
|
||||||
btnCopyFullOpencode: '复制完整 mcp.json',
|
|
||||||
btnCopySecretsOpencode: '仅复制 secrets 节点',
|
|
||||||
btnCopied: '已复制!',
|
|
||||||
btnClear: '清除密钥',
|
|
||||||
btnChangePass: '更换密码',
|
|
||||||
btnRegen: '重置 API Key',
|
|
||||||
changeTitle: '更换密码',
|
|
||||||
btnChange: '确认更换',
|
|
||||||
btnCancel: '取消',
|
|
||||||
regenConfirm: '重置 API Key 后,当前 Key 立即失效,需同步更新 AI 客户端配置。确认继续?',
|
|
||||||
regenFailed: '重置失败,请刷新页面重试。',
|
|
||||||
ariaShowPw: '显示密码',
|
|
||||||
ariaHidePw: '隐藏密码',
|
|
||||||
},
|
|
||||||
'zh-TW': {
|
|
||||||
signOut: '登出',
|
|
||||||
lockedTitle: '取得 MCP 設定',
|
|
||||||
labelPassphrase: '加密密碼',
|
|
||||||
labelConfirm: '確認密碼',
|
|
||||||
labelNew: '新密碼',
|
|
||||||
phPassphrase: '輸入密碼…',
|
|
||||||
phConfirm: '再次輸入…',
|
|
||||||
btnSetup: '設定並取得設定',
|
|
||||||
btnUnlock: '解鎖並取得設定',
|
|
||||||
setupNote: '密碼不會上傳伺服器。遺忘後資料將無法復原。',
|
|
||||||
errEmpty: '密碼不能為空。',
|
|
||||||
errShort: '密碼至少需要 8 個字元。',
|
|
||||||
errMismatch: '兩次輸入不一致。',
|
|
||||||
errWrong: '密碼錯誤,請重試。',
|
|
||||||
unlockedTitle: 'MCP 設定',
|
|
||||||
tabMcp: 'Cursor、Claude Code、Codex、Gemini CLI',
|
|
||||||
tabOpencode: 'OpenCode',
|
|
||||||
btnCopyFull: '複製完整 mcp.json',
|
|
||||||
btnCopySecrets: '僅複製 secrets 節點',
|
|
||||||
btnCopyFullOpencode: '複製完整 mcp.json',
|
|
||||||
btnCopySecretsOpencode: '僅複製 secrets 節點',
|
|
||||||
btnCopied: '已複製!',
|
|
||||||
btnClear: '清除密鑰',
|
|
||||||
btnChangePass: '更換密碼',
|
|
||||||
btnRegen: '重置 API Key',
|
|
||||||
changeTitle: '更換密碼',
|
|
||||||
btnChange: '確認更換',
|
|
||||||
btnCancel: '取消',
|
|
||||||
regenConfirm: '重置 API Key 後,目前 Key 立即失效,需同步更新 AI 用戶端設定。確認繼續?',
|
|
||||||
regenFailed: '重置失敗,請重新整理頁面再試。',
|
|
||||||
ariaShowPw: '顯示密碼',
|
|
||||||
ariaHidePw: '隱藏密碼',
|
|
||||||
},
|
|
||||||
'en': {
|
|
||||||
signOut: 'Sign out',
|
|
||||||
lockedTitle: 'Get MCP Config',
|
|
||||||
labelPassphrase: 'Encryption password',
|
|
||||||
labelConfirm: 'Confirm password',
|
|
||||||
labelNew: 'New password',
|
|
||||||
phPassphrase: 'Enter password…',
|
|
||||||
phConfirm: 'Repeat password…',
|
|
||||||
btnSetup: 'Set up & get config',
|
|
||||||
btnUnlock: 'Unlock & get config',
|
|
||||||
setupNote: 'Your password never leaves this device. If forgotten, encrypted data cannot be recovered.',
|
|
||||||
errEmpty: 'Password cannot be empty.',
|
|
||||||
errShort: 'Password must be at least 8 characters.',
|
|
||||||
errMismatch: 'Passwords do not match.',
|
|
||||||
errWrong: 'Incorrect password, please try again.',
|
|
||||||
unlockedTitle: 'MCP Config',
|
|
||||||
tabMcp: 'Cursor, Claude Code, Codex, Gemini CLI',
|
|
||||||
tabOpencode: 'OpenCode',
|
|
||||||
btnCopyFull: 'Copy full mcp.json',
|
|
||||||
btnCopySecrets: 'Copy only secrets node',
|
|
||||||
btnCopyFullOpencode: 'Copy full mcp.json',
|
|
||||||
btnCopySecretsOpencode: 'Copy only secrets node',
|
|
||||||
btnCopied: 'Copied!',
|
|
||||||
btnClear: 'Clear key',
|
|
||||||
btnChangePass: 'Change password',
|
|
||||||
btnRegen: 'Reset API key',
|
|
||||||
changeTitle: 'Change password',
|
|
||||||
btnChange: 'Confirm',
|
|
||||||
btnCancel: 'Cancel',
|
|
||||||
regenConfirm: 'Resetting will immediately invalidate your current API key. You will need to update your AI client config. Continue?',
|
|
||||||
regenFailed: 'Reset failed. Please refresh and try again.',
|
|
||||||
ariaShowPw: 'Show password',
|
|
||||||
ariaHidePw: 'Hide password',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentLang = localStorage.getItem('lang') || 'zh-CN';
|
|
||||||
|
|
||||||
function t(key) { return T[currentLang][key] || T['en'][key] || key; }
|
|
||||||
|
|
||||||
function applyLang() {
|
|
||||||
document.documentElement.lang = currentLang;
|
|
||||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
||||||
const key = el.getAttribute('data-i18n');
|
|
||||||
el.textContent = t(key);
|
|
||||||
});
|
|
||||||
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
|
|
||||||
el.placeholder = t(el.getAttribute('data-i18n-ph'));
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
|
||||||
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
|
|
||||||
btn.classList.toggle('active', btn.textContent === map[currentLang]);
|
|
||||||
});
|
|
||||||
// Rebuild placeholder config (language affects nothing but triggers re-render)
|
|
||||||
renderPlaceholderConfig();
|
|
||||||
// Rebuild real config if unlocked
|
|
||||||
if (currentEncKey && currentApiKey) renderRealConfig();
|
|
||||||
syncPwToggleI18n();
|
|
||||||
syncConfigFormatUi();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLang(lang) {
|
|
||||||
currentLang = lang;
|
|
||||||
localStorage.setItem('lang', lang);
|
|
||||||
applyLang();
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncPwToggleI18n() {
|
|
||||||
document.querySelectorAll('.pw-toggle').forEach(btn => {
|
|
||||||
const input = document.getElementById(btn.getAttribute('data-target'));
|
|
||||||
if (!input) return;
|
|
||||||
const visible = input.type === 'text';
|
|
||||||
btn.setAttribute('aria-pressed', visible ? 'true' : 'false');
|
|
||||||
btn.setAttribute('aria-label', visible ? t('ariaHidePw') : t('ariaShowPw'));
|
|
||||||
const showIc = btn.querySelector('.pw-icon-show');
|
|
||||||
const hideIc = btn.querySelector('.pw-icon-hide');
|
|
||||||
if (showIc) showIc.classList.toggle('hidden', visible);
|
|
||||||
if (hideIc) hideIc.classList.toggle('hidden', !visible);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePwVisibility(btn) {
|
|
||||||
const input = document.getElementById(btn.getAttribute('data-target'));
|
|
||||||
if (!input) return;
|
|
||||||
input.type = input.type === 'password' ? 'text' : 'password';
|
|
||||||
syncPwToggleI18n();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const HAS_PASSPHRASE = document.body.dataset.hasPassphrase === 'true';
|
|
||||||
const BASE_URL = document.body.dataset.baseUrl;
|
|
||||||
const KEY_CHECK_PLAINTEXT = 'secrets-mcp-key-check';
|
|
||||||
const PBKDF2_ITERATIONS = 600000;
|
|
||||||
const ENC = new TextEncoder();
|
|
||||||
let currentEncKey = null;
|
|
||||||
let currentApiKey = null;
|
|
||||||
/** @type {'mcp' | 'opencode'} */
|
|
||||||
let configFormat = 'mcp';
|
|
||||||
|
|
||||||
function redirectLoginExpired() {
|
|
||||||
sessionStorage.removeItem('enc_key');
|
|
||||||
currentEncKey = null;
|
|
||||||
currentApiKey = null;
|
|
||||||
window.location.replace('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Like fetch; on 401 clears local state and navigates to login (await does not complete). */
|
|
||||||
async function fetchAuth(input, init) {
|
|
||||||
const resp = await fetch(input, init);
|
|
||||||
if (resp.status === 401) {
|
|
||||||
redirectLoginExpired();
|
|
||||||
await new Promise(() => {});
|
|
||||||
}
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Placeholder config ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function renderPlaceholderConfig() {
|
|
||||||
document.getElementById('placeholder-config').textContent =
|
|
||||||
buildConfigText('sk_' + '•'.repeat(64), '•'.repeat(64));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBaseServerConfig(apiKey, encKey) {
|
|
||||||
return {
|
|
||||||
url: BASE_URL + '/mcp',
|
|
||||||
headers: {
|
|
||||||
Authorization: 'Bearer ' + apiKey,
|
|
||||||
'X-Encryption-Key': encKey
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSecretsEntryObject(apiKey, encKey) {
|
|
||||||
return buildBaseServerConfig(apiKey, encKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildConfigText(apiKey, encKey) {
|
|
||||||
return JSON.stringify({ mcpServers: { secrets: buildSecretsEntryObject(apiKey, encKey) } }, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSecretsConfigText(apiKey, encKey) {
|
|
||||||
const wrapped = JSON.stringify({
|
|
||||||
secrets: buildSecretsEntryObject(apiKey, encKey)
|
|
||||||
}, null, 2);
|
|
||||||
const lines = wrapped.split('\n');
|
|
||||||
return lines.length < 3 ? wrapped : lines.slice(1, -1).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** OpenCode: local stdio bridge to Streamable HTTP MCP (mcp-remote --transport http-only). */
|
|
||||||
function buildOpencodeEntry(apiKey, encKey) {
|
|
||||||
return {
|
|
||||||
type: 'local',
|
|
||||||
command: [
|
|
||||||
'npx', '-y', 'mcp-remote',
|
|
||||||
BASE_URL + '/mcp',
|
|
||||||
'--header',
|
|
||||||
'Authorization: Bearer ' + apiKey,
|
|
||||||
'--header',
|
|
||||||
'X-Encryption-Key: ' + encKey,
|
|
||||||
'--transport',
|
|
||||||
'http-only'
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Full OpenCode config: MCP servers live under top-level `mcp`. */
|
|
||||||
function buildOpencodeConfigText(apiKey, encKey) {
|
|
||||||
return JSON.stringify({ mcp: { secrets: buildOpencodeEntry(apiKey, encKey) } }, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Strip outer `{` `}` so user can paste `secrets` under an existing `mcp` object. */
|
|
||||||
function buildOpencodeMergeSnippet(apiKey, encKey) {
|
|
||||||
const wrapped = JSON.stringify({ secrets: buildOpencodeEntry(apiKey, encKey) }, null, 2);
|
|
||||||
const lines = wrapped.split('\n');
|
|
||||||
return lines.length < 3 ? wrapped : lines.slice(1, -1).join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCopyFullKey() {
|
|
||||||
return 'btnCopyFull';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCopySecretsKey() {
|
|
||||||
return 'btnCopySecrets';
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONFIG_FORMAT_STORAGE = 'dash_config_format';
|
|
||||||
|
|
||||||
function setConfigFormat(fmt) {
|
|
||||||
configFormat = fmt;
|
|
||||||
try { sessionStorage.setItem(CONFIG_FORMAT_STORAGE, fmt); } catch (_) {}
|
|
||||||
syncConfigFormatUi();
|
|
||||||
if (currentEncKey && currentApiKey) renderRealConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Refresh tabs, format hint, and copy button labels (after language change or tab switch). */
|
|
||||||
function syncConfigFormatUi() {
|
|
||||||
const uv = document.getElementById('unlocked-view');
|
|
||||||
if (!uv || uv.style.display === 'none') return;
|
|
||||||
const tabMcp = document.getElementById('tab-mcp');
|
|
||||||
const tabOc = document.getElementById('tab-opencode');
|
|
||||||
if (tabMcp && tabOc) {
|
|
||||||
tabMcp.classList.toggle('active', configFormat === 'mcp');
|
|
||||||
tabOc.classList.toggle('active', configFormat === 'opencode');
|
|
||||||
tabMcp.setAttribute('aria-selected', configFormat === 'mcp' ? 'true' : 'false');
|
|
||||||
tabOc.setAttribute('aria-selected', configFormat === 'opencode' ? 'true' : 'false');
|
|
||||||
}
|
|
||||||
const cf = document.getElementById('copy-full-text');
|
|
||||||
const cs = document.getElementById('copy-secrets-text');
|
|
||||||
if (cf) cf.textContent = t(getCopyFullKey());
|
|
||||||
if (cs) cs.textContent = t(getCopySecretsKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Unlock / Setup flow ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showLockedView() {
|
|
||||||
document.getElementById('locked-view').style.display = '';
|
|
||||||
document.getElementById('unlocked-view').style.display = 'none';
|
|
||||||
if (HAS_PASSPHRASE) {
|
|
||||||
document.getElementById('setup-form').style.display = 'none';
|
|
||||||
document.getElementById('unlock-form').style.display = '';
|
|
||||||
setTimeout(() => document.getElementById('unlock-pass').focus(), 50);
|
|
||||||
} else {
|
|
||||||
document.getElementById('setup-form').style.display = '';
|
|
||||||
document.getElementById('unlock-form').style.display = 'none';
|
|
||||||
setTimeout(() => document.getElementById('setup-pass1').focus(), 50);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showUnlockedView(encKeyHex, apiKey) {
|
|
||||||
currentEncKey = encKeyHex;
|
|
||||||
currentApiKey = apiKey;
|
|
||||||
sessionStorage.setItem('enc_key', encKeyHex);
|
|
||||||
renderRealConfig();
|
|
||||||
document.getElementById('locked-view').style.display = 'none';
|
|
||||||
document.getElementById('unlocked-view').style.display = '';
|
|
||||||
syncConfigFormatUi();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRealConfig() {
|
|
||||||
if (!currentApiKey || !currentEncKey) return;
|
|
||||||
const text = configFormat === 'mcp'
|
|
||||||
? buildConfigText(currentApiKey, currentEncKey)
|
|
||||||
: buildOpencodeConfigText(currentApiKey, currentEncKey);
|
|
||||||
document.getElementById('real-config').textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAndLock() {
|
|
||||||
sessionStorage.removeItem('enc_key');
|
|
||||||
currentEncKey = null;
|
|
||||||
currentApiKey = null;
|
|
||||||
showLockedView();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Web Crypto helpers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function deriveKey(passphrase, saltBytes, extractable = false) {
|
|
||||||
const km = await crypto.subtle.importKey('raw', ENC.encode(passphrase), 'PBKDF2', false, ['deriveKey']);
|
|
||||||
return crypto.subtle.deriveKey(
|
|
||||||
{ name: 'PBKDF2', salt: saltBytes, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
|
|
||||||
km, { name: 'AES-GCM', length: 256 }, extractable, ['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exportKeyHex(cryptoKey) {
|
|
||||||
const raw = await crypto.subtle.exportKey('raw', cryptoKey);
|
|
||||||
return Array.from(new Uint8Array(raw)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hexToBytes(hex) {
|
|
||||||
const b = new Uint8Array(hex.length / 2);
|
|
||||||
for (let i = 0; i < hex.length; i += 2) b[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
|
||||||
return b;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytesToHex(bytes) {
|
|
||||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function encryptKeyCheck(cryptoKey) {
|
|
||||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
|
||||||
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, cryptoKey, ENC.encode(KEY_CHECK_PLAINTEXT));
|
|
||||||
const out = new Uint8Array(12 + ct.byteLength);
|
|
||||||
out.set(nonce); out.set(new Uint8Array(ct), 12);
|
|
||||||
return bytesToHex(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function verifyKeyCheck(cryptoKey, keyCheckHex) {
|
|
||||||
try {
|
|
||||||
const b = hexToBytes(keyCheckHex);
|
|
||||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.slice(0, 12) }, cryptoKey, b.slice(12));
|
|
||||||
return new TextDecoder().decode(plain) === KEY_CHECK_PLAINTEXT;
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Passphrase setup (first time) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
function setBtnLoading(id, loading, labelKey) {
|
|
||||||
const btn = document.getElementById(id);
|
|
||||||
btn.disabled = loading;
|
|
||||||
btn.innerHTML = loading
|
|
||||||
? '<span class="spinner"></span>'
|
|
||||||
: `<span data-i18n="${labelKey}">${t(labelKey)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doSetup() {
|
|
||||||
const pass1 = document.getElementById('setup-pass1').value;
|
|
||||||
const pass2 = document.getElementById('setup-pass2').value;
|
|
||||||
const errEl = document.getElementById('setup-error');
|
|
||||||
errEl.style.display = 'none';
|
|
||||||
|
|
||||||
if (!pass1) { showErr(errEl, t('errEmpty')); return; }
|
|
||||||
if (pass1.length < 8) { showErr(errEl, t('errShort')); return; }
|
|
||||||
if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; }
|
|
||||||
|
|
||||||
setBtnLoading('setup-btn', true, 'btnSetup');
|
|
||||||
try {
|
|
||||||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
|
||||||
const cryptoKey = await deriveKey(pass1, salt, true);
|
|
||||||
const keyCheckHex = await encryptKeyCheck(cryptoKey);
|
|
||||||
const hexKey = await exportKeyHex(cryptoKey);
|
|
||||||
|
|
||||||
const resp = await fetchAuth('/api/key-setup', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
salt: bytesToHex(salt),
|
|
||||||
key_check: keyCheckHex,
|
|
||||||
params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS }
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
||||||
|
|
||||||
const apiKey = await fetchApiKey();
|
|
||||||
await showUnlockedView(hexKey, apiKey);
|
|
||||||
} catch (e) {
|
|
||||||
showErr(errEl, 'Error: ' + e.message);
|
|
||||||
} finally {
|
|
||||||
setBtnLoading('setup-btn', false, 'btnSetup');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Passphrase unlock ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function doUnlock() {
|
|
||||||
const pass = document.getElementById('unlock-pass').value;
|
|
||||||
const errEl = document.getElementById('unlock-error');
|
|
||||||
errEl.style.display = 'none';
|
|
||||||
|
|
||||||
if (!pass) { showErr(errEl, t('errEmpty')); return; }
|
|
||||||
|
|
||||||
setBtnLoading('unlock-btn', true, 'btnUnlock');
|
|
||||||
try {
|
|
||||||
const saltResp = await fetchAuth('/api/key-salt');
|
|
||||||
if (!saltResp.ok) throw new Error('HTTP ' + saltResp.status);
|
|
||||||
const saltData = await saltResp.json();
|
|
||||||
|
|
||||||
const cryptoKey = await deriveKey(pass, hexToBytes(saltData.salt), true);
|
|
||||||
const valid = await verifyKeyCheck(cryptoKey, saltData.key_check);
|
|
||||||
if (!valid) { showErr(errEl, t('errWrong')); return; }
|
|
||||||
|
|
||||||
const hexKey = await exportKeyHex(cryptoKey);
|
|
||||||
const apiKey = await fetchApiKey();
|
|
||||||
await showUnlockedView(hexKey, apiKey);
|
|
||||||
} catch (e) {
|
|
||||||
showErr(errEl, 'Error: ' + e.message);
|
|
||||||
} finally {
|
|
||||||
setBtnLoading('unlock-btn', false, 'btnUnlock');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Copy config ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function copyFullConfig() {
|
|
||||||
await copyWithFeedback(
|
|
||||||
document.getElementById('real-config').textContent,
|
|
||||||
'copy-full-btn',
|
|
||||||
'copy-full-text',
|
|
||||||
getCopyFullKey()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copySecretsConfig() {
|
|
||||||
const snippet = configFormat === 'mcp'
|
|
||||||
? buildSecretsConfigText(currentApiKey, currentEncKey)
|
|
||||||
: buildOpencodeMergeSnippet(currentApiKey, currentEncKey);
|
|
||||||
await copyWithFeedback(
|
|
||||||
snippet,
|
|
||||||
'copy-secrets-btn',
|
|
||||||
'copy-secrets-text',
|
|
||||||
getCopySecretsKey()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyWithFeedback(text, btnId, textId, resetLabelKey) {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
const btn = document.getElementById(btnId);
|
|
||||||
const textEl = document.getElementById(textId);
|
|
||||||
btn.classList.add('copied');
|
|
||||||
textEl.textContent = t('btnCopied');
|
|
||||||
setTimeout(() => {
|
|
||||||
btn.classList.remove('copied');
|
|
||||||
textEl.textContent = t(resetLabelKey);
|
|
||||||
}, 2500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Reset API key ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function confirmRegenerate() {
|
|
||||||
if (!confirm(t('regenConfirm'))) return;
|
|
||||||
try {
|
|
||||||
const resp = await fetchAuth('/api/apikey/regenerate', { method: 'POST' });
|
|
||||||
if (!resp.ok) throw new Error();
|
|
||||||
const data = await resp.json();
|
|
||||||
currentApiKey = data.api_key;
|
|
||||||
renderRealConfig();
|
|
||||||
} catch {
|
|
||||||
alert(t('regenFailed'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Change passphrase modal ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function openChangeModal() {
|
|
||||||
document.getElementById('change-pass1').value = '';
|
|
||||||
document.getElementById('change-pass2').value = '';
|
|
||||||
document.getElementById('change-pass1').type = 'password';
|
|
||||||
document.getElementById('change-pass2').type = 'password';
|
|
||||||
document.getElementById('change-error').style.display = 'none';
|
|
||||||
document.getElementById('change-modal').classList.add('open');
|
|
||||||
syncPwToggleI18n();
|
|
||||||
setTimeout(() => document.getElementById('change-pass1').focus(), 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeChangeModal() {
|
|
||||||
document.getElementById('change-modal').classList.remove('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doChange() {
|
|
||||||
const pass1 = document.getElementById('change-pass1').value;
|
|
||||||
const pass2 = document.getElementById('change-pass2').value;
|
|
||||||
const errEl = document.getElementById('change-error');
|
|
||||||
errEl.style.display = 'none';
|
|
||||||
|
|
||||||
if (!pass1) { showErr(errEl, t('errEmpty')); return; }
|
|
||||||
if (pass1.length < 8) { showErr(errEl, t('errShort')); return; }
|
|
||||||
if (pass1 !== pass2) { showErr(errEl, t('errMismatch')); return; }
|
|
||||||
|
|
||||||
const btn = document.getElementById('change-btn');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<span class="spinner" style="border-top-color:#0d1117"></span>';
|
|
||||||
try {
|
|
||||||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
|
||||||
const cryptoKey = await deriveKey(pass1, salt, true);
|
|
||||||
const keyCheckHex = await encryptKeyCheck(cryptoKey);
|
|
||||||
const hexKey = await exportKeyHex(cryptoKey);
|
|
||||||
|
|
||||||
const resp = await fetchAuth('/api/key-setup', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
salt: bytesToHex(salt),
|
|
||||||
key_check: keyCheckHex,
|
|
||||||
params: { alg: 'pbkdf2-sha256', iterations: PBKDF2_ITERATIONS }
|
|
||||||
})
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
||||||
|
|
||||||
currentEncKey = hexKey;
|
|
||||||
sessionStorage.setItem('enc_key', hexKey);
|
|
||||||
renderRealConfig();
|
|
||||||
closeChangeModal();
|
|
||||||
} catch (e) {
|
|
||||||
showErr(errEl, 'Error: ' + e.message);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = t('btnChange');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fetch API key ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function fetchApiKey() {
|
|
||||||
const resp = await fetchAuth('/api/apikey');
|
|
||||||
if (!resp.ok) throw new Error('Failed to load API key');
|
|
||||||
const data = await resp.json();
|
|
||||||
return data.api_key;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showErr(el, msg) {
|
|
||||||
el.textContent = msg;
|
|
||||||
el.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Keyboard shortcuts ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
document.addEventListener('keydown', e => {
|
|
||||||
if (e.key === 'Escape') closeChangeModal();
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
if (document.getElementById('change-modal').classList.contains('open')) { doChange(); return; }
|
|
||||||
if (document.getElementById('unlock-form').style.display !== 'none' &&
|
|
||||||
document.getElementById('locked-view').style.display !== 'none') { doUnlock(); return; }
|
|
||||||
if (document.getElementById('setup-form').style.display !== 'none' &&
|
|
||||||
document.getElementById('locked-view').style.display !== 'none') { doSetup(); return; }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Init ───────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
(async function init() {
|
|
||||||
applyLang();
|
|
||||||
try {
|
|
||||||
const sf = sessionStorage.getItem(CONFIG_FORMAT_STORAGE);
|
|
||||||
if (sf === 'mcp' || sf === 'opencode') configFormat = sf;
|
|
||||||
} catch (_) { /* ignore */ }
|
|
||||||
const savedKey = sessionStorage.getItem('enc_key');
|
|
||||||
if (savedKey) {
|
|
||||||
try {
|
|
||||||
const apiKey = await fetchApiKey();
|
|
||||||
await showUnlockedView(savedKey, apiKey);
|
|
||||||
return;
|
|
||||||
} catch { /* fall through to locked */ }
|
|
||||||
}
|
|
||||||
showLockedView();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,490 +0,0 @@
|
|||||||
<!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>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: 220px; flex-shrink: 0; background: var(--surface); border-right: 1px solid var(--border);
|
|
||||||
padding: 24px 16px; 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-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);
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.topbar-spacer { flex: 1; }
|
|
||||||
.nav-user { font-size: 13px; color: var(--text-muted); }
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.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: 1480px; margin: 0 auto; }
|
|
||||||
.card-title { font-size: 20px; font-weight: 600; margin-bottom: 8px; }
|
|
||||||
.card-subtitle { color: var(--text-muted); font-size: 13px; margin-bottom: 20px; }
|
|
||||||
.filter-bar {
|
|
||||||
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 16px;
|
|
||||||
margin-bottom: 20px; padding: 16px; background: var(--bg); border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
.filter-field { display: flex; flex-direction: column; gap: 6px; min-width: 140px; flex: 1; }
|
|
||||||
.filter-field label { font-size: 12px; color: var(--text-muted); font-weight: 500; }
|
|
||||||
.filter-field input {
|
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: 6px;
|
|
||||||
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
|
||||||
outline: none; width: 100%;
|
|
||||||
}
|
|
||||||
.filter-field input:focus { border-color: var(--accent); }
|
|
||||||
.filter-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
|
||||||
.btn-filter {
|
|
||||||
padding: 8px 16px; border-radius: 6px; border: none; background: var(--accent); color: #0d1117;
|
|
||||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-filter:hover { background: var(--accent-hover); }
|
|
||||||
.btn-clear {
|
|
||||||
padding: 8px 14px; border-radius: 6px; border: 1px solid var(--border); background: transparent;
|
|
||||||
color: var(--text-muted); font-size: 13px; text-decoration: none; cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-clear:hover { background: var(--surface2); color: var(--text); }
|
|
||||||
.empty { color: var(--text-muted); font-size: 14px; padding: 20px 0; }
|
|
||||||
.table-wrap {
|
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--bg);
|
|
||||||
max-height: 72vh;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
width: max-content;
|
|
||||||
min-width: 1240px;
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 0;
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
white-space: nowrap;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
td { font-size: 13px; line-height: 1.45; }
|
|
||||||
tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); }
|
|
||||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
|
||||||
.col-updated { min-width: 168px; }
|
|
||||||
.col-folder { min-width: 128px; }
|
|
||||||
.col-type { min-width: 108px; }
|
|
||||||
.col-name { min-width: 180px; max-width: 260px; }
|
|
||||||
.col-tags { min-width: 160px; max-width: 220px; }
|
|
||||||
.col-actions { min-width: 132px; }
|
|
||||||
.cell-name, .cell-tags-val {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.cell-notes, .cell-meta { min-width: 260px; max-width: 360px; }
|
|
||||||
.notes-scroll {
|
|
||||||
max-height: 120px;
|
|
||||||
overflow: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
padding: 8px;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.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: 360px; max-height: 120px; overflow: auto;
|
|
||||||
}
|
|
||||||
.col-actions { white-space: nowrap; }
|
|
||||||
.row-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
||||||
.col-secrets { min-width: 300px; max-width: 420px; }
|
|
||||||
.secret-list { display: flex; flex-wrap: wrap; gap: 6px; max-width: 400px; }
|
|
||||||
.secret-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 3px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
background: var(--surface2);
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
max-width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.secret-name {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.secret-type {
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
padding-left: 6px;
|
|
||||||
}
|
|
||||||
.btn-unlink-secret {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #f85149;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.btn-row {
|
|
||||||
padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer;
|
|
||||||
border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.btn-row:hover { color: var(--text); border-color: var(--text-muted); }
|
|
||||||
.btn-row.danger:hover { border-color: #f85149; color: #f85149; }
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed; inset: 0; background: rgba(1, 4, 9, 0.65); z-index: 200;
|
|
||||||
display: flex; align-items: center; justify-content: center; padding: 16px;
|
|
||||||
}
|
|
||||||
.modal-overlay[hidden] { display: none !important; }
|
|
||||||
.modal {
|
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
|
||||||
padding: 22px; width: 100%; max-width: 520px; max-height: 90vh; overflow: auto;
|
|
||||||
box-shadow: 0 16px 48px rgba(0,0,0,0.45);
|
|
||||||
}
|
|
||||||
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 14px; }
|
|
||||||
.modal-field { margin-bottom: 12px; }
|
|
||||||
.modal-field label { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 5px; }
|
|
||||||
.modal-field input, .modal-field textarea {
|
|
||||||
width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
|
||||||
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.modal-field textarea { min-height: 72px; resize: vertical; }
|
|
||||||
.modal-field textarea.metadata-edit { min-height: 140px; }
|
|
||||||
.modal-error { color: #f85149; font-size: 12px; margin-bottom: 10px; display: none; }
|
|
||||||
.modal-error.visible { display: block; }
|
|
||||||
.modal-footer { display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
|
||||||
.btn-modal { padding: 8px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; font-family: inherit; border: 1px solid var(--border); background: transparent; color: var(--text); }
|
|
||||||
.btn-modal.primary { background: var(--accent); color: #0d1117; border-color: transparent; font-weight: 600; }
|
|
||||||
.btn-modal.primary:hover { background: var(--accent-hover); }
|
|
||||||
.btn-modal.danger { border-color: #f85149; color: #f85149; }
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.layout { flex-direction: column; }
|
|
||||||
.sidebar {
|
|
||||||
width: 100%; border-right: none; border-bottom: 1px solid var(--border);
|
|
||||||
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-wrap { max-height: none; border: none; background: transparent; }
|
|
||||||
table, thead, tbody, th, td, tr { display: block; min-width: 0; width: 100%; }
|
|
||||||
thead { display: none; }
|
|
||||||
tr { border-top: 1px solid var(--border); padding: 12px 0; }
|
|
||||||
td { border-top: none; padding: 6px 0; max-width: none; }
|
|
||||||
td::before {
|
|
||||||
display: block; color: var(--text-muted); font-size: 11px;
|
|
||||||
margin-bottom: 4px; text-transform: uppercase;
|
|
||||||
}
|
|
||||||
td.col-updated::before { content: "更新"; }
|
|
||||||
td.col-folder::before { content: "Folder"; }
|
|
||||||
td.col-type::before { content: "Type"; }
|
|
||||||
td.col-name::before { content: "Name"; }
|
|
||||||
td.col-notes::before { content: "Notes"; }
|
|
||||||
td.col-tags::before { content: "Tags"; }
|
|
||||||
td.col-meta::before { content: "Metadata"; }
|
|
||||||
td.col-secrets::before { content: "Secrets"; }
|
|
||||||
td.col-actions::before { content: "操作"; }
|
|
||||||
.detail, .notes-scroll, .secret-list { max-width: none; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="layout">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<a href="/dashboard" class="sidebar-logo"><span>secrets</span></a>
|
|
||||||
<nav class="sidebar-menu">
|
|
||||||
<a href="/dashboard" class="sidebar-link">MCP</a>
|
|
||||||
<a href="/entries" class="sidebar-link active">条目</a>
|
|
||||||
<a href="/audit" class="sidebar-link">审计</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>
|
|
||||||
<form action="/auth/logout" method="post" style="display:inline">
|
|
||||||
<button type="submit" class="btn-sign-out">退出</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="main">
|
|
||||||
<section class="card">
|
|
||||||
<div class="card-title">我的条目</div>
|
|
||||||
<div class="card-subtitle">在当前筛选条件下,共 <strong>{{ total_count }}</strong> 条记录;本页显示 <strong>{{ shown_count }}</strong> 条(按更新时间降序,单页最多 {{ limit }} 条)。不含密文字段。时间为浏览器本地时区。提示:非敏感地址类字段(如 address / endpoint / url)建议放在 Metadata(例如 <code>metadata.address</code>),仅密码/令牌等放 Secrets。</div>
|
|
||||||
|
|
||||||
<form class="filter-bar" method="get" action="/entries">
|
|
||||||
<div class="filter-field">
|
|
||||||
<label for="filter-folder">Folder(精确匹配)</label>
|
|
||||||
<input id="filter-folder" name="folder" type="text" value="{{ filter_folder }}" placeholder="例如 refining" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<div class="filter-field">
|
|
||||||
<label for="filter-type">Type(精确匹配)</label>
|
|
||||||
<input id="filter-type" name="type" type="text" value="{{ filter_type }}" placeholder="例如 server" autocomplete="off">
|
|
||||||
</div>
|
|
||||||
<div class="filter-actions">
|
|
||||||
<button type="submit" class="btn-filter">筛选</button>
|
|
||||||
<a href="/entries" class="btn-clear">清空</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if entries.is_empty() %}
|
|
||||||
<div class="empty">暂无条目。</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>更新</th>
|
|
||||||
<th>Folder</th>
|
|
||||||
<th>Type</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Notes</th>
|
|
||||||
<th>Tags</th>
|
|
||||||
<th>Metadata</th>
|
|
||||||
<th>Secrets</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for entry in entries %}
|
|
||||||
<tr data-entry-id="{{ entry.id }}">
|
|
||||||
<td class="col-updated mono"><time class="entry-local-time" datetime="{{ entry.updated_at_iso }}">{{ entry.updated_at_iso }}</time></td>
|
|
||||||
<td class="col-folder mono cell-folder">{{ entry.folder }}</td>
|
|
||||||
<td class="col-type mono cell-type">{{ entry.entry_type }}</td>
|
|
||||||
<td class="col-name mono cell-name">{{ entry.name }}</td>
|
|
||||||
<td class="col-notes cell-notes">{% if !entry.notes.is_empty() %}<div class="notes-scroll cell-notes-val">{{ entry.notes }}</div>{% endif %}</td>
|
|
||||||
<td class="col-tags mono cell-tags-val">{{ entry.tags }}</td>
|
|
||||||
<td class="col-meta cell-meta"><pre class="detail cell-meta-val">{{ entry.metadata }}</pre></td>
|
|
||||||
<td class="col-secrets">
|
|
||||||
<div class="secret-list">
|
|
||||||
{% for s in entry.secrets %}
|
|
||||||
<span class="secret-chip">
|
|
||||||
<span class="secret-name" title="{{ s.name }}">{{ s.name }}</span>
|
|
||||||
<span class="secret-type">{{ s.secret_type }}</span>
|
|
||||||
<button type="button" class="btn-unlink-secret" data-secret-id="{{ s.id }}" data-secret-name="{{ s.name }}" title="解除关联">x</button>
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="col-actions">
|
|
||||||
<div class="row-actions">
|
|
||||||
<button type="button" class="btn-row btn-edit">编辑</button>
|
|
||||||
<button type="button" class="btn-row danger btn-del">删除</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="edit-overlay" class="modal-overlay" hidden>
|
|
||||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="edit-title">
|
|
||||||
<div class="modal-title" id="edit-title">编辑条目</div>
|
|
||||||
<div id="edit-error" class="modal-error"></div>
|
|
||||||
<div class="modal-field"><label for="edit-folder">Folder</label><input id="edit-folder" type="text" autocomplete="off"></div>
|
|
||||||
<div class="modal-field"><label for="edit-type">Type</label><input id="edit-type" type="text" autocomplete="off"></div>
|
|
||||||
<div class="modal-field"><label for="edit-name">Name</label><input id="edit-name" type="text" autocomplete="off"></div>
|
|
||||||
<div class="modal-field"><label for="edit-notes">Notes</label><textarea id="edit-notes"></textarea></div>
|
|
||||||
<div class="modal-field"><label for="edit-tags">Tags(逗号分隔)</label><input id="edit-tags" type="text" autocomplete="off"></div>
|
|
||||||
<div class="modal-field"><label for="edit-metadata">Metadata(JSON 对象)</label><textarea id="edit-metadata" class="metadata-edit"></textarea></div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn-modal" id="edit-cancel">取消</button>
|
|
||||||
<button type="button" class="btn-modal primary" id="edit-save">保存</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
document.querySelectorAll('time.entry-local-time[datetime]').forEach(function (el) {
|
|
||||||
var raw = el.getAttribute('datetime');
|
|
||||||
var d = raw ? new Date(raw) : null;
|
|
||||||
if (d && !isNaN(d.getTime())) {
|
|
||||||
el.textContent = d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' });
|
|
||||||
el.title = raw + ' (UTC)';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var editOverlay = document.getElementById('edit-overlay');
|
|
||||||
var editError = document.getElementById('edit-error');
|
|
||||||
var editFolder = document.getElementById('edit-folder');
|
|
||||||
var editType = document.getElementById('edit-type');
|
|
||||||
var editName = document.getElementById('edit-name');
|
|
||||||
var editNotes = document.getElementById('edit-notes');
|
|
||||||
var editTags = document.getElementById('edit-tags');
|
|
||||||
var editMetadata = document.getElementById('edit-metadata');
|
|
||||||
var currentEntryId = null;
|
|
||||||
|
|
||||||
function showEditErr(msg) {
|
|
||||||
editError.textContent = msg || '';
|
|
||||||
editError.classList.toggle('visible', !!msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(tr) {
|
|
||||||
var id = tr.getAttribute('data-entry-id');
|
|
||||||
if (!id) return;
|
|
||||||
currentEntryId = id;
|
|
||||||
showEditErr('');
|
|
||||||
editFolder.value = tr.querySelector('.cell-folder') ? tr.querySelector('.cell-folder').textContent.trim() : '';
|
|
||||||
editType.value = tr.querySelector('.cell-type') ? tr.querySelector('.cell-type').textContent.trim() : '';
|
|
||||||
editName.value = tr.querySelector('.cell-name') ? tr.querySelector('.cell-name').textContent.trim() : '';
|
|
||||||
editNotes.value = tr.querySelector('.cell-notes-val') ? tr.querySelector('.cell-notes-val').textContent : '';
|
|
||||||
var tagsText = tr.querySelector('.cell-tags-val') ? tr.querySelector('.cell-tags-val').textContent.trim() : '';
|
|
||||||
editTags.value = tagsText;
|
|
||||||
var metaPre = tr.querySelector('.cell-meta-val');
|
|
||||||
editMetadata.value = metaPre ? metaPre.textContent : '{}';
|
|
||||||
editOverlay.hidden = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEdit() {
|
|
||||||
editOverlay.hidden = true;
|
|
||||||
currentEntryId = null;
|
|
||||||
showEditErr('');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('edit-cancel').addEventListener('click', closeEdit);
|
|
||||||
editOverlay.addEventListener('click', function (e) {
|
|
||||||
if (e.target === editOverlay) closeEdit();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('edit-save').addEventListener('click', function () {
|
|
||||||
if (!currentEntryId) return;
|
|
||||||
var meta;
|
|
||||||
try {
|
|
||||||
meta = JSON.parse(editMetadata.value);
|
|
||||||
} catch (err) {
|
|
||||||
showEditErr('Metadata 不是合法 JSON');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
|
|
||||||
showEditErr('Metadata 必须是 JSON 对象');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var tags = editTags.value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
|
|
||||||
var body = {
|
|
||||||
folder: editFolder.value,
|
|
||||||
type: editType.value,
|
|
||||||
name: editName.value.trim(),
|
|
||||||
notes: editNotes.value,
|
|
||||||
tags: tags,
|
|
||||||
metadata: meta
|
|
||||||
};
|
|
||||||
showEditErr('');
|
|
||||||
fetch('/api/entries/' + encodeURIComponent(currentEntryId), {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify(body)
|
|
||||||
}).then(function (r) {
|
|
||||||
return r.json().then(function (data) {
|
|
||||||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
}).then(function () {
|
|
||||||
closeEdit();
|
|
||||||
window.location.reload();
|
|
||||||
}).catch(function (e) {
|
|
||||||
showEditErr(e.message || String(e));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
|
|
||||||
tr.querySelector('.btn-edit').addEventListener('click', function () { openEdit(tr); });
|
|
||||||
tr.querySelector('.btn-del').addEventListener('click', function () {
|
|
||||||
var id = tr.getAttribute('data-entry-id');
|
|
||||||
var nameEl = tr.querySelector('.cell-name');
|
|
||||||
var name = nameEl ? nameEl.textContent.trim() : '';
|
|
||||||
if (!id) return;
|
|
||||||
if (!confirm('确定删除条目「' + name + '」?')) return;
|
|
||||||
fetch('/api/entries/' + encodeURIComponent(id), { method: 'DELETE', credentials: 'same-origin' })
|
|
||||||
.then(function (r) {
|
|
||||||
return r.json().then(function (data) {
|
|
||||||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(function (data) {
|
|
||||||
if (data && Array.isArray(data.migrated) && data.migrated.length > 0) {
|
|
||||||
alert('已自动迁移共享 key 引用:' + data.migrated.length + ' 个条目完成重定向。');
|
|
||||||
}
|
|
||||||
window.location.reload();
|
|
||||||
})
|
|
||||||
.catch(function (e) { alert(e.message || String(e)); });
|
|
||||||
});
|
|
||||||
|
|
||||||
tr.querySelectorAll('.btn-unlink-secret').forEach(function (btn) {
|
|
||||||
btn.addEventListener('click', function () {
|
|
||||||
var entryId = tr.getAttribute('data-entry-id');
|
|
||||||
var secretId = btn.getAttribute('data-secret-id');
|
|
||||||
var secretName = btn.getAttribute('data-secret-name') || '';
|
|
||||||
if (!entryId || !secretId) return;
|
|
||||||
if (!confirm('确定解除 secret 关联「' + secretName + '」?')) return;
|
|
||||||
fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/' + encodeURIComponent(secretId), {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'same-origin'
|
|
||||||
}).then(function (r) {
|
|
||||||
return r.json().then(function (data) {
|
|
||||||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status));
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
}).then(function () {
|
|
||||||
window.location.reload();
|
|
||||||
}).catch(function (e) {
|
|
||||||
alert(e.message || String(e));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="description" content="Secrets MCP:基于 Model Context Protocol 的密钥与配置管理。密码短语在浏览器本地 PBKDF2 派生,密文 AES-GCM 存储,完整审计与历史版本。">
|
|
||||||
<meta name="keywords" content="secrets management,MCP,Model Context Protocol,end-to-end encryption,AES-GCM,PBKDF2,API key,密钥管理">
|
|
||||||
<meta name="robots" content="index, follow">
|
|
||||||
<link rel="canonical" href="{{ base_url }}/">
|
|
||||||
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
|
|
||||||
<title>Secrets MCP — 端到端加密的密钥管理</title>
|
|
||||||
<meta property="og:type" content="website">
|
|
||||||
<meta property="og:url" content="{{ base_url }}/">
|
|
||||||
<meta property="og:title" content="Secrets MCP — 端到端加密的密钥管理">
|
|
||||||
<meta property="og:description" content="密码短语客户端派生,密文存储;MCP API 与 Web 控制台,多租户与审计。">
|
|
||||||
<meta name="twitter:card" content="summary">
|
|
||||||
<meta name="twitter:title" content="Secrets MCP — 端到端加密的密钥管理">
|
|
||||||
<meta name="twitter:description" content="密码短语客户端派生,密文存储;MCP API 与 Web 控制台,多租户与审计。">
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500;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;
|
|
||||||
}
|
|
||||||
html, body { height: 100%; overflow: hidden; }
|
|
||||||
@supports (height: 100dvh) {
|
|
||||||
html, body { height: 100dvh; }
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.nav {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 14px 24px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
.brand {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.brand span { color: var(--accent); }
|
|
||||||
.nav-right { display: flex; align-items: center; gap: 14px; }
|
|
||||||
.lang-bar { display: flex; gap: 2px; background: rgba(255,255,255,0.04); border-radius: 6px; padding: 2px; }
|
|
||||||
.lang-btn {
|
|
||||||
padding: 4px 10px; 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); }
|
|
||||||
.cta {
|
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
padding: 8px 18px; border-radius: 8px; font-size: 13px; font-weight: 600;
|
|
||||||
text-decoration: none; border: 1px solid var(--accent);
|
|
||||||
background: rgba(88, 166, 255, 0.12); color: var(--accent);
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.cta:hover { background: var(--accent); color: var(--bg); }
|
|
||||||
.main {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 16px 24px 12px;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
.hero { text-align: center; max-width: 720px; }
|
|
||||||
.hero h1 { font-size: clamp(20px, 4vw, 28px); font-weight: 600; margin-bottom: 8px; line-height: 1.25; }
|
|
||||||
.hero .tagline { color: var(--text-muted); font-size: clamp(13px, 2vw, 15px); line-height: 1.5; }
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 900px;
|
|
||||||
}
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.grid { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
}
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.grid { grid-template-columns: 1fr; gap: 8px; }
|
|
||||||
.main { justify-content: flex-start; padding-top: 12px; }
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 14px 14px 12px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
.card-icon {
|
|
||||||
width: 32px; height: 32px; border-radius: 8px;
|
|
||||||
background: var(--surface2);
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
margin-bottom: 10px; color: var(--accent);
|
|
||||||
}
|
|
||||||
.card-icon svg { width: 18px; height: 18px; }
|
|
||||||
.card h2 { font-size: 13px; font-weight: 600; margin-bottom: 6px; line-height: 1.3; }
|
|
||||||
.card p { font-size: 12px; color: var(--text-muted); line-height: 1.45; }
|
|
||||||
.foot {
|
|
||||||
flex-shrink: 0;
|
|
||||||
text-align: center;
|
|
||||||
padding: 8px 16px 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
.foot a { color: var(--accent); text-decoration: none; }
|
|
||||||
.foot a:hover { text-decoration: underline; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header class="nav">
|
|
||||||
<a class="brand" href="/">secrets<span>-mcp</span></a>
|
|
||||||
<div class="nav-right">
|
|
||||||
<div class="lang-bar">
|
|
||||||
<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>
|
|
||||||
{% if is_logged_in %}
|
|
||||||
<a class="cta" href="/dashboard" data-i18n="ctaDashboard">进入控制台</a>
|
|
||||||
{% else %}
|
|
||||||
<a class="cta" href="/login" data-i18n="ctaLogin">登录</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<main class="main">
|
|
||||||
<div class="hero">
|
|
||||||
<h1 data-i18n="heroTitle">端到端加密的密钥与配置管理</h1>
|
|
||||||
<p class="tagline" data-i18n="heroTagline">Streamable HTTP MCP 与 Web 控制台:元数据与密文分库存储,密钥永不离开你的客户端逻辑。</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid">
|
|
||||||
<article class="card">
|
|
||||||
<div class="card-icon" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 11c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v3c0 1.66 1.34 3 3 3z"/><path d="M19 10v1a7 7 0 01-14 0v-1"/><path d="M12 14v7M9 18h6"/></svg>
|
|
||||||
</div>
|
|
||||||
<h2 data-i18n="c1t">客户端密钥派生</h2>
|
|
||||||
<p data-i18n="c1d">PBKDF2-SHA256(约 60 万次)在浏览器本地从密码短语派生密钥;服务端仅保存盐与校验值,不持有密码或明文主密钥。</p>
|
|
||||||
</article>
|
|
||||||
<article class="card">
|
|
||||||
<div class="card-icon" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
|
||||||
</div>
|
|
||||||
<h2 data-i18n="c2t">AES-256-GCM 加密</h2>
|
|
||||||
<p data-i18n="c2d">敏感字段以 AES-GCM 密文落库;Web 端在本地加解密,明文默认不经过服务端持久化。</p>
|
|
||||||
</article>
|
|
||||||
<article class="card">
|
|
||||||
<div class="card-icon" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></svg>
|
|
||||||
</div>
|
|
||||||
<h2 data-i18n="c3t">审计与历史</h2>
|
|
||||||
<p data-i18n="c3d">操作写入审计日志;条目与密文保留历史版本,支持按版本查看与恢复。</p>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer class="foot">
|
|
||||||
<span data-i18n="versionLabel">版本</span> {{ version }} ·
|
|
||||||
<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 %}
|
|
||||||
</footer>
|
|
||||||
<script>
|
|
||||||
const T = {
|
|
||||||
'zh-CN': {
|
|
||||||
docTitle: 'Secrets MCP — 端到端加密的密钥管理',
|
|
||||||
ctaDashboard: '进入控制台',
|
|
||||||
ctaLogin: '登录',
|
|
||||||
heroTitle: '端到端加密的密钥与配置管理',
|
|
||||||
heroTagline: 'Streamable HTTP MCP 与 Web 控制台:元数据与密文分库存储,密钥永不离开你的客户端逻辑。',
|
|
||||||
c1t: '客户端密钥派生',
|
|
||||||
c1d: 'PBKDF2-SHA256(约 60 万次)在浏览器本地从密码短语派生密钥;服务端仅保存盐与校验值,不持有密码或明文主密钥。',
|
|
||||||
c2t: 'AES-256-GCM 加密',
|
|
||||||
c2d: '敏感字段以 AES-GCM 密文落库;Web 端在本地加解密,明文默认不经过服务端持久化。',
|
|
||||||
c3t: '审计与历史',
|
|
||||||
c3d: '操作写入审计日志;条目与密文保留历史版本,支持按版本查看与恢复。',
|
|
||||||
versionLabel: '版本',
|
|
||||||
sep: ' · ',
|
|
||||||
footRepo: '源码仓库',
|
|
||||||
footLogin: '登录',
|
|
||||||
},
|
|
||||||
'zh-TW': {
|
|
||||||
docTitle: 'Secrets MCP — 端到端加密的金鑰管理',
|
|
||||||
ctaDashboard: '進入控制台',
|
|
||||||
ctaLogin: '登入',
|
|
||||||
heroTitle: '端到端加密的金鑰與設定管理',
|
|
||||||
heroTagline: 'Streamable HTTP MCP 與 Web 控制台:中繼資料與密文分庫儲存,金鑰不離開你的用戶端邏輯。',
|
|
||||||
c1t: '用戶端金鑰派生',
|
|
||||||
c1d: 'PBKDF2-SHA256(約 60 萬次)在瀏覽器本地從密碼片語派生金鑰;伺服端僅保存鹽與校驗值,不持有密碼或明文主金鑰。',
|
|
||||||
c2t: 'AES-256-GCM 加密',
|
|
||||||
c2d: '敏感欄位以 AES-GCM 密文落庫;Web 端在本地加解密,明文預設不經伺服端持久化。',
|
|
||||||
c3t: '稽核與歷史',
|
|
||||||
c3d: '操作寫入稽核日誌;條目與密文保留歷史版本,支援依版本檢視與還原。',
|
|
||||||
versionLabel: '版本',
|
|
||||||
sep: ' · ',
|
|
||||||
footRepo: '原始碼倉庫',
|
|
||||||
footLogin: '登入',
|
|
||||||
},
|
|
||||||
'en': {
|
|
||||||
docTitle: 'Secrets MCP — End-to-end encrypted secrets',
|
|
||||||
ctaDashboard: 'Open dashboard',
|
|
||||||
ctaLogin: 'Sign in',
|
|
||||||
heroTitle: 'End-to-end encrypted secrets and configuration',
|
|
||||||
heroTagline: 'Streamable HTTP MCP plus web console: metadata and ciphertext stored separately; keys stay on your client.',
|
|
||||||
c1t: 'Client-side key derivation',
|
|
||||||
c1d: 'PBKDF2-SHA256 (~600k iterations) derives keys from your passphrase in the browser; the server stores only salt and a verification blob, never your password or raw master key.',
|
|
||||||
c2t: 'AES-256-GCM',
|
|
||||||
c2d: 'Secret fields are stored as AES-GCM ciphertext; the web UI encrypts and decrypts locally so plaintext is not persisted server-side by default.',
|
|
||||||
c3t: 'Audit and history',
|
|
||||||
c3d: 'Operations are audited; entries and secrets keep version history for review and rollback.',
|
|
||||||
versionLabel: 'Version',
|
|
||||||
sep: ' · ',
|
|
||||||
footRepo: 'Source repository',
|
|
||||||
footLogin: 'Sign in',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 => {
|
|
||||||
const key = el.getAttribute('data-i18n');
|
|
||||||
el.textContent = t(key);
|
|
||||||
});
|
|
||||||
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>
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<meta name="robots" content="noindex, follow">
|
|
||||||
<meta name="description" content="登录 Secrets MCP Web 控制台,安全管理跨设备加密 secrets。">
|
|
||||||
<meta name="keywords" content="Secrets MCP,登录,OAuth,密钥管理">
|
|
||||||
<link rel="canonical" href="{{ base_url }}/login">
|
|
||||||
<link rel="icon" href="/favicon.svg?v={{ version }}" type="image/svg+xml">
|
|
||||||
<title>登录 — Secrets MCP</title>
|
|
||||||
<meta property="og:type" content="website">
|
|
||||||
<meta property="og:url" content="{{ base_url }}/login">
|
|
||||||
<meta property="og:title" content="登录 — Secrets MCP">
|
|
||||||
<meta property="og:description" content="登录 Web 控制台,管理加密存储的密钥与配置。">
|
|
||||||
<meta name="twitter:card" content="summary">
|
|
||||||
<meta name="twitter:title" content="登录 — Secrets MCP">
|
|
||||||
<meta name="twitter:description" content="登录 Web 控制台,管理加密存储的密钥与配置。">
|
|
||||||
<style>
|
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
@import url('https://fonts.googleapis.com/css2?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;
|
|
||||||
--google: #4285f4;
|
|
||||||
--danger: #f85149;
|
|
||||||
}
|
|
||||||
body { background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif;
|
|
||||||
min-height: 100vh; display: flex; align-items: center; justify-content: center; }
|
|
||||||
.card {
|
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
|
||||||
padding: 48px 40px; width: 100%; max-width: 400px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
.topbar { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; gap: 12px; }
|
|
||||||
.back-home {
|
|
||||||
font-size: 13px; color: var(--accent); text-decoration: none; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.back-home:hover { text-decoration: underline; }
|
|
||||||
.lang-bar { display: flex; gap: 2px; background: rgba(255,255,255,0.04); border-radius: 6px; padding: 2px; flex-shrink: 0; }
|
|
||||||
.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); }
|
|
||||||
.oauth-alert {
|
|
||||||
display: none;
|
|
||||||
margin-bottom: 16px; padding: 10px 12px; border-radius: 8px;
|
|
||||||
font-size: 13px; line-height: 1.4;
|
|
||||||
background: rgba(248, 81, 73, 0.12);
|
|
||||||
border: 1px solid rgba(248, 81, 73, 0.35);
|
|
||||||
color: #ffa198;
|
|
||||||
}
|
|
||||||
.oauth-alert.visible { display: block; }
|
|
||||||
h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
|
|
||||||
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 32px; }
|
|
||||||
.btn {
|
|
||||||
display: flex; align-items: center; justify-content: center; gap: 12px;
|
|
||||||
width: 100%; padding: 12px 20px; border: 1px solid var(--border); border-radius: 8px;
|
|
||||||
background: var(--surface); color: var(--text); font-size: 14px; font-weight: 500;
|
|
||||||
cursor: pointer; text-decoration: none; transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.btn:hover { background: var(--border); border-color: var(--text-muted); }
|
|
||||||
.btn + .btn { margin-top: 12px; }
|
|
||||||
.btn svg { flex-shrink: 0; }
|
|
||||||
.footer { margin-top: 28px; text-align: center; color: var(--text-muted); font-size: 12px; }
|
|
||||||
.footer a { color: var(--accent); text-decoration: none; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<div class="topbar">
|
|
||||||
<a class="back-home" href="/" data-i18n="backHome">返回首页</a>
|
|
||||||
<div class="lang-bar">
|
|
||||||
<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>
|
|
||||||
<div id="oauth-alert" class="oauth-alert" role="alert"></div>
|
|
||||||
<h1 data-i18n="title">登录</h1>
|
|
||||||
<p class="subtitle" data-i18n="subtitle">安全管理你的跨设备 secrets。</p>
|
|
||||||
|
|
||||||
{% if has_google %}
|
|
||||||
<a href="/auth/google" class="btn">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
|
||||||
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 01-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
|
|
||||||
<path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 009 18z" fill="#34A853"/>
|
|
||||||
<path d="M3.964 10.71A5.41 5.41 0 013.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 000 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
|
|
||||||
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 00.957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
|
|
||||||
</svg>
|
|
||||||
<span data-i18n="google">使用 Google 登录</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if !has_google %}
|
|
||||||
<p style="text-align:center; color: var(--text-muted); font-size: 14px;" data-i18n="noProviders">
|
|
||||||
未配置登录方式,请联系管理员。
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
const T = {
|
|
||||||
'zh-CN': {
|
|
||||||
docTitle: '登录 — Secrets MCP',
|
|
||||||
backHome: '返回首页',
|
|
||||||
title: '登录',
|
|
||||||
subtitle: '安全管理你的跨设备 secrets。',
|
|
||||||
google: '使用 Google 登录',
|
|
||||||
noProviders: '未配置登录方式,请联系管理员。',
|
|
||||||
err_oauth_error: '登录失败:授权提供方返回错误,请重试。',
|
|
||||||
err_oauth_missing_code: '登录失败:未收到授权码,请重试。',
|
|
||||||
err_oauth_missing_state: '登录失败:缺少安全校验参数,请重试。',
|
|
||||||
err_oauth_state: '登录失败:会话校验不匹配(可能因 Cookie 策略或服务器重启)。请返回首页再试。',
|
|
||||||
},
|
|
||||||
'zh-TW': {
|
|
||||||
docTitle: '登入 — Secrets MCP',
|
|
||||||
backHome: '返回首頁',
|
|
||||||
title: '登入',
|
|
||||||
subtitle: '安全管理你的跨裝置 secrets。',
|
|
||||||
google: '使用 Google 登入',
|
|
||||||
noProviders: '尚未設定登入方式,請聯絡管理員。',
|
|
||||||
err_oauth_error: '登入失敗:授權方回傳錯誤,請再試一次。',
|
|
||||||
err_oauth_missing_code: '登入失敗:未取得授權碼,請再試一次。',
|
|
||||||
err_oauth_missing_state: '登入失敗:缺少安全校驗參數,請再試一次。',
|
|
||||||
err_oauth_state: '登入失敗:工作階段校驗不符(可能與 Cookie 政策或伺服器重啟有關)。請回到首頁再試。',
|
|
||||||
},
|
|
||||||
'en': {
|
|
||||||
docTitle: 'Sign in — Secrets MCP',
|
|
||||||
backHome: 'Back to home',
|
|
||||||
title: 'Sign in',
|
|
||||||
subtitle: 'Manage your cross-device secrets securely.',
|
|
||||||
google: 'Continue with Google',
|
|
||||||
noProviders: 'No login providers configured. Please contact your administrator.',
|
|
||||||
err_oauth_error: 'Sign-in failed: the identity provider returned an error. Please try again.',
|
|
||||||
err_oauth_missing_code: 'Sign-in failed: no authorization code was returned. Please try again.',
|
|
||||||
err_oauth_missing_state: 'Sign-in failed: missing security state. Please try again.',
|
|
||||||
err_oauth_state: 'Sign-in failed: session state mismatch (often cookies or server restart). Open the home page and try again.',
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentLang = localStorage.getItem('lang') || 'zh-CN';
|
|
||||||
|
|
||||||
function t(key) { return T[currentLang][key] || T['en'][key] || key; }
|
|
||||||
|
|
||||||
function showOAuthError() {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const code = params.get('error');
|
|
||||||
const el = document.getElementById('oauth-alert');
|
|
||||||
if (!code || !code.startsWith('oauth_')) {
|
|
||||||
el.classList.remove('visible');
|
|
||||||
el.textContent = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const key = 'err_' + code;
|
|
||||||
el.textContent = t(key) || t('err_oauth_error');
|
|
||||||
el.classList.add('visible');
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyLang() {
|
|
||||||
document.documentElement.lang = currentLang;
|
|
||||||
document.title = t('docTitle');
|
|
||||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
||||||
const key = el.getAttribute('data-i18n');
|
|
||||||
el.textContent = t(key);
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
|
||||||
const map = { 'zh-CN': '简', 'zh-TW': '繁', 'en': 'EN' };
|
|
||||||
btn.classList.toggle('active', btn.textContent === map[currentLang]);
|
|
||||||
});
|
|
||||||
showOAuthError();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLang(lang) {
|
|
||||||
currentLang = lang;
|
|
||||||
localStorage.setItem('lang', lang);
|
|
||||||
applyLang();
|
|
||||||
}
|
|
||||||
|
|
||||||
applyLang();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Secrets MCP Server 环境变量配置
|
# Secrets v3 环境变量配置
|
||||||
# 复制此文件为 .env 并填写真实值
|
# 复制此文件为 .env 并填写真实值
|
||||||
|
|
||||||
# ─── 数据库 ───────────────────────────────────────────────────────────
|
# ─── 数据库 ───────────────────────────────────────────────────────────
|
||||||
# Web 会话(tower-sessions)与业务数据共用此库;启动时会自动 migrate 会话表,无需额外环境变量。
|
# v3 API 与桌面端都复用这套数据库
|
||||||
SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@db.refining.ltd:5432/secrets-mcp
|
SECRETS_DATABASE_URL=postgres://postgres:PASSWORD@db.refining.ltd:5432/secrets-v3
|
||||||
# 强烈建议生产使用 verify-full(至少 verify-ca)
|
# 强烈建议生产使用 verify-full(至少 verify-ca)
|
||||||
SECRETS_DATABASE_SSL_MODE=verify-full
|
SECRETS_DATABASE_SSL_MODE=verify-full
|
||||||
# 私有 CA 或自建链路时填写 CA 根证书路径;使用公共受信 CA 可留空
|
# 私有 CA 或自建链路时填写 CA 根证书路径;使用公共受信 CA 可留空
|
||||||
@@ -12,26 +12,46 @@ SECRETS_DATABASE_SSL_MODE=verify-full
|
|||||||
SECRETS_ENV=production
|
SECRETS_ENV=production
|
||||||
|
|
||||||
# ─── 服务地址 ─────────────────────────────────────────────────────────
|
# ─── 服务地址 ─────────────────────────────────────────────────────────
|
||||||
# 内网监听地址(Cloudflare / Nginx 反代时填内网端口)
|
SECRETS_API_BIND=127.0.0.1:9415
|
||||||
SECRETS_MCP_BIND=127.0.0.1:9315
|
SECRETS_DAEMON_BIND=127.0.0.1:9515
|
||||||
|
SECRETS_API_BASE=http://127.0.0.1:9415
|
||||||
|
SECRETS_DAEMON_URL=http://127.0.0.1:9515/mcp
|
||||||
|
|
||||||
# 对外 HTTPS 地址(用于 OAuth 回调 URL 拼接)
|
# ─── Google OAuth(服务端托管)──────────────────────────────────────────
|
||||||
BASE_URL=https://secrets.example.com
|
# 官网 DMG 正式分发时,Google OAuth 凭据只配置在 API 服务端
|
||||||
|
SECRETS_PUBLIC_BASE_URL=http://127.0.0.1:9415
|
||||||
# ─── Google OAuth ─────────────────────────────────────────────────────
|
GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
|
||||||
# Google Cloud Console → APIs & Services → Credentials
|
GOOGLE_OAUTH_CLIENT_SECRET=your-google-oauth-client-secret
|
||||||
# 授权回调 URI 须配置为:${BASE_URL}/auth/google/callback
|
GOOGLE_OAUTH_REDIRECT_URI=http://127.0.0.1:9415/auth/google/callback
|
||||||
GOOGLE_CLIENT_ID=
|
# 可选:如不配置则使用 Google 默认公开端点
|
||||||
GOOGLE_CLIENT_SECRET=
|
# GOOGLE_OAUTH_AUTH_URI=https://accounts.google.com/o/oauth2/v2/auth
|
||||||
|
# GOOGLE_OAUTH_TOKEN_URI=https://oauth2.googleapis.com/token
|
||||||
# ─── 微信登录(暂未开放,预留)───────────────────────────────────────
|
# 若仍无法换 token(仅提供端口代理、无系统代理):可取消注释并改为本机代理地址
|
||||||
# WECHAT_APP_CLIENT_ID=
|
# HTTPS_PROXY=http://127.0.0.1:7890
|
||||||
# WECHAT_APP_CLIENT_SECRET=
|
# NO_PROXY=localhost,127.0.0.1
|
||||||
|
|
||||||
# ─── 日志(可选)──────────────────────────────────────────────────────
|
# ─── 日志(可选)──────────────────────────────────────────────────────
|
||||||
# RUST_LOG=secrets_mcp=debug
|
# RUST_LOG=secrets_api=debug,secrets_desktop_daemon=debug
|
||||||
|
|
||||||
# ─── 注意 ─────────────────────────────────────────────────────────────
|
# ─── 数据库连接池(可选)──────────────────────────────────────────────
|
||||||
# SERVER_MASTER_KEY 已不再需要。
|
# 最大连接数,默认 10
|
||||||
# 新架构(E2EE)中,加密密钥由用户密码短语在客户端本地派生,服务端不持有原始密钥。
|
# SECRETS_DATABASE_POOL_SIZE=10
|
||||||
# 仅在需要迁移旧版 wrapped_key 数据时临时启用。
|
# 获取连接超时秒数,默认 5
|
||||||
|
# SECRETS_DATABASE_ACQUIRE_TIMEOUT=5
|
||||||
|
|
||||||
|
# ─── 限流(可选)──────────────────────────────────────────────────────
|
||||||
|
# 全局限流速率(req/s),默认 100
|
||||||
|
# RATE_LIMIT_GLOBAL_PER_SECOND=100
|
||||||
|
# 全局限流突发量,默认 200
|
||||||
|
# RATE_LIMIT_GLOBAL_BURST=200
|
||||||
|
# 单 IP 限流速率(req/s),默认 20
|
||||||
|
# RATE_LIMIT_IP_PER_SECOND=20
|
||||||
|
# 单 IP 限流突发量,默认 40
|
||||||
|
# RATE_LIMIT_IP_BURST=40
|
||||||
|
|
||||||
|
# ─── 代理信任(可选)─────────────────────────────────────────────────
|
||||||
|
# 设为 1/true/yes 时从 X-Forwarded-For / X-Real-IP 提取客户端 IP
|
||||||
|
# 仅在反代环境下启用,否则客户端可伪造 IP 绕过限流
|
||||||
|
# TRUST_PROXY=1
|
||||||
|
|
||||||
|
# 桌面端会在 ~/.secrets-v3/desktop 下持久化 device token 与 device fingerprint
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Secrets MCP Server
|
Description=Secrets API Server
|
||||||
After=network.target
|
After=network.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=secrets-mcp
|
User=secrets
|
||||||
Group=secrets-mcp
|
Group=secrets
|
||||||
WorkingDirectory=/opt/secrets-mcp
|
WorkingDirectory=/opt/secrets
|
||||||
EnvironmentFile=/opt/secrets-mcp/.env
|
EnvironmentFile=/opt/secrets/.env
|
||||||
ExecStart=/opt/secrets-mcp/secrets-mcp
|
ExecStart=/opt/secrets/secrets-api
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
StandardError=journal
|
StandardError=journal
|
||||||
SyslogIdentifier=secrets-mcp
|
SyslogIdentifier=secrets-api
|
||||||
|
|
||||||
# 安全加固
|
# 安全加固
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=yes
|
ProtectHome=yes
|
||||||
ReadWritePaths=/opt/secrets-mcp
|
ReadWritePaths=/opt/secrets
|
||||||
PrivateTmp=yes
|
PrivateTmp=yes
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user