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:
nvrl committed 2026-10-20 18:15:22 +02:00
1 parent 439d5b083b
commit 8ffad6350f
22 files changed
+1154 -78

No files matched your search

+9 -4
View File
@@ -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()
+8
View File
@@ -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
View File
@@ -120,7 +120,7 @@ async fn main() -> Result<()> {
let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?;
notify::spawn(Arc::clone(&notify));
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
View File
@@ -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)
+1 -1
View File
@@ -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))
+6 -5
View File
@@ -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()
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+601
View File
@@ -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
View File
@@ -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
View File
@@ -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