From f629f5af979522b2ab9190e6255521183ac05927 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 20 Oct 2026 23:05:17 +0200 Subject: [PATCH] capture: a token writes down from outside the browser POST /api/capture takes a line of text and puts it on a card in an inbox, or at the end of today's diary entry, starting the entry when there is none yet. It answers a logged-in browser, and also a request that carries an Authorization: Bearer token from :token add, so a phone shortcut, a script or curl can capture with no tab open. A token is nwc_ and 32 random bytes. It is shown once, copied, and stored only as its SHA-256, the way a share key is. Only the capture route reads the header. The Session extractor never does, so a token opens nothing else. Wrong tokens go through the login's backoff on a throttle of their own. A removed token is refused from the next request on, because each capture looks the hash up again. A card lands where :card puts one outside the board: the inbox of the space named by "space", or else of the first space with a board. That rule now lives server-side as boards::inbox_of. The first line of the text becomes the title and the rest becomes the notes. Diary text becomes "- HH:MM text" in WORKSPACE_TIMEZONE, appended to the first document written today at the top of the diary. That is the entry :today opens. The append keeps a revision and indexes links like any other save. :token lists, adds and removes tokens, and completes their names after rm. The message that shows a new token stays in the status bar instead of fading, because it cannot be shown again. --- README.md | 43 +++- migrations/0022_tokens.sql | 13 ++ src/auth.rs | 62 ++++++ src/main.rs | 1 + src/notify/mod.rs | 2 +- src/routes/boards.rs | 61 +++++- src/routes/capture.rs | 380 ++++++++++++++++++++++++++++++++++++ src/routes/mod.rs | 6 + src/routes/tokens.rs | 114 +++++++++++ src/state.rs | 2 + web/src/lib/api.ts | 22 +++ web/src/lib/commands.ts | 53 +++++ web/src/lib/store.svelte.ts | 8 +- 13 files changed, 758 insertions(+), 9 deletions(-) create mode 100644 migrations/0022_tokens.sql create mode 100644 src/routes/capture.rs create mode 100644 src/routes/tokens.rs diff --git a/README.md b/README.md index 8691ed6..256fc19 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,8 @@ list (`:bn` and `:bp` walk the tiles), `:delete [id]` deletes, `:copy` puts the `:clear` drops the search, `:trash` lists what was deleted, `:reload` re-reads the folder, `:stats` counts words and lines, `:search!` searches every space, `:set clipboard` / `:set noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the -session, `:logout!` every session and `:sessions rm ` one other. The tree has its own: `:space [name]` switches (`:space! ` +session, `:logout!` every session and `:sessions rm ` one other, and +`:token` manages the tokens that capture from outside (see below). The tree has its own: `:space [name]` switches (`:space! ` makes one, `:rmspace [name]` puts one in the trash with everything in it), `:mkdir ` makes a folder, `:cd ` walks it and `:mv ` moves what is selected, and `:import` uploads a folder into the one being browsed @@ -241,6 +242,41 @@ the list left / right on its board, and `T` writes today's entry. The bar shows whenever either is above zero, and the agenda opens by itself once a day when something is late or due, so nothing has to be remembered to be looked at. +## Capturing from elsewhere + +A thought arrives when no tab is open. `POST /api/capture` writes it down from +anything that can send a request — a phone shortcut, a script, `curl` — with a +token in place of a session: + +```text +:token add phone makes one, shows it once and copies it +:token lists them, with when each was last used +:token rm phone and that one captures nothing from the next request on +``` + +```sh +curl -H "Authorization: Bearer nwc_…" -H 'content-type: application/json' \ + -d '{"text":"call the bank"}' https://your-host/api/capture +``` + +`"to": "card"`, the default, puts a card in an inbox: the one of the space +named by `"space": ""`, or else of the first space that has a board, +the same as `:card` outside the board. The first line is the card's title and +the rest its notes. `"to": "diary"` adds `- 14:05 call the bank` to the end of +today's entry, in `WORKSPACE_TIMEZONE`, and starts the entry when there is +none yet. The answer says where it went: `{"to":"card","id":7,"where":"work/backlog"}`. + +A token opens this one route and nothing else — it reads nothing, not even the +list of spaces — and only its SHA-256 is kept, the way a share key is. Wrong +tokens slow down like wrong passwords, on a line of their own, so guessing at +them does not hold up the login. + +On an iPhone, a Shortcut with *Ask for Input* followed by *Get Contents of +URL* does it: method `POST`, a header `Authorization` of `Bearer nwc_…`, and a +JSON body with `text` set to the input. On Android, *HTTP Shortcuts* does the +same with a text-input variable in the body. Either one goes on the home +screen, or behind the share sheet. + ## Not losing things Every change is written to `localStorage` as it is typed and to the server a @@ -689,7 +725,7 @@ both halves. ## API Everything except the login, the health check and the two share routes -requires the session cookie. Media answers `Range` requests, so a video seeks, +requires the session cookie; `/api/capture` takes a token instead. Media answers `Range` requests, so a video seeks, sealed or not. | method | path | purpose | @@ -707,6 +743,9 @@ sealed or not. | `POST` | `/api/trash/{id}/restore` | back where it was deleted from, or to its space when that folder is gone | | `GET` | `/api/spaces/{id}/board` | the lists and cards of one space | | `GET` | `/api/agenda` | open cards due within a week or in hand, on every board, and whether the diary has today | +| `POST` | `/api/capture` | `{ text, to: "card" \| "diary", space? }`, with a session or `Authorization: Bearer` a token | +| `GET`/`POST` | `/api/tokens` | the capture tokens / make one, `{ name }`, answered with the token this once | +| `DELETE` | `/api/tokens/{id}` | remove a capture token | | `POST` | `/api/spaces/{id}/lists` | add a list | | `PUT`/`DELETE` | `/api/lists/{id}` | rename / delete a list and its cards | | `POST` | `/api/lists/{id}/cards` | add a card | diff --git a/migrations/0022_tokens.sql b/migrations/0022_tokens.sql new file mode 100644 index 0000000..a5a52dd --- /dev/null +++ b/migrations/0022_tokens.sql @@ -0,0 +1,13 @@ +-- Tokens that may write into the workspace from outside a logged-in browser: +-- a phone shortcut, a script, `curl`. One per thing that captures, so losing +-- a phone means removing one row. A token opens `POST /api/capture` and +-- nothing else; the session cookie remains the only way to read anything. +CREATE TABLE api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + -- SHA-256 of the token, base64url. The token itself is shown once, when + -- it is made, and kept nowhere. + hash TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL, + last_used_at INTEGER +); diff --git a/src/auth.rs b/src/auth.rs index 47d22f0..b9e154e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -365,6 +365,68 @@ impl OptionalFromRequestParts for Session { } } +/// What a capture token is stored as: its SHA-256, the way a share key is. +/// A token is 32 random bytes, so a hash with no salt and no stretching is +/// enough — there is nothing to guess it from. +pub(crate) fn token_hash(token: &str) -> String { + use sha2::Digest; + B64.encode(Sha256::digest(token.as_bytes())) +} + +/// Who may capture: a logged-in browser, or an `Authorization: Bearer` token +/// from `:token add`. Only `POST /api/capture` takes this extractor, and +/// [`Session`] never looks at the header, so a token opens nothing else. +pub enum Capturer { + Session, + /// The `api_tokens` row. + Token(i64), +} + +impl FromRequestParts for Capturer { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let Some(bearer) = parts.headers.get(header::AUTHORIZATION) else { + return >::from_request_parts(parts, state) + .await + .map(|_| Capturer::Session); + }; + // Wrong tokens slow down the way wrong passwords do, on a line of + // their own, so a script guessing at them does not hold up the login. + let attempt = state.token_throttle.begin().map_err(|wait| { + tracing::warn!("capture token refused, {wait}s of backoff remaining"); + AppError::TooManyRequests(wait) + })?; + let token = bearer + .to_str() + .ok() + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::trim) + .unwrap_or_default(); + let found: Option = sqlx::query_scalar( + "UPDATE api_tokens SET last_used_at = ?1 WHERE hash = ?2 RETURNING id", + ) + .bind(crate::now()) + .bind(token_hash(token)) + .fetch_optional(&state.db) + .await?; + match found { + Some(id) => { + attempt.succeeded(); + Ok(Capturer::Token(id)) + } + None => { + let failures = attempt.failed(); + tracing::warn!(failures, "a capture with a token that does not exist"); + Err(AppError::Unauthorized) + } + } + } +} + /// Refuse a write that another site's page asked the browser to make. /// `SameSite=Lax` already keeps the cookie off a cross-site POST, but not off /// one from a sibling subdomain, which counts as the same site — and a diff --git a/src/main.rs b/src/main.rs index 1b2b091..f8da249 100644 --- a/src/main.rs +++ b/src/main.rs @@ -137,6 +137,7 @@ async fn main() -> Result<()> { login_throttle: Arc::default(), vault_throttle: Arc::default(), proton_throttle: Arc::default(), + token_throttle: Arc::default(), backup, notify, epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)), diff --git a/src/notify/mod.rs b/src/notify/mod.rs index c3e687d..48da8e3 100644 --- a/src/notify/mod.rs +++ b/src/notify/mod.rs @@ -509,7 +509,7 @@ fn local_instant(tz: Tz, day: NaiveDate, hour: u32, minute: u32) -> i64 { } /// Start (inclusive) and end (exclusive) of a local day, in Unix seconds. -fn day_bounds(tz: Tz, day: NaiveDate) -> (i64, i64) { +pub(crate) fn day_bounds(tz: Tz, day: NaiveDate) -> (i64, i64) { ( local_instant(tz, day, 0, 0), local_instant(tz, day + ChronoDuration::days(1), 0, 0), diff --git a/src/routes/boards.rs b/src/routes/boards.rs index eac1c94..010a5ce 100644 --- a/src/routes/boards.rs +++ b/src/routes/boards.rs @@ -635,13 +635,25 @@ pub async fn create_card( Json(input): Json, ) -> AppResult> { let space_id = list_space(&state.db, list_id).await?; + let id = insert_card(&state.db, space_id, list_id, &input).await?; + state.backup.signal(); + card(&state.db, id).await.map(Json) +} + +/// A new card at the bottom of a list, which must be on `space_id`'s board. +pub(crate) async fn insert_card( + db: &SqlitePool, + space_id: i64, + list_id: i64, + input: &CardInput, +) -> AppResult { let title = input.title.trim(); if title.is_empty() { return Err(AppError::BadRequest("a card needs a title".into())); } - linkable(&state.db, input.node_id).await?; + linkable(db, input.node_id).await?; - let mut tx = crate::db::begin_write(&state.db).await?; + let mut tx = crate::db::begin_write(db).await?; let position: i64 = sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 AS next FROM cards WHERE list_id = ?1") .bind(list_id) @@ -668,9 +680,50 @@ pub async fn create_card( // A card made straight into the done list is finished as it lands. settle(&mut tx, space_id).await?; tx.commit().await?; + Ok(id) +} - state.backup.signal(); - card(&state.db, id).await.map(Json) +/// Where a card goes that is written down from somewhere else. +pub struct Inbox { + pub space_id: i64, + pub space_slug: String, + pub list_id: i64, + pub list_name: String, +} + +/// The inbox — the first list — of the board of the space with that slug, or +/// of the first space in the switcher that has a board: the rule `:card` +/// follows away from the board, for a capture that has no screen to ask on. +pub(crate) async fn inbox_of(db: &SqlitePool, space: Option<&str>) -> AppResult { + let found: Option<(i64, String)> = sqlx::query_as( + "SELECT id, slug FROM nodes + WHERE parent_id IS NULL AND kind = 'space' AND has_board = 1 + AND (?1 IS NULL OR slug = ?1) + ORDER BY position, name + LIMIT 1", + ) + .bind(space) + .fetch_optional(db) + .await?; + let (space_id, space_slug) = found.ok_or_else(|| { + AppError::BadRequest(match space { + Some(slug) => format!("no space {slug} with a board"), + None => "no space has a board — :set board turns one on".into(), + }) + })?; + ensure_lists(db, space_id).await?; + let (list_id, list_name): (i64, String) = sqlx::query_as( + "SELECT id, name FROM board_lists WHERE space_id = ?1 ORDER BY position, id LIMIT 1", + ) + .bind(space_id) + .fetch_one(db) + .await?; + Ok(Inbox { + space_id, + space_slug, + list_id, + list_name, + }) } pub async fn update_card( diff --git a/src/routes/capture.rs b/src/routes/capture.rs new file mode 100644 index 0000000..b24a534 --- /dev/null +++ b/src/routes/capture.rs @@ -0,0 +1,380 @@ +//! Writing something down from outside the app: a phone shortcut, a script, +//! `curl`. One request, with a token from `:token add` or a session, and no +//! screen to answer questions on — so where the text goes is decided here, +//! by the same rules the app follows when it is asked the same thing. + +use axum::{extract::State, Json}; +use chrono::{TimeZone, Utc}; +use chrono_tz::Tz; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; + +use crate::{ + auth::Capturer, + error::{AppError, AppResult}, + routes::{ + boards::{self, CardInput}, + links, media, + nodes::{self, slugify, unique_slug}, + revisions, + }, + state::AppState, +}; + +#[derive(Deserialize, Serialize, Default, Clone, Copy, Debug, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum To { + /// A card in an inbox: something to do. + #[default] + Card, + /// A line in today's diary entry: something that happened. + Diary, +} + +#[derive(Deserialize)] +pub struct CaptureInput { + pub text: String, + #[serde(default)] + pub to: To, + /// The slug of the space whose inbox a card goes to. Without it, the + /// first space that has a board. + #[serde(default)] + pub space: Option, +} + +#[derive(Serialize, Debug)] +pub struct Captured { + pub to: To, + /// The card, or the diary entry. + pub id: i64, + /// Where it went, for the shortcut to say: `work/backlog`, `diary/untitled`. + #[serde(rename = "where")] + pub place: String, +} + +/// `POST /api/capture`. +pub async fn capture( + who: Capturer, + State(state): State, + Json(input): Json, +) -> AppResult> { + let text = input.text.trim(); + if text.is_empty() { + return Err(AppError::BadRequest("there is no text to capture".into())); + } + let captured = match input.to { + To::Card => card(&state.db, text, input.space.as_deref()).await?, + To::Diary => diary(&state.db, state.config.mail.timezone, text, crate::now()).await?, + }; + if let Capturer::Token(token) = who { + tracing::info!(token, to = ?captured.to, "captured into {}", captured.place); + } + state.backup.signal(); + Ok(Json(captured)) +} + +/// The first line is the card's title, and anything after it its notes. +async fn card(db: &SqlitePool, text: &str, space: Option<&str>) -> AppResult { + let inbox = boards::inbox_of(db, space).await?; + let (title, body) = text + .split_once('\n') + .map(|(title, body)| (title.trim(), body.trim())) + .unwrap_or((text, "")); + let input = CardInput { + title: title.to_string(), + body: body.to_string(), + node_id: None, + due_at: None, + }; + let id = boards::insert_card(db, inbox.space_id, inbox.list_id, &input).await?; + Ok(Captured { + to: To::Card, + id, + place: format!("{}/{}", inbox.space_slug, inbox.list_name), + }) +} + +/// `- 14:05 text`, at the end of today's entry: the first document written +/// today at the top of the diary, the one `:today` opens. With none yet, the +/// line starts one, the way `:today` would. +async fn diary(db: &SqlitePool, tz: Tz, text: &str, now: i64) -> AppResult { + let line = format!("- {} {}", clock(tz, now), item(text)); + + let mut tx = crate::db::begin_write(db).await?; + let diary: i64 = sqlx::query_scalar("SELECT id FROM nodes WHERE role = ?1") + .bind(nodes::DIARY) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| AppError::BadRequest("there is no diary to write in".into()))?; + let (start, end) = crate::notify::day_bounds(tz, crate::notify::local_day(tz, now)); + let today: Option<(i64, String)> = sqlx::query_as( + "SELECT id, body FROM nodes + WHERE parent_id = ?1 AND kind = 'document' AND created_at >= ?2 AND created_at < ?3 + ORDER BY created_at, id + LIMIT 1", + ) + .bind(diary) + .bind(start) + .bind(end) + .fetch_optional(&mut *tx) + .await?; + + let id = match today { + Some((id, body)) => { + let body = appended(&body, &line); + revisions::keep(&mut tx, id, &body, revisions::Reason::Edit).await?; + sqlx::query("UPDATE nodes SET body = ?1, updated_at = ?2 WHERE id = ?3") + .bind(&body) + .bind(now) + .bind(id) + .execute(&mut *tx) + .await?; + media::link_to_node(&mut tx, id, &body).await?; + links::index(&mut tx, id, &body).await?; + id + } + None => { + let body = format!("{line}\n"); + let slug = unique_slug(&mut tx, Some(diary), &slugify(""), None).await?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO nodes (parent_id, kind, name, slug, body, created_at, updated_at) + VALUES (?1, 'document', '', ?2, ?3, ?4, ?4) + RETURNING id", + ) + .bind(diary) + .bind(&slug) + .bind(&body) + .bind(now) + .fetch_one(&mut *tx) + .await?; + media::link_to_node(&mut tx, id, &body).await?; + links::index(&mut tx, id, &body).await?; + id + } + }; + tx.commit().await?; + + let place = nodes::path_of(db, id) + .await? + .iter() + .map(|crumb| crumb.slug.as_str()) + .collect::>() + .join("/"); + Ok(Captured { + to: To::Diary, + id, + place, + }) +} + +/// `HH:MM` on the wall clock of the configured timezone. +fn clock(tz: Tz, at: i64) -> String { + Utc.timestamp_opt(at, 0) + .single() + .unwrap_or_else(Utc::now) + .with_timezone(&tz) + .format("%H:%M") + .to_string() +} + +/// Text of several lines, indented to stay inside its list item. +fn item(text: &str) -> String { + text.lines() + .map(str::trim_end) + .collect::>() + .join("\n ") +} + +/// `body` with `line` after it. A list item right after a paragraph would be +/// read as part of it in some renderers, so a blank line goes between them; +/// after another item it continues the list. +fn appended(body: &str, line: &str) -> String { + let body = body.trim_end(); + if body.is_empty() { + return format!("{line}\n"); + } + let last = body.lines().last().unwrap_or_default(); + let gap = if last.trim_start().starts_with("- ") || last.starts_with(" ") { + "\n" + } else { + "\n\n" + }; + format!("{body}{gap}{line}\n") +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{header, Request, StatusCode}, + Router, + }; + use chrono::TimeZone; + use chrono_tz::Europe::Berlin; + use tower::ServiceExt; + + use super::{appended, card, diary, To}; + use crate::{auth::token_hash, state::AppState}; + + const TOKEN: &str = "nwc_a-token-for-the-tests"; + + async fn state() -> AppState { + let state = crate::routes::tests::state_in(".".into()).await; + sqlx::query("INSERT INTO api_tokens (name, hash, created_at) VALUES ('phone', ?1, 0)") + .bind(token_hash(TOKEN)) + .execute(&state.db) + .await + .unwrap(); + state + } + + async fn call(app: &Router, uri: &str, token: &str, body: &str) -> (StatusCode, String) { + let request = Request::builder() + .method(if body.is_empty() { "GET" } else { "POST" }) + .uri(uri) + .header("content-type", "application/json") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::from(body.to_string())) + .unwrap(); + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + (status, String::from_utf8_lossy(&body).into_owned()) + } + + #[tokio::test] + async fn a_token_captures_and_opens_nothing_else() { + let state = state().await; + let app = crate::routes::api_router(1024).with_state(state.clone()); + + let (status, answer) = call(&app, "/capture", TOKEN, r#"{"text":"buy milk"}"#).await; + assert_eq!(status, StatusCode::OK, "{answer}"); + assert!(answer.contains(r#""where":"work/backlog""#), "{answer}"); + let used: Option = sqlx::query_scalar("SELECT last_used_at FROM api_tokens") + .fetch_one(&state.db) + .await + .unwrap(); + assert!(used.is_some()); + + for uri in ["/nodes", "/spaces", "/tokens", "/me"] { + assert_eq!( + call(&app, uri, TOKEN, "").await.0, + StatusCode::UNAUTHORIZED, + "{uri}" + ); + } + } + + #[tokio::test] + async fn a_wrong_token_is_slowed_down_and_a_removed_one_refused() { + let state = state().await; + let app = crate::routes::api_router(1024).with_state(state.clone()); + let text = r#"{"text":"x"}"#; + + for _ in 0..crate::throttle::FREE_ATTEMPTS + 1 { + let (status, _) = call(&app, "/capture", "nwc_guess", text).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + } + assert_eq!( + call(&app, "/capture", "nwc_guess", text).await.0, + StatusCode::TOO_MANY_REQUESTS + ); + + // A fresh line, so the wait above does not stand in for the refusal. + let app = crate::routes::api_router(1024).with_state(AppState { + token_throttle: Default::default(), + ..state.clone() + }); + assert_eq!(call(&app, "/capture", TOKEN, text).await.0, StatusCode::OK); + sqlx::query("DELETE FROM api_tokens") + .execute(&state.db) + .await + .unwrap(); + assert_eq!( + call(&app, "/capture", TOKEN, text).await.0, + StatusCode::UNAUTHORIZED + ); + } + + #[tokio::test] + async fn a_card_lands_in_the_inbox_it_names() { + let state = state().await; + let first = card(&state.db, "call the bank\nabout the card", None) + .await + .unwrap(); + assert_eq!((first.to, first.place.as_str()), (To::Card, "work/backlog")); + let named = card(&state.db, "read chapter 3", Some("uni")) + .await + .unwrap(); + assert_eq!(named.place, "uni/backlog"); + + let (title, body): (String, String) = + sqlx::query_as("SELECT title, body FROM cards WHERE id = ?1") + .bind(first.id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!( + (title.as_str(), body.as_str()), + ("call the bank", "about the card") + ); + + // The diary has no board, and a slug nobody has is not a guess. + for space in ["diary", "nowhere"] { + assert!(card(&state.db, "x", Some(space)).await.is_err(), "{space}"); + } + } + + #[tokio::test] + async fn the_diary_starts_todays_entry_then_adds_to_it() { + let state = state().await; + let at = |h, m| { + Berlin + .with_ymd_and_hms(2026, 10, 20, h, m, 0) + .unwrap() + .timestamp() + }; + + let first = diary(&state.db, Berlin, "woke up early", at(7, 5)) + .await + .unwrap(); + assert!(first.place.starts_with("diary/"), "{}", first.place); + let second = diary(&state.db, Berlin, "lunch\nwith Anna", at(12, 30)) + .await + .unwrap(); + assert_eq!(first.id, second.id); + // Past midnight in Berlin, though not yet in UTC: the next day. + let tomorrow = diary(&state.db, Berlin, "late", at(23, 59) + 120) + .await + .unwrap(); + assert_ne!(tomorrow.id, first.id); + + let body: String = sqlx::query_scalar("SELECT body FROM nodes WHERE id = ?1") + .bind(first.id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(body, "- 07:05 woke up early\n- 12:30 lunch\n with Anna\n"); + let found: i64 = + sqlx::query_scalar("SELECT count(*) FROM nodes_fts WHERE nodes_fts MATCH 'anna'") + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(found, 1); + } + + #[test] + fn a_line_goes_after_a_list_or_a_blank_line_after_prose() { + assert_eq!(appended("", "- 10:00 x"), "- 10:00 x\n"); + assert_eq!( + appended("- 09:00 a\n", "- 10:00 x"), + "- 09:00 a\n- 10:00 x\n" + ); + assert_eq!( + appended("A long day.\n\n", "- 10:00 x"), + "A long day.\n\n- 10:00 x\n" + ); + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index a0dbaec..9a430ef 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,5 +1,6 @@ pub mod backup; pub mod boards; +pub mod capture; pub mod export; pub mod import; pub mod inspect; @@ -12,6 +13,7 @@ pub mod seal; pub mod session; pub mod share; pub mod ssh_keys; +pub mod tokens; pub mod trash; pub mod vault; @@ -54,6 +56,7 @@ pub fn api_router(max_upload: usize) -> Router { .route("/spaces", get(nodes::spaces).post(nodes::create_space)) .route("/spaces/overview", get(nodes::overview)) .route("/agenda", get(boards::agenda)) + .route("/capture", post(capture::capture)) .route("/spaces/{id}/board", get(boards::board)) .route("/spaces/{id}/lists", post(boards::create_list)) .route( @@ -107,6 +110,8 @@ pub fn api_router(max_upload: usize) -> Router { "/ssh-keys/{id}", delete(ssh_keys::remove).patch(ssh_keys::update), ) + .route("/tokens", get(tokens::list).post(tokens::add)) + .route("/tokens/{id}", delete(tokens::remove)) .layer(DefaultBodyLimit::max(JSON_LIMIT)) } @@ -154,6 +159,7 @@ pub(crate) mod tests { login_throttle: Arc::default(), vault_throttle: Arc::default(), proton_throttle: Arc::default(), + token_throttle: Arc::default(), epoch: Arc::new(AtomicI64::new(0)), sessions: Arc::default(), config, diff --git a/src/routes/tokens.rs b/src/routes/tokens.rs new file mode 100644 index 0000000..ec318db --- /dev/null +++ b/src/routes/tokens.rs @@ -0,0 +1,114 @@ +//! Capture tokens: what a phone shortcut or a script sends to +//! `POST /api/capture` instead of a session. See [`crate::auth::Capturer`]. + +use axum::{ + extract::{Path, State}, + Json, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::{ + auth::{token_hash, Session}, + error::{AppError, AppResult}, + state::AppState, +}; + +/// Put in front of every token, so one pasted somewhere it should not be is +/// recognisable for what it is. +const PREFIX: &str = "nwc_"; + +#[derive(Serialize, sqlx::FromRow)] +pub struct TokenSummary { + pub id: i64, + pub name: String, + pub created_at: i64, + pub last_used_at: Option, +} + +#[derive(Serialize)] +pub struct NewToken { + #[serde(flatten)] + pub summary: TokenSummary, + /// The token itself, here and nowhere else: only its hash is kept. + pub token: String, +} + +#[derive(Deserialize)] +pub struct AddInput { + pub name: String, +} + +fn new_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + format!("{PREFIX}{}", B64.encode(bytes)) +} + +pub async fn list(_: Session, State(state): State) -> AppResult>> { + let tokens = sqlx::query_as( + "SELECT id, name, created_at, last_used_at FROM api_tokens ORDER BY created_at, id", + ) + .fetch_all(&state.db) + .await?; + Ok(Json(tokens)) +} + +pub async fn add( + _: Session, + State(state): State, + Json(input): Json, +) -> AppResult> { + let name = input.name.trim(); + if name.is_empty() { + return Err(AppError::BadRequest("a token needs a name".into())); + } + let token = new_token(); + let now = crate::now(); + let id: i64 = sqlx::query_scalar( + "INSERT INTO api_tokens (name, hash, created_at) VALUES (?1, ?2, ?3) RETURNING id", + ) + .bind(name) + .bind(token_hash(&token)) + .bind(now) + .fetch_one(&state.db) + .await + .map_err(|e| match e { + sqlx::Error::Database(db) if db.is_unique_violation() => { + AppError::BadRequest(format!("there is a token called {name} already")) + } + e => e.into(), + })?; + tracing::info!(token = id, "capture token added"); + state.backup.signal(); + Ok(Json(NewToken { + summary: TokenSummary { + id, + name: name.to_string(), + created_at: now, + last_used_at: None, + }, + token, + })) +} + +/// Refused from the next request on: every capture looks the hash up again. +pub async fn remove( + _: Session, + State(state): State, + Path(id): Path, +) -> AppResult> { + let affected = sqlx::query("DELETE FROM api_tokens WHERE id = ?1") + .bind(id) + .execute(&state.db) + .await? + .rows_affected(); + if affected == 0 { + return Err(AppError::NotFound); + } + tracing::info!(token = id, "capture token removed"); + state.backup.signal(); + Ok(Json(json!({ "ok": true }))) +} diff --git a/src/state.rs b/src/state.rs index 80b11e1..7415941 100644 --- a/src/state.rs +++ b/src/state.rs @@ -21,6 +21,8 @@ pub struct AppState { pub vault_throttle: Arc, /// Logins to Proton from the workspace, slowed apart from the rest. pub proton_throttle: Arc, + /// Capture tokens that do not exist, slowed apart from the login. + pub token_throttle: Arc, /// Sealed files. Always present; holds no key until it is unlocked. pub vault: Arc, /// The Proton Drive mirror. Always present; dormant until it has a session. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1bd539e..75a45d4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -91,6 +91,21 @@ export interface SshKey { vault: boolean } +/** A token that may capture from outside the browser; the token itself is shown once. */ +export interface ApiToken { + id: number + name: string + created_at: number + last_used_at: number | null +} + +/** Where `POST /api/capture` put the text. */ +export interface Captured { + to: 'card' | 'diary' + id: number + where: string +} + /** What a document said before a save replaced it. */ export interface RevisionSummary { id: number @@ -582,4 +597,11 @@ export const api = { setSshKeyVault: (id: number, vault: boolean) => request<{ ok: true }>(`/ssh-keys/${id}`, { method: 'PATCH', body: body({ vault }) }), + + listTokens: () => request('/tokens'), + + addToken: (name: string) => + request('/tokens', { method: 'POST', body: body({ name }) }), + + removeToken: (id: number) => request<{ ok: true }>(`/tokens/${id}`, { method: 'DELETE' }), } diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index f0ad8e5..6b27180 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -153,6 +153,9 @@ const folderNames = () => [ const spaceNames = () => workspace.spaces.map((space) => space.slug) +/** The capture tokens' names, for Tab after `:token rm`; null until fetched. */ +let tokenNames: string[] | null = null + /** `:encrypt` and `:decrypt`: a file, or everything under a folder. */ const sealTarget = (encrypt: boolean) => workspace.guard(async () => { @@ -1169,6 +1172,56 @@ export const commands: CommandSpec[] = [ workspace.say('usage: :sshkey [add | rm | vault on|off]', 'error') }), }, + { + name: 'token', + args: '[add | rm ]', + group: 'view', + help: 'list, add or remove tokens that capture into the workspace from outside it — add shows the token once and copies it', + complete: (arg) => { + // Tab cannot wait for the server, so the names are fetched the first + // time it is pressed here and offered from then on. + if (tokenNames === null) { + tokenNames = [] + void api.listTokens().then((tokens) => (tokenNames = tokens.map((t) => t.name)), () => (tokenNames = null)) + } + return arg.startsWith('rm ') ? tokenNames.map((name) => `rm ${name}`) : ['add ', 'rm '] + }, + run: (arg) => + workspace.guard(async () => { + const trimmed = arg.trim() + if (trimmed === '' || trimmed === 'ls' || trimmed === 'list') { + const tokens = await api.listTokens() + tokenNames = tokens.map((t) => t.name) + if (tokens.length === 0) return workspace.say('no tokens — :token add makes one') + return workspace.say( + tokens + .map((t) => `${t.name}, ${t.last_used_at === null ? 'never used' : `used ${relative(t.last_used_at)}`}`) + .join(' · '), + ) + } + const addMatch = /^add\s+(.+)$/.exec(trimmed) + if (addMatch) { + const added = await api.addToken(addMatch[1].trim()) + tokenNames = null + const copied = await workspace.copy(added.token) + return workspace.say( + `token ${added.name}: ${added.token} — ${copied ? 'copied; ' : ''}shown this once, the server keeps only its hash`, + 'info', + true, + ) + } + const rmMatch = /^rm\s+(.+)$/.exec(trimmed) + if (rmMatch) { + const tokens = await api.listTokens() + const target = tokens.find((t) => t.name === rmMatch[1].trim()) + if (!target) return workspace.say(`no token named ${rmMatch[1].trim()}`, 'error') + await api.removeToken(target.id) + tokenNames = null + return workspace.say(`removed ${target.name} — it captures nothing from now on`) + } + workspace.say('usage: :token [add | rm ]', 'error') + }), + }, { name: 'vault', group: 'vault', diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index e353336..837ac6b 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -498,9 +498,13 @@ class Workspace { }) } - say(text: string, kind: 'info' | 'error' = 'info') { + /** + * A line in the status bar. Information fades after a few seconds, unless + * `keep`: for what is shown only once, and may have to be copied by hand. + */ + say(text: string, kind: 'info' | 'error' = 'info', keep = false) { this.flash = { text, kind } - if (kind === 'info') { + if (kind === 'info' && !keep) { const current = this.flash setTimeout(() => { if (this.flash === current) this.flash = null