use rustlavel::prelude::*;
use crate::controllers::admin::users_controller::rbac;
use crate::support::{backup, page, tokens};
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 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(backup::humanise_bytes(bytes))),
(
"when",
Json::from(tokens::humanise(&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_for(&name)?;
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?;
page::flash(
&req,
"success",
format!("Backup {name} is ready ({}).", backup::humanise_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::path_for(&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::path_for(&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(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::path_for(&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 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")))
}
}
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>")
}