Skip to main content

convergio_backup/
routes.rs

1//! HTTP routes for backup, restore, retention, and org export/import.
2
3use axum::extract::State;
4use axum::routing::{get, post};
5use axum::{Json, Router};
6use convergio_db::pool::ConnPool;
7use serde::Deserialize;
8use serde_json::{json, Value};
9use std::path::PathBuf;
10use std::sync::Arc;
11
12/// Shared state for backup routes.
13#[derive(Clone)]
14pub struct BackupState {
15    pub pool: ConnPool,
16    pub db_path: PathBuf,
17    pub backup_dir: PathBuf,
18    pub node_name: String,
19}
20
21/// Build the backup router.
22pub fn router(state: Arc<BackupState>) -> Router {
23    Router::new()
24        .route("/api/backup/snapshots", get(list_snapshots))
25        .route("/api/backup/snapshots/create", post(create_snapshot))
26        .route("/api/backup/snapshots/verify", post(verify_snapshot))
27        .route("/api/backup/restore", post(restore_snapshot))
28        .route("/api/backup/retention/rules", get(get_retention_rules))
29        .route("/api/backup/retention/rules", post(set_retention_rule))
30        .route("/api/backup/retention/purge", post(run_purge))
31        .route("/api/backup/purge-log", get(get_purge_log))
32        .route("/api/backup/export", post(export_org))
33        .route("/api/backup/import", post(import_org))
34        .with_state(state)
35}
36
37async fn list_snapshots(State(st): State<Arc<BackupState>>) -> Json<Value> {
38    match crate::snapshot::list_snapshots(&st.pool) {
39        Ok(list) => Json(json!({"ok": true, "snapshots": list})),
40        Err(e) => Json(json!({"ok": false, "error": e.to_string()})),
41    }
42}
43
44#[derive(Deserialize)]
45struct CreateSnapshotReq {
46    /// Optional label for the snapshot (reserved for future use).
47    #[serde(default)]
48    #[allow(dead_code)]
49    label: Option<String>,
50}
51
52async fn create_snapshot(
53    State(st): State<Arc<BackupState>>,
54    Json(_body): Json<CreateSnapshotReq>,
55) -> Json<Value> {
56    match crate::snapshot::create_snapshot(&st.pool, &st.db_path, &st.backup_dir, &st.node_name) {
57        Ok(rec) => Json(json!({"ok": true, "snapshot": rec})),
58        Err(e) => Json(json!({"ok": false, "error": e.to_string()})),
59    }
60}
61
62#[derive(Deserialize)]
63struct SnapshotIdReq {
64    id: String,
65}
66
67async fn verify_snapshot(
68    State(st): State<Arc<BackupState>>,
69    Json(body): Json<SnapshotIdReq>,
70) -> Json<Value> {
71    match crate::snapshot::get_snapshot(&st.pool, &body.id) {
72        Ok(rec) => match crate::snapshot::verify_snapshot(&rec) {
73            Ok(valid) => Json(json!({"ok": true, "valid": valid})),
74            Err(e) => Json(json!({"ok": false, "error": e.to_string()})),
75        },
76        Err(e) => Json(json!({"ok": false, "error": e.to_string()})),
77    }
78}
79
80async fn restore_snapshot(
81    State(st): State<Arc<BackupState>>,
82    Json(body): Json<SnapshotIdReq>,
83) -> Json<Value> {
84    match crate::restore::restore_from_snapshot(&st.pool, &body.id, &st.db_path) {
85        Ok(path) => Json(json!({"ok": true, "restored_from": path})),
86        Err(e) => Json(json!({"ok": false, "error": e.to_string()})),
87    }
88}
89
90async fn get_retention_rules(State(st): State<Arc<BackupState>>) -> Json<Value> {
91    match crate::retention::load_rules(&st.pool) {
92        Ok(rules) => Json(json!({"ok": true, "rules": rules})),
93        Err(e) => Json(json!({"ok": false, "error": e.to_string()})),
94    }
95}
96
97#[derive(Deserialize)]
98struct SetRuleReq {
99    table: String,
100    timestamp_column: Option<String>,
101    max_age_days: u32,
102    org_id: Option<String>,
103}
104
105async fn set_retention_rule(
106    State(st): State<Arc<BackupState>>,
107    Json(body): Json<SetRuleReq>,
108) -> Json<Value> {
109    let rule = crate::types::RetentionRule {
110        table: body.table,
111        timestamp_column: body.timestamp_column.unwrap_or("created_at".into()),
112        max_age_days: body.max_age_days,
113    };
114    match crate::retention::save_rule(&st.pool, &rule, body.org_id.as_deref()) {
115        Ok(()) => Json(json!({"ok": true, "rule": rule})),
116        Err(e) => Json(json!({"ok": false, "error": e.to_string()})),
117    }
118}
119
120async fn run_purge(State(st): State<Arc<BackupState>>) -> Json<Value> {
121    match crate::retention::run_auto_purge(&st.pool) {
122        Ok(events) => Json(json!({"ok": true, "events": events})),
123        Err(e) => Json(json!({"ok": false, "error": e.to_string()})),
124    }
125}
126
127async fn get_purge_log(State(st): State<Arc<BackupState>>) -> Json<Value> {
128    let conn = match st.pool.get() {
129        Ok(c) => c,
130        Err(e) => return Json(json!({"ok": false, "error": e.to_string()})),
131    };
132    let mut stmt = match conn.prepare(
133        "SELECT table_name, rows_deleted, cutoff_date, executed_at \
134         FROM backup_purge_log ORDER BY executed_at DESC LIMIT 100",
135    ) {
136        Ok(s) => s,
137        Err(e) => return Json(json!({"ok": false, "error": e.to_string()})),
138    };
139    let rows = match stmt.query_map([], |row: &rusqlite::Row<'_>| {
140        Ok(json!({
141            "table": row.get::<_, String>(0)?,
142            "rows_deleted": row.get::<_, i64>(1)?,
143            "cutoff_date": row.get::<_, String>(2)?,
144            "executed_at": row.get::<_, String>(3)?,
145        }))
146    }) {
147        Ok(r) => r,
148        Err(e) => return Json(json!({"ok": false, "error": e.to_string()})),
149    };
150    let mut entries = Vec::new();
151    for r in rows {
152        match r {
153            Ok(v) => entries.push(v),
154            Err(e) => {
155                tracing::warn!(err = %e, "failed to read purge log row");
156            }
157        }
158    }
159    Json(json!({"ok": true, "log": entries}))
160}
161
162#[derive(Deserialize)]
163struct ExportReq {
164    org_id: String,
165    org_name: String,
166}
167
168async fn export_org(
169    State(st): State<Arc<BackupState>>,
170    Json(body): Json<ExportReq>,
171) -> Json<Value> {
172    // Sanitise org_id to prevent path traversal in the filename
173    let safe_id: String = body
174        .org_id
175        .chars()
176        .map(|c| {
177            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
178                c
179            } else {
180                '_'
181            }
182        })
183        .collect();
184    let filename = format!("org-export-{safe_id}.json");
185    let dest = st.backup_dir.join(&filename);
186    match crate::export::export_org_data(
187        &st.pool,
188        &body.org_id,
189        &body.org_name,
190        &st.node_name,
191        &dest,
192    ) {
193        Ok(meta) => Json(json!({"ok": true, "meta": meta, "path": dest.to_string_lossy()})),
194        Err(e) => Json(json!({"ok": false, "error": e.to_string()})),
195    }
196}
197
198#[derive(Deserialize)]
199struct ImportReq {
200    path: String,
201}
202
203async fn import_org(
204    State(st): State<Arc<BackupState>>,
205    Json(body): Json<ImportReq>,
206) -> Json<Value> {
207    let path = PathBuf::from(&body.path);
208    // Validate path to prevent directory traversal
209    if let Err(e) = convergio_types::platform_paths::validate_path_components(&path) {
210        return Json(json!({"ok": false, "error": e}));
211    }
212    match crate::import::import_org_data(&st.pool, &path) {
213        Ok(result) => Json(json!({"ok": true, "result": result})),
214        Err(e) => Json(json!({"ok": false, "error": e.to_string()})),
215    }
216}