use rustlavel::prelude::*;
use crate::controllers::admin::users_controller::rbac;
use crate::modules::backup::{archive as backup, schedule};
use crate::support::{format, page, tokens};
pub const SCHEDULED: &str = "scheduled";
pub struct BackupController;
impl BackupController {
pub async fn context(req: &Request, context: ViewContext) -> Result<ViewContext> {
let context = context
.with("can_create_backup", Json::from(req.can("backups.create").await?))
.with("can_restore_backup", Json::from(req.can("backups.restore").await?))
.with("can_delete_backup", Json::from(req.can("backups.delete").await?))
.with("can_view_backups", Json::from(req.can("backups.view").await?));
if !req.can("backups.view").await? {
return Ok(context
.with("q", Json::from(""))
.with("backups_empty", Json::from(true))
.with("backups", Json::Array(Vec::new())));
}
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let settings = req.state::<crate::support::settings::Settings>();
let schedule = match &settings {
Some(store) => store.get("backup.schedule").await,
None => "disabled".to_string(),
};
let last_scheduled = db
.table("backups")
.filter("note", SCHEDULED)
.latest("created_at")
.limit(1)
.get(&db)
.await?
.first()
.and_then(|row| row.get::<String>("created_at").ok());
let now = tokens::now();
let dates = format::Dates::of(req).await;
let context = context
.with("schedule", Json::from(schedule.as_str()))
.with("schedule_on", Json::from(schedule::interval(&schedule).is_some()))
.with("schedule_says", Json::from(schedule::describe(&schedule)))
.with(
"next_due",
Json::from(
schedule::next_due(&schedule, last_scheduled.as_deref(), &now)
.map(|at| dates.moment(&at))
.unwrap_or_default(),
),
)
.with(
"schedule_unproven",
Json::from(schedule::interval(&schedule).is_some() && last_scheduled.is_none()),
);
let dates = format::Dates::of(req).await;
let search = req.query("q").unwrap_or_default().trim().to_string();
let mut query = db.table("backups").latest("created_at");
if !search.is_empty() {
query = query.filter_like("name", format!("%{search}%"));
}
let mut rows = Vec::new();
for row in query.get(&db).await? {
let id = row.get::<i64>("id").unwrap_or_default();
let name = row.get::<String>("name").unwrap_or_default();
let status = row.get::<String>("status").unwrap_or_else(|_| "failed".into());
let bytes = row.get::<i64>("bytes").unwrap_or(0);
let ready = status == "ready";
rows.push(Json::object([
("id", Json::from(id)),
("name", Json::from(name.as_str())),
("size", Json::from(format::bytes(bytes))),
(
"when",
Json::from(dates.moment(&row.get::<String>("created_at").unwrap_or_default())),
),
("status", Json::from(status.as_str())),
(
"status_label",
Json::from(match status.as_str() {
"ready" => "Ready",
"running" => "Running",
_ => "Failed",
}),
),
(
"status_class",
Json::from(match status.as_str() {
"ready" => "badge-success",
"running" => "badge-warning",
_ => "badge-danger",
}),
),
("ready", Json::from(ready)),
("note", row.get::<String>("note").map(Json::from).unwrap_or(Json::Null)),
]));
}
Ok(context
.with("q", Json::from(search.as_str()))
.with("backups_empty", Json::from(rows.is_empty()))
.with("backups", Json::Array(rows)))
}
pub async fn store(req: Request) -> Result<Response> {
if !req.can("backups.create").await? {
return Ok(forbidden());
}
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let me = req.identity().and_then(|id| id.id_as::<i64>());
let at = tokens::now();
let name = backup::name_for(&at);
let destination = backup::path_under(&Self::directory(&req).await, &name)?;
if let Some(parent) = destination.parent() {
rustlavel::tokio::fs::create_dir_all(parent).await.map_err(|e| {
Error::msg(format!("cannot write backups to {}: {e}", parent.display()))
})?;
}
let schema = backup::schema_version(&db).await?;
if db.table("backups").filter("name", name.as_str()).exists(&db).await? {
page::flash(&req, "warning", "A backup was taken a moment ago. Try again in a second.");
return Ok(Response::see_other(BACK));
}
let id = db
.table("backups")
.insert(
&db,
&[
("name", name.as_str().into()),
("path", destination.display().to_string().into()),
("bytes", 0.into()),
("status", "running".into()),
("created_by", me.into()),
("created_at", at.as_str().into()),
("updated_at", at.as_str().into()),
],
)
.await?;
if let Some(audit) = crate::support::audit::of(&req, "backups.created") {
audit.on("Backup", id).describe(format!("Took the backup {name}")).record().await;
}
let header = backup::Header {
format: backup::FORMAT,
schema,
at: at.clone(),
app: req.config().string("app.name", "Rustlavel"),
};
let names = backup::tables(Some(&rbac(&req)?));
match backup::write(&db, &names, &header, &destination).await {
Ok(bytes) => {
db.table("backups")
.filter("id", id)
.update(
&db,
&[
("bytes", (bytes as i64).into()),
("status", "ready".into()),
("updated_at", tokens::now().into()),
],
)
.await?;
Self::prune(&req, &db).await?;
page::flash(
&req,
"success",
format!("Backup {name} is ready ({}).", format::bytes(bytes as i64)),
);
}
Err(error) => {
error!("the backup {name} failed: {error}");
db.table("backups")
.filter("id", id)
.update(
&db,
&[
("status", "failed".into()),
("note", error.to_string().into()),
("updated_at", tokens::now().into()),
],
)
.await?;
page::flash(&req, "error", format!("The backup failed: {error}"));
}
}
Ok(Response::see_other(BACK))
}
pub async fn download(req: Request) -> Result<Response> {
if !req.can("backups.view").await? {
return Ok(forbidden());
}
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let Some((name, ready)) = Self::locate(&db, &req).await? else {
return Ok(Response::not_found());
};
if !ready {
page::flash(&req, "error", "That backup did not finish, so there is nothing to send.");
return Ok(Response::see_other(BACK));
}
let path = backup::existing(&Self::directory(&req).await, &name)?;
let Ok(body) = rustlavel::tokio::fs::read(&path).await else {
page::flash(&req, "error", format!("The file for {name} is no longer on disk."));
return Ok(Response::see_other(BACK));
};
Ok(Response::ok()
.with_body(body)
.with_header("content-type", "application/x-ndjson")
.with_header("content-disposition", format!("attachment; filename=\"{name}.ndjson\""))
.with_header("cache-control", "no-store, private"))
}
pub async fn restore(req: Request) -> Result<Response> {
if !req.can("backups.restore").await? {
return Ok(forbidden());
}
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let Some((name, ready)) = Self::locate(&db, &req).await? else {
return Ok(Response::not_found());
};
if !ready {
page::flash(&req, "error", "That backup did not finish and cannot be restored from.");
return Ok(Response::see_other(BACK));
}
let path = backup::existing(&Self::directory(&req).await, &name)?;
let Ok(source) = rustlavel::tokio::fs::read_to_string(&path).await else {
page::flash(&req, "error", format!("The file for {name} is no longer on disk."));
return Ok(Response::see_other(BACK));
};
let dump = match backup::parse(&source) {
Ok(dump) => dump,
Err(error) => {
page::flash(&req, "error", format!("{name} cannot be restored: {error}"));
return Ok(Response::see_other(BACK));
}
};
let current = backup::schema_version(&db).await?;
if dump.header.schema != current {
page::flash(
&req,
"error",
format!(
"{name} was taken from schema {} and this database is at {current}. \
Restoring rows into a different shape is how a database ends up with \
columns full of the wrong thing, so it is refused.",
dump.header.schema
),
);
return Ok(Response::see_other(BACK));
}
let names = backup::tables(Some(&rbac(&req)?));
match backup::restore(&db, &names, &dump).await {
Ok(done) => {
warn!("the database was restored from the backup {name} by user {:?}", req.identity().and_then(|id| id.id_as::<i64>()));
if let Some(settings) = req.state::<crate::support::settings::Settings>() {
settings.forget();
}
if let Some(audit) = crate::support::audit::of(&req, "backups.restored") {
audit
.on("Backup", name.as_str())
.describe(format!("Restored the database from {name}"))
.with("rows", Json::from(done.rows as i64))
.with("tables", Json::from(done.tables as i64))
.record()
.await;
}
page::flash(
&req,
"success",
format!(
"Restored {} rows across {} tables from {name}. Everyone signed in \
before this may need to sign in again.",
done.rows, done.tables
),
);
}
Err(error) => {
error!("restoring from {name} failed: {error}");
page::flash(
&req,
"error",
format!(
"The restore failed and was rolled back, so nothing changed: {error}"
),
);
}
}
Ok(Response::see_other(BACK))
}
pub async fn destroy(req: Request) -> Result<Response> {
if !req.can("backups.delete").await? {
return Ok(forbidden());
}
let db = req.state::<Database>().expect("the database is registered in main.rs").clone();
let id = req.param_as::<i64>("id").unwrap_or_default();
let Some((name, _)) = Self::locate(&db, &req).await? else {
return Ok(Response::not_found());
};
let path = backup::existing(&Self::directory(&req).await, &name)?;
let _ = rustlavel::tokio::fs::remove_file(&path).await;
db.table("backups").filter("id", id).delete(&db).await?;
if let Some(audit) = crate::support::audit::of(&req, "backups.deleted") {
audit.on("Backup", name.as_str()).describe(format!("Deleted the backup {name}")).record().await;
}
page::flash(&req, "warning", format!("Backup {name} has been deleted."));
Ok(Response::see_other(BACK))
}
async fn directory(req: &Request) -> String {
match req.state::<crate::support::settings::Settings>() {
Some(settings) => settings.get("backup.path").await,
None => String::new(),
}
}
async fn locate(db: &Database, req: &Request) -> Result<Option<(String, bool)>> {
let id = req.param_as::<i64>("id").unwrap_or_default();
let Some(row) = db.table("backups").filter("id", id).first(db).await? else {
return Ok(None);
};
let name = row.get::<String>("name").unwrap_or_default();
if !backup::valid_name(&name) {
warn!("the backup row {id} has a name this application would not have written");
return Ok(None);
}
Ok(Some((name, row.get::<String>("status").unwrap_or_default() == "ready")))
}
async fn prune(req: &Request, db: &Database) -> Result<()> {
let Some(settings) = req.state::<crate::support::settings::Settings>() else {
return Ok(());
};
let keep = settings.get("backup.retention").await.parse::<usize>().unwrap_or(0);
if keep == 0 {
return Ok(());
}
let rows =
db.table("backups").filter("status", "ready").latest("created_at").get(db).await?;
let ids: Vec<i64> = rows.iter().filter_map(|row| row.get::<i64>("id").ok()).collect();
for id in schedule::beyond_retention(&ids, keep) {
if let Some(path) = rows
.iter()
.find(|row| row.get::<i64>("id").ok() == Some(id))
.and_then(|row| row.get::<String>("path").ok())
{
let _ = rustlavel::tokio::fs::remove_file(&path).await;
}
db.table("backups").filter("id", id).delete(db).await?;
}
Ok(())
}
}
const BACK: &str = "/admin/settings/backup";
fn forbidden() -> Response {
Response::new(rustlavel::Status::FORBIDDEN)
.with_html("<h1>403</h1><p>You do not have permission to manage backups.</p>")
}