mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +01:00
trash: a delete can be undone
`x`, `dd`, `:delete` and `:rmspace` no longer end anything. What they delete is moved, whole, under one hidden root, and `u` puts the last of it back where it was. `:trash` lists everything deleted, the last first: Enter or `r` restores, `x` deletes one for good and `:trash purge` empties it, each asking first. Something whose folder has gone since comes back to the root of its space; something whose space is in the trash too waits for the space. The daily sweep purges what has waited WORKSPACE_TRASH_DAYS (30), and only then are its files deleted. While it waits, a trashed node is out of the tree, the search, the agenda, the mails, the SFTP mount, the export and the readable files/ view, and a shared document answers 404 until it comes back with the same link. `rm` over the mount trashes too; a rename onto a taken name still replaces what was there, so editor saves do not fill the trash.
This commit is contained in:
1 parent
439d5b083b
commit
8ffad6350f
22 files changed
+1154
-78
No files matched your search
+9
-4
@@ -185,12 +185,17 @@ pub async fn run(
|
||||
) -> Result<()> {
|
||||
// A sealed file has no place in a view meant to be opened by name from
|
||||
// anywhere: it is left out, so it leaves the view when it is sealed.
|
||||
let tree: Vec<TreeNode> = sqlx::query(
|
||||
"SELECT n.id, n.parent_id, n.kind, n.name, n.slug,
|
||||
// Nor has the trash: a file deleted leaves the view as it leaves the tree,
|
||||
// and comes back to it if it is restored.
|
||||
let tree: Vec<TreeNode> = sqlx::query(&format!(
|
||||
"WITH RECURSIVE {}
|
||||
SELECT n.id, n.parent_id, n.kind, n.name, n.slug,
|
||||
CASE WHEN m.encrypted = 0 THEN n.media_id END AS media_id,
|
||||
COALESCE(m.mime, '') AS mime
|
||||
FROM nodes n LEFT JOIN media m ON m.id = n.media_id",
|
||||
)
|
||||
FROM nodes n LEFT JOIN media m ON m.id = n.media_id
|
||||
WHERE n.id IN (SELECT id FROM live)",
|
||||
crate::routes::nodes::LIVE
|
||||
))
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
.iter()
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct Config {
|
||||
pub secure_cookie: bool,
|
||||
/// How long the vault stays open with nothing using it.
|
||||
pub vault_idle: std::time::Duration,
|
||||
/// How many days a deleted node waits in the trash before it is purged.
|
||||
pub trash_days: i64,
|
||||
pub backup: BackupConfig,
|
||||
pub mail: MailConfig,
|
||||
pub sftp: SftpConfig,
|
||||
@@ -41,6 +43,7 @@ impl std::fmt::Debug for Config {
|
||||
.field("max_upload_bytes", &self.max_upload_bytes)
|
||||
.field("secure_cookie", &self.secure_cookie)
|
||||
.field("vault_idle", &self.vault_idle)
|
||||
.field("trash_days", &self.trash_days)
|
||||
.field("backup", &self.backup)
|
||||
.field("mail", &self.mail)
|
||||
.field("sftp", &self.sftp)
|
||||
@@ -287,6 +290,9 @@ impl Config {
|
||||
// A vault that locks the moment it opens could not be used; one that
|
||||
// stays open for more than a day is not much of a vault.
|
||||
let vault_idle = Duration::from_secs(number("VAULT_IDLE_MIN", 15, 1..=24 * 60)? * 60);
|
||||
// A day at least, so an undo is never raced by the sweep; ten years
|
||||
// at most, as for the sessions.
|
||||
let trash_days = number("TRASH_DAYS", 30, 1..=3650)? as i64;
|
||||
let secure_cookie = flag("SECURE_COOKIE")?;
|
||||
|
||||
Ok(Self {
|
||||
@@ -299,6 +305,7 @@ impl Config {
|
||||
max_upload_bytes,
|
||||
secure_cookie,
|
||||
vault_idle,
|
||||
trash_days,
|
||||
backup: BackupConfig::from_env()?,
|
||||
mail: MailConfig::from_env()?,
|
||||
sftp: SftpConfig::from_env()?,
|
||||
@@ -336,6 +343,7 @@ impl Config {
|
||||
max_upload_bytes: 1,
|
||||
secure_cookie: false,
|
||||
vault_idle: std::time::Duration::from_secs(60),
|
||||
trash_days: 30,
|
||||
backup: BackupConfig {
|
||||
device_name: "narl-diary".into(),
|
||||
interval: None,
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ async fn main() -> Result<()> {
|
||||
let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?;
|
||||
notify::spawn(Arc::clone(¬ify));
|
||||
|
||||
sweep::spawn(db.clone(), config.uploads_dir());
|
||||
sweep::spawn(db.clone(), config.uploads_dir(), config.trash_days);
|
||||
|
||||
let epoch = auth::Epoch::load(&db, &config).await?;
|
||||
let sessions = Arc::new(auth::Sessions::load(&db).await?);
|
||||
|
||||
+6
-3
@@ -124,7 +124,7 @@ impl Notifier {
|
||||
JOIN board_lists l ON l.id = c.list_id
|
||||
JOIN nodes s ON s.id = l.space_id
|
||||
WHERE c.done_at IS NULL AND c.due_at IS NOT NULL AND c.due_at < ?
|
||||
AND s.has_board = 1
|
||||
AND s.has_board = 1 AND s.parent_id IS NULL
|
||||
ORDER BY c.due_at",
|
||||
)
|
||||
.bind(end_of_today)
|
||||
@@ -196,6 +196,7 @@ impl Notifier {
|
||||
JOIN board_lists l ON l.id = c.list_id
|
||||
JOIN nodes s ON s.id = l.space_id
|
||||
WHERE c.done_at >= ? AND c.done_at < ? AND s.has_board = 1
|
||||
AND s.parent_id IS NULL
|
||||
ORDER BY s.position, c.done_at",
|
||||
)
|
||||
.bind(start)
|
||||
@@ -209,7 +210,7 @@ impl Notifier {
|
||||
JOIN board_lists l ON l.id = c.list_id
|
||||
JOIN nodes s ON s.id = l.space_id
|
||||
WHERE c.done_at IS NULL AND c.due_at IS NOT NULL AND c.due_at < ?
|
||||
AND s.has_board = 1
|
||||
AND s.has_board = 1 AND s.parent_id IS NULL
|
||||
ORDER BY c.due_at",
|
||||
)
|
||||
.bind(end)
|
||||
@@ -349,7 +350,9 @@ impl Notifier {
|
||||
async fn drop_stale_cards(&self) -> Result<()> {
|
||||
let tz = self.config.mail.timezone;
|
||||
let waiting = sqlx::query(
|
||||
"SELECT n.id, n.dedupe_key, c.due_at, c.done_at, s.has_board
|
||||
"SELECT n.id, n.dedupe_key, c.due_at, c.done_at,
|
||||
-- A space in the trash has no board to be due on.
|
||||
CASE WHEN s.parent_id IS NULL THEN s.has_board ELSE 0 END AS has_board
|
||||
FROM notifications n
|
||||
LEFT JOIN cards c
|
||||
ON c.id = CAST(substr(n.dedupe_key, 10, instr(substr(n.dedupe_key, 10), ':') - 1) AS INTEGER)
|
||||
|
||||
@@ -191,7 +191,7 @@ async fn agenda_cards(db: &SqlitePool, horizon: i64) -> AppResult<Vec<AgendaCard
|
||||
JOIN board_lists l ON l.id = c.list_id
|
||||
JOIN bounds b ON b.space_id = l.space_id
|
||||
JOIN nodes s ON s.id = l.space_id
|
||||
WHERE s.has_board <> 0
|
||||
WHERE s.has_board <> 0 AND s.parent_id IS NULL
|
||||
AND c.done_at IS NULL
|
||||
AND (l.position < b.last OR b.first = b.last)
|
||||
AND ((c.due_at IS NOT NULL AND c.due_at < ?1) OR (l.position > b.first))
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::{
|
||||
routes::{
|
||||
manifest::{self, CardMeta, ListMeta, Manifest, NodeMeta, SpaceMeta},
|
||||
media::MEDIA_REF,
|
||||
nodes::{is_container, DOCUMENT, SPACE},
|
||||
nodes::{is_container, DOCUMENT, LIVE, SPACE},
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -179,11 +179,12 @@ tree stays intact. Nothing here needs the workspace application to read it.
|
||||
/// Everything, as one zip. A workspace you cannot get out of is not one you
|
||||
/// can trust, so this is deliberately a plain archive of plain files.
|
||||
pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Response> {
|
||||
let nodes: HashMap<i64, ExportNode> = sqlx::query(
|
||||
"SELECT id, parent_id, kind, name, slug, body, media_id, created_at, updated_at,
|
||||
let nodes: HashMap<i64, ExportNode> = sqlx::query(&format!(
|
||||
"WITH RECURSIVE {LIVE}
|
||||
SELECT id, parent_id, kind, name, slug, body, media_id, created_at, updated_at,
|
||||
has_board, role
|
||||
FROM nodes ORDER BY created_at, id",
|
||||
)
|
||||
FROM nodes WHERE id IN (SELECT id FROM live) ORDER BY created_at, id"
|
||||
))
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod seal;
|
||||
pub mod session;
|
||||
pub mod share;
|
||||
pub mod ssh_keys;
|
||||
pub mod trash;
|
||||
pub mod vault;
|
||||
|
||||
use axum::{
|
||||
@@ -93,6 +94,9 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
||||
.route("/media/{id}", get(media::serve).delete(media::remove))
|
||||
.route("/share/{token}", post(share::read))
|
||||
.route("/share/{token}/media/{id}", get(share::serve_media))
|
||||
.route("/trash", get(trash::list).delete(trash::empty))
|
||||
.route("/trash/{id}", delete(trash::purge))
|
||||
.route("/trash/{id}/restore", post(trash::restore))
|
||||
.route("/ssh-keys", get(ssh_keys::list).post(ssh_keys::add))
|
||||
.route(
|
||||
"/ssh-keys/{id}",
|
||||
|
||||
+62
-24
@@ -10,7 +10,7 @@ use sqlx::{Row, SqliteConnection, SqlitePool};
|
||||
use crate::{
|
||||
auth::Session,
|
||||
error::{AppError, AppResult},
|
||||
routes::media,
|
||||
routes::{media, trash},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -28,6 +28,15 @@ pub const FILE: &str = "file";
|
||||
/// slug, so it stays the diary under any name.
|
||||
pub const DIARY: &str = "diary";
|
||||
|
||||
/// The nodes in the tree proper — the spaces and everything under them, so
|
||||
/// not the trash — for a query that reads every node rather than walking down
|
||||
/// from one: `WITH RECURSIVE {LIVE} SELECT … WHERE id IN (SELECT id FROM live)`.
|
||||
pub const LIVE: &str = "live (id) AS (
|
||||
SELECT id FROM nodes WHERE parent_id IS NULL AND kind = 'space'
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN live ON n.parent_id = live.id
|
||||
)";
|
||||
|
||||
/// Whether a node of this kind can hold other nodes.
|
||||
pub fn is_container(kind: &str) -> bool {
|
||||
kind == SPACE || kind == FOLDER
|
||||
@@ -54,7 +63,7 @@ pub struct NodeSummary {
|
||||
pub excerpt: String,
|
||||
pub position: i64,
|
||||
pub has_board: bool,
|
||||
/// Spaces the code has to tell apart: `diary`, or nothing.
|
||||
/// Nodes the code has to tell apart: `diary`, `trash`, or nothing.
|
||||
pub role: Option<String>,
|
||||
pub child_count: i64,
|
||||
pub created_at: i64,
|
||||
@@ -486,11 +495,13 @@ pub async fn list(
|
||||
let searching = search.and_then(fts_query).is_some();
|
||||
let rows = match search.and_then(fts_query) {
|
||||
// A search reaches through the whole tree, optionally confined to one
|
||||
// space: the recursive term collects that space and everything under it.
|
||||
// space: the recursive term collects that space, or every space, and
|
||||
// everything under it — which leaves the trash out.
|
||||
Some(query) => {
|
||||
sqlx::query(&format!(
|
||||
"WITH RECURSIVE sub (id) AS (
|
||||
SELECT id FROM nodes WHERE id = ?4
|
||||
SELECT id FROM nodes
|
||||
WHERE id = ?4 OR (?4 IS NULL AND parent_id IS NULL AND kind = 'space')
|
||||
UNION ALL
|
||||
SELECT n.id FROM nodes n JOIN sub ON n.parent_id = sub.id
|
||||
)
|
||||
@@ -499,7 +510,7 @@ pub async fn list(
|
||||
JOIN nodes n ON n.id = nodes_fts.rowid
|
||||
WHERE nodes_fts MATCH ?1
|
||||
AND n.kind <> 'space'
|
||||
AND (?4 IS NULL OR n.id IN (SELECT id FROM sub))
|
||||
AND n.id IN (SELECT id FROM sub)
|
||||
ORDER BY rank
|
||||
LIMIT ?2 OFFSET ?3"
|
||||
))
|
||||
@@ -516,7 +527,7 @@ pub async fn list(
|
||||
sqlx::query(&format!(
|
||||
"SELECT {SUMMARY_COLUMNS}, substr(n.body, 1, {HEAD_CHARS}) AS head
|
||||
FROM nodes n
|
||||
WHERE n.parent_id IS ?1
|
||||
WHERE n.parent_id IS ?1 AND n.role IS NOT 'trash'
|
||||
ORDER BY CASE n.kind WHEN 'folder' THEN 0 ELSE 1 END,
|
||||
n.position, n.created_at DESC, n.id DESC
|
||||
LIMIT ?2 OFFSET ?3"
|
||||
@@ -552,11 +563,16 @@ pub async fn list(
|
||||
Ok(Json(found))
|
||||
}
|
||||
|
||||
/// A node in the trash reads as gone: whatever still points at it — a card,
|
||||
/// a link, a tab left open — finds nothing, as it did before there was one.
|
||||
pub async fn get(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Node>> {
|
||||
if trash::in_trash(&mut *state.db.acquire().await?, id).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
@@ -577,12 +593,16 @@ pub async fn resolve(
|
||||
) -> AppResult<Json<Node>> {
|
||||
let mut current: Option<i64> = None;
|
||||
for segment in params.path.split('/').filter(|s| !s.is_empty()) {
|
||||
let row = sqlx::query("SELECT id FROM nodes WHERE parent_id IS ?1 AND slug = ?2")
|
||||
.bind(current)
|
||||
.bind(segment)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
// A path starts at a space, never at the trash beside them.
|
||||
let row = sqlx::query(
|
||||
"SELECT id FROM nodes
|
||||
WHERE parent_id IS ?1 AND slug = ?2 AND (?1 IS NOT NULL OR kind = 'space')",
|
||||
)
|
||||
.bind(current)
|
||||
.bind(segment)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
current = Some(row.get("id"));
|
||||
}
|
||||
load(&state, current.ok_or(AppError::NotFound)?)
|
||||
@@ -717,21 +737,28 @@ pub async fn update(
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
/// A delete puts the node in the trash; one already in the trash is purged.
|
||||
pub async fn remove(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let removed = delete_subtree(&state.db, &state.config.uploads_dir(), id).await?;
|
||||
let purged = trash::in_trash(&mut *state.db.acquire().await?, id).await?;
|
||||
let removed = if purged {
|
||||
purge_subtree(&state.db, &state.config.uploads_dir(), id).await?
|
||||
} else {
|
||||
trash::trash_subtree(&state.db, id).await?
|
||||
};
|
||||
state.backup.signal();
|
||||
Ok(Json(serde_json::json!({ "ok": true, "removed": removed })))
|
||||
Ok(Json(
|
||||
serde_json::json!({ "ok": true, "removed": removed, "purged": purged }),
|
||||
))
|
||||
}
|
||||
|
||||
/// Delete a node, everything beneath it, and the files nothing outside it
|
||||
/// embeds. The one way a node is deleted, so a delete through the SFTP mount
|
||||
/// cleans up exactly what one through the API does. Returns how many nodes
|
||||
/// went.
|
||||
pub async fn delete_subtree(
|
||||
/// Delete a node for good, everything beneath it, and the files nothing
|
||||
/// outside it embeds. What emptying the trash does, and what the mount does
|
||||
/// when a save replaces one file with another. Returns how many nodes went.
|
||||
pub async fn purge_subtree(
|
||||
db: &SqlitePool,
|
||||
uploads: &std::path::Path,
|
||||
id: i64,
|
||||
@@ -748,6 +775,11 @@ pub async fn delete_subtree(
|
||||
"the diary space stays — empty it instead, or rename it".into(),
|
||||
));
|
||||
}
|
||||
if role.as_deref() == Some(trash::TRASH) {
|
||||
return Err(AppError::BadRequest(
|
||||
"the trash stays — :trash purge empties it".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Collected before the delete, which takes the node_media rows with it.
|
||||
// Everything under a folder goes with it, so the files to clean up are
|
||||
@@ -903,7 +935,8 @@ async fn movable(
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Refuse anything but a space or a folder as the place a node goes into.
|
||||
/// Refuse anything but a space or a folder as the place a node goes into —
|
||||
/// and one in the trash, where whatever went in would go when it is purged.
|
||||
pub async fn container(db: &mut SqliteConnection, id: i64) -> AppResult<()> {
|
||||
let kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
@@ -916,6 +949,11 @@ pub async fn container(db: &mut SqliteConnection, id: i64) -> AppResult<()> {
|
||||
"a {kind} cannot contain other nodes"
|
||||
)));
|
||||
}
|
||||
if trash::in_trash(&mut *db, id).await? {
|
||||
return Err(AppError::BadRequest(
|
||||
"that folder is in the trash — :trash brings it back".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1001,7 +1039,7 @@ mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::{
|
||||
delete_subtree, excerpt, fts_query, next_slug, recent_documents, slugify, EXCERPT_CHARS,
|
||||
excerpt, fts_query, next_slug, purge_subtree, recent_documents, slugify, EXCERPT_CHARS,
|
||||
HEAD_CHARS,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
@@ -1029,7 +1067,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
delete_subtree(&db, dir.path(), diary).await,
|
||||
purge_subtree(&db, dir.path(), diary).await,
|
||||
Err(AppError::BadRequest(_))
|
||||
));
|
||||
|
||||
@@ -1071,7 +1109,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(delete_subtree(&db, dir.path(), folder).await.unwrap(), 2);
|
||||
assert_eq!(purge_subtree(&db, dir.path(), folder).await.unwrap(), 2);
|
||||
let media: Vec<String> = sqlx::query_scalar("SELECT id FROM media")
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
@@ -1080,7 +1118,7 @@ mod tests {
|
||||
assert!(!dir.path().join(only).exists());
|
||||
assert!(dir.path().join(shared).exists());
|
||||
assert!(matches!(
|
||||
delete_subtree(&db, dir.path(), folder).await,
|
||||
purge_subtree(&db, dir.path(), folder).await,
|
||||
Err(AppError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
+43
-6
@@ -16,7 +16,7 @@ use crate::{
|
||||
auth::{self, Session},
|
||||
config::Config,
|
||||
error::{AppError, AppResult},
|
||||
routes::media,
|
||||
routes::{media, trash},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -187,7 +187,7 @@ pub async fn read(
|
||||
Json(body): Json<ReadRequest>,
|
||||
) -> AppResult<impl IntoResponse> {
|
||||
let row = sqlx::query(
|
||||
"SELECT name, body, created_at, updated_at, share_key_hash
|
||||
"SELECT id, name, body, created_at, updated_at, share_key_hash
|
||||
FROM nodes WHERE share_token = ?1",
|
||||
)
|
||||
.bind(&token)
|
||||
@@ -201,6 +201,11 @@ pub async fn read(
|
||||
// confirm that the token itself is live.
|
||||
return Err(AppError::NotFound);
|
||||
};
|
||||
// A document in the trash is not published, but its link comes back with
|
||||
// it: the key is still the one its readers hold.
|
||||
if trash::in_trash(&mut *state.db.acquire().await?, row.get("id")).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
let cookie = share_cookie(&state.config, &token, &stored);
|
||||
Ok((
|
||||
@@ -223,8 +228,8 @@ pub async fn serve_media(
|
||||
Path((token, id)): Path<(String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> AppResult<Response> {
|
||||
let stored: Option<String> = sqlx::query(
|
||||
"SELECT n.share_key_hash
|
||||
let row = sqlx::query(
|
||||
"SELECT n.id, n.share_key_hash
|
||||
FROM node_media nm
|
||||
JOIN nodes n ON n.id = nm.node_id
|
||||
WHERE nm.media_id = ?1 AND n.share_token = ?2",
|
||||
@@ -232,14 +237,19 @@ pub async fn serve_media(
|
||||
.bind(&id)
|
||||
.bind(&token)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.and_then(|row| row.get("share_key_hash"));
|
||||
.await?;
|
||||
let stored: Option<String> = row.as_ref().and_then(|row| row.get("share_key_hash"));
|
||||
|
||||
let allowed =
|
||||
stored.is_some_and(|stored| holds_share_cookie(&state.config, &headers, &token, &stored));
|
||||
if !allowed {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
if let Some(row) = &row {
|
||||
if trash::in_trash(&mut *state.db.acquire().await?, row.get("id")).await? {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
}
|
||||
media::stream_media(&state, &id, false, headers.get(header::RANGE)).await
|
||||
}
|
||||
|
||||
@@ -367,6 +377,33 @@ mod tests {
|
||||
assert_eq!(s.media(Some(&cookie)).await, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// A shared document in the trash answers like an unknown link, and comes
|
||||
/// back with the link its readers already hold.
|
||||
#[tokio::test]
|
||||
async fn the_trash_unpublishes_until_a_restore() {
|
||||
let s = shared().await;
|
||||
let (status, set) = s.read(&s.key).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let cookie = set.unwrap().split(';').next().unwrap().to_string();
|
||||
let doc: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE share_token = ?1")
|
||||
.bind(&s.token)
|
||||
.fetch_one(&s.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
crate::routes::trash::trash_subtree(&s.state.db, doc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(s.read(&s.key).await, (StatusCode::NOT_FOUND, None));
|
||||
assert_eq!(s.media(Some(&cookie)).await, StatusCode::NOT_FOUND);
|
||||
|
||||
crate::routes::trash::restore_node(&s.state, doc)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(s.read(&s.key).await.0, StatusCode::OK);
|
||||
assert_eq!(s.media(Some(&cookie)).await, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_key_is_not_taken_from_the_path() {
|
||||
let s = shared().await;
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
//! The trash: where a delete puts things, so a slip of `dd` is a `u` away
|
||||
//! from being undone rather than an old revision of the whole database away.
|
||||
//!
|
||||
//! A trashed node is moved, whole, under one hidden root (see migration
|
||||
//! `0019`). Nothing that walks down from the spaces reaches it there, so it
|
||||
//! leaves the tree, the search, the mount, the export and the readable mirror
|
||||
//! without any of them having to know about the trash. What is still in it
|
||||
//! after `WORKSPACE_TRASH_DAYS` is purged by the sweep, and only then are its
|
||||
//! files deleted.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use sqlx::{Row, SqliteConnection, SqlitePool};
|
||||
|
||||
use crate::{
|
||||
auth::Session,
|
||||
error::{AppError, AppResult},
|
||||
routes::nodes::{self, Node},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// The `role` of the hidden root everything trashed hangs under.
|
||||
pub const TRASH: &str = "trash";
|
||||
|
||||
/// The trash root's id. Made by a migration, so it is always there.
|
||||
pub async fn root(db: &mut SqliteConnection) -> AppResult<i64> {
|
||||
Ok(sqlx::query_scalar("SELECT id FROM nodes WHERE role = ?1")
|
||||
.bind(TRASH)
|
||||
.fetch_one(&mut *db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Whether a node is in the trash, at any depth — or is the trash itself.
|
||||
pub async fn in_trash(db: &mut SqliteConnection, id: i64) -> AppResult<bool> {
|
||||
let found: i64 = sqlx::query_scalar(
|
||||
"WITH RECURSIVE up (id, parent_id) AS (
|
||||
SELECT id, parent_id FROM nodes WHERE id = ?1
|
||||
UNION ALL
|
||||
SELECT n.id, n.parent_id FROM nodes n JOIN up ON n.id = up.parent_id
|
||||
)
|
||||
SELECT count(*) FROM up JOIN nodes n ON n.id = up.id WHERE n.role = ?2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(TRASH)
|
||||
.fetch_one(&mut *db)
|
||||
.await?;
|
||||
Ok(found > 0)
|
||||
}
|
||||
|
||||
/// Move a node and everything under it into the trash. Returns how many
|
||||
/// nodes went. The one way a node is deleted, from the browser and from the
|
||||
/// mount alike; its files stay on the disk until it is purged.
|
||||
pub async fn trash_subtree(db: &SqlitePool, id: i64) -> AppResult<usize> {
|
||||
let mut tx = crate::db::begin_write(db).await?;
|
||||
|
||||
let row = sqlx::query("SELECT parent_id, slug, role FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
match row.get::<Option<String>, _>("role").as_deref() {
|
||||
Some(nodes::DIARY) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"the diary space stays — empty it instead, or rename it".into(),
|
||||
))
|
||||
}
|
||||
Some(TRASH) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"the trash stays — :trash purge empties it".into(),
|
||||
))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if in_trash(&mut tx, id).await? {
|
||||
return Err(AppError::BadRequest("that is in the trash already".into()));
|
||||
}
|
||||
|
||||
let ancestry = ancestry(&mut tx, id).await?;
|
||||
let from_path = ancestry
|
||||
.iter()
|
||||
.take(ancestry.len().saturating_sub(1))
|
||||
.map(|(_, slug)| slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
let from_space = ancestry
|
||||
.first()
|
||||
.map(|(id, _)| *id)
|
||||
.filter(|&space| space != id);
|
||||
let count = nodes::descendants(&mut tx, id).await?.len();
|
||||
|
||||
let trash = root(&mut tx).await?;
|
||||
let slug = nodes::unique_slug(
|
||||
&mut tx,
|
||||
Some(trash),
|
||||
&row.get::<String, _>("slug"),
|
||||
Some(id),
|
||||
)
|
||||
.await?;
|
||||
// `updated_at` stays as it was: it says when the thing was last written,
|
||||
// and a restore should bring it back as it was, not as new.
|
||||
sqlx::query("UPDATE nodes SET parent_id = ?1, slug = ?2 WHERE id = ?3")
|
||||
.bind(trash)
|
||||
.bind(&slug)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT OR REPLACE INTO trash (node_id, from_parent, from_space, from_path, deleted_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(row.get::<Option<i64>, _>("parent_id"))
|
||||
.bind(from_space)
|
||||
.bind(&from_path)
|
||||
.bind(crate::now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// The ids and slugs from the root down to the node itself.
|
||||
async fn ancestry(db: &mut SqliteConnection, id: i64) -> AppResult<Vec<(i64, String)>> {
|
||||
let rows = sqlx::query(
|
||||
"WITH RECURSIVE up (id, parent_id, slug, depth) AS (
|
||||
SELECT id, parent_id, slug, 0 FROM nodes WHERE id = ?1
|
||||
UNION ALL
|
||||
SELECT n.id, n.parent_id, n.slug, up.depth + 1
|
||||
FROM nodes n JOIN up ON n.id = up.parent_id
|
||||
)
|
||||
SELECT id, slug FROM up ORDER BY depth DESC",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&mut *db)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| (row.get("id"), row.get("slug")))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TrashItem {
|
||||
pub id: i64,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
/// The path of the folder it was deleted from, `work/notes`; empty for a
|
||||
/// space.
|
||||
pub from: String,
|
||||
pub deleted_at: i64,
|
||||
/// When the sweep purges it.
|
||||
pub purge_at: i64,
|
||||
/// How many nodes are inside it, for a folder or a space.
|
||||
pub inside: usize,
|
||||
}
|
||||
|
||||
/// The rows that stand for something still in the trash. A row whose node
|
||||
/// has left it some other way — moved out by hand — stands for nothing.
|
||||
const LIVE_ROWS: &str = "FROM trash t JOIN nodes n ON n.id = t.node_id
|
||||
WHERE n.parent_id = (SELECT id FROM nodes WHERE role = 'trash')";
|
||||
|
||||
/// `GET /api/trash` — what is in it, the last thing deleted first.
|
||||
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<TrashItem>>> {
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT t.node_id, t.from_path, t.deleted_at, n.kind, n.name, n.slug {LIVE_ROWS}
|
||||
ORDER BY t.deleted_at DESC, t.node_id DESC"
|
||||
))
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
|
||||
let keep = state.config.trash_days * 86_400;
|
||||
let mut items = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let id: i64 = row.get("node_id");
|
||||
let deleted_at: i64 = row.get("deleted_at");
|
||||
items.push(TrashItem {
|
||||
id,
|
||||
kind: row.get("kind"),
|
||||
name: row.get("name"),
|
||||
slug: row.get("slug"),
|
||||
from: row.get("from_path"),
|
||||
deleted_at,
|
||||
purge_at: deleted_at + keep,
|
||||
inside: nodes::descendants(&mut conn, id).await?.len() - 1,
|
||||
});
|
||||
}
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Restored {
|
||||
pub node: Node,
|
||||
/// Where it came from was gone or in the trash itself, so it went to the
|
||||
/// root of its space — or of the first space, when that is gone too.
|
||||
pub elsewhere: bool,
|
||||
}
|
||||
|
||||
/// `POST /api/trash/{id}/restore` — back where it was deleted from.
|
||||
pub async fn restore(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<Restored>> {
|
||||
let elsewhere = restore_node(&state, id).await?;
|
||||
state.backup.signal();
|
||||
Ok(Json(Restored {
|
||||
node: nodes::load(&state, id).await?,
|
||||
elsewhere,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Put one trashed node back. A space goes back to the root, last in the
|
||||
/// switcher; anything else is moved like any other move, so a sealed folder
|
||||
/// it goes back into seals it on the way in, or refuses while the vault is
|
||||
/// shut. Returns whether it had to go somewhere other than where it was.
|
||||
pub async fn restore_node(state: &AppState, id: i64) -> AppResult<bool> {
|
||||
let mut conn = state.db.acquire().await?;
|
||||
let row = sqlx::query(&format!(
|
||||
"SELECT n.kind, n.name, t.from_parent, t.from_space {LIVE_ROWS} AND t.node_id = ?1"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?;
|
||||
let kind: String = row.get("kind");
|
||||
let name: String = row.get("name");
|
||||
|
||||
if kind == nodes::SPACE {
|
||||
drop(conn);
|
||||
let mut tx = crate::db::begin_write(&state.db).await?;
|
||||
let slug = nodes::unique_slug(&mut tx, None, &nodes::slugify(&name), Some(id)).await?;
|
||||
sqlx::query(
|
||||
"UPDATE nodes SET parent_id = NULL, slug = ?1,
|
||||
position = (SELECT COALESCE(MAX(position), -1) + 1 FROM nodes WHERE parent_id IS NULL)
|
||||
WHERE id = ?2",
|
||||
)
|
||||
.bind(&slug)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM trash WHERE node_id = ?1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let from_parent: Option<i64> = row.get("from_parent");
|
||||
let from_space: Option<i64> = row.get("from_space");
|
||||
let (target, elsewhere) = match from_parent {
|
||||
Some(parent) if is_live_container(&mut conn, parent).await? => (parent, false),
|
||||
_ => (fallback(&mut conn, from_space).await?, true),
|
||||
};
|
||||
drop(conn);
|
||||
|
||||
nodes::move_nodes(state, &[id], target, None).await?;
|
||||
// After the move, not with it: the move seals what it has to, which takes
|
||||
// transactions of its own. Should this delete not happen, the row stands
|
||||
// for nothing — it no longer matches `LIVE_ROWS` — and the sweep drops it.
|
||||
sqlx::query("DELETE FROM trash WHERE node_id = ?1")
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(elsewhere)
|
||||
}
|
||||
|
||||
async fn is_live_container(db: &mut SqliteConnection, id: i64) -> AppResult<bool> {
|
||||
let kind: Option<String> = sqlx::query_scalar("SELECT kind FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *db)
|
||||
.await?;
|
||||
Ok(kind.is_some_and(|kind| nodes::is_container(&kind)) && !in_trash(db, id).await?)
|
||||
}
|
||||
|
||||
/// Where a node goes back to when its folder is gone: the root of its space,
|
||||
/// or, when the space was purged, of the first space there is. A space that
|
||||
/// is in the trash itself has to come back first, or the node would go
|
||||
/// straight back into the trash with it.
|
||||
async fn fallback(db: &mut SqliteConnection, space: Option<i64>) -> AppResult<i64> {
|
||||
if let Some(space) = space {
|
||||
let row = sqlx::query("SELECT name, parent_id FROM nodes WHERE id = ?1")
|
||||
.bind(space)
|
||||
.fetch_optional(&mut *db)
|
||||
.await?;
|
||||
if let Some(row) = row {
|
||||
if row.get::<Option<i64>, _>("parent_id").is_none() {
|
||||
return Ok(space);
|
||||
}
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"its space, {}, is in the trash too — restore that first",
|
||||
row.get::<String, _>("name")
|
||||
)));
|
||||
}
|
||||
}
|
||||
sqlx::query_scalar(
|
||||
"SELECT id FROM nodes WHERE parent_id IS NULL AND kind = 'space'
|
||||
ORDER BY position, name LIMIT 1",
|
||||
)
|
||||
.fetch_optional(&mut *db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
AppError::BadRequest("there is no space to restore it into — :space! makes one".into())
|
||||
})
|
||||
}
|
||||
|
||||
/// `DELETE /api/trash/{id}` — purge one thing now, files and all.
|
||||
pub async fn purge(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let listed: Option<i64> =
|
||||
sqlx::query_scalar(&format!("SELECT t.node_id {LIVE_ROWS} AND t.node_id = ?1"))
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
if listed.is_none() {
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
let removed = nodes::purge_subtree(&state.db, &state.config.uploads_dir(), id).await?;
|
||||
state.backup.signal();
|
||||
Ok(Json(serde_json::json!({ "ok": true, "removed": removed })))
|
||||
}
|
||||
|
||||
/// `DELETE /api/trash` — empty it.
|
||||
pub async fn empty(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let removed = purge_older(&state.db, &state.config.uploads_dir(), i64::MAX).await?;
|
||||
state.backup.signal();
|
||||
Ok(Json(serde_json::json!({ "ok": true, "removed": removed })))
|
||||
}
|
||||
|
||||
/// Purge everything deleted before `cutoff`, and forget the rows that no
|
||||
/// longer stand for anything. Returns how many things were purged.
|
||||
pub async fn purge_older(
|
||||
db: &SqlitePool,
|
||||
uploads: &std::path::Path,
|
||||
cutoff: i64,
|
||||
) -> AppResult<usize> {
|
||||
let due: Vec<i64> = sqlx::query_scalar(&format!(
|
||||
"SELECT t.node_id {LIVE_ROWS} AND t.deleted_at < ?1"
|
||||
))
|
||||
.bind(cutoff)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
for &id in &due {
|
||||
nodes::purge_subtree(db, uploads, id).await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"DELETE FROM trash WHERE node_id NOT IN
|
||||
(SELECT id FROM nodes WHERE parent_id = (SELECT id FROM nodes WHERE role = 'trash'))",
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(due.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{in_trash, purge_older, restore_node, trash_subtree};
|
||||
use crate::{error::AppError, state::AppState};
|
||||
|
||||
async fn node(state: &AppState, parent: i64, kind: &str, slug: &str) -> i64 {
|
||||
sqlx::query_scalar(
|
||||
"INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?3, 'text', 0, 0) RETURNING id",
|
||||
)
|
||||
.bind(parent)
|
||||
.bind(kind)
|
||||
.bind(slug)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn space(state: &AppState, slug: &str) -> i64 {
|
||||
sqlx::query_scalar("SELECT id FROM nodes WHERE slug = ?1 AND parent_id IS NULL")
|
||||
.bind(slug)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn parent_and_slug(state: &AppState, id: i64) -> (Option<i64>, String) {
|
||||
sqlx::query_as("SELECT parent_id, slug FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn trashed(state: &AppState, id: i64) -> bool {
|
||||
in_trash(&mut state.db.acquire().await.unwrap(), id)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A folder goes with what is in it and comes back to where it was —
|
||||
/// under its old slug, unless a new sibling took it meanwhile.
|
||||
#[tokio::test]
|
||||
async fn a_delete_comes_back_where_it_was() {
|
||||
let state = crate::routes::tests::state_in(".".into()).await;
|
||||
let work = space(&state, "work").await;
|
||||
let folder = node(&state, work, "folder", "notes").await;
|
||||
let inside = node(&state, folder, "document", "monday").await;
|
||||
|
||||
assert_eq!(trash_subtree(&state.db, folder).await.unwrap(), 2);
|
||||
assert!(trashed(&state, folder).await);
|
||||
assert!(trashed(&state, inside).await);
|
||||
|
||||
let taken = node(&state, work, "folder", "notes").await;
|
||||
assert!(!restore_node(&state, folder).await.unwrap());
|
||||
assert_eq!(
|
||||
parent_and_slug(&state, folder).await,
|
||||
(Some(work), "notes-2".into())
|
||||
);
|
||||
assert_eq!(parent_and_slug(&state, inside).await.0, Some(folder));
|
||||
assert!(!trashed(&state, inside).await);
|
||||
assert!(!trashed(&state, taken).await);
|
||||
|
||||
let rows: i64 = sqlx::query_scalar("SELECT count(*) FROM trash")
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, 0);
|
||||
}
|
||||
|
||||
/// Out of a folder that has since gone, a node lands at the root of its
|
||||
/// space; out of a space that is in the trash too, it waits for that.
|
||||
#[tokio::test]
|
||||
async fn without_its_folder_it_lands_in_its_space() {
|
||||
let state = crate::routes::tests::state_in(".".into()).await;
|
||||
let uni = space(&state, "uni").await;
|
||||
let folder = node(&state, uni, "folder", "algo").await;
|
||||
let doc = node(&state, folder, "document", "sheet-1").await;
|
||||
|
||||
trash_subtree(&state.db, doc).await.unwrap();
|
||||
trash_subtree(&state.db, folder).await.unwrap();
|
||||
assert!(restore_node(&state, doc).await.unwrap());
|
||||
assert_eq!(parent_and_slug(&state, doc).await.0, Some(uni));
|
||||
|
||||
trash_subtree(&state.db, doc).await.unwrap();
|
||||
trash_subtree(&state.db, uni).await.unwrap();
|
||||
assert!(matches!(
|
||||
restore_node(&state, doc).await,
|
||||
Err(AppError::BadRequest(_))
|
||||
));
|
||||
assert!(!restore_node(&state, uni).await.unwrap());
|
||||
assert_eq!(parent_and_slug(&state, uni).await, (None, "uni".into()));
|
||||
assert!(!restore_node(&state, doc).await.unwrap());
|
||||
assert_eq!(parent_and_slug(&state, doc).await.0, Some(uni));
|
||||
}
|
||||
|
||||
/// The diary and the trash itself cannot be thrown away, and nothing is
|
||||
/// thrown away twice.
|
||||
#[tokio::test]
|
||||
async fn some_things_stay() {
|
||||
let state = crate::routes::tests::state_in(".".into()).await;
|
||||
let diary: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE role = 'diary'")
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let trash = super::root(&mut state.db.acquire().await.unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
for id in [diary, trash] {
|
||||
assert!(matches!(
|
||||
trash_subtree(&state.db, id).await,
|
||||
Err(AppError::BadRequest(_))
|
||||
));
|
||||
}
|
||||
let doc = node(&state, diary, "document", "today").await;
|
||||
trash_subtree(&state.db, doc).await.unwrap();
|
||||
assert!(matches!(
|
||||
trash_subtree(&state.db, doc).await,
|
||||
Err(AppError::BadRequest(_))
|
||||
));
|
||||
}
|
||||
|
||||
/// What is in the trash is not found by a search across every space, and
|
||||
/// the cards of a space in the trash leave the agenda.
|
||||
#[tokio::test]
|
||||
async fn the_trash_is_out_of_sight() {
|
||||
use axum::extract::{Query, State};
|
||||
|
||||
let state = crate::routes::tests::state_in(".".into()).await;
|
||||
let session = || crate::auth::Session { id: "test".into() };
|
||||
let work = space(&state, "work").await;
|
||||
let doc = node(&state, work, "document", "quarterly").await;
|
||||
sqlx::query("UPDATE nodes SET body = 'zebra crossing' WHERE id = ?1")
|
||||
.bind(doc)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
for (name, position) in [("inbox", 0), ("done", 1)] {
|
||||
sqlx::query(
|
||||
"INSERT INTO board_lists (space_id, name, position, created_at)
|
||||
VALUES (?1, ?2, ?3, 0)",
|
||||
)
|
||||
.bind(work)
|
||||
.bind(name)
|
||||
.bind(position)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let list: i64 = sqlx::query_scalar(
|
||||
"SELECT id FROM board_lists WHERE space_id = ?1 ORDER BY position LIMIT 1",
|
||||
)
|
||||
.bind(work)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO cards (list_id, title, due_at, position, created_at, updated_at)
|
||||
VALUES (?1, 'late', 1, 0, 0, 0)",
|
||||
)
|
||||
.bind(list)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let search = || async {
|
||||
let query = serde_json::from_value(serde_json::json!({ "q": "zebra" })).unwrap();
|
||||
crate::routes::nodes::list(session(), State(state.clone()), Query(query))
|
||||
.await
|
||||
.unwrap()
|
||||
.0
|
||||
.len()
|
||||
};
|
||||
let agenda = || async {
|
||||
let agenda = crate::routes::boards::agenda(session(), State(state.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
serde_json::to_value(agenda).unwrap()["cards"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len()
|
||||
};
|
||||
assert_eq!(search().await, 1);
|
||||
assert_eq!(agenda().await, 1);
|
||||
|
||||
trash_subtree(&state.db, doc).await.unwrap();
|
||||
assert_eq!(search().await, 0);
|
||||
trash_subtree(&state.db, work).await.unwrap();
|
||||
assert_eq!(agenda().await, 0);
|
||||
}
|
||||
|
||||
/// The sweep purges what is past the cutoff and leaves the rest, and a
|
||||
/// row left behind by a node moved out by hand is forgotten, not purged.
|
||||
#[tokio::test]
|
||||
async fn only_what_is_old_enough_is_purged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = crate::routes::tests::state_in(dir.path().into()).await;
|
||||
let work = space(&state, "work").await;
|
||||
let old = node(&state, work, "document", "old").await;
|
||||
let new = node(&state, work, "document", "new").await;
|
||||
let moved = node(&state, work, "document", "moved").await;
|
||||
for id in [old, new, moved] {
|
||||
trash_subtree(&state.db, id).await.unwrap();
|
||||
}
|
||||
sqlx::query("UPDATE trash SET deleted_at = 100 WHERE node_id IN (?1, ?2)")
|
||||
.bind(old)
|
||||
.bind(moved)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE nodes SET parent_id = ?1 WHERE id = ?2")
|
||||
.bind(work)
|
||||
.bind(moved)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(purge_older(&state.db, dir.path(), 1000).await.unwrap(), 1);
|
||||
let left: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM nodes WHERE id IN (?1, ?2, ?3) ORDER BY id")
|
||||
.bind(old)
|
||||
.bind(new)
|
||||
.bind(moved)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(left, [new, moved]);
|
||||
let rows: Vec<i64> = sqlx::query_scalar("SELECT node_id FROM trash")
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, [new]);
|
||||
}
|
||||
}
|
||||
+29
-6
@@ -35,7 +35,7 @@ use crate::{
|
||||
Backup,
|
||||
},
|
||||
error::{AppError, AppResult},
|
||||
routes::{media, nodes, seal},
|
||||
routes::{media, nodes, seal, trash},
|
||||
state::AppState,
|
||||
vault,
|
||||
};
|
||||
@@ -600,7 +600,8 @@ impl SpaceFs {
|
||||
for slug in [leaf, slugged.as_str()] {
|
||||
let row = sqlx::query(
|
||||
"SELECT id, kind, slug, updated_at FROM nodes
|
||||
WHERE parent_id IS ?1 AND slug = ?2 AND kind IN ('space', 'folder')",
|
||||
WHERE parent_id IS ?1 AND slug = ?2 AND kind IN ('space', 'folder')
|
||||
AND role IS NOT 'trash'",
|
||||
)
|
||||
.bind(parent)
|
||||
.bind(slug)
|
||||
@@ -773,8 +774,24 @@ impl SpaceFs {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `rm` and `rmdir`: into the trash, as a delete in the browser does.
|
||||
async fn delete_node(&self, id: i64) -> SftpResult<()> {
|
||||
nodes::delete_subtree(&self.db, &self.uploads, id)
|
||||
trash::trash_subtree(&self.db, id)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
AppError::NotFound => StatusCode::NoSuchFile,
|
||||
AppError::BadRequest(_) => StatusCode::PermissionDenied,
|
||||
_ => StatusCode::Failure,
|
||||
})?;
|
||||
self.changed();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A rename onto a name that is taken: what was there is replaced, as
|
||||
/// `rename(2)` replaces it, and not kept in the trash — an editor or
|
||||
/// `rsync` saving that way would fill it with every save.
|
||||
async fn replace_node(&self, id: i64) -> SftpResult<()> {
|
||||
nodes::purge_subtree(&self.db, &self.uploads, id)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
AppError::NotFound => StatusCode::NoSuchFile,
|
||||
@@ -1081,7 +1098,8 @@ impl SpaceFs {
|
||||
let parent = self.resolve_dir(&segs).await?;
|
||||
let rows = sqlx::query(&format!(
|
||||
"SELECT kind, slug, updated_at, {DOC_SIZE} AS size FROM nodes
|
||||
WHERE parent_id IS ?1 AND kind <> 'file' ORDER BY kind, slug"
|
||||
WHERE parent_id IS ?1 AND kind <> 'file' AND role IS NOT 'trash'
|
||||
ORDER BY kind, slug"
|
||||
))
|
||||
.bind(parent)
|
||||
.fetch_all(&self.db)
|
||||
@@ -1716,7 +1734,7 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
(Some(_), Some(Target::Doc(other))) if other.id != doc.id => {
|
||||
self.save_doc(other.id, render_doc(&doc.name, &doc.body).into_bytes())
|
||||
.await?;
|
||||
self.delete_node(doc.id).await?;
|
||||
self.replace_node(doc.id).await?;
|
||||
}
|
||||
(Some(stem), _) => {
|
||||
let new_slug = nodes::unique_slug(
|
||||
@@ -1763,7 +1781,7 @@ impl russh_sftp::server::Handler for SpaceFs {
|
||||
Some(Target::Dir { .. } | Target::Doc(_)) => return Err(StatusCode::Failure),
|
||||
Some(Target::File(other)) if other.id == file.id => {}
|
||||
// Onto another file, as rename(2) does: that one goes.
|
||||
Some(Target::File(other)) => self.delete_node(other.id).await?,
|
||||
Some(Target::File(other)) => self.replace_node(other.id).await?,
|
||||
Some(Target::Scratch(key)) => {
|
||||
self.scratch.remove(&key);
|
||||
}
|
||||
@@ -1958,6 +1976,11 @@ mod tests {
|
||||
.await
|
||||
.expect("remove");
|
||||
assert_eq!(names(&fs).await, ["notes.md"]);
|
||||
// In the trash, with their bytes, until it is emptied.
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2);
|
||||
crate::routes::trash::purge_older(&fs.db, dir.path(), i64::MAX)
|
||||
.await
|
||||
.expect("empty the trash");
|
||||
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
|
||||
+24
-10
@@ -1,6 +1,7 @@
|
||||
//! Housekeeping for `uploads/`: what a crash, a killed upload or a forgotten
|
||||
//! embed leaves behind. Nothing else ever deletes these, so without this they
|
||||
//! would only grow — on the disk and in every backup of it.
|
||||
//! would only grow — on the disk and in every backup of it. The trash is
|
||||
//! emptied here too, of what has waited in it long enough.
|
||||
|
||||
use std::{collections::HashSet, path::Path, time::Duration};
|
||||
|
||||
@@ -18,21 +19,22 @@ const UNREFERENCED_DAYS: i64 = 7;
|
||||
|
||||
const EVERY: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
pub fn spawn(db: SqlitePool, uploads: std::path::PathBuf) {
|
||||
pub fn spawn(db: SqlitePool, uploads: std::path::PathBuf, trash_days: i64) {
|
||||
crate::tasks::supervise("the uploads sweep", move || {
|
||||
let (db, uploads) = (db.clone(), uploads.clone());
|
||||
sweep_forever(db, uploads)
|
||||
sweep_forever(db, uploads, trash_days)
|
||||
});
|
||||
}
|
||||
|
||||
async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf) {
|
||||
async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf, trash_days: i64) {
|
||||
{
|
||||
let mut ticker = tokio::time::interval(EVERY);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
match run(&db, &uploads).await {
|
||||
match run(&db, &uploads, trash_days).await {
|
||||
Ok(swept) if swept.any() => tracing::info!(
|
||||
"swept uploads: {} partial, {} without a row, {} that nothing embeds",
|
||||
"swept: {} out of the trash; uploads: {} partial, {} without a row, {} that nothing embeds",
|
||||
swept.trashed,
|
||||
swept.partial,
|
||||
swept.stray,
|
||||
swept.unreferenced
|
||||
@@ -46,6 +48,8 @@ async fn sweep_forever(db: SqlitePool, uploads: std::path::PathBuf) {
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Swept {
|
||||
/// Things deleted long enough ago to be purged from the trash.
|
||||
pub trashed: usize,
|
||||
pub partial: usize,
|
||||
pub stray: usize,
|
||||
pub unreferenced: usize,
|
||||
@@ -53,12 +57,21 @@ pub struct Swept {
|
||||
|
||||
impl Swept {
|
||||
fn any(&self) -> bool {
|
||||
self.partial + self.stray + self.unreferenced > 0
|
||||
self.trashed + self.partial + self.stray + self.unreferenced > 0
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(db: &SqlitePool, uploads: &Path) -> anyhow::Result<Swept> {
|
||||
let mut swept = Swept::default();
|
||||
pub async fn run(db: &SqlitePool, uploads: &Path, trash_days: i64) -> anyhow::Result<Swept> {
|
||||
// First, so the files only the purged nodes held are released and go in
|
||||
// this same pass.
|
||||
let trashed =
|
||||
crate::routes::trash::purge_older(db, uploads, crate::now() - trash_days * 86_400)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("emptying the trash: {e:?}"))?;
|
||||
let mut swept = Swept {
|
||||
trashed,
|
||||
..Swept::default()
|
||||
};
|
||||
|
||||
sqlx::query("DELETE FROM sessions WHERE expires_at < ?1")
|
||||
.bind(crate::now())
|
||||
@@ -189,10 +202,11 @@ mod tests {
|
||||
let fresh = "55555555-5555-4555-8555-555555555555.part";
|
||||
std::fs::write(dir.path().join(fresh), b"x").unwrap();
|
||||
|
||||
let swept = run(&db, dir.path()).await.unwrap();
|
||||
let swept = run(&db, dir.path(), 30).await.unwrap();
|
||||
assert_eq!(
|
||||
swept,
|
||||
Swept {
|
||||
trashed: 0,
|
||||
partial: 1,
|
||||
stray: 2,
|
||||
unreferenced: 1
|
||||
|
||||
Reference in new issue
Block a user