From 8ffad6350f3da89ade498170f7f94aa4e7d0be5b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 20 Oct 2026 18:15:22 +0200 Subject: [PATCH] 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. --- .env.example | 4 + README.md | 38 ++- migrations/0019_trash.sql | 22 ++ src/backup/view.rs | 13 +- src/config.rs | 8 + src/main.rs | 2 +- src/notify/mod.rs | 9 +- src/routes/boards.rs | 2 +- src/routes/export.rs | 11 +- src/routes/mod.rs | 4 + src/routes/nodes.rs | 86 ++++-- src/routes/share.rs | 49 ++- src/routes/trash.rs | 601 ++++++++++++++++++++++++++++++++++++ src/sftp/fs.rs | 35 ++- src/sweep.rs | 34 +- web/src/App.svelte | 43 ++- web/src/lib/Help.svelte | 3 +- web/src/lib/Rail.svelte | 1 + web/src/lib/Trash.svelte | 131 ++++++++ web/src/lib/api.ts | 30 +- web/src/lib/commands.ts | 25 +- web/src/lib/store.svelte.ts | 81 ++++- 22 files changed, 1154 insertions(+), 78 deletions(-) create mode 100644 migrations/0019_trash.sql create mode 100644 src/routes/trash.rs create mode 100644 web/src/lib/Trash.svelte diff --git a/.env.example b/.env.example index 34ccc49..01b70a3 100644 --- a/.env.example +++ b/.env.example @@ -104,3 +104,7 @@ WORKSPACE_MAIL_KINDS=reminder,card_due,digest,login # Minutes the vault stays open with nothing using it, before it locks itself. # It also locks on logout and whenever the server restarts. WORKSPACE_VAULT_IDLE_MIN=15 + +# Days a deleted document, folder, file or space waits in the trash, where +# :trash can bring it back, before it and its files are deleted for good. +WORKSPACE_TRASH_DAYS=30 diff --git a/README.md b/README.md index 19cbba7..2b80473 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,8 @@ it; in the document pane it is that document. In the browse pane: | `Ctrl-P` | full-text search across every space; `Enter` goes to the match | | `n` / `N` | the next / previous search result, opened beside the results; past the last it starts again at the first | | `s` / `y` | toggle sharing / mint a new share link — either asks first when it would cut readers off | -| `x`, `dd` | delete the entry under the cursor (asks `y`/`N`) | +| `x`, `dd` | delete the entry under the cursor into the trash (asks `y`/`N`) | +| `u` | undo the last delete: it comes back out of the trash to where it was | | `:` | command line — `Tab` / `Shift-Tab` cycle the matching commands, then the argument (folders for `:cd` and `:mv`, spaces for `:space`, options for `:set` …); `↑` / `↓` recall the lines run before that start as this one does | | `Ctrl-S` | save from anywhere | | `?` | help | @@ -118,11 +119,11 @@ headings as they do in the reader. `gf` follows the link under the cursor, savin first, and `gx` opens it in a new tab. The rest: `:today` opens today's entry, `:edit [id]` (or `:12`) opens a node by id, `:next`, `:prev`, `:first`, `:last` and `:random` walk the list (`:bn` and `:bp` walk the tiles), `:delete [id]` deletes, `:copy` puts the whole document on the clipboard, -`:clear` drops the search, `:reload` re-reads the folder, `:stats` counts words +`:clear` drops the search, `:trash` lists what was deleted, `:reload` re-reads the folder, `:stats` counts words and lines, `:search!` searches every space, `:set clipboard` / `:set noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the session, `:logout!` every session and `:sessions rm ` one other. The tree has its own: `:space [name]` switches (`:space! ` -makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir ` +makes one, `:rmspace [name]` puts one in the trash with everything in it), `:mkdir ` makes a folder, `:cd ` walks it and `:mv ` moves what is selected, and `:import` uploads a folder into the one being browsed (`:import!` takes a `.zip` instead). `:upload` outside the editor puts files @@ -137,7 +138,7 @@ the same actions are buttons: the `⋯` on a row — or a long press — opens w the keys do to it: rename, move, share, seal, delete. A move is picked up there and dropped with the button the dock then shows, in whichever folder is open. Inside a folder the list starts with `..`, the way up. The dock also -has today's entry, an upload into the folder, a search across every space, +has today's entry, an upload into the folder, the trash, a search across every space, and on the board the card moves, renames and deletes; the agenda has a done button per card. @@ -254,6 +255,24 @@ space and folder each match is in. A document made with `o` and left with neithe it is removed again on the way out, so abandoned starts do not pile up. If a tab dies mid-document, opening it again restores the draft and says so — `:e!` throws it away, the way vim handles a swap file. +A delete is not the end of anything. `x`, `dd`, `:delete` and `:rmspace` +put what they delete in the trash, with everything inside it, and `u` puts +the last of them back where it was — the same folder, the same name, unless +a new one took the name meanwhile. `:trash` lists everything in it, the last +deleted first: `j`/`k` walk it, `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, and something whose space +is in the trash too waits until the space is restored. What has been in the +trash for `WORKSPACE_TRASH_DAYS` (30 by default) is deleted for good by the +daily sweep, files and all. + +While it waits, a node in the trash is out of sight: not in the tree, the +search, the agenda, the mails, the SFTP mount, the export or the readable +`files/` view on Proton Drive. A card whose notes are in the trash keeps +pointing at them and finds them again after a restore, and a shared document +answers like an unknown link until it is restored — with the same link its +readers already hold. + `:export` downloads the open document as markdown. `:export!` downloads the whole workspace as a zip: the tree as real directories, empty folders too, one markdown file per document, every file in the tree where it sits and every @@ -272,7 +291,7 @@ An upload that nothing embeds any more — pasted, then deleted from the document — is kept a week and then removed, along with what a killed upload or a crash leaves in `uploads/`. That runs at start and once a day. A file in the tree is never one of them: it is deleted from its folder, not from -`:media`. +`:media`, and its bytes stay on the disk until it leaves the trash. `:media` lists every upload: `j`/`k` walk it, `i` or `Enter` embeds the highlighted one at the cursor while writing, `y` copies its address and `x` @@ -322,6 +341,10 @@ Nothing done over the mount holds the vault open; only the browser does. A session holds at most 256 handles and 64 MB in memory, the server at most 32 connections, and one whose client has stopped answering is dropped within a few minutes. +`rm` and `rmdir` over the mount put things in the trash, as a delete in +the browser does. A rename onto a name that is taken replaces what was +there, as `rename(2)` does, without keeping it in the trash: editors and +`rsync` save that way, and the trash would fill up with every save. Editor droppings — swap files, `~` backups, dotted temp files — stay in memory for the session, and a dotted temp file renamed onto a real name is kept as that file, which is how `rsync` and most editors save. @@ -635,7 +658,10 @@ sealed or not. | `DELETE` | `/api/sessions/{id}` | sign another browser out, and lock the vault | | `GET`/`POST` | `/api/spaces` | the spaces / create one | | `GET`/`POST` | `/api/nodes` | children (`?parent=`) or search (`?q=`, `?space=`) / create | -| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete (a delete takes the subtree) | +| `GET`/`PUT`/`DELETE` | `/api/nodes/{id}` | read / update / delete into the trash, subtree and all — or for good, for something already in it | +| `GET`/`DELETE` | `/api/trash` | what was deleted, the last first / empty it | +| `DELETE` | `/api/trash/{id}` | delete one thing in the trash for good | +| `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone | | `GET` | `/api/spaces/{id}/board` | the lists and cards of one space | | `GET` | `/api/agenda` | open cards due within a week or in hand, on every board, and whether the diary has today | | `POST` | `/api/spaces/{id}/lists` | add a list | diff --git a/migrations/0019_trash.sql b/migrations/0019_trash.sql new file mode 100644 index 0000000..fac045c --- /dev/null +++ b/migrations/0019_trash.sql @@ -0,0 +1,22 @@ +-- A delete puts a node in the trash rather than ending it. The trash is one +-- hidden root: a folder, not a space, so nothing that lists the spaces or +-- walks down from them ever reaches it, and a trashed node — moved under it +-- whole, with everything inside — drops out of the tree, the search, the +-- mount, the export and the readable mirror at once. The slug starts with a +-- dot, which no name ever slugifies to, so no space can take it. +INSERT INTO nodes (parent_id, kind, name, slug, role, created_at, updated_at) +VALUES (NULL, 'folder', 'trash', '.trash', 'trash', strftime('%s', 'now'), strftime('%s', 'now')); + +-- One row per thing deleted, not per node under it: a folder comes back with +-- what was in it. Where it came from is kept by id, to go back to, and as the +-- path it had, to show once that folder is gone too. A trashed space has no +-- parent to go back to; it goes back to the root. +CREATE TABLE trash ( + node_id INTEGER PRIMARY KEY REFERENCES nodes (id) ON DELETE CASCADE, + from_parent INTEGER, + from_space INTEGER, + from_path TEXT NOT NULL, + deleted_at INTEGER NOT NULL +); + +CREATE INDEX idx_trash_deleted ON trash (deleted_at); diff --git a/src/backup/view.rs b/src/backup/view.rs index cab13be..5aadf63 100644 --- a/src/backup/view.rs +++ b/src/backup/view.rs @@ -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 = 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 = 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() diff --git a/src/config.rs b/src/config.rs index d494c4a..67e0986 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, diff --git a/src/main.rs b/src/main.rs index be82177..d40e34a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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?); diff --git a/src/notify/mod.rs b/src/notify/mod.rs index ad59046..c3e687d 100644 --- a/src/notify/mod.rs +++ b/src/notify/mod.rs @@ -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) diff --git a/src/routes/boards.rs b/src/routes/boards.rs index 8901f46..eac1c94 100644 --- a/src/routes/boards.rs +++ b/src/routes/boards.rs @@ -191,7 +191,7 @@ async fn agenda_cards(db: &SqlitePool, horizon: i64) -> AppResult 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)) diff --git a/src/routes/export.rs b/src/routes/export.rs index 71dfbd5..99b3818 100644 --- a/src/routes/export.rs +++ b/src/routes/export.rs @@ -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) -> AppResult { - let nodes: HashMap = sqlx::query( - "SELECT id, parent_id, kind, name, slug, body, media_id, created_at, updated_at, + let nodes: HashMap = 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() diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 6d93d62..a7ece1c 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -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 { .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}", diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index 0a456c9..5d1f035 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -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, 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, Path(id): Path, ) -> AppResult> { + 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> { let mut current: Option = 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, Path(id): Path, ) -> AppResult> { - 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 = 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) )); } diff --git a/src/routes/share.rs b/src/routes/share.rs index 4454bc4..36e666b 100644 --- a/src/routes/share.rs +++ b/src/routes/share.rs @@ -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, ) -> AppResult { 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 { - let stored: Option = 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 = 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; diff --git a/src/routes/trash.rs b/src/routes/trash.rs new file mode 100644 index 0000000..5a9b3f4 --- /dev/null +++ b/src/routes/trash.rs @@ -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 { + 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 { + 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 { + 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::, _>("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::>() + .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::("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::, _>("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> { + 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) -> AppResult>> { + 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, + Path(id): Path, +) -> AppResult> { + 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 { + 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 = row.get("from_parent"); + let from_space: Option = 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 { + let kind: Option = 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) -> AppResult { + 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::, _>("parent_id").is_none() { + return Ok(space); + } + return Err(AppError::BadRequest(format!( + "its space, {}, is in the trash too — restore that first", + row.get::("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, + Path(id): Path, +) -> AppResult> { + let listed: Option = + 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, +) -> AppResult> { + 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 { + let due: Vec = 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, 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 = + 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 = sqlx::query_scalar("SELECT node_id FROM trash") + .fetch_all(&state.db) + .await + .unwrap(); + assert_eq!(rows, [new]); + } +} diff --git a/src/sftp/fs.rs b/src/sftp/fs.rs index a0726aa..e612fd2 100644 --- a/src/sftp/fs.rs +++ b/src/sftp/fs.rs @@ -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); } diff --git a/src/sweep.rs b/src/sweep.rs index 2bc20e7..291c577 100644 --- a/src/sweep.rs +++ b/src/sweep.rs @@ -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 { - let mut swept = Swept::default(); +pub async fn run(db: &SqlitePool, uploads: &Path, trash_days: i64) -> anyhow::Result { + // 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 diff --git a/web/src/App.svelte b/web/src/App.svelte index 985766c..d706336 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -14,6 +14,7 @@ import Login from './lib/Login.svelte' import Overview from './lib/Overview.svelte' import MediaBrowser, { copyMedia, insertMedia, removeMedia } from './lib/MediaBrowser.svelte' + import Trash, { purgeTrashed, restoreTrashed } from './lib/Trash.svelte' import Actions from './lib/Actions.svelte' import Prompt from './lib/Prompt.svelte' import SecretPrompt from './lib/SecretPrompt.svelte' @@ -391,6 +392,35 @@ } } + /** The trash's keys: j/k down what was deleted, Enter or r restores, x deletes for good. */ + function trashKey(event: KeyboardEvent): boolean { + const items = workspace.trash + const last = Math.max(items.length - 1, 0) + const item = items[workspace.trashCursor] + switch (event.key) { + case 'j': + case 'ArrowDown': + workspace.trashCursor = Math.min(workspace.trashCursor + 1, last) + return true + case 'k': + case 'ArrowUp': + workspace.trashCursor = Math.max(workspace.trashCursor - 1, 0) + return true + case 'G': + workspace.trashCursor = last + return true + case 'Enter': + case 'r': + if (item) void restoreTrashed(item) + return true + case 'x': + if (item) void purgeTrashed(item) + return true + default: + return false + } + } + /* * `F`: the hints are pinned where the targets are, so a pane still sliding * in — a space switch, the board just opened — is let settle first. @@ -578,6 +608,12 @@ return } } + if (workspace.overlay === 'trash' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { + if (trashKey(event)) { + event.preventDefault() + return + } + } if (workspace.overlay === 'outline' && listening && !event.ctrlKey && !event.metaKey && !event.altKey) { if (outlineKey(event)) { event.preventDefault() @@ -862,6 +898,11 @@ event.preventDefault() void runCommand('d') break + case 'u': + // A delete goes to the trash, so it can be undone the way vim undoes. + event.preventDefault() + void workspace.guard(() => workspace.undoDelete()) + break case 's': // Sharing is a key away; taking a link back from its readers asks first. event.preventDefault() @@ -1000,7 +1041,7 @@
e.target === e.currentTarget && (workspace.overlay = 'none')} > - {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'agenda'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else if workspace.overlay === 'outline'}{:else}{/if} + {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'agenda'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else if workspace.overlay === 'outline'}{:else if workspace.overlay === 'trash'}{:else}{/if}
{/if} diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 76a3377..4a5f276 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -41,7 +41,8 @@ ['n / N', 'next / previous search result, opened beside the results — :noh clears the search'], ['s', 'toggle sharing for the document — asks before taking it back'], ['y', 'mint a new share link and copy it — asks, the old one stops working'], - ['x · dd', 'delete what has focus — the row in the list, the document in its pane (asks y/N)'], + ['x · dd', 'delete what has focus — the row in the list, the document in its pane — into the trash (asks y/N)'], + ['u', 'undo the last delete: it comes back out of the trash — :trash has the rest'], [':', 'command line — Tab / Shift-Tab cycle the matches, then the argument; ↑ ↓ recall what ran before'], ['Ctrl-S', 'save, from anywhere'], ['?', 'this screen — in the editor too'], diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte index 4012263..5b77de3 100644 --- a/web/src/lib/Rail.svelte +++ b/web/src/lib/Rail.svelte @@ -83,6 +83,7 @@ ], [ { icon: 'upload', label: 'Upload files into this folder', keys: [':upload'], run: () => hooks.pickTreeFiles(false) }, + { icon: 'trash', label: 'The trash — what was deleted', keys: [':trash'], run: () => runCommand('trash') }, ...(workspace.marked.length ? [{ icon: 'drop' as const, label: `Drop ${dropping(workspace.marked)} here`, keys: ['p'], run: () => workspace.guard(() => workspace.drop()), on: true }] : []), diff --git a/web/src/lib/Trash.svelte b/web/src/lib/Trash.svelte new file mode 100644 index 0000000..978f14a --- /dev/null +++ b/web/src/lib/Trash.svelte @@ -0,0 +1,131 @@ + + + + +
+
+ trash {workspace.trash.length} deleted + {#if workspace.trash.length > 0} + + {/if} + +
+ +
    + {#each workspace.trash as item, index (item.id)} + +
  • (workspace.trashCursor = index)}> + {item.kind} + +
    + {item.name.trim() || item.slug} + + {item.from || 'the root'} · deleted {relative(item.deleted_at)}{item.inside > 0 ? ` · ${item.inside} inside` : ''} · + gone in {days(item.purge_at)}d + +
    + +
    + + +
    +
  • + {:else} +
  • the trash is empty.
  • + {/each} +
+ +
+ jk move + + enterr restore + + x delete for good + + qesc close +
+
+ + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 183aecd..bac7b54 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -23,7 +23,7 @@ export interface NodeSummary { excerpt: string position: number has_board: boolean - /** Spaces the app has to tell apart: `diary`, or nothing. */ + /** Nodes the app has to tell apart: `diary`, `trash`, or nothing. */ role: string | null child_count: number created_at: number @@ -78,6 +78,21 @@ export interface SshKey { vault: boolean } +/** Something deleted, waiting in the trash to be restored or purged. */ +export interface TrashItem { + id: number + kind: NodeKind + name: string + slug: string + /** The folder it was deleted from, `work/notes`; empty for a space. */ + from: string + deleted_at: number + /** When the sweep deletes it for good. */ + purge_at: number + /** How many nodes are inside it. */ + inside: number +} + export interface SharedDocument { name: string body: string @@ -383,8 +398,19 @@ export const api = { input: { name: string; body: string; created_at?: number; has_board?: boolean; base_updated_at?: number }, ) => request(`/nodes/${id}`, { method: 'PUT', body: body(input) }), + /** Into the trash; `purged` when it was in the trash already and is now gone. */ deleteNode: (id: number) => - request<{ ok: true; removed: number }>(`/nodes/${id}`, { method: 'DELETE' }), + request<{ ok: true; removed: number; purged: boolean }>(`/nodes/${id}`, { method: 'DELETE' }), + + trash: () => request('/trash'), + + /** Back where it was; `elsewhere` when that folder is gone and it went to its space. */ + restore: (id: number) => + request<{ node: WorkspaceNode; elsewhere: boolean }>(`/trash/${id}/restore`, { method: 'POST' }), + + purge: (id: number) => request<{ ok: true; removed: number }>(`/trash/${id}`, { method: 'DELETE' }), + + emptyTrash: () => request<{ ok: true; removed: number }>('/trash', { method: 'DELETE' }), moveNode: (id: number, parent_id: number, position?: number) => request(`/nodes/${id}/move`, { method: 'POST', body: body({ parent_id, position }) }), diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index edac305..2fb33d8 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -1,6 +1,7 @@ import { tick } from 'svelte' import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api' import { workspace } from './store.svelte' +import { emptyTrash } from './Trash.svelte' import { formatDay, parseDay, parseDue, relative, sealedNote, type Heading, type ScrollAmount, type SidewaysAmount } from './util' export type { ScrollAmount, SidewaysAmount } @@ -693,7 +694,7 @@ export const commands: CommandSpec[] = [ aliases: ['d', 'rm'], args: '[id]', group: 'documents', - help: 'delete a node, and everything under it (asks first)', + help: 'delete a node, and everything under it, into the trash (asks first) — u brings it back', run: async (arg) => { // On the board the thing under the cursor is a card, not a node. if (workspace.view === 'board') { @@ -712,7 +713,7 @@ export const commands: CommandSpec[] = [ node && isContainer(node) ? `delete the ${node.kind} ${name} and everything in it?` : `delete ${name}?` - if (!(await workspace.ask(`${what} this cannot be undone.`))) return + if (!(await workspace.ask(`${what} it goes to the trash.`))) return await workspace.guard(() => workspace.deleteNode(id)) }, }, @@ -761,7 +762,7 @@ export const commands: CommandSpec[] = [ aliases: ['rms'], args: '[name]', group: 'tree', - help: 'delete a space and everything in it (asks first)', + help: 'delete a space and everything in it, into the trash (asks first)', complete: spaceNames, run: async (arg) => { const wanted = arg.trim().toLowerCase() @@ -769,12 +770,28 @@ export const commands: CommandSpec[] = [ ? workspace.spaces.find((s) => s.slug === wanted || s.name.toLowerCase() === wanted) : workspace.space if (!space) return workspace.say(`no such space: ${wanted || '(none open)'}`, 'error') - if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? this cannot be undone.`))) { + if (!(await workspace.ask(`delete the space "${space.name}" and everything in it? it goes to the trash.`))) { return } await workspace.guard(() => workspace.deleteSpace(space.id)) }, }, + { + name: 'trash', + args: '[purge]', + group: 'tree', + help: 'what was deleted — j/k, Enter brings it back, x deletes it for good; :trash purge empties it', + complete: () => ['purge'], + run: async (arg) => { + if (arg.trim() === 'purge') return emptyTrash() + if (arg.trim()) return workspace.say('usage: :trash [purge]', 'error') + await workspace.guard(async () => { + await workspace.loadTrash() + workspace.trashCursor = 0 + workspace.overlay = 'trash' + }) + }, + }, { name: 'date', args: '', diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index b18d53c..6971d45 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -12,6 +12,7 @@ import { type MediaFile, type NodeSummary, type SpaceOverview, + type TrashItem, type VaultStatus, } from './api' import { formatBytes, isTouchDevice, sealedNote, type Heading } from './util' @@ -278,7 +279,7 @@ class Workspace { pane = $state('list') flash = $state(null) - overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline'>('none') + overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify' | 'outline' | 'trash'>('none') /** `gO`: the headings of what is on screen, to jump between from the keyboard. */ outline = $state.raw([]) outlineCursor = $state(0) @@ -305,6 +306,11 @@ class Workspace { vault = $state(null) media = $state([]) mediaCursor = $state(0) + /** What `:trash` lists: everything deleted, the last first. */ + trash = $state([]) + trashCursor = $state(0) + /** The last thing deleted here, which `u` puts back. */ + lastDeleted = $state(null) overview = $state([]) overviewCursor = $state(0) @@ -998,13 +1004,67 @@ class Workspace { for (const tile of showing) tile.stopAutosave() this.dropStash(id) const node = showing[0]?.open ?? this.nodes.find((n) => n.id === id) - const { removed } = await api.deleteNode(id) + const { removed, purged } = await api.deleteNode(id) for (const tile of showing) tile.clear() this.prune() this.marked = this.marked.filter((n) => n.id !== id) await this.refresh() const name = node ? `"${node.name.trim() || node.slug}"` : `#${id}` - this.say(removed > 1 ? `deleted ${name} and ${removed - 1} inside it` : `deleted ${name}`) + if (purged) return this.say(`deleted ${name} for good`) + this.lastDeleted = id + const what = removed > 1 ? `${name} and ${removed - 1} inside it` : name + this.say(`${what} went to the trash — u brings it back`) + } + + /** `u`: the last delete, undone. */ + async undoDelete() { + const id = this.lastDeleted + if (id === null) return this.say('nothing deleted here to undo — :trash has the rest', 'error') + await this.restore(id) + } + + /** + * Out of the trash, back where it was. A space is entered again; anything + * else in the space being browsed gets the cursor, so `u` lands on what it + * brought back. + */ + async restore(id: number) { + const { node, elsewhere } = await api.restore(id) + if (this.lastDeleted === id) this.lastDeleted = null + const name = `"${node.name.trim() || node.slug}"` + if (node.kind === 'space') { + this.spaces = await api.listSpaces() + if (this.overlay !== 'trash') await this.enterSpace(node.id) + this.say(`restored the space ${name}`) + } else { + const parent = node.path.at(-2) + if (parent && node.path[0]?.id === this.spaceId && this.view === 'tree') { + if (parent.id === this.parentId && !this.query) await this.refresh(node.id) + else if (this.overlay !== 'trash') await this.goTo(parent.id, node.id) + } + const where = node.path.slice(0, -1).map((crumb) => crumb.slug).join('/') + this.say(elsewhere ? `restored ${name} to ${where} — the folder it was in is gone` : `restored ${name} to ${where}`) + } + if (this.overlay === 'trash') await this.loadTrash() + } + + async loadTrash() { + this.trash = await api.trash() + this.trashCursor = Math.min(this.trashCursor, Math.max(this.trash.length - 1, 0)) + } + + /** Delete one thing in the trash for good, files and all. */ + async purge(id: number) { + await api.purge(id) + if (this.lastDeleted === id) this.lastDeleted = null + await this.loadTrash() + } + + async emptyTrash() { + const { removed } = await api.emptyTrash() + this.lastDeleted = null + if (this.overlay === 'trash') await this.loadTrash() + this.say(removed === 1 ? 'the trash is empty — 1 thing deleted for good' : `the trash is empty — ${removed} things deleted for good`) } /** @@ -1038,6 +1098,7 @@ class Workspace { */ async deleteSpace(id: number) { const { removed } = await api.deleteNode(id) + this.lastDeleted = id for (const tile of this.tiles) tile.clear() this.prune() this.rawBoard = null @@ -1053,7 +1114,7 @@ class Workspace { this.nodes = [] this.cursor = 0 } - this.say(`deleted the space and ${removed - 1} node(s) in it`) + this.say(`the space and ${removed - 1} node(s) in it went to the trash — u brings it back`) } /** Reparent the node under the cursor, for `:mv`. */ @@ -1443,6 +1504,18 @@ class Workspace { const space = this.spaceId if (!card || space === null) return let nodeId = card.node_id + if (nodeId !== null) { + // Deleted notes read as gone, but the card still points at them, so + // the way back to them is named rather than a bare "not found". + try { + await api.getNode(nodeId) + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return this.say(`the notes of "${card.title}" are gone — :trash may still have them`, 'error') + } + throw error + } + } if (nodeId === null) { const node = await api.createNode({ parent_id: space, name: card.title, body: '' }) await api.updateCard(card.id, { node_id: node.id })