use super::system::AppState;
use crate::error::DbError;
use axum::{extract::State, response::Json};
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Deserialize)]
pub struct CreateBackupRequest {
pub path: String,
}
fn resolve_backup_path(data_dir: &str, requested: &str) -> Result<std::path::PathBuf, DbError> {
let root = std::env::var("SOLIDB_BACKUP_ROOT")
.ok()
.filter(|s| !s.trim().is_empty())
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from(data_dir).join("backups"));
std::fs::create_dir_all(&root).map_err(|e| {
DbError::InternalError(format!(
"Failed to create backup root '{}': {}",
root.display(),
e
))
})?;
let root = root
.canonicalize()
.map_err(|e| DbError::InternalError(format!("Failed to resolve backup root: {}", e)))?;
let requested = std::path::Path::new(requested);
if requested
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(DbError::BadRequest(
"backup path must not contain '..'".to_string(),
));
}
let candidate = if requested.is_absolute() {
requested.to_path_buf()
} else {
root.join(requested)
};
let parent = candidate.parent().unwrap_or(&root);
if parent.exists() {
let parent_canon = parent
.canonicalize()
.map_err(|e| DbError::BadRequest(format!("invalid backup path: {}", e)))?;
if !parent_canon.starts_with(&root) {
return Err(DbError::BadRequest(
"backup path must be inside the configured backup root".to_string(),
));
}
} else if !candidate.starts_with(&root) {
return Err(DbError::BadRequest(
"backup path must be inside the configured backup root".to_string(),
));
}
Ok(candidate)
}
pub async fn create_backup(
State(state): State<AppState>,
axum::Extension(claims): axum::Extension<crate::server::auth::Claims>,
Json(req): Json<CreateBackupRequest>,
) -> Result<Json<Value>, DbError> {
crate::server::authz_middleware::enforce(
&claims,
&state,
crate::server::authorization::PermissionAction::Admin,
None,
)
.await?;
if req.path.trim().is_empty() {
return Err(DbError::BadRequest("path is required".to_string()));
}
let target = resolve_backup_path(state.storage.data_dir(), &req.path)?;
let storage = state.storage.clone();
let target_for_task = target.clone();
tokio::task::spawn_blocking(move || storage.create_checkpoint(&target_for_task))
.await
.map_err(|e| DbError::InternalError(format!("Backup task failed: {}", e)))??;
tracing::info!(
target: "audit",
"backup: user '{}' created a checkpoint at '{}'",
claims.sub,
target.display()
);
Ok(Json(serde_json::json!({
"status": "created",
"path": target.to_string_lossy(),
"scope": "instance",
"note": "Whole-instance checkpoint. RocksDB hard-links SST files, so a \
checkpoint on the same filesystem is not protection against \
losing that filesystem — copy it elsewhere.",
})))
}