Compare commits
2 Commits
53d53ff96a
...
b0fcb83592
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0fcb83592 | ||
|
|
8942718641 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@
|
|||||||
tmp/
|
tmp/
|
||||||
client_secret_*.apps.googleusercontent.com.json
|
client_secret_*.apps.googleusercontent.com.json
|
||||||
node_modules/
|
node_modules/
|
||||||
|
*.pyc
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -2066,7 +2066,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.5.8"
|
version = "0.5.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"askama",
|
"askama",
|
||||||
|
|||||||
@@ -428,6 +428,9 @@ async fn migrate_schema(pool: &PgPool) -> Result<()> {
|
|||||||
-- ── Drop legacy actor columns ─────────────────────────────────────────────
|
-- ── Drop legacy actor columns ─────────────────────────────────────────────
|
||||||
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
|
ALTER TABLE secrets_history DROP COLUMN IF EXISTS actor;
|
||||||
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
|
ALTER TABLE audit_log DROP COLUMN IF EXISTS actor;
|
||||||
|
|
||||||
|
-- ── key_version: incremented on passphrase change to invalidate other sessions ──
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS key_version BIGINT NOT NULL DEFAULT 0;
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ pub struct User {
|
|||||||
pub key_params: Option<serde_json::Value>,
|
pub key_params: Option<serde_json::Value>,
|
||||||
/// Plaintext API key for MCP Bearer authentication. Auto-created on first login.
|
/// Plaintext API key for MCP Bearer authentication. Auto-created on first login.
|
||||||
pub api_key: Option<String>,
|
pub api_key: Option<String>,
|
||||||
|
/// Incremented each time the passphrase is changed; used to invalidate sessions on other devices.
|
||||||
|
pub key_version: i64,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result
|
|||||||
|
|
||||||
if let Some(oa) = existing {
|
if let Some(oa) = existing {
|
||||||
let user: User = sqlx::query_as(
|
let user: User = sqlx::query_as(
|
||||||
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
|
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, key_version, created_at, updated_at \
|
||||||
FROM users WHERE id = $1",
|
FROM users WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(oa.user_id)
|
.bind(oa.user_id)
|
||||||
@@ -50,7 +50,7 @@ pub async fn find_or_create_user(pool: &PgPool, profile: OAuthProfile) -> Result
|
|||||||
let user: User = sqlx::query_as(
|
let user: User = sqlx::query_as(
|
||||||
"INSERT INTO users (email, name, avatar_url) \
|
"INSERT INTO users (email, name, avatar_url) \
|
||||||
VALUES ($1, $2, $3) \
|
VALUES ($1, $2, $3) \
|
||||||
RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at",
|
RETURNING id, email, name, avatar_url, key_salt, key_check, key_params, api_key, key_version, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(&profile.email)
|
.bind(&profile.email)
|
||||||
.bind(&display_name)
|
.bind(&display_name)
|
||||||
@@ -108,7 +108,8 @@ pub async fn change_user_key(
|
|||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, updated_at = NOW() \
|
"UPDATE users SET key_salt = $1, key_check = $2, key_params = $3, \
|
||||||
|
key_version = key_version + 1, updated_at = NOW() \
|
||||||
WHERE id = $4",
|
WHERE id = $4",
|
||||||
)
|
)
|
||||||
.bind(new_salt)
|
.bind(new_salt)
|
||||||
@@ -146,7 +147,7 @@ pub async fn update_user_key_setup(
|
|||||||
/// Fetch a user by ID.
|
/// Fetch a user by ID.
|
||||||
pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<User>> {
|
pub async fn get_user_by_id(pool: &PgPool, user_id: Uuid) -> Result<Option<User>> {
|
||||||
let user = sqlx::query_as(
|
let user = sqlx::query_as(
|
||||||
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, created_at, updated_at \
|
"SELECT id, email, name, avatar_url, key_salt, key_check, key_params, api_key, key_version, created_at, updated_at \
|
||||||
FROM users WHERE id = $1",
|
FROM users WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "secrets-mcp"
|
name = "secrets-mcp"
|
||||||
version = "0.5.8"
|
version = "0.5.9"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ use secrets_core::service::{
|
|||||||
api_key::{ensure_api_key, regenerate_api_key},
|
api_key::{ensure_api_key, regenerate_api_key},
|
||||||
audit_log::{count_for_user, list_for_user},
|
audit_log::{count_for_user, list_for_user},
|
||||||
delete::delete_by_id,
|
delete::delete_by_id,
|
||||||
|
get_secret::get_all_secrets_by_id,
|
||||||
search::{SearchParams, count_entries, fetch_secret_schemas, ilike_pattern, list_entries},
|
search::{SearchParams, count_entries, fetch_secret_schemas, ilike_pattern, list_entries},
|
||||||
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
|
update::{UpdateEntryFieldsByIdParams, update_fields_by_id},
|
||||||
user::{
|
user::{
|
||||||
@@ -37,6 +38,7 @@ const SESSION_USER_ID: &str = "user_id";
|
|||||||
const SESSION_OAUTH_STATE: &str = "oauth_state";
|
const SESSION_OAUTH_STATE: &str = "oauth_state";
|
||||||
const SESSION_OAUTH_BIND_MODE: &str = "oauth_bind_mode";
|
const SESSION_OAUTH_BIND_MODE: &str = "oauth_bind_mode";
|
||||||
const SESSION_LOGIN_PROVIDER: &str = "login_provider";
|
const SESSION_LOGIN_PROVIDER: &str = "login_provider";
|
||||||
|
const SESSION_KEY_VERSION: &str = "key_version";
|
||||||
|
|
||||||
// ── Template types ────────────────────────────────────────────────────────────
|
// ── Template types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -175,6 +177,57 @@ async fn current_user_id(session: &Session) -> Option<Uuid> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load and validate the current user from session and DB.
|
||||||
|
///
|
||||||
|
/// Returns the user if the session is valid. Flushes the session and returns
|
||||||
|
/// `Err(Redirect::to("/login"))` when:
|
||||||
|
/// - the session has no `user_id`,
|
||||||
|
/// - the user no longer exists in the database, or
|
||||||
|
/// - the stored `key_version` does not match the DB value (passphrase changed on
|
||||||
|
/// another device since this session was created).
|
||||||
|
async fn require_valid_user(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
session: &Session,
|
||||||
|
context: &str,
|
||||||
|
) -> Result<secrets_core::models::User, Response> {
|
||||||
|
let Some(user_id) = current_user_id(session).await else {
|
||||||
|
return Err(Redirect::to("/login").into_response());
|
||||||
|
};
|
||||||
|
|
||||||
|
let user = match secrets_core::service::user::get_user_by_id(pool, user_id).await {
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, %user_id, context, "failed to load user");
|
||||||
|
return Err(StatusCode::INTERNAL_SERVER_ERROR.into_response());
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
if let Err(e) = session.flush().await {
|
||||||
|
tracing::warn!(error = %e, "failed to flush stale session");
|
||||||
|
}
|
||||||
|
return Err(Redirect::to("/login").into_response());
|
||||||
|
}
|
||||||
|
Ok(Some(u)) => u,
|
||||||
|
};
|
||||||
|
|
||||||
|
let session_kv: Option<i64> = match session.get::<i64>(SESSION_KEY_VERSION).await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "failed to read key_version from session; treating as missing");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(kv) = session_kv
|
||||||
|
&& kv != user.key_version
|
||||||
|
{
|
||||||
|
tracing::info!(%user_id, session_kv = kv, db_kv = user.key_version, "key_version mismatch; invalidating session");
|
||||||
|
if let Err(e) = session.flush().await {
|
||||||
|
tracing::warn!(error = %e, "failed to flush outdated session");
|
||||||
|
}
|
||||||
|
return Err(Redirect::to("/login").into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> {
|
fn request_client_ip(headers: &HeaderMap, connect_info: ConnectInfo<SocketAddr>) -> Option<String> {
|
||||||
let trust_proxy = std::env::var("TRUST_PROXY")
|
let trust_proxy = std::env::var("TRUST_PROXY")
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -267,6 +320,10 @@ pub fn web_router() -> Router<AppState> {
|
|||||||
"/api/entries/{entry_id}/secrets/{secret_id}",
|
"/api/entries/{entry_id}/secrets/{secret_id}",
|
||||||
axum::routing::delete(api_entry_secret_unlink),
|
axum::routing::delete(api_entry_secret_unlink),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/entries/{id}/secrets/decrypt",
|
||||||
|
get(api_entry_secrets_decrypt),
|
||||||
|
)
|
||||||
.route("/api/secrets/{secret_id}", patch(api_secret_patch))
|
.route("/api/secrets/{secret_id}", patch(api_secret_patch))
|
||||||
.route("/api/secrets/check-name", get(api_secret_check_name))
|
.route("/api/secrets/check-name", get(api_secret_check_name))
|
||||||
}
|
}
|
||||||
@@ -542,6 +599,9 @@ where
|
|||||||
);
|
);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
|
if let Err(e) = session.insert(SESSION_KEY_VERSION, user.key_version).await {
|
||||||
|
tracing::warn!(error = %e, user_id = %user.id, "failed to insert key_version into session after OAuth");
|
||||||
|
}
|
||||||
|
|
||||||
log_login(
|
log_login(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
@@ -571,16 +631,9 @@ async fn dashboard(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let Some(user_id) = current_user_id(&session).await else {
|
let user = match require_valid_user(&state.pool, &session, "dashboard").await {
|
||||||
return Ok(Redirect::to("/login").into_response());
|
Ok(u) => u,
|
||||||
};
|
Err(r) => return Ok(r),
|
||||||
|
|
||||||
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
|
|
||||||
tracing::error!(error = %e, %user_id, "failed to load user for dashboard");
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})? {
|
|
||||||
Some(u) => u,
|
|
||||||
None => return Ok(Redirect::to("/login").into_response()),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let tmpl = DashboardTemplate {
|
let tmpl = DashboardTemplate {
|
||||||
@@ -599,17 +652,11 @@ async fn entries_page(
|
|||||||
session: Session,
|
session: Session,
|
||||||
Query(q): Query<EntriesQuery>,
|
Query(q): Query<EntriesQuery>,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let Some(user_id) = current_user_id(&session).await else {
|
let user = match require_valid_user(&state.pool, &session, "entries_page").await {
|
||||||
return Ok(Redirect::to("/login").into_response());
|
Ok(u) => u,
|
||||||
};
|
Err(r) => return Ok(r),
|
||||||
|
|
||||||
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
|
|
||||||
tracing::error!(error = %e, %user_id, "failed to load user for entries page");
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})? {
|
|
||||||
Some(u) => u,
|
|
||||||
None => return Ok(Redirect::to("/login").into_response()),
|
|
||||||
};
|
};
|
||||||
|
let user_id = user.id;
|
||||||
|
|
||||||
let folder_filter = q
|
let folder_filter = q
|
||||||
.folder
|
.folder
|
||||||
@@ -855,17 +902,11 @@ async fn audit_page(
|
|||||||
session: Session,
|
session: Session,
|
||||||
Query(aq): Query<AuditQuery>,
|
Query(aq): Query<AuditQuery>,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
let Some(user_id) = current_user_id(&session).await else {
|
let user = match require_valid_user(&state.pool, &session, "audit_page").await {
|
||||||
return Ok(Redirect::to("/login").into_response());
|
Ok(u) => u,
|
||||||
};
|
Err(r) => return Ok(r),
|
||||||
|
|
||||||
let user = match get_user_by_id(&state.pool, user_id).await.map_err(|e| {
|
|
||||||
tracing::error!(error = %e, %user_id, "failed to load user for audit page");
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})? {
|
|
||||||
Some(u) => u,
|
|
||||||
None => return Ok(Redirect::to("/login").into_response()),
|
|
||||||
};
|
};
|
||||||
|
let user_id = user.id;
|
||||||
|
|
||||||
let page = aq.page.unwrap_or(1).max(1);
|
let page = aq.page.unwrap_or(1).max(1);
|
||||||
|
|
||||||
@@ -1172,6 +1213,25 @@ async fn api_key_change(
|
|||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Refresh the session's key_version so the current session is not immediately
|
||||||
|
// invalidated by require_valid_user on the next page load.
|
||||||
|
match get_user_by_id(&state.pool, user_id).await {
|
||||||
|
Ok(Some(updated_user)) => {
|
||||||
|
if let Err(e) = session
|
||||||
|
.insert(SESSION_KEY_VERSION, updated_user.key_version)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, %user_id, "failed to update key_version in session after key change");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
tracing::warn!(%user_id, "user not found after key change; session not updated");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, %user_id, "failed to reload user after key change; session not updated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tracing::info!(%user_id, secrets_count = "(see service log)", "passphrase changed and secrets re-encrypted");
|
tracing::info!(%user_id, secrets_count = "(see service log)", "passphrase changed and secrets re-encrypted");
|
||||||
Ok(Json(KeySetupResponse { ok: true }))
|
Ok(Json(KeySetupResponse { ok: true }))
|
||||||
}
|
}
|
||||||
@@ -1811,6 +1871,65 @@ async fn oauth_protected_resource_metadata(State(state): State<AppState>) -> imp
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Decrypt entry secrets (Web UI) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn api_entry_secrets_decrypt(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
session: Session,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(entry_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<serde_json::Value>, EntryApiError> {
|
||||||
|
let lang = request_ui_lang(&headers);
|
||||||
|
let user_id = current_user_id(&session).await.ok_or((
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(json!({ "error": tr(lang, "未登录", "尚未登入", "Not logged in") })),
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let enc_key_hex = headers
|
||||||
|
.get("x-encryption-key")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({ "error": tr(lang, "缺少 X-Encryption-Key 请求头", "缺少 X-Encryption-Key 請求標頭", "Missing X-Encryption-Key header") })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let master_key =
|
||||||
|
secrets_core::crypto::extract_key_from_hex(enc_key_hex).map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(json!({ "error": tr(lang, "X-Encryption-Key 格式无效", "X-Encryption-Key 格式無效", "Invalid X-Encryption-Key format") })),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let secrets =
|
||||||
|
get_all_secrets_by_id(&state.pool, entry_id, &master_key, Some(user_id))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("DecryptionFailed") || msg.contains("decryption") {
|
||||||
|
(
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
Json(json!({ "error": tr(lang, "解密失败,请确认密码短语正确", "解密失敗,請確認密碼短語正確", "Decryption failed, please verify your passphrase") })),
|
||||||
|
)
|
||||||
|
} else if msg.contains("not found") {
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(json!({ "error": tr(lang, "条目不存在或无权访问", "條目不存在或無權存取", "Entry not found or no access") })),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tracing::error!(error = %e, %entry_id, "decrypt entry secrets failed");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(json!({ "error": tr(lang, "操作失败,请稍后重试", "操作失敗,請稍後重試", "Operation failed, please try again later") })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "ok": true, "secrets": secrets })))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {
|
fn render_template<T: Template>(tmpl: T) -> Result<Response, StatusCode> {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@
|
|||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0;
|
border-spacing: 0;
|
||||||
}
|
}
|
||||||
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-top: 1px solid var(--border); }
|
th, td { text-align: left; vertical-align: middle; padding: 12px 10px; border-top: 1px solid var(--border); }
|
||||||
th {
|
th {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -138,24 +138,28 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
td { font-size: 13px; line-height: 1.45; }
|
td { font-size: 13px; line-height: 1.45; }
|
||||||
tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); }
|
tbody tr:nth-child(2n) td { background: rgba(255, 255, 255, 0.01); }
|
||||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
.col-type { min-width: 108px; width: 1%; }
|
.col-type { min-width: 108px; width: 1%; text-align: center; vertical-align: middle; }
|
||||||
.col-name { min-width: 180px; max-width: 260px; }
|
.col-name { min-width: 180px; max-width: 260px; text-align: center; vertical-align: middle; }
|
||||||
.col-tags { min-width: 160px; max-width: 220px; }
|
.col-tags { min-width: 160px; max-width: 220px; }
|
||||||
.col-secrets { min-width: 220px; max-width: 420px; vertical-align: top; }
|
.col-secrets { min-width: 220px; max-width: 420px; vertical-align: middle; }
|
||||||
.col-secrets .secret-list { max-height: 120px; overflow: auto; }
|
.col-secrets .secret-list { max-height: 120px; overflow: auto; }
|
||||||
.col-actions { min-width: 132px; width: 1%; }
|
.col-actions { min-width: 132px; width: 1%; text-align: center; vertical-align: middle; }
|
||||||
.cell-name, .cell-tags-val {
|
.cell-name, .cell-tags-val {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.cell-notes { min-width: 260px; max-width: 360px; }
|
.cell-notes { min-width: 260px; max-width: 360px; }
|
||||||
.notes-scroll {
|
.notes-scroll {
|
||||||
max-height: 120px;
|
height: calc(1.5em * 2 + 16px);
|
||||||
|
min-height: calc(1.5em * 2 + 16px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
resize: vertical;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
@@ -170,7 +174,7 @@
|
|||||||
max-width: 360px; max-height: 120px; overflow: auto;
|
max-width: 360px; max-height: 120px; overflow: auto;
|
||||||
}
|
}
|
||||||
.col-actions { white-space: nowrap; }
|
.col-actions { white-space: nowrap; }
|
||||||
.row-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
.row-actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; align-items: center; }
|
||||||
.secret-list { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; }
|
.secret-list { display: flex; flex-wrap: wrap; gap: 6px; max-width: 100%; }
|
||||||
.secret-chip {
|
.secret-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -312,7 +316,11 @@
|
|||||||
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
color: var(--text); padding: 8px 10px; font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.modal-field textarea { min-height: 72px; resize: vertical; }
|
.modal-field textarea { resize: vertical; }
|
||||||
|
#edit-notes {
|
||||||
|
height: calc(1.5em * 2 + 16px);
|
||||||
|
min-height: calc(1.5em * 2 + 16px);
|
||||||
|
}
|
||||||
.modal-field textarea.metadata-edit { min-height: 140px; }
|
.modal-field textarea.metadata-edit { min-height: 140px; }
|
||||||
.modal-readonly-value {
|
.modal-readonly-value {
|
||||||
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
@@ -348,6 +356,9 @@
|
|||||||
margin-bottom: 4px; text-transform: uppercase;
|
margin-bottom: 4px; text-transform: uppercase;
|
||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
}
|
}
|
||||||
|
.col-name, .col-type, .col-actions { text-align: left; }
|
||||||
|
th, td { vertical-align: top; }
|
||||||
|
.row-actions { justify-content: flex-start; }
|
||||||
.detail, .notes-scroll, .secret-list { max-width: none; }
|
.detail, .notes-scroll, .secret-list { max-width: none; }
|
||||||
}
|
}
|
||||||
.pagination {
|
.pagination {
|
||||||
@@ -368,6 +379,43 @@
|
|||||||
.page-info {
|
.page-info {
|
||||||
color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
color: var(--text-muted); font-size: 13px; font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
|
.view-secret-row {
|
||||||
|
display: flex; flex-direction: column; gap: 4px; padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.view-secret-row:last-child { border-bottom: none; }
|
||||||
|
.view-secret-header {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.view-secret-name {
|
||||||
|
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
||||||
|
color: var(--text); font-weight: 600;
|
||||||
|
}
|
||||||
|
.view-secret-type {
|
||||||
|
font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
||||||
|
color: var(--text-muted); background: var(--surface2);
|
||||||
|
border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px;
|
||||||
|
}
|
||||||
|
.view-secret-actions { margin-left: auto; display: flex; gap: 6px; }
|
||||||
|
.view-secret-value-wrap { position: relative; }
|
||||||
|
.view-secret-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace; font-size: 12px;
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 7px 10px; word-break: break-all; white-space: pre-wrap;
|
||||||
|
max-height: 140px; overflow: auto; color: var(--text); line-height: 1.5;
|
||||||
|
}
|
||||||
|
.view-secret-value.masked { letter-spacing: 2px; user-select: none; filter: blur(4px); }
|
||||||
|
.btn-icon {
|
||||||
|
padding: 3px 8px; border-radius: 5px; font-size: 11px; cursor: pointer;
|
||||||
|
border: 1px solid var(--border); background: var(--surface2); color: var(--text-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-icon:hover { color: var(--text); border-color: var(--text-muted); }
|
||||||
|
.view-locked-msg {
|
||||||
|
font-size: 13px; color: var(--text-muted); padding: 16px 0;
|
||||||
|
line-height: 1.6; text-align: center;
|
||||||
|
}
|
||||||
|
.view-locked-msg a { color: var(--accent); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -465,7 +513,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="col-actions" data-label="操作">
|
<td class="col-actions" data-label="操作">
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<button type="button" class="btn-row btn-edit" data-i18n="rowEdit">编辑</button>
|
<button type="button" class="btn-row btn-view-secrets" data-i18n="rowView">查看密文</button>
|
||||||
|
<button type="button" class="btn-row btn-edit" data-i18n="rowEdit">编辑条目</button>
|
||||||
<button type="button" class="btn-row danger btn-del" data-i18n="rowDelete">删除</button>
|
<button type="button" class="btn-row danger btn-del" data-i18n="rowDelete">删除</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -498,7 +547,7 @@
|
|||||||
|
|
||||||
<div id="edit-overlay" class="modal-overlay" hidden>
|
<div id="edit-overlay" class="modal-overlay" hidden>
|
||||||
<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="edit-title">
|
<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="edit-title">
|
||||||
<div class="modal-title" id="edit-title" data-i18n="modalTitle">编辑条目</div>
|
<div class="modal-title" id="edit-title" data-i18n="modalTitle">编辑条目信息</div>
|
||||||
<div id="edit-error" class="modal-error"></div>
|
<div id="edit-error" class="modal-error"></div>
|
||||||
<div class="modal-field"><label for="edit-name" data-i18n="modalName">名称</label><input id="edit-name" type="text" autocomplete="off"></div>
|
<div class="modal-field"><label for="edit-name" data-i18n="modalName">名称</label><input id="edit-name" type="text" autocomplete="off"></div>
|
||||||
<div class="modal-field"><label for="edit-type" data-i18n="modalType">类型</label><input id="edit-type" type="text" autocomplete="off"></div>
|
<div class="modal-field"><label for="edit-type" data-i18n="modalType">类型</label><input id="edit-type" type="text" autocomplete="off"></div>
|
||||||
@@ -528,6 +577,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="view-overlay" class="modal-overlay" hidden>
|
||||||
|
<div class="modal modal-wide" role="dialog" aria-modal="true" aria-labelledby="view-title">
|
||||||
|
<div class="modal-title" id="view-title" data-i18n="viewTitle">查看条目密文</div>
|
||||||
|
<div id="view-entry-name" style="font-size:13px;color:var(--text-muted);margin-bottom:14px;font-family:'JetBrains Mono',monospace;"></div>
|
||||||
|
<div id="view-body"></div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn-modal" id="view-close" data-i18n="modalCancel">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script src="/static/i18n.js"></script>
|
<script src="/static/i18n.js"></script>
|
||||||
<script id="secret-type-options" type="application/json">{{ secret_type_options_json|safe }}</script>
|
<script id="secret-type-options" type="application/json">{{ secret_type_options_json|safe }}</script>
|
||||||
<script>
|
<script>
|
||||||
@@ -551,9 +611,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
colTags: '标签',
|
colTags: '标签',
|
||||||
colSecrets: '密文',
|
colSecrets: '密文',
|
||||||
colActions: '操作',
|
colActions: '操作',
|
||||||
rowEdit: '编辑',
|
rowEdit: '编辑条目',
|
||||||
rowDelete: '删除',
|
rowDelete: '删除',
|
||||||
modalTitle: '编辑条目',
|
modalTitle: '编辑条目信息',
|
||||||
modalName: '名称',
|
modalName: '名称',
|
||||||
modalType: '类型',
|
modalType: '类型',
|
||||||
modalFolder: '文件夹',
|
modalFolder: '文件夹',
|
||||||
@@ -591,6 +651,16 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
secretTypeInvalid: '类型不能为空',
|
secretTypeInvalid: '类型不能为空',
|
||||||
prevPage: '上一页',
|
prevPage: '上一页',
|
||||||
nextPage: '下一页',
|
nextPage: '下一页',
|
||||||
|
rowView: '查看密文',
|
||||||
|
viewTitle: '查看条目密文',
|
||||||
|
viewNoSecrets: '该条目没有关联的密文字段。',
|
||||||
|
viewLockedMsg: '请先前往 <a href="/dashboard">MCP 配置页</a> 解锁密码短语,然后再查看密文。',
|
||||||
|
viewDecryptError: '解密失败,请确认密码短语与加密时一致。',
|
||||||
|
viewCopy: '复制',
|
||||||
|
viewCopied: '已复制',
|
||||||
|
viewShow: '显示',
|
||||||
|
viewHide: '隐藏',
|
||||||
|
viewLoading: '解密中…',
|
||||||
},
|
},
|
||||||
'zh-TW': {
|
'zh-TW': {
|
||||||
pageTitle: 'Secrets — 條目',
|
pageTitle: 'Secrets — 條目',
|
||||||
@@ -609,9 +679,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
colTags: '標籤',
|
colTags: '標籤',
|
||||||
colSecrets: '密文',
|
colSecrets: '密文',
|
||||||
colActions: '操作',
|
colActions: '操作',
|
||||||
rowEdit: '編輯',
|
rowEdit: '編輯條目',
|
||||||
rowDelete: '刪除',
|
rowDelete: '刪除',
|
||||||
modalTitle: '編輯條目',
|
modalTitle: '編輯條目資訊',
|
||||||
modalName: '名稱',
|
modalName: '名稱',
|
||||||
modalType: '類型',
|
modalType: '類型',
|
||||||
modalFolder: '資料夾',
|
modalFolder: '資料夾',
|
||||||
@@ -649,6 +719,16 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
secretTypeInvalid: '類型不能為空',
|
secretTypeInvalid: '類型不能為空',
|
||||||
prevPage: '上一頁',
|
prevPage: '上一頁',
|
||||||
nextPage: '下一頁',
|
nextPage: '下一頁',
|
||||||
|
rowView: '查看密文',
|
||||||
|
viewTitle: '查看條目密文',
|
||||||
|
viewNoSecrets: '該條目沒有關聯的密文欄位。',
|
||||||
|
viewLockedMsg: '請先前往 <a href="/dashboard">MCP 設定頁</a> 解鎖密碼短語,再查看密文。',
|
||||||
|
viewDecryptError: '解密失敗,請確認密碼短語與加密時一致。',
|
||||||
|
viewCopy: '複製',
|
||||||
|
viewCopied: '已複製',
|
||||||
|
viewShow: '顯示',
|
||||||
|
viewHide: '隱藏',
|
||||||
|
viewLoading: '解密中…',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
pageTitle: 'Secrets — Entries',
|
pageTitle: 'Secrets — Entries',
|
||||||
@@ -667,9 +747,9 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
colTags: 'Tags',
|
colTags: 'Tags',
|
||||||
colSecrets: 'Secrets',
|
colSecrets: 'Secrets',
|
||||||
colActions: 'Actions',
|
colActions: 'Actions',
|
||||||
rowEdit: 'Edit',
|
rowEdit: 'Edit entry',
|
||||||
rowDelete: 'Delete',
|
rowDelete: 'Delete',
|
||||||
modalTitle: 'Edit entry',
|
modalTitle: 'Edit entry details',
|
||||||
modalName: 'Name',
|
modalName: 'Name',
|
||||||
modalType: 'Type',
|
modalType: 'Type',
|
||||||
modalFolder: 'Folder',
|
modalFolder: 'Folder',
|
||||||
@@ -706,7 +786,17 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
secretTypePlaceholder: 'Select type',
|
secretTypePlaceholder: 'Select type',
|
||||||
secretTypeInvalid: 'Type cannot be empty',
|
secretTypeInvalid: 'Type cannot be empty',
|
||||||
prevPage: 'Previous',
|
prevPage: 'Previous',
|
||||||
nextPage: 'Next'
|
nextPage: 'Next',
|
||||||
|
rowView: 'View secrets',
|
||||||
|
viewTitle: 'View entry secrets',
|
||||||
|
viewNoSecrets: 'This entry has no associated secret fields.',
|
||||||
|
viewLockedMsg: 'Please go to the <a href="/dashboard">MCP config page</a> to unlock your passphrase first.',
|
||||||
|
viewDecryptError: 'Decryption failed. Please verify your passphrase matches the one used when encrypting.',
|
||||||
|
viewCopy: 'Copy',
|
||||||
|
viewCopied: 'Copied',
|
||||||
|
viewShow: 'Show',
|
||||||
|
viewHide: 'Hide',
|
||||||
|
viewLoading: 'Decrypting…',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -757,6 +847,137 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
var currentEntryId = null;
|
var currentEntryId = null;
|
||||||
var pendingDeleteId = null;
|
var pendingDeleteId = null;
|
||||||
|
|
||||||
|
// ── View secrets modal ────────────────────────────────────────────────────
|
||||||
|
var viewOverlay = document.getElementById('view-overlay');
|
||||||
|
var viewEntryName = document.getElementById('view-entry-name');
|
||||||
|
var viewBody = document.getElementById('view-body');
|
||||||
|
|
||||||
|
function closeView() {
|
||||||
|
viewOverlay.hidden = true;
|
||||||
|
viewBody.innerHTML = '';
|
||||||
|
viewEntryName.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('view-close').addEventListener('click', closeView);
|
||||||
|
viewOverlay.addEventListener('click', function (e) {
|
||||||
|
if (e.target === viewOverlay) closeView();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderViewSecrets(secrets) {
|
||||||
|
viewBody.innerHTML = '';
|
||||||
|
var names = Object.keys(secrets);
|
||||||
|
if (names.length === 0) {
|
||||||
|
var msg = document.createElement('div');
|
||||||
|
msg.className = 'view-locked-msg';
|
||||||
|
msg.textContent = t('viewNoSecrets');
|
||||||
|
viewBody.appendChild(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
names.forEach(function (name) {
|
||||||
|
var raw = secrets[name];
|
||||||
|
var valueStr = (raw === null || raw === undefined) ? '' :
|
||||||
|
(typeof raw === 'object') ? JSON.stringify(raw, null, 2) : String(raw);
|
||||||
|
var isPassword = (name === 'password' || name === 'passwd' || name === 'secret');
|
||||||
|
var masked = isPassword;
|
||||||
|
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'view-secret-row';
|
||||||
|
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.className = 'view-secret-header';
|
||||||
|
|
||||||
|
var nameSpan = document.createElement('span');
|
||||||
|
nameSpan.className = 'view-secret-name';
|
||||||
|
nameSpan.textContent = name;
|
||||||
|
header.appendChild(nameSpan);
|
||||||
|
|
||||||
|
var actions = document.createElement('div');
|
||||||
|
actions.className = 'view-secret-actions';
|
||||||
|
|
||||||
|
if (isPassword) {
|
||||||
|
var toggleBtn = document.createElement('button');
|
||||||
|
toggleBtn.type = 'button';
|
||||||
|
toggleBtn.className = 'btn-icon btn-toggle-mask';
|
||||||
|
toggleBtn.textContent = t('viewShow');
|
||||||
|
toggleBtn.addEventListener('click', function () {
|
||||||
|
masked = !masked;
|
||||||
|
valueEl.classList.toggle('masked', masked);
|
||||||
|
toggleBtn.textContent = masked ? t('viewShow') : t('viewHide');
|
||||||
|
});
|
||||||
|
actions.appendChild(toggleBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
var copyBtn = document.createElement('button');
|
||||||
|
copyBtn.type = 'button';
|
||||||
|
copyBtn.className = 'btn-icon';
|
||||||
|
copyBtn.textContent = t('viewCopy');
|
||||||
|
copyBtn.addEventListener('click', function () {
|
||||||
|
navigator.clipboard.writeText(valueStr).then(function () {
|
||||||
|
copyBtn.textContent = t('viewCopied');
|
||||||
|
setTimeout(function () { copyBtn.textContent = t('viewCopy'); }, 1800);
|
||||||
|
}).catch(function () {});
|
||||||
|
});
|
||||||
|
actions.appendChild(copyBtn);
|
||||||
|
|
||||||
|
header.appendChild(actions);
|
||||||
|
row.appendChild(header);
|
||||||
|
|
||||||
|
var valueWrap = document.createElement('div');
|
||||||
|
valueWrap.className = 'view-secret-value-wrap';
|
||||||
|
var valueEl = document.createElement('div');
|
||||||
|
valueEl.className = 'view-secret-value' + (masked ? ' masked' : '');
|
||||||
|
valueEl.textContent = valueStr;
|
||||||
|
valueWrap.appendChild(valueEl);
|
||||||
|
row.appendChild(valueWrap);
|
||||||
|
|
||||||
|
viewBody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openView(tr) {
|
||||||
|
var entryId = tr.getAttribute('data-entry-id');
|
||||||
|
var nameEl = tr.querySelector('.cell-name');
|
||||||
|
var entryName = nameEl ? nameEl.textContent.trim() : '';
|
||||||
|
var encKey = sessionStorage.getItem('enc_key');
|
||||||
|
|
||||||
|
viewEntryName.textContent = entryName;
|
||||||
|
viewBody.innerHTML = '';
|
||||||
|
viewOverlay.hidden = false;
|
||||||
|
|
||||||
|
if (!encKey) {
|
||||||
|
var msg = document.createElement('div');
|
||||||
|
msg.className = 'view-locked-msg';
|
||||||
|
msg.innerHTML = t('viewLockedMsg');
|
||||||
|
viewBody.appendChild(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadingMsg = document.createElement('div');
|
||||||
|
loadingMsg.className = 'view-locked-msg';
|
||||||
|
loadingMsg.textContent = t('viewLoading');
|
||||||
|
viewBody.appendChild(loadingMsg);
|
||||||
|
|
||||||
|
fetch('/api/entries/' + encodeURIComponent(entryId) + '/secrets/decrypt', {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-Encryption-Key': encKey }
|
||||||
|
}).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) {
|
||||||
|
renderViewSecrets(data.secrets || {});
|
||||||
|
}).catch(function (e) {
|
||||||
|
viewBody.innerHTML = '';
|
||||||
|
var errMsg = document.createElement('div');
|
||||||
|
errMsg.className = 'view-locked-msg';
|
||||||
|
errMsg.style.color = '#f85149';
|
||||||
|
errMsg.textContent = e.message || t('viewDecryptError');
|
||||||
|
viewBody.appendChild(errMsg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function showEditErr(msg) {
|
function showEditErr(msg) {
|
||||||
editError.textContent = msg || '';
|
editError.textContent = msg || '';
|
||||||
editError.classList.toggle('visible', !!msg);
|
editError.classList.toggle('visible', !!msg);
|
||||||
@@ -981,6 +1202,7 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Escape' && !editOverlay.hidden) closeEdit();
|
if (e.key === 'Escape' && !editOverlay.hidden) closeEdit();
|
||||||
if (e.key === 'Escape' && !deleteOverlay.hidden) closeDelete();
|
if (e.key === 'Escape' && !deleteOverlay.hidden) closeDelete();
|
||||||
|
if (e.key === 'Escape' && !viewOverlay.hidden) closeView();
|
||||||
});
|
});
|
||||||
|
|
||||||
function showDeleteErr(msg) {
|
function showDeleteErr(msg) {
|
||||||
@@ -1295,6 +1517,12 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
|
document.querySelectorAll('tr[data-entry-id]').forEach(function (tr) {
|
||||||
|
var viewBtn = tr.querySelector('.btn-view-secrets');
|
||||||
|
if (viewBtn) {
|
||||||
|
var hasSecrets = tr.querySelectorAll('.secret-chip').length > 0;
|
||||||
|
if (!hasSecrets) viewBtn.disabled = true;
|
||||||
|
viewBtn.addEventListener('click', function () { openView(tr); });
|
||||||
|
}
|
||||||
tr.querySelector('.btn-edit').addEventListener('click', function () { openEdit(tr); });
|
tr.querySelector('.btn-edit').addEventListener('click', function () { openEdit(tr); });
|
||||||
tr.querySelector('.btn-del').addEventListener('click', function () {
|
tr.querySelector('.btn-del').addEventListener('click', function () {
|
||||||
var id = tr.getAttribute('data-entry-id');
|
var id = tr.getAttribute('data-entry-id');
|
||||||
|
|||||||
1
scripts/repair-secrets.template.csv
Normal file
1
scripts/repair-secrets.template.csv
Normal file
@@ -0,0 +1 @@
|
|||||||
|
entry_id,secret_name,secret_value
|
||||||
|
383
scripts/repair_secrets_from_csv.py
Normal file
383
scripts/repair_secrets_from_csv.py
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Batch re-encrypt secret fields from a CSV file.
|
||||||
|
|
||||||
|
CSV format:
|
||||||
|
entry_id,secret_name,secret_value
|
||||||
|
019d...,api_key,sk-xxxx
|
||||||
|
019d...,password,hunter2
|
||||||
|
|
||||||
|
The script groups rows by entry_id, then calls `secrets_update` with `secrets_obj`
|
||||||
|
so the server re-encrypts the provided plaintext values with the current key.
|
||||||
|
|
||||||
|
Warnings:
|
||||||
|
- Keep the CSV outside version control whenever possible.
|
||||||
|
- Delete the filled CSV after the repair is complete.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from collections import OrderedDict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_USER_AGENT = "Cursor/3.0.12 (darwin arm64)"
|
||||||
|
REQUIRED_COLUMNS = {"entry_id", "secret_name", "secret_value"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Repair secret ciphertexts by re-submitting plaintext via secrets_update."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--csv",
|
||||||
|
required=True,
|
||||||
|
help="Path to CSV file with columns: entry_id,secret_name,secret_value",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mcp-json",
|
||||||
|
default=str(Path.home() / ".cursor" / "mcp.json"),
|
||||||
|
help="Path to mcp.json used to resolve URL and headers",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--server",
|
||||||
|
default="secrets",
|
||||||
|
help="MCP server name inside mcp.json (default: secrets)",
|
||||||
|
)
|
||||||
|
parser.add_argument("--url", help="Override MCP URL")
|
||||||
|
parser.add_argument("--auth", help="Override Authorization header value")
|
||||||
|
parser.add_argument("--encryption-key", help="Override X-Encryption-Key header value")
|
||||||
|
parser.add_argument(
|
||||||
|
"--user-agent",
|
||||||
|
default=DEFAULT_USER_AGENT,
|
||||||
|
help=f"User-Agent header (default: {DEFAULT_USER_AGENT})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Parse and print grouped updates without sending requests",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def load_mcp_config(path: str, server_name: str) -> dict[str, Any]:
|
||||||
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
servers = data.get("mcpServers", {})
|
||||||
|
if server_name not in servers:
|
||||||
|
raise KeyError(f"Server '{server_name}' not found in {path}")
|
||||||
|
return servers[server_name]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_connection_settings(args: argparse.Namespace) -> tuple[str, str, str]:
|
||||||
|
server = load_mcp_config(args.mcp_json, args.server)
|
||||||
|
headers = server.get("headers", {})
|
||||||
|
|
||||||
|
url = args.url or server.get("url")
|
||||||
|
auth = args.auth or headers.get("Authorization")
|
||||||
|
encryption_key = args.encryption_key or headers.get("X-Encryption-Key")
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
raise ValueError("Missing MCP URL. Pass --url or configure it in mcp.json.")
|
||||||
|
if not auth:
|
||||||
|
raise ValueError(
|
||||||
|
"Missing Authorization header. Pass --auth or configure it in mcp.json."
|
||||||
|
)
|
||||||
|
if not encryption_key:
|
||||||
|
raise ValueError(
|
||||||
|
"Missing X-Encryption-Key. Pass --encryption-key or configure it in mcp.json."
|
||||||
|
)
|
||||||
|
|
||||||
|
return url, auth, encryption_key
|
||||||
|
|
||||||
|
|
||||||
|
def load_updates(csv_path: str) -> OrderedDict[str, OrderedDict[str, str]]:
|
||||||
|
grouped: OrderedDict[str, OrderedDict[str, str]] = OrderedDict()
|
||||||
|
|
||||||
|
with Path(csv_path).open("r", encoding="utf-8-sig", newline="") as fh:
|
||||||
|
reader = csv.DictReader(fh)
|
||||||
|
fieldnames = set(reader.fieldnames or [])
|
||||||
|
missing = REQUIRED_COLUMNS - fieldnames
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
"CSV missing required columns: " + ", ".join(sorted(missing))
|
||||||
|
)
|
||||||
|
|
||||||
|
for line_no, row in enumerate(reader, start=2):
|
||||||
|
entry_id = (row.get("entry_id") or "").strip()
|
||||||
|
secret_name = (row.get("secret_name") or "").strip()
|
||||||
|
secret_value = row.get("secret_value") or ""
|
||||||
|
|
||||||
|
if not entry_id and not secret_name and not secret_value:
|
||||||
|
continue
|
||||||
|
if not entry_id:
|
||||||
|
raise ValueError(f"Line {line_no}: entry_id is required")
|
||||||
|
if not secret_name:
|
||||||
|
raise ValueError(f"Line {line_no}: secret_name is required")
|
||||||
|
|
||||||
|
entry_group = grouped.setdefault(entry_id, OrderedDict())
|
||||||
|
if secret_name in entry_group:
|
||||||
|
raise ValueError(
|
||||||
|
f"Line {line_no}: duplicate secret_name '{secret_name}' for entry_id '{entry_id}'"
|
||||||
|
)
|
||||||
|
entry_group[secret_name] = secret_value
|
||||||
|
|
||||||
|
if not grouped:
|
||||||
|
raise ValueError("CSV contains no updates")
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def post_json(
|
||||||
|
url: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
auth: str,
|
||||||
|
encryption_key: str,
|
||||||
|
user_agent: str,
|
||||||
|
session_id: str | None = None,
|
||||||
|
) -> tuple[int, str | None, str]:
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
"Authorization": auth,
|
||||||
|
"X-Encryption-Key": encryption_key,
|
||||||
|
"User-Agent": user_agent,
|
||||||
|
}
|
||||||
|
if session_id:
|
||||||
|
headers["mcp-session-id"] = session_id
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers=headers,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return (
|
||||||
|
resp.status,
|
||||||
|
resp.headers.get("mcp-session-id") or session_id,
|
||||||
|
resp.read().decode("utf-8"),
|
||||||
|
)
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode("utf-8", errors="replace")
|
||||||
|
return exc.code, session_id, body
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sse_json(body: str) -> list[dict[str, Any]]:
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for line in body.splitlines():
|
||||||
|
if line.startswith("data: {"):
|
||||||
|
items.append(json.loads(line[6:]))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_session(
|
||||||
|
url: str, auth: str, encryption_key: str, user_agent: str
|
||||||
|
) -> str:
|
||||||
|
status, session_id, body = post_json(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2025-06-18",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "repair-script", "version": "1.0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
encryption_key,
|
||||||
|
user_agent,
|
||||||
|
)
|
||||||
|
if status != 200 or not session_id:
|
||||||
|
raise RuntimeError(f"initialize failed: status={status}, body={body[:500]}")
|
||||||
|
|
||||||
|
status, _, body = post_json(
|
||||||
|
url,
|
||||||
|
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
|
||||||
|
auth,
|
||||||
|
encryption_key,
|
||||||
|
user_agent,
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if status not in (200, 202):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"notifications/initialized failed: status={status}, body={body[:500]}"
|
||||||
|
)
|
||||||
|
return session_id
|
||||||
|
|
||||||
|
|
||||||
|
def load_entry_index(
|
||||||
|
url: str, auth: str, encryption_key: str, user_agent: str, session_id: str
|
||||||
|
) -> dict[str, tuple[str, str]]:
|
||||||
|
status, _, body = post_json(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 999_001,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "secrets_find",
|
||||||
|
"arguments": {
|
||||||
|
"limit": 1000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
encryption_key,
|
||||||
|
user_agent,
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
items = parse_sse_json(body)
|
||||||
|
last = items[-1] if items else {"raw": body[:1000]}
|
||||||
|
if status != 200:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"secrets_find failed: status={status}, body={body[:500]}"
|
||||||
|
)
|
||||||
|
if "error" in last:
|
||||||
|
raise RuntimeError(f"secrets_find returned error: {last}")
|
||||||
|
|
||||||
|
content = last.get("result", {}).get("content", [])
|
||||||
|
if not content:
|
||||||
|
raise RuntimeError("secrets_find returned no content")
|
||||||
|
payload = json.loads(content[0]["text"])
|
||||||
|
|
||||||
|
index: dict[str, tuple[str, str]] = {}
|
||||||
|
for entry in payload.get("entries", []):
|
||||||
|
entry_id = entry.get("id")
|
||||||
|
name = entry.get("name")
|
||||||
|
folder = entry.get("folder", "")
|
||||||
|
if entry_id and name is not None:
|
||||||
|
index[entry_id] = (name, folder)
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def call_secrets_update(
|
||||||
|
url: str,
|
||||||
|
auth: str,
|
||||||
|
encryption_key: str,
|
||||||
|
user_agent: str,
|
||||||
|
session_id: str,
|
||||||
|
request_id: int,
|
||||||
|
entry_id: str,
|
||||||
|
entry_name: str,
|
||||||
|
entry_folder: str,
|
||||||
|
secrets_obj: dict[str, str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"method": "tools/call",
|
||||||
|
"params": {
|
||||||
|
"name": "secrets_update",
|
||||||
|
"arguments": {
|
||||||
|
"id": entry_id,
|
||||||
|
"name": entry_name,
|
||||||
|
"folder": entry_folder,
|
||||||
|
"secrets_obj": secrets_obj,
|
||||||
|
# Pass the key as an argument too, so repair can still work
|
||||||
|
# even when a client/proxy mishandles custom headers.
|
||||||
|
"encryption_key": encryption_key,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
status, _, body = post_json(
|
||||||
|
url, payload, auth, encryption_key, user_agent, session_id
|
||||||
|
)
|
||||||
|
items = parse_sse_json(body)
|
||||||
|
last = items[-1] if items else {"raw": body[:1000]}
|
||||||
|
if status != 200:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"secrets_update failed for {entry_id}: status={status}, body={body[:500]}"
|
||||||
|
)
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
url, auth, encryption_key = resolve_connection_settings(args)
|
||||||
|
updates = load_updates(args.csv)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Loaded {len(updates)} entries from {args.csv}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
for entry_id, secrets_obj in updates.items():
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"id": entry_id, "secrets_obj": secrets_obj},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_id = initialize_session(url, auth, encryption_key, args.user_agent)
|
||||||
|
entry_index = load_entry_index(
|
||||||
|
url, auth, encryption_key, args.user_agent, session_id
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
success = 0
|
||||||
|
failures = 0
|
||||||
|
for request_id, (entry_id, secrets_obj) in enumerate(updates.items(), start=2):
|
||||||
|
try:
|
||||||
|
if entry_id not in entry_index:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"entry id not found in secrets_find results: {entry_id}"
|
||||||
|
)
|
||||||
|
entry_name, entry_folder = entry_index[entry_id]
|
||||||
|
result = call_secrets_update(
|
||||||
|
url,
|
||||||
|
auth,
|
||||||
|
encryption_key,
|
||||||
|
args.user_agent,
|
||||||
|
session_id,
|
||||||
|
request_id,
|
||||||
|
entry_id,
|
||||||
|
entry_name,
|
||||||
|
entry_folder,
|
||||||
|
secrets_obj,
|
||||||
|
)
|
||||||
|
if "error" in result:
|
||||||
|
failures += 1
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"id": entry_id, "status": "error", "result": result},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
success += 1
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{"id": entry_id, "status": "ok", "result": result},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
failures += 1
|
||||||
|
print(f"{entry_id}: ERROR: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
print(f"Done. success={success} failure={failures}")
|
||||||
|
return 0 if failures == 0 else 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user