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.
This commit is contained in:
nvrl committed 2026-10-20 06:02:16 +02:00
1 parent e0f0ebb646
commit 9749c500cf
9 files changed
+229 -40

No files matched your search

+1
View File
@@ -0,0 +1 @@
e0f0ebb64693dcdd5867d87854ea1506d0ff8bce
+2
View File
@@ -2,6 +2,8 @@
name = "narl-workspace" name = "narl-workspace"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
# The Proton crates set the floor.
rust-version = "1.96"
description = "A terminal-themed personal diary for the browser" description = "A terminal-themed personal diary for the browser"
[dependencies] [dependencies]
+2 -1
View File
@@ -9,13 +9,14 @@ dev-web:
# Everything, as one self-contained release binary at target/release/narl-workspace. # Everything, as one self-contained release binary at target/release/narl-workspace.
build: build:
cd web && bun install && bun run build cd web && bun install --frozen-lockfile && bun run build
cargo build --release cargo build --release
run: build run: build
./target/release/narl-workspace ./target/release/narl-workspace
check: check:
cargo fmt --check
cargo clippy --all-targets -- -D warnings cargo clippy --all-targets -- -D warnings
cargo test cargo test
cd web && bun run check cd web && bun run check
+52 -8
View File
@@ -63,18 +63,26 @@ it; in the document pane it is that document. In the browse pane:
| key | action | | key | action |
| --- | --- | | --- | --- |
| `j` / `k` | move down / up | | `1` … `9`, `0` | switch to that space |
| `gg` / `G` | first / last entry | | `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 | | `Enter`, `l` | open the highlighted entry |
| `o` | a new document here, straight into insert mode | | `o` | a new document here, straight into insert mode |
| `O` | a new folder here | | `O` | a new folder here |
| `i`, `a` | edit the open entry | | `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 | | `b` | the board of this space |
| `t` | the agenda: what is late, due this week, or in hand, on every board | | `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 | | `T` | today's diary entry, started if there is none |
| `c` | capture a card into this space's inbox without leaving where you are | | `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 | | `h` | up one level in the tree |
| `/` | full-text search in this space (`n` clears it) | | `/` | full-text search in this space (`n` clears it) |
| `Ctrl-P` | full-text search across every space; `Enter` goes to the match | | `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 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`, `:w`, `:wq`, `:q!`, `:new [yyyy-mm-dd]`, `:date`, `:name`, `:share`, `:link`,
`:upload`, `:media`, `:search`, `:backup` and `:upload`, `:media`, `:search`, `:backup` and
`:set novim`. The tree has its own: `:space [name]` switches (`:space! <name>` `: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! <name>`
makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir <name>` makes one, `:rmspace [name]` deletes one and everything in it), `:mkdir <name>`
makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is makes a folder, `:cd <name|..|/>` walks it and `:mv <folder|..>` moves what is
selected, and `:import` uploads a folder into the one being browsed 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 and on the board the card moves, renames and deletes; the agenda has a done
button per card. 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 `<slug>.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 ## 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 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 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 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 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 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` | `/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/backup` | Proton Drive mirror: status / run now |
| `GET`/`POST` | `/api/media` | list / upload (multipart) | | `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}` | public: read a shared document |
| `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared document | | `GET` | `/api/share/{token}/{key}/media/{id}` | public: media inside a shared document |
+2 -30
View File
@@ -219,38 +219,10 @@ mod tests {
use axum::http::{HeaderMap, HeaderValue}; use axum::http::{HeaderMap, HeaderValue};
use super::{credentials_match, is_same_origin, issue_token, verify_token}; 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 { fn config(session_days: i64) -> Config {
Config { Config::for_tests(session_days)
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 },
}
} }
#[test] #[test]
+36
View File
@@ -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 { impl MailConfig {
fn from_env() -> Result<Self> { fn from_env() -> Result<Self> {
let url = var("SMTP_URL"); let url = var("SMTP_URL");
+114
View File
@@ -89,3 +89,117 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
.route("/ssh-keys/{id}", delete(ssh_keys::remove)) .route("/ssh-keys/{id}", delete(ssh_keys::remove))
.layer(DefaultBodyLimit::max(JSON_LIMIT)) .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"
);
}
}
}
}
+20
View File
@@ -41,6 +41,7 @@
['rule', 'the first list is the inbox, the last list is done'], ['rule', 'the first list is the inbox, the last list is done'],
['h / l', 'to the list left / right'], ['h / l', 'to the list left / right'],
['j / k', 'down / up the cards'], ['j / k', 'down / up the cards'],
['gg / G', 'first / last card of the list'],
['J / K', 'move the card down / up its list'], ['J / K', 'move the card down / up its list'],
['H / L', 'move the card to the list left / right'], ['H / L', 'move the card to the list left / right'],
['Space', 'into the done list, or back out to the inbox'], ['Space', 'into the done list, or back out to the inbox'],
@@ -49,10 +50,19 @@
['D', 'due date: fri, tm, +3d, 10-31 …'], ['D', 'due date: fri, tm, +3d, 10-31 …'],
['R', 'rename the card, or the list when it is empty'], ['R', 'rename the card, or the list when it is empty'],
['z', 'show done cards older than a week'], ['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)'], ['x · dd', 'delete the card (asks y/N)'],
['q · Esc', 'back to the tree'], ['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<Group, string> = { const titles: Record<Group, string> = {
tree: 'spaces & folders', tree: 'spaces & folders',
board: 'boards', board: 'boards',
@@ -99,6 +109,16 @@
</dl> </dl>
</section> </section>
<section class="keys">
<h2>agenda keys</h2>
<dl>
{#each agendaKeys as [key, description] (key)}
<dt>{key}</dt>
<dd>{description}</dd>
{/each}
</dl>
</section>
{#each grouped as [group, specs] (group)} {#each grouped as [group, specs] (group)}
<section> <section>
<h2>{titles[group]}</h2> <h2>{titles[group]}</h2>
-1
View File
@@ -387,7 +387,6 @@ export const commands: CommandSpec[] = [
}, },
{ {
name: 'today', name: 'today',
aliases: ['t'],
group: 'documents', group: 'documents',
help: "open today's diary entry, starting one if there is none yet", help: "open today's diary entry, starting one if there is none yet",
run: async () => { run: async () => {