pinakes-server: wire backup, session refresh, webhooks, and rate limit config
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: If2855d44cc700c0f65a5f5ac850ee3866a6a6964
This commit is contained in:
parent
4e91cb6679
commit
52f0b5defc
8 changed files with 257 additions and 105 deletions
|
|
@ -57,10 +57,10 @@ pub async fn login(
|
|||
// Authentication fails if user wasn't found OR password was invalid
|
||||
if !user_found || !password_valid {
|
||||
// Log different messages for debugging but return same error
|
||||
if !user_found {
|
||||
tracing::warn!(username = %req.username, "login failed: unknown user");
|
||||
} else {
|
||||
if user_found {
|
||||
tracing::warn!(username = %req.username, "login failed: invalid password");
|
||||
} else {
|
||||
tracing::warn!(username = %req.username, "login failed: unknown user");
|
||||
}
|
||||
|
||||
// Record failed login attempt in audit log
|
||||
|
|
@ -103,7 +103,8 @@ pub async fn login(
|
|||
username: username.clone(),
|
||||
role: role.to_string(),
|
||||
created_at: now,
|
||||
expires_at: now + chrono::Duration::hours(24), // 24 hour sessions
|
||||
expires_at: now
|
||||
+ chrono::Duration::hours(config.accounts.session_expiry_hours as i64),
|
||||
last_accessed: now,
|
||||
};
|
||||
|
||||
|
|
@ -119,7 +120,7 @@ pub async fn login(
|
|||
&state.storage,
|
||||
None,
|
||||
pinakes_core::model::AuditAction::LoginSuccess,
|
||||
Some(format!("username: {}, role: {}", username, role)),
|
||||
Some(format!("username: {username}, role: {role}")),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
@ -151,17 +152,16 @@ pub async fn logout(
|
|||
}
|
||||
|
||||
// Record logout in audit log
|
||||
if let Some(user) = username {
|
||||
if let Err(e) = pinakes_core::audit::record_action(
|
||||
if let Some(user) = username
|
||||
&& let Err(e) = pinakes_core::audit::record_action(
|
||||
&state.storage,
|
||||
None,
|
||||
pinakes_core::model::AuditAction::Logout,
|
||||
Some(format!("username: {}", user)),
|
||||
Some(format!("username: {user}")),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to record logout audit");
|
||||
}
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to record logout audit");
|
||||
}
|
||||
}
|
||||
StatusCode::OK
|
||||
|
|
@ -191,7 +191,7 @@ pub async fn me(
|
|||
|
||||
Ok(Json(UserInfoResponse {
|
||||
username: session.username.clone(),
|
||||
role: session.role.clone(),
|
||||
role: session.role,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +202,35 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
|
|||
.and_then(|s| s.strip_prefix("Bearer "))
|
||||
}
|
||||
|
||||
/// Refresh the current session, extending its expiry by the configured
|
||||
/// duration.
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<serde_json::Value>, StatusCode> {
|
||||
let token = extract_bearer_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let config = state.config.read().await;
|
||||
let expiry_hours = config.accounts.session_expiry_hours as i64;
|
||||
drop(config);
|
||||
|
||||
let new_expires_at =
|
||||
chrono::Utc::now() + chrono::Duration::hours(expiry_hours);
|
||||
|
||||
match state.storage.extend_session(token, new_expires_at).await {
|
||||
Ok(Some(expires)) => {
|
||||
Ok(Json(serde_json::json!({
|
||||
"expires_at": expires.to_rfc3339()
|
||||
})))
|
||||
},
|
||||
Ok(None) => Err(StatusCode::UNAUTHORIZED),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to extend session");
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Revoke all sessions for the current user
|
||||
pub async fn revoke_all_sessions(
|
||||
State(state): State<AppState>,
|
||||
|
|
@ -234,7 +263,7 @@ pub async fn revoke_all_sessions(
|
|||
&state.storage,
|
||||
None,
|
||||
pinakes_core::model::AuditAction::Logout,
|
||||
Some(format!("revoked all sessions for username: {}", username)),
|
||||
Some(format!("revoked all sessions for username: {username}")),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
|
|
|||
47
crates/pinakes-server/src/routes/backup.rs
Normal file
47
crates/pinakes-server/src/routes/backup.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use axum::{
|
||||
extract::State,
|
||||
http::header::{CONTENT_DISPOSITION, CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
/// Create a database backup and return it as a downloadable file.
|
||||
/// POST /api/v1/admin/backup
|
||||
///
|
||||
/// For `SQLite`: creates a backup via VACUUM INTO and returns the file.
|
||||
/// For `PostgreSQL`: returns unsupported error (use `pg_dump` instead).
|
||||
pub async fn create_backup(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Response, ApiError> {
|
||||
// Use a unique temp directory to avoid predictable paths
|
||||
let backup_dir = std::env::temp_dir()
|
||||
.join(format!("pinakes-backup-{}", uuid::Uuid::now_v7()));
|
||||
tokio::fs::create_dir_all(&backup_dir)
|
||||
.await
|
||||
.map_err(|e| ApiError(pinakes_core::error::PinakesError::Io(e)))?;
|
||||
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
|
||||
let filename = format!("pinakes_backup_{timestamp}.db");
|
||||
let backup_path = backup_dir.join(&filename);
|
||||
|
||||
state.storage.backup(&backup_path).await?;
|
||||
|
||||
// Read the backup into memory and clean up the temp file
|
||||
let bytes = tokio::fs::read(&backup_path)
|
||||
.await
|
||||
.map_err(|e| ApiError(pinakes_core::error::PinakesError::Io(e)))?;
|
||||
let _ = tokio::fs::remove_dir_all(&backup_dir).await;
|
||||
|
||||
let disposition = format!("attachment; filename=\"{filename}\"");
|
||||
Ok(
|
||||
(
|
||||
[
|
||||
(CONTENT_TYPE, "application/octet-stream".to_owned()),
|
||||
(CONTENT_DISPOSITION, disposition),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
pub mod analytics;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod books;
|
||||
pub mod collections;
|
||||
pub mod config;
|
||||
|
|
|
|||
|
|
@ -31,10 +31,19 @@ pub async fn test_webhook(
|
|||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let config = state.config.read().await;
|
||||
let count = config.webhooks.len();
|
||||
// Emit a test event to all configured webhooks
|
||||
// In production, the event bus would handle delivery
|
||||
Ok(Json(serde_json::json!({
|
||||
"webhooks_configured": count,
|
||||
"test_sent": true
|
||||
})))
|
||||
drop(config);
|
||||
|
||||
if let Some(ref dispatcher) = state.webhook_dispatcher {
|
||||
dispatcher.dispatch(pinakes_core::webhooks::WebhookEvent::Test);
|
||||
Ok(Json(serde_json::json!({
|
||||
"webhooks_configured": count,
|
||||
"test_sent": true
|
||||
})))
|
||||
} else {
|
||||
Ok(Json(serde_json::json!({
|
||||
"webhooks_configured": 0,
|
||||
"test_sent": false,
|
||||
"message": "no webhooks configured"
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue