mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +01:00
files: a node kind of their own, in the tree beside the notes
A file node points at one media row and owns it the way a document owns
what it embeds, so folder deletes, the media browser and exports keep
working. POST /api/nodes/{id}/files streams uploads into a folder;
imports turn non-markdown files into file nodes; exports put them at
their tree path. The browser previews images, video, audio and text,
offers the rest as a download, and takes files dropped on the list.
Migration 0010 widens the kind CHECK by editing the stored schema, as
SQLite documents for loosening a constraint: sqlx runs migrations in a
transaction, where foreign keys cannot be switched off for a rebuild.
This commit is contained in:
1 parent
3d6944c860
commit
2103edc299
15 files changed
+808
-94
No files matched your search
+80
-14
@@ -23,14 +23,20 @@ struct ExportNode {
|
||||
name: String,
|
||||
slug: String,
|
||||
body: String,
|
||||
media_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a document lands in the archive: the slugs of its ancestors as
|
||||
/// directories, its own slug as the file. Slugs are already unique among
|
||||
/// siblings, so the path is unique without adding the id to it.
|
||||
fn document_path(nodes: &HashMap<i64, ExportNode>, node: &ExportNode) -> String {
|
||||
let mut segments = vec![format!("{}.md", node.slug)];
|
||||
let mut parent = node.parent_id;
|
||||
tree_path(nodes, node.parent_id, format!("{}.md", node.slug))
|
||||
}
|
||||
|
||||
/// `leaf` under the directories of a node's ancestors.
|
||||
fn tree_path(nodes: &HashMap<i64, ExportNode>, parent: Option<i64>, leaf: String) -> String {
|
||||
let mut segments = vec![leaf];
|
||||
let mut parent = parent;
|
||||
// Bounded by the number of nodes, so a parent chain that somehow loops
|
||||
// cannot spin here.
|
||||
for _ in 0..nodes.len() {
|
||||
@@ -78,15 +84,19 @@ fn day_string(seconds: i64) -> String {
|
||||
format!("{y:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
/// Uploads are stored under a uuid; the archive gives them their name back.
|
||||
/// Only characters that are safe in a path *and* need no escaping inside a
|
||||
/// markdown link survive, because the entries link to these names directly.
|
||||
fn media_filename(id: &str, original: &str) -> String {
|
||||
fn safe_name(original: &str) -> String {
|
||||
let safe: String = original
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' })
|
||||
.collect();
|
||||
let safe = safe.trim_matches(['.', '_']).to_string();
|
||||
safe.trim_matches(['.', '_']).to_string()
|
||||
}
|
||||
|
||||
/// Uploads are stored under a uuid; the archive gives them their name back.
|
||||
fn media_filename(id: &str, original: &str) -> String {
|
||||
let safe = safe_name(original);
|
||||
if safe.is_empty() {
|
||||
id.to_string()
|
||||
} else {
|
||||
@@ -94,12 +104,32 @@ fn media_filename(id: &str, original: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a file node lands: in its folder, under its own name. Two files may
|
||||
/// share a name where they can never share a slug, so the loser of a clash is
|
||||
/// told apart by its node id.
|
||||
fn file_path(
|
||||
nodes: &HashMap<i64, ExportNode>,
|
||||
node: &ExportNode,
|
||||
taken: &mut std::collections::HashSet<String>,
|
||||
) -> String {
|
||||
let safe = safe_name(&node.name);
|
||||
let name = if safe.is_empty() { node.slug.clone() } else { safe };
|
||||
let path = tree_path(nodes, node.parent_id, name.clone());
|
||||
if taken.insert(path.clone()) {
|
||||
return path;
|
||||
}
|
||||
let path = tree_path(nodes, node.parent_id, format!("{}-{name}", node.id));
|
||||
taken.insert(path.clone());
|
||||
path
|
||||
}
|
||||
|
||||
const README: &str = "\
|
||||
This is a complete copy of a ~/workspace.
|
||||
|
||||
<space>/ one directory per space, holding the same tree of folders and
|
||||
documents that the workspace shows; one markdown file per document
|
||||
media/ every file the documents embed
|
||||
<space>/ one directory per space, holding the same tree of folders,
|
||||
documents and files that the workspace shows; one markdown file
|
||||
per document, and every file under its own name
|
||||
media/ the files documents embed that are not in the tree themselves
|
||||
|
||||
Embedded media, and links from one document to another, are relative — so the
|
||||
documents render and cross-link correctly in any markdown reader as long as the
|
||||
@@ -110,7 +140,7 @@ tree stays intact. Nothing here needs the workspace application to read it.
|
||||
/// 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 FROM nodes ORDER BY created_at, id",
|
||||
"SELECT id, parent_id, kind, name, slug, body, media_id FROM nodes ORDER BY created_at, id",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await?
|
||||
@@ -123,6 +153,7 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
name: row.get("name"),
|
||||
slug: row.get("slug"),
|
||||
body: row.get("body"),
|
||||
media_id: row.get("media_id"),
|
||||
};
|
||||
(node.id, node)
|
||||
})
|
||||
@@ -150,8 +181,19 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
zip.start_file("README.txt", text)?;
|
||||
zip.write_all(README.as_bytes())?;
|
||||
|
||||
// A file node's bytes go where the node is; the rest go to media/.
|
||||
// Ordered by id, so a clash of names resolves the same way every time.
|
||||
let mut files: Vec<&ExportNode> = nodes.values().filter(|n| n.media_id.is_some()).collect();
|
||||
files.sort_by_key(|n| n.id);
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let in_tree: HashMap<String, String> = files
|
||||
.into_iter()
|
||||
.filter_map(|n| Some((n.media_id.clone()?, file_path(&nodes, n, &mut taken))))
|
||||
.collect();
|
||||
|
||||
let names: Vec<(String, String)> = media
|
||||
.iter()
|
||||
.filter(|(id, _)| !in_tree.contains_key(id))
|
||||
.map(|(id, filename)| (id.clone(), media_filename(id, filename)))
|
||||
.collect();
|
||||
|
||||
@@ -176,6 +218,9 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
for (id, name) in &names {
|
||||
body = body.replace(&format!("/api/media/{id}"), &format!("{up}media/{name}"));
|
||||
}
|
||||
for (id, target) in &in_tree {
|
||||
body = body.replace(&format!("/api/media/{id}"), &relative_link(&path, target));
|
||||
}
|
||||
for (address, target) in &addresses {
|
||||
// Matching the `)` or `#` that ends a markdown link is what
|
||||
// keeps `/n/work/a` from matching inside `/n/work/ab`.
|
||||
@@ -192,13 +237,17 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
zip.write_all(document.as_bytes())?;
|
||||
}
|
||||
|
||||
for (id, name) in &names {
|
||||
let entries = names
|
||||
.iter()
|
||||
.map(|(id, name)| (id, format!("media/{name}")))
|
||||
.chain(in_tree.iter().map(|(id, path)| (id, path.clone())));
|
||||
for (id, entry) in entries {
|
||||
// A row whose file has gone missing must not fail the whole backup.
|
||||
let Ok(bytes) = std::fs::read(uploads.join(id)) else {
|
||||
let Ok(mut source) = std::fs::File::open(uploads.join(id)) else {
|
||||
continue;
|
||||
};
|
||||
zip.start_file(format!("media/{name}"), blob)?;
|
||||
zip.write_all(&bytes)?;
|
||||
zip.start_file(entry, blob)?;
|
||||
std::io::copy(&mut source, &mut zip)?;
|
||||
}
|
||||
|
||||
let mut file = zip.finish()?;
|
||||
@@ -229,7 +278,7 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{day_string, document_path, media_filename, relative_link, ExportNode};
|
||||
use super::{day_string, document_path, file_path, media_filename, relative_link, ExportNode};
|
||||
|
||||
#[test]
|
||||
fn renders_days_without_a_date_crate() {
|
||||
@@ -251,12 +300,29 @@ mod tests {
|
||||
name: (*slug).into(),
|
||||
slug: (*slug).into(),
|
||||
body: String::new(),
|
||||
media_id: (*kind == "file").then(|| format!("m{id}")),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_keeps_its_name_and_a_clash_gets_its_id() {
|
||||
let mut nodes = tree(&[
|
||||
(1, None, "space", "uni"),
|
||||
(2, Some(1), "folder", "algo"),
|
||||
(3, Some(2), "file", "slides-pdf"),
|
||||
(4, Some(2), "file", "slides-pdf-2"),
|
||||
]);
|
||||
nodes.get_mut(&3).unwrap().name = "Slides 3.pdf".into();
|
||||
nodes.get_mut(&4).unwrap().name = "Slides 3.pdf".into();
|
||||
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
assert_eq!(file_path(&nodes, &nodes[&3], &mut taken), "uni/algo/Slides_3.pdf");
|
||||
assert_eq!(file_path(&nodes, &nodes[&4], &mut taken), "uni/algo/4-Slides_3.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_keeps_its_place_in_the_tree() {
|
||||
let nodes = tree(&[
|
||||
|
||||
+32
-21
@@ -4,7 +4,7 @@
|
||||
//! `docs/`, a folder of lecture notes, an export of this very diary. Storing it
|
||||
//! as opaque files would make it unsearchable and uneditable, so the importer
|
||||
//! reproduces the directory as nodes: directories become folders, markdown
|
||||
//! becomes documents, everything else becomes media. The links between the
|
||||
//! becomes documents, everything else becomes a file. The links between the
|
||||
//! files are rewritten as they land, because a set of documents that can no
|
||||
//! longer reach each other is a worse copy of what was uploaded.
|
||||
|
||||
@@ -25,7 +25,7 @@ use crate::{
|
||||
error::{AppError, AppResult},
|
||||
routes::{
|
||||
media,
|
||||
nodes::{self, next_slug, slugify, DOCUMENT, FOLDER},
|
||||
nodes::{self, next_slug, slugify, DOCUMENT, FILE, FOLDER},
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -434,7 +434,7 @@ pub async fn import(
|
||||
multipart: Multipart,
|
||||
) -> AppResult<Json<ImportReport>> {
|
||||
let target = nodes::load(&state, id).await?;
|
||||
if target.kind == DOCUMENT {
|
||||
if !nodes::is_container(&target.kind) {
|
||||
return Err(AppError::BadRequest(
|
||||
"a folder can only be imported into a space or a folder".into(),
|
||||
));
|
||||
@@ -597,6 +597,35 @@ async fn build(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// The file sits in the tree under the name it had, and owns its
|
||||
// bytes the way a document owns what it embeds — so deleting the
|
||||
// folder it landed in takes it along.
|
||||
let name = file_name(&upload.path);
|
||||
let taken = siblings.entry(parent).or_default();
|
||||
let slug = next_slug(taken, &slugify(name))?;
|
||||
taken.insert(slug.clone());
|
||||
|
||||
let node: i64 = sqlx::query(
|
||||
"INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at, media_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?5, ?6)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(parent)
|
||||
.bind(FILE)
|
||||
.bind(name)
|
||||
.bind(&slug)
|
||||
.bind(now)
|
||||
.bind(media_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.get("id");
|
||||
|
||||
sqlx::query("INSERT INTO node_media (node_id, media_id) VALUES (?1, ?2)")
|
||||
.bind(node)
|
||||
.bind(media_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
landed.insert(
|
||||
upload.path.clone(),
|
||||
Landed::Media(format!("/api/media/{media_id}")),
|
||||
@@ -608,7 +637,6 @@ async fn build(
|
||||
// Second pass: point the links at where things landed, and attach every
|
||||
// file a document embeds to it the way a hand-written document does.
|
||||
let mut links = 0usize;
|
||||
let mut attached: BTreeSet<String> = BTreeSet::new();
|
||||
|
||||
for (id, path, body) in &documents {
|
||||
let (rewritten, count) = rewrite_links(body, parent_of(path), &landed);
|
||||
@@ -632,23 +660,6 @@ async fn build(
|
||||
.bind(&media_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
attached.insert(media_id);
|
||||
}
|
||||
}
|
||||
|
||||
// A file nobody links to still belongs to the folder it was uploaded in,
|
||||
// or deleting that folder would leave it behind with no way back to it.
|
||||
for upload in &uploads {
|
||||
if let Some(Landed::Media(url)) = landed.get(&upload.path) {
|
||||
let media_id = url.trim_start_matches("/api/media/").to_string();
|
||||
if attached.contains(&media_id) {
|
||||
continue;
|
||||
}
|
||||
sqlx::query("INSERT OR IGNORE INTO node_media (node_id, media_id) VALUES (?1, ?2)")
|
||||
.bind(dir_ids[parent_of(&upload.path)])
|
||||
.bind(&media_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
auth::Session,
|
||||
error::{AppError, AppResult},
|
||||
routes::nodes,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
@@ -213,6 +214,109 @@ pub async fn upload(
|
||||
Ok(Json(uploaded))
|
||||
}
|
||||
|
||||
/// `POST /api/nodes/{id}/files` — every file in the body becomes a file node
|
||||
/// in that space or folder, under the name it was uploaded with.
|
||||
pub async fn upload_into(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Path(parent): Path<i64>,
|
||||
mut multipart: Multipart,
|
||||
) -> AppResult<Json<Vec<nodes::Node>>> {
|
||||
nodes::container(&state.db, parent).await?;
|
||||
|
||||
let mut created = Vec::new();
|
||||
let mut budget = state.config.max_upload_bytes as u64;
|
||||
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError::BadRequest(format!("malformed upload: {e}")))?
|
||||
{
|
||||
// A browser sends a bare name; anything path-shaped is cut to its last
|
||||
// segment, because the folder is the one in the URL.
|
||||
let filename = field
|
||||
.file_name()
|
||||
.map(|name| name.rsplit(['/', '\\']).next().unwrap_or(name).trim().to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| "upload".to_string());
|
||||
let mime = mime_of(&filename, field.content_type());
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let size = store_field(&state, &mut field, &id, &mut budget).await?;
|
||||
if size == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
match insert_file(&state.db, parent, &id, &filename, &mime, size).await {
|
||||
Ok(node) => created.push(node),
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(state.config.uploads_dir().join(&id)).await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if created.is_empty() {
|
||||
return Err(AppError::BadRequest("no files in upload".into()));
|
||||
}
|
||||
state.backup.signal();
|
||||
|
||||
let mut out = Vec::with_capacity(created.len());
|
||||
for id in created {
|
||||
out.push(nodes::load(&state, id).await?);
|
||||
}
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
/// The media row and the file node that owns it, in one transaction so the
|
||||
/// tree never shows a file whose bytes it does not know about.
|
||||
async fn insert_file(
|
||||
db: &SqlitePool,
|
||||
parent: i64,
|
||||
media_id: &str,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
size: u64,
|
||||
) -> AppResult<i64> {
|
||||
let slug = nodes::unique_slug(db, Some(parent), &nodes::slugify(filename), None).await?;
|
||||
let now = crate::now();
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO media (id, filename, mime, size, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)
|
||||
.bind(media_id)
|
||||
.bind(filename)
|
||||
.bind(mime)
|
||||
.bind(size as i64)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let id: i64 = sqlx::query(
|
||||
"INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at, media_id)
|
||||
VALUES (?1, 'file', ?2, ?3, ?4, ?4, ?5)
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(parent)
|
||||
.bind(filename)
|
||||
.bind(&slug)
|
||||
.bind(now)
|
||||
.bind(media_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.get("id");
|
||||
|
||||
sqlx::query("INSERT INTO node_media (node_id, media_id) VALUES (?1, ?2)")
|
||||
.bind(id)
|
||||
.bind(media_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT m.id, m.filename, m.mime, m.size, m.created_at,
|
||||
|
||||
@@ -38,6 +38,7 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/nodes/{id}", delete(nodes::remove))
|
||||
.route("/nodes/{id}/move", post(nodes::move_node))
|
||||
.route("/nodes/{id}/import", post(import::import))
|
||||
.route("/nodes/{id}/files", post(media::upload_into))
|
||||
.route("/nodes/{id}/share", post(share::enable))
|
||||
.route("/nodes/{id}/share", delete(share::disable))
|
||||
.route("/media", post(media::upload).get(media::list))
|
||||
|
||||
+161
-27
@@ -16,10 +16,26 @@ use crate::{
|
||||
|
||||
/// What a node is. Spaces are the roots (`diary`, `work`, `uni`, ...), folders
|
||||
/// group things inside them to any depth, documents are the leaves that hold
|
||||
/// text. One table, one recursion, so a space can nest exactly like a folder.
|
||||
/// text and files the leaves that hold anything else. One table, one recursion,
|
||||
/// so a space can nest exactly like a folder.
|
||||
pub const SPACE: &str = "space";
|
||||
pub const FOLDER: &str = "folder";
|
||||
pub const DOCUMENT: &str = "document";
|
||||
pub const FILE: &str = "file";
|
||||
|
||||
/// Whether a node of this kind can hold other nodes.
|
||||
pub fn is_container(kind: &str) -> bool {
|
||||
kind == SPACE || kind == FOLDER
|
||||
}
|
||||
|
||||
/// The bytes behind a file node, as the client needs them to show or fetch it.
|
||||
#[derive(Serialize)]
|
||||
pub struct FileInfo {
|
||||
pub media_id: String,
|
||||
pub mime: String,
|
||||
pub size: i64,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeSummary {
|
||||
@@ -36,6 +52,8 @@ pub struct NodeSummary {
|
||||
pub updated_at: i64,
|
||||
pub shared: bool,
|
||||
pub share_token: Option<String>,
|
||||
/// Files only.
|
||||
pub file: Option<FileInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -56,6 +74,7 @@ pub struct Node {
|
||||
pub updated_at: i64,
|
||||
pub shared: bool,
|
||||
pub share_token: Option<String>,
|
||||
pub file: Option<FileInfo>,
|
||||
/// This node's ancestors, the space first and the node itself last — the
|
||||
/// breadcrumb, and everything needed to build its /n/... path.
|
||||
pub path: Vec<Crumb>,
|
||||
@@ -136,8 +155,10 @@ const EXCERPT_CHARS: usize = 180;
|
||||
|
||||
/// The columns every list query selects, so a row always converts the same way.
|
||||
const SUMMARY_COLUMNS: &str = "n.id, n.parent_id, n.kind, n.name, n.slug, n.position, n.has_board,
|
||||
n.created_at, n.updated_at, n.share_token,
|
||||
(SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count";
|
||||
n.created_at, n.updated_at, n.share_token, n.media_id,
|
||||
(SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count,
|
||||
(SELECT m.mime FROM media m WHERE m.id = n.media_id) AS file_mime,
|
||||
(SELECT m.size FROM media m WHERE m.id = n.media_id) AS file_size";
|
||||
|
||||
/// A short, plain-ish preview of the body for the list pane. `head` is only the
|
||||
/// leading `HEAD_CHARS` of the document, so truncation is inferred from its
|
||||
@@ -258,6 +279,14 @@ pub async fn unique_slug(
|
||||
|
||||
fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary {
|
||||
let share_token: Option<String> = row.get("share_token");
|
||||
let file = row
|
||||
.get::<Option<String>, _>("media_id")
|
||||
.map(|media_id| FileInfo {
|
||||
url: format!("/api/media/{media_id}"),
|
||||
media_id,
|
||||
mime: row.get::<Option<String>, _>("file_mime").unwrap_or_default(),
|
||||
size: row.get::<Option<i64>, _>("file_size").unwrap_or_default(),
|
||||
});
|
||||
NodeSummary {
|
||||
id: row.get("id"),
|
||||
parent_id: row.get("parent_id"),
|
||||
@@ -272,6 +301,7 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary {
|
||||
updated_at: row.get("updated_at"),
|
||||
shared: share_token.is_some(),
|
||||
share_token,
|
||||
file,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,17 +522,7 @@ pub async fn create(
|
||||
)));
|
||||
}
|
||||
|
||||
let parent_kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1")
|
||||
.bind(input.parent_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?
|
||||
.get("kind");
|
||||
if parent_kind == DOCUMENT {
|
||||
return Err(AppError::BadRequest(
|
||||
"a document cannot contain other nodes".into(),
|
||||
));
|
||||
}
|
||||
container(&state.db, input.parent_id).await?;
|
||||
|
||||
let now = crate::now();
|
||||
let created_at = input.created_at.unwrap_or(now);
|
||||
@@ -565,6 +585,17 @@ pub async fn update(
|
||||
return Err(AppError::BadRequest("only a space has a board".into()));
|
||||
}
|
||||
|
||||
// A file has no text, only a name, and the name is also what a download
|
||||
// of it is called.
|
||||
let body = if kind == FILE { "" } else { input.body.as_str() };
|
||||
if kind == FILE {
|
||||
sqlx::query("UPDATE media SET filename = ?1 WHERE id = (SELECT media_id FROM nodes WHERE id = ?2)")
|
||||
.bind(name)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let affected = sqlx::query(
|
||||
"UPDATE nodes
|
||||
SET name = ?1, body = ?2, slug = ?3, updated_at = ?4,
|
||||
@@ -573,7 +604,7 @@ pub async fn update(
|
||||
WHERE id = ?7",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(&input.body)
|
||||
.bind(body)
|
||||
.bind(&slug)
|
||||
.bind(crate::now())
|
||||
.bind(input.created_at)
|
||||
@@ -587,7 +618,9 @@ pub async fn update(
|
||||
return Err(AppError::NotFound);
|
||||
}
|
||||
|
||||
media::link_to_node(&state.db, id, &input.body).await?;
|
||||
if kind != FILE {
|
||||
media::link_to_node(&state.db, id, body).await?;
|
||||
}
|
||||
state.backup.signal();
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
@@ -635,17 +668,7 @@ pub async fn move_node(
|
||||
return Err(AppError::BadRequest("a node cannot contain itself".into()));
|
||||
}
|
||||
|
||||
let target_kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1")
|
||||
.bind(input.parent_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?
|
||||
.get("kind");
|
||||
if target_kind == DOCUMENT {
|
||||
return Err(AppError::BadRequest(
|
||||
"a document cannot contain other nodes".into(),
|
||||
));
|
||||
}
|
||||
container(&state.db, input.parent_id).await?;
|
||||
|
||||
// Moving a folder into its own subtree would cut that subtree loose from
|
||||
// every root, where nothing could reach it again.
|
||||
@@ -688,6 +711,22 @@ pub async fn move_node(
|
||||
load(&state, id).await.map(Json)
|
||||
}
|
||||
|
||||
/// Refuse anything but a space or a folder as the place a node goes into.
|
||||
pub async fn container(db: &SqlitePool, id: i64) -> AppResult<()> {
|
||||
let kind: String = sqlx::query("SELECT kind FROM nodes WHERE id = ?1")
|
||||
.bind(id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound)?
|
||||
.get("kind");
|
||||
if !is_container(&kind) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"a {kind} cannot contain other nodes"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A node and everything beneath it, the node itself first.
|
||||
pub async fn descendants(db: &SqlitePool, id: i64) -> AppResult<Vec<i64>> {
|
||||
let rows = sqlx::query(
|
||||
@@ -758,6 +797,7 @@ pub async fn load(state: &AppState, id: i64) -> AppResult<Node> {
|
||||
updated_at: summary.updated_at,
|
||||
shared: summary.shared,
|
||||
share_token: summary.share_token,
|
||||
file: summary.file,
|
||||
path: path_of(&state.db, id).await?,
|
||||
})
|
||||
}
|
||||
@@ -816,6 +856,100 @@ mod tests {
|
||||
assert_eq!(slugs, ["b", "c"]);
|
||||
}
|
||||
|
||||
/// 0010 widens a CHECK by editing the stored schema. Every connection —
|
||||
/// not just the one that ran it — has to accept a file node afterwards,
|
||||
/// the database has to stay intact, and a file an old import hung on a
|
||||
/// folder has to come out as a file node in that folder.
|
||||
#[tokio::test]
|
||||
async fn files_join_the_tree_on_a_database_that_predates_them() {
|
||||
use sqlx::{sqlite::SqliteConnectOptions, Connection, Executor};
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect_with(
|
||||
SqliteConnectOptions::new()
|
||||
.filename(dir.path().join("t.db"))
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true),
|
||||
)
|
||||
.await
|
||||
.expect("a database");
|
||||
|
||||
let mut before = sqlx::migrate!("./migrations");
|
||||
before.migrations = before.migrations.iter().filter(|m| m.version < 10).cloned().collect();
|
||||
before.run(&db).await.expect("migrations up to 0009");
|
||||
|
||||
let folder: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at)
|
||||
VALUES ((SELECT id FROM nodes WHERE slug = 'uni'), 'folder', 'algo', 'algo', 1, 1)
|
||||
RETURNING id",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("a folder");
|
||||
db.execute(
|
||||
"INSERT INTO media (id, filename, mime, size, created_at)
|
||||
VALUES ('0a1b2c3d-0000-0000-0000-000000000000', 'slides.pdf', 'application/pdf', 3, 1)",
|
||||
)
|
||||
.await
|
||||
.expect("media");
|
||||
sqlx::query("INSERT INTO node_media (node_id, media_id) VALUES (?1, '0a1b2c3d-0000-0000-0000-000000000000')")
|
||||
.bind(folder)
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("hung on the folder");
|
||||
|
||||
// Opened before the migration, so it holds the schema as it was.
|
||||
let mut stale = db.acquire().await.expect("a second connection");
|
||||
stale.ping().await.expect("ping");
|
||||
|
||||
sqlx::migrate!("./migrations").run(&db).await.expect("0010 applies");
|
||||
|
||||
let (name, slug, owned): (String, String, i64) = sqlx::query_as(
|
||||
"SELECT n.name, n.slug, (SELECT COUNT(*) FROM node_media nm WHERE nm.node_id = n.id)
|
||||
FROM nodes n WHERE n.kind = 'file' AND n.parent_id = ?1",
|
||||
)
|
||||
.bind(folder)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("the adopted file");
|
||||
assert_eq!((name.as_str(), slug.as_str(), owned), ("slides.pdf", "file-0a1b2c3d", 1));
|
||||
let on_folder: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM node_media WHERE node_id = ?1")
|
||||
.bind(folder)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("count");
|
||||
assert_eq!(on_folder, 0);
|
||||
|
||||
db.execute("INSERT INTO media (id, filename, mime, size, created_at) VALUES ('m2', 'b.zip', 'application/octet-stream', 1, 1)")
|
||||
.await
|
||||
.expect("media");
|
||||
sqlx::query(
|
||||
"INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at, media_id)
|
||||
VALUES (?1, 'file', 'b.zip', 'b-zip', 1, 1, 'm2')",
|
||||
)
|
||||
.bind(folder)
|
||||
.execute(&mut *stale)
|
||||
.await
|
||||
.expect("the stale connection accepts a file node");
|
||||
|
||||
// A file without bytes, or bytes on a document, is refused.
|
||||
assert!(sqlx::query(
|
||||
"INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at)
|
||||
VALUES (?1, 'file', 'x', 'x', 1, 1)",
|
||||
)
|
||||
.bind(folder)
|
||||
.execute(&db)
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
let check: String = sqlx::query_scalar("PRAGMA integrity_check").fetch_one(&db).await.expect("check");
|
||||
assert_eq!(check, "ok");
|
||||
let dangling = sqlx::query("PRAGMA foreign_key_check").fetch_all(&db).await.expect("fk check");
|
||||
assert!(dangling.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_markdown_furniture() {
|
||||
let body = "# Title\n\n\n\n> a quote\n- a point";
|
||||
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
//! The virtual filesystem an SFTP client sees: `/<space>/<folder>/.../<doc>.md`.
|
||||
//! Directories are spaces and folders; the files that persist are documents,
|
||||
//! always `.md`. There is no on-disk mirror — every call reads or writes
|
||||
//! always `.md`. File nodes are not shown here yet: a folder holding only those
|
||||
//! lists as empty, and refuses `rmdir` because it is not. There is no on-disk mirror — every call reads or writes
|
||||
//! `nodes` directly, so a save here is a save in the browser too.
|
||||
//!
|
||||
//! A document reads as `# <title>` followed by its body, the same shape the
|
||||
@@ -214,7 +215,7 @@ 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 <> 'document'",
|
||||
WHERE parent_id IS ?1 AND slug = ?2 AND kind IN ('space', 'folder')",
|
||||
)
|
||||
.bind(parent)
|
||||
.bind(slug)
|
||||
@@ -383,7 +384,7 @@ 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 ORDER BY kind, slug"
|
||||
WHERE parent_id IS ?1 AND kind <> 'file' ORDER BY kind, slug"
|
||||
))
|
||||
.bind(parent)
|
||||
.fetch_all(&self.db)
|
||||
|
||||
Reference in new issue
Block a user