From 9749c500cfa7bbb323d7980ad39c95fa789eae84 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 20 Oct 2026 06:02:16 +0200 Subject: [PATCH] docs, help, checks: the README and the help say what the app does README: the API table lists every route, twelve of which were missing; an SFTP section says how to turn the mount on and register a key; the keys table and the command list cover what was only in the code (digits, Tab, w, f, Alt-h/l, Ctrl-D/U, m/p, :today, :edit, :next and the rest, :search!, :set clipboard, :logout!). Three claims that were wrong are fixed: there is no board button above the list, R does not rename a space, and Esc/q close the document. Help gains the agenda keys and the board's gg/G and r. :t no longer means :today, which read as the agenda's key. make check runs cargo fmt --check, after the one formatting commit before this, which .git-blame-ignore-revs names (git config blame.ignoreRevsFile .git-blame-ignore-revs). Cargo.toml states rust-version 1.96, the floor the Proton crates set, and the build is checked on it. make build installs the frontend from the lockfile. A test reads the route table out of routes/mod.rs and asks every route without a cookie: all but the login, the health check and the share links must answer 401, including any route added later. --- .git-blame-ignore-revs | 1 + Cargo.toml | 2 + Makefile | 3 +- README.md | 60 ++++++++++++++++++--- src/auth.rs | 32 +---------- src/config.rs | 36 +++++++++++++ src/routes/mod.rs | 114 ++++++++++++++++++++++++++++++++++++++++ web/src/lib/Help.svelte | 20 +++++++ web/src/lib/commands.ts | 1 - 9 files changed, 229 insertions(+), 40 deletions(-) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..1470f44 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1 @@ +e0f0ebb64693dcdd5867d87854ea1506d0ff8bce diff --git a/Cargo.toml b/Cargo.toml index eaaee71..451c839 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ name = "narl-workspace" version = "0.1.0" edition = "2021" +# The Proton crates set the floor. +rust-version = "1.96" description = "A terminal-themed personal diary for the browser" [dependencies] diff --git a/Makefile b/Makefile index 4fc7259..06a0562 100644 --- a/Makefile +++ b/Makefile @@ -9,13 +9,14 @@ dev-web: # Everything, as one self-contained release binary at target/release/narl-workspace. build: - cd web && bun install && bun run build + cd web && bun install --frozen-lockfile && bun run build cargo build --release run: build ./target/release/narl-workspace check: + cargo fmt --check cargo clippy --all-targets -- -D warnings cargo test cd web && bun run check diff --git a/README.md b/README.md index eed878c..15032c2 100644 --- a/README.md +++ b/README.md @@ -63,18 +63,26 @@ it; in the document pane it is that document. In the browse pane: | key | action | | --- | --- | -| `j` / `k` | move down / up | -| `gg` / `G` | first / last entry | +| `1` … `9`, `0` | switch to that space | +| `Shift-1` … `9` | send the marked or selected node to that space | +| `Tab`, `Shift-Tab` | next / previous space | +| `w` | overview of every space; `hjkl` then `Enter` | +| `f` | fullscreen the focused pane | +| `Alt-h` / `Alt-l` | focus the list / the document, from the editor too | +| `j` / `k` | move down / up, or scroll the document when it has focus | +| `gg` / `G` | first / last entry, or top / bottom of the document | +| `Ctrl-D` / `Ctrl-U` | half a page down / up the open document | | `Enter`, `l` | open the highlighted entry | | `o` | a new document here, straight into insert mode | | `O` | a new folder here | | `i`, `a` | edit the open entry | -| `R` | rename what is highlighted — document, folder or space | +| `R` | rename what is highlighted — a document, a folder or a file (`:name!` renames the space) | +| `m` / `p` | pick a node up, then drop it into the folder you walk to | | `b` | the board of this space | | `t` | the agenda: what is late, due this week, or in hand, on every board | | `T` | today's diary entry, started if there is none | | `c` | capture a card into this space's inbox without leaving where you are | -| `Esc`, `q` | back out to the document | +| `Esc`, `q` | close the document | | `h` | up one level in the tree | | `/` | full-text search in this space (`n` clears it) | | `Ctrl-P` | full-text search across every space; `Enter` goes to the match | @@ -88,7 +96,13 @@ Inside the editor the full vim keymap is live — `w`/`b`, `dd`, `ciw`, visual mode, macros, `:w` to write. `:help` lists every ex command; the useful ones are `:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`, `:upload`, `:media`, `:search`, `:backup` and -`:set novim`. The tree has its own: `:space [name]` switches (`:space! ` +`:set novim`. 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, `:delete [id]` deletes, `:copy` puts the whole document on the clipboard, +`:clear` drops the search, `: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 and `:logout!` every session. The tree has its own: `:space [name]` switches (`:space! ` makes one, `:rmspace [name]` deletes one and 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 @@ -108,12 +122,35 @@ has today's entry, an upload into the folder, a search across every space, and on the board the card moves, renames and deletes; the agenda has a done button per card. +## The SFTP mount + +The workspace can be mounted as a filesystem over SFTP, so an editor or a file +manager on another machine works on it directly. It is off until +`WORKSPACE_SFTP_BIND` is set (`127.0.0.1:2222`, or `0.0.0.0:2222` inside a +container, with the port published in `compose.override.yml`). Only public +keys get in, and only the ones registered from inside the workspace: + +```text +:sshkey add laptop ssh-ed25519 AAAA… you@laptop +:sshkey lists them, with their fingerprints +:sshkey rm laptop and this one is out, mounted or not +``` + +```sh +sshfs -p 2222 me@your-host:/ ~/workspace # the user name is ignored +``` + +The root holds the spaces, `mkdir` there makes one, and anywhere else a +folder. A document is `.md`, read as `# Title` followed by its body, and a +leading heading written back renames it. The server's host key is made on first +start as `sftp_host_key` in the data directory. + ## Boards -`b`, the `board` button above the list, or `:board` swaps the tree for the board +`b`, the board door on the rail, or `:board` swaps the tree for the board of the space being browsed, and swaps it back. Every space has one except the diary — a journal has no backlog — and `:set board` / `:set noboard` changes -that for the space you are in (the `+board` button above the list does the +that for the space you are in (the dimmed board door on the rail does the same). A new board starts with `backlog`, `doing` and `done`, and the columns mean something by where they stand rather than by their names: the first is the inbox new cards land in, the last is done. A card is finished exactly when it @@ -492,7 +529,14 @@ sealed or not. | `GET` | `/api/export` | the whole workspace, boards and dates included, as a zip | | `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now | | `GET`/`POST` | `/api/media` | list / upload (multipart) | -| `GET`/`DELETE` | `/api/media/{id}` | serve / delete a file | +| `GET`/`DELETE` | `/api/media/{id}` | serve (with `Range`) / delete a file nothing in the tree owns | +| `GET` | `/api/spaces/overview` | every space with what was last written in it | +| `POST` | `/api/nodes/{id}/files` | upload files into this folder as file nodes (`?encrypt=true` seals them) | +| `POST` | `/api/nodes/{id}/encrypt`, `/decrypt` | seal a file, or every file under a folder, or take it out | +| `GET`/`POST`/`PUT` | `/api/vault` | status / set the password once / change it | +| `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault | +| `GET`/`POST` | `/api/ssh-keys` | the keys that may mount over SFTP / add one | +| `DELETE` | `/api/ssh-keys/{id}` | remove a key, which ends its mounts | | `GET` | `/api/share/{token}/{key}` | public: read a shared document | | `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared document | diff --git a/src/auth.rs b/src/auth.rs index 4ea0470..67ab4ab 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -219,38 +219,10 @@ mod tests { use axum::http::{HeaderMap, HeaderValue}; use super::{credentials_match, is_same_origin, issue_token, verify_token}; - use crate::config::{BackupConfig, Config, MailConfig, SftpConfig}; + use crate::config::Config; fn config(session_days: i64) -> Config { - Config { - username: "narl".into(), - password: "hunter2".into(), - secret: b"a-secret-of-at-least-16-bytes".to_vec(), - bind: "127.0.0.1:0".parse().unwrap(), - data_dir: ".".into(), - session_days, - max_upload_bytes: 1, - secure_cookie: false, - vault_idle: std::time::Duration::from_secs(60), - backup: BackupConfig { - device_name: "narl-diary".into(), - interval: None, - debounce: std::time::Duration::from_secs(0), - prune: false, - session_key_file: None, - }, - mail: MailConfig { - url: None, - from: String::new(), - to: String::new(), - timezone: chrono_tz::UTC, - reminder_at: None, - card_due_at: None, - digest_at: None, - kinds: Vec::new(), - }, - sftp: SftpConfig { bind: None }, - } + Config::for_tests(session_days) } #[test] diff --git a/src/config.rs b/src/config.rs index b47d4b9..bcb7548 100644 --- a/src/config.rs +++ b/src/config.rs @@ -311,6 +311,42 @@ impl Config { } } +#[cfg(test)] +impl Config { + /// A configuration with nothing switched on, for tests. + pub fn for_tests(session_days: i64) -> Self { + Self { + username: "narl".into(), + password: "hunter2".into(), + secret: b"a-secret-of-at-least-16-bytes".to_vec(), + bind: "127.0.0.1:0".parse().unwrap(), + data_dir: ".".into(), + session_days, + max_upload_bytes: 1, + secure_cookie: false, + vault_idle: std::time::Duration::from_secs(60), + backup: BackupConfig { + device_name: "narl-diary".into(), + interval: None, + debounce: std::time::Duration::from_secs(0), + prune: false, + session_key_file: None, + }, + mail: MailConfig { + url: None, + from: String::new(), + to: String::new(), + timezone: chrono_tz::UTC, + reminder_at: None, + card_due_at: None, + digest_at: None, + kinds: Vec::new(), + }, + sftp: SftpConfig { bind: None }, + } + } +} + impl MailConfig { fn from_env() -> Result { let url = var("SMTP_URL"); diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 168ca03..d141a8e 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -89,3 +89,117 @@ pub fn api_router(max_upload: usize) -> Router { .route("/ssh-keys/{id}", delete(ssh_keys::remove)) .layer(DefaultBodyLimit::max(JSON_LIMIT)) } + +#[cfg(test)] +mod tests { + use std::sync::{atomic::AtomicI64, Arc}; + + use axum::{body::Body, http::Request}; + use regex::Regex; + use tower::ServiceExt; + + use crate::{backup::Backup, config::Config, notify::Notifier, state::AppState, vault::Vault}; + + /// The routes that answer someone who has not logged in: to log in, to + /// say the server is up, and the share links, which carry their own key. + const PUBLIC: &[&str] = &[ + "/health", + "/login", + "/logout", + "/share/{token}/{key}", + "/share/{token}/{key}/media/{id}", + ]; + + async fn state() -> AppState { + let db = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::migrate!("./migrations").run(&db).await.unwrap(); + let config = Arc::new(Config::for_tests(30)); + AppState { + vault: Vault::new(db.clone(), config.vault_idle), + backup: Backup::unavailable(db.clone(), Arc::clone(&config), "tests".into()), + notify: Notifier::new(db.clone(), Arc::clone(&config)).unwrap(), + login_throttle: Arc::default(), + vault_throttle: Arc::default(), + epoch: Arc::new(AtomicI64::new(0)), + config, + db, + } + } + + /// Every route this file registers, read from the file itself, so a + /// route added later is covered without anyone remembering to. + fn routes() -> Vec<(String, String)> { + let path = Regex::new(r#"^\s*"([^"]+)""#).unwrap(); + let method = Regex::new(r"\b(get|post|put|delete)\(").unwrap(); + let source = include_str!("mod.rs"); + let source = &source[..source.find("#[cfg(test)]").unwrap()]; + let mut found = Vec::new(); + // `.route(...)` up to its matching bracket, however rustfmt wrapped it. + for (at, _) in source.match_indices(".route(") { + let rest = &source[at + ".route(".len()..]; + let mut depth = 1; + let close = rest + .char_indices() + .find(|&(_, c)| { + depth += match c { + '(' => 1, + ')' => -1, + _ => 0, + }; + depth == 0 + }) + .map(|(i, _)| i) + .unwrap(); + let call = &rest[..close]; + let Some(caps) = path.captures(call) else { + continue; + }; + for m in method.captures_iter(call) { + found.push((m[1].to_uppercase(), caps[1].to_string())); + } + } + found + } + + #[tokio::test] + async fn every_route_but_the_public_ones_wants_a_session() { + let routes = routes(); + assert!( + routes.len() > 40, + "the route table was not read: {routes:?}" + ); + let app = super::api_router(1024 * 1024).with_state(state().await); + + for (method, path) in routes { + let uri = path + .replace("{id}", "1") + .replace("{token}", "t") + .replace("{key}", "k"); + let response = app + .clone() + .oneshot( + Request::builder() + .method(method.as_str()) + .uri(&uri) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status().as_u16(); + if PUBLIC.contains(&path.as_str()) { + assert_ne!(status, 401, "{method} {path} is public and answered 401"); + } else { + assert_eq!( + status, 401, + "{method} {path} answered {status} without a session" + ); + } + } + } +} diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 4e0672f..bbe7b61 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -41,6 +41,7 @@ ['rule', 'the first list is the inbox, the last list is done'], ['h / l', 'to the list left / right'], ['j / k', 'down / up the cards'], + ['gg / G', 'first / last card of the list'], ['J / K', 'move the card down / up its list'], ['H / L', 'move the card to the list left / right'], ['Space', 'into the done list, or back out to the inbox'], @@ -49,10 +50,19 @@ ['D', 'due date: fri, tm, +3d, 10-31 …'], ['R', 'rename the card, or the list when it is empty'], ['z', 'show done cards older than a week'], + ['r', 'read the board again, for changes made elsewhere'], ['x · dd', 'delete the card (asks y/N)'], ['q · Esc', 'back to the tree'], ] + const agendaKeys: [string, string][] = [ + ['j / k', 'down / up the cards'], + ['Enter', 'to the card, on its own board'], + ['Space', 'done, where it stands'], + ['T', "today's diary entry"], + ['t · q · Esc', 'close the agenda'], + ] + const titles: Record = { tree: 'spaces & folders', board: 'boards', @@ -99,6 +109,16 @@ +
+

agenda keys

+
+ {#each agendaKeys as [key, description] (key)} +
{key}
+
{description}
+ {/each} +
+
+ {#each grouped as [group, specs] (group)}

{titles[group]}

diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index cdc680d..c375dd2 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -387,7 +387,6 @@ export const commands: CommandSpec[] = [ }, { name: 'today', - aliases: ['t'], group: 'documents', help: "open today's diary entry, starting one if there is none yet", run: async () => {