- 拆分 web.rs 为 web/ 子模块;统一 client_ip 提取 - core: user_scope SQL 复用、env_map N+1 消除、FETCH_ALL 上限调整 - entries 列表页并行查询;PgPool 去 Arc;结构化 NotFound 等错误 - CI: SSH 私钥安全写入;crypto/hex 与依赖清理;MCP 输入长度校验 - AGENTS: API Key 明文存储设计说明
105 lines
3.0 KiB
Rust
105 lines
3.0 KiB
Rust
use askama::Template;
|
|
use axum::{
|
|
extract::{Query, State},
|
|
http::StatusCode,
|
|
response::Response,
|
|
};
|
|
use chrono::SecondsFormat;
|
|
use serde::Deserialize;
|
|
use tower_sessions::Session;
|
|
|
|
use crate::AppState;
|
|
|
|
use super::{AUDIT_PAGE_LIMIT, paginate, render_template, require_valid_user};
|
|
|
|
#[derive(Template)]
|
|
#[template(path = "audit.html")]
|
|
struct AuditPageTemplate {
|
|
user_name: String,
|
|
user_email: String,
|
|
entries: Vec<AuditEntryView>,
|
|
current_page: u32,
|
|
total_pages: u32,
|
|
total_count: i64,
|
|
version: &'static str,
|
|
}
|
|
|
|
struct AuditEntryView {
|
|
/// RFC3339 UTC for `<time datetime>`; rendered as browser-local in audit.html.
|
|
created_at_iso: String,
|
|
action: String,
|
|
target: String,
|
|
detail: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub(super) struct AuditQuery {
|
|
page: Option<u32>,
|
|
}
|
|
|
|
fn format_audit_target(folder: &str, entry_type: &str, name: &str) -> String {
|
|
// Auth events (folder="auth") use entry_type/name as provider-scoped target.
|
|
if folder == "auth" {
|
|
format!("{}/{}", entry_type, name)
|
|
} else if !folder.is_empty() && !entry_type.is_empty() {
|
|
format!("[{}/{}] {}", folder, entry_type, name)
|
|
} else if !folder.is_empty() {
|
|
format!("[{}] {}", folder, name)
|
|
} else {
|
|
name.to_string()
|
|
}
|
|
}
|
|
|
|
pub(super) async fn audit_page(
|
|
State(state): State<AppState>,
|
|
session: Session,
|
|
Query(aq): Query<AuditQuery>,
|
|
) -> Result<Response, StatusCode> {
|
|
use secrets_core::service::audit_log::{count_for_user, list_for_user};
|
|
|
|
let user = match require_valid_user(&state.pool, &session, "audit_page").await {
|
|
Ok(u) => u,
|
|
Err(r) => return Ok(r),
|
|
};
|
|
let user_id = user.id;
|
|
|
|
let page = aq.page.unwrap_or(1).max(1);
|
|
|
|
let total_count = count_for_user(&state.pool, user_id).await.map_err(|e| {
|
|
tracing::error!(error = %e, "failed to count audit log for user");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
let (current_page, total_pages, offset) = paginate(page, total_count, AUDIT_PAGE_LIMIT as u32);
|
|
let actual_offset = i64::from(offset);
|
|
|
|
let rows = list_for_user(&state.pool, user_id, AUDIT_PAGE_LIMIT, actual_offset)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(error = %e, "failed to load audit log for user");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
let entries = rows
|
|
.into_iter()
|
|
.map(|row| AuditEntryView {
|
|
created_at_iso: row.created_at.to_rfc3339_opts(SecondsFormat::Secs, true),
|
|
action: row.action,
|
|
target: format_audit_target(&row.folder, &row.entry_type, &row.name),
|
|
detail: serde_json::to_string_pretty(&row.detail).unwrap_or_else(|_| "{}".to_string()),
|
|
})
|
|
.collect();
|
|
|
|
let tmpl = AuditPageTemplate {
|
|
user_name: user.name.clone(),
|
|
user_email: user.email.clone().unwrap_or_default(),
|
|
entries,
|
|
current_page,
|
|
total_pages,
|
|
total_count,
|
|
version: env!("CARGO_PKG_VERSION"),
|
|
};
|
|
|
|
render_template(tmpl)
|
|
}
|