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.
This commit is contained in:
nvrl committed 2026-10-20 23:05:17 +02:00
1 parent a42f9074a2
commit f629f5af97
13 files changed
+758 -9

No files matched your search

+41 -2
View File
@@ -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 `: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 and lines, `:search!` searches every space, `:set clipboard` / `:set
noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the noclipboard` mirrors vim yanks to the system clipboard, `:logout` ends the
session, `:logout!` every session and `:sessions rm <n>` one other. The tree has its own: `:space [name]` switches (`:space! <name>` session, `:logout!` every session and `:sessions rm <n>` one other, and
`:token` manages the tokens that capture from outside (see below). The tree has its own: `:space [name]` switches (`:space! <name>`
makes one, `:rmspace [name]` puts one in the trash with everything in it), `:mkdir <name>` makes one, `:rmspace [name]` puts one in the trash with 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
@@ -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 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. 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": "<slug>"`, 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 ## Not losing things
Every change is written to `localStorage` as it is typed and to the server a Every change is written to `localStorage` as it is typed and to the server a
@@ -689,7 +725,7 @@ both halves.
## API ## API
Everything except the login, the health check and the two share routes 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. sealed or not.
| method | path | purpose | | 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 | | `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/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 | | `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 | | `POST` | `/api/spaces/{id}/lists` | add a list |
| `PUT`/`DELETE` | `/api/lists/{id}` | rename / delete a list and its cards | | `PUT`/`DELETE` | `/api/lists/{id}` | rename / delete a list and its cards |
| `POST` | `/api/lists/{id}/cards` | add a card | | `POST` | `/api/lists/{id}/cards` | add a card |
+13
View File
@@ -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
);
+62
View File
@@ -365,6 +365,68 @@ impl OptionalFromRequestParts<AppState> 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<AppState> for Capturer {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let Some(bearer) = parts.headers.get(header::AUTHORIZATION) else {
return <Session as FromRequestParts<AppState>>::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<i64> = 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. /// 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 /// `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 /// one from a sibling subdomain, which counts as the same site — and a
+1
View File
@@ -137,6 +137,7 @@ async fn main() -> Result<()> {
login_throttle: Arc::default(), login_throttle: Arc::default(),
vault_throttle: Arc::default(), vault_throttle: Arc::default(),
proton_throttle: Arc::default(), proton_throttle: Arc::default(),
token_throttle: Arc::default(),
backup, backup,
notify, notify,
epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)), epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)),
+1 -1
View File
@@ -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. /// 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, 0, 0),
local_instant(tz, day + ChronoDuration::days(1), 0, 0), local_instant(tz, day + ChronoDuration::days(1), 0, 0),
+57 -4
View File
@@ -635,13 +635,25 @@ pub async fn create_card(
Json(input): Json<CardInput>, Json(input): Json<CardInput>,
) -> AppResult<Json<Card>> { ) -> AppResult<Json<Card>> {
let space_id = list_space(&state.db, list_id).await?; 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<i64> {
let title = input.title.trim(); let title = input.title.trim();
if title.is_empty() { if title.is_empty() {
return Err(AppError::BadRequest("a card needs a title".into())); 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 = let position: i64 =
sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 AS next FROM cards WHERE list_id = ?1") sqlx::query("SELECT COALESCE(MAX(position), -1) + 1 AS next FROM cards WHERE list_id = ?1")
.bind(list_id) .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. // A card made straight into the done list is finished as it lands.
settle(&mut tx, space_id).await?; settle(&mut tx, space_id).await?;
tx.commit().await?; tx.commit().await?;
Ok(id)
}
state.backup.signal(); /// Where a card goes that is written down from somewhere else.
card(&state.db, id).await.map(Json) 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<Inbox> {
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( pub async fn update_card(
+380
View File
@@ -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<String>,
}
#[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<AppState>,
Json(input): Json<CaptureInput>,
) -> AppResult<Json<Captured>> {
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<Captured> {
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<Captured> {
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::<Vec<_>>()
.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::<Vec<_>>()
.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<i64> = 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"
);
}
}
+6
View File
@@ -1,5 +1,6 @@
pub mod backup; pub mod backup;
pub mod boards; pub mod boards;
pub mod capture;
pub mod export; pub mod export;
pub mod import; pub mod import;
pub mod inspect; pub mod inspect;
@@ -12,6 +13,7 @@ pub mod seal;
pub mod session; pub mod session;
pub mod share; pub mod share;
pub mod ssh_keys; pub mod ssh_keys;
pub mod tokens;
pub mod trash; pub mod trash;
pub mod vault; pub mod vault;
@@ -54,6 +56,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
.route("/spaces", get(nodes::spaces).post(nodes::create_space)) .route("/spaces", get(nodes::spaces).post(nodes::create_space))
.route("/spaces/overview", get(nodes::overview)) .route("/spaces/overview", get(nodes::overview))
.route("/agenda", get(boards::agenda)) .route("/agenda", get(boards::agenda))
.route("/capture", post(capture::capture))
.route("/spaces/{id}/board", get(boards::board)) .route("/spaces/{id}/board", get(boards::board))
.route("/spaces/{id}/lists", post(boards::create_list)) .route("/spaces/{id}/lists", post(boards::create_list))
.route( .route(
@@ -107,6 +110,8 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
"/ssh-keys/{id}", "/ssh-keys/{id}",
delete(ssh_keys::remove).patch(ssh_keys::update), 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)) .layer(DefaultBodyLimit::max(JSON_LIMIT))
} }
@@ -154,6 +159,7 @@ pub(crate) mod tests {
login_throttle: Arc::default(), login_throttle: Arc::default(),
vault_throttle: Arc::default(), vault_throttle: Arc::default(),
proton_throttle: Arc::default(), proton_throttle: Arc::default(),
token_throttle: Arc::default(),
epoch: Arc::new(AtomicI64::new(0)), epoch: Arc::new(AtomicI64::new(0)),
sessions: Arc::default(), sessions: Arc::default(),
config, config,
+114
View File
@@ -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<i64>,
}
#[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<AppState>) -> AppResult<Json<Vec<TokenSummary>>> {
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<AppState>,
Json(input): Json<AddInput>,
) -> AppResult<Json<NewToken>> {
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<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<serde_json::Value>> {
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 })))
}
+2
View File
@@ -21,6 +21,8 @@ pub struct AppState {
pub vault_throttle: Arc<LoginThrottle>, pub vault_throttle: Arc<LoginThrottle>,
/// Logins to Proton from the workspace, slowed apart from the rest. /// Logins to Proton from the workspace, slowed apart from the rest.
pub proton_throttle: Arc<LoginThrottle>, pub proton_throttle: Arc<LoginThrottle>,
/// Capture tokens that do not exist, slowed apart from the login.
pub token_throttle: Arc<LoginThrottle>,
/// Sealed files. Always present; holds no key until it is unlocked. /// Sealed files. Always present; holds no key until it is unlocked.
pub vault: Arc<Vault>, pub vault: Arc<Vault>,
/// The Proton Drive mirror. Always present; dormant until it has a session. /// The Proton Drive mirror. Always present; dormant until it has a session.
+22
View File
@@ -91,6 +91,21 @@ export interface SshKey {
vault: boolean 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. */ /** What a document said before a save replaced it. */
export interface RevisionSummary { export interface RevisionSummary {
id: number id: number
@@ -582,4 +597,11 @@ export const api = {
setSshKeyVault: (id: number, vault: boolean) => setSshKeyVault: (id: number, vault: boolean) =>
request<{ ok: true }>(`/ssh-keys/${id}`, { method: 'PATCH', body: body({ vault }) }), request<{ ok: true }>(`/ssh-keys/${id}`, { method: 'PATCH', body: body({ vault }) }),
listTokens: () => request<ApiToken[]>('/tokens'),
addToken: (name: string) =>
request<ApiToken & { token: string }>('/tokens', { method: 'POST', body: body({ name }) }),
removeToken: (id: number) => request<{ ok: true }>(`/tokens/${id}`, { method: 'DELETE' }),
} }
+53
View File
@@ -153,6 +153,9 @@ const folderNames = () => [
const spaceNames = () => workspace.spaces.map((space) => space.slug) 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. */ /** `:encrypt` and `:decrypt`: a file, or everything under a folder. */
const sealTarget = (encrypt: boolean) => const sealTarget = (encrypt: boolean) =>
workspace.guard(async () => { workspace.guard(async () => {
@@ -1169,6 +1172,56 @@ export const commands: CommandSpec[] = [
workspace.say('usage: :sshkey [add <name> <key> | rm <name> | vault <name> on|off]', 'error') workspace.say('usage: :sshkey [add <name> <key> | rm <name> | vault <name> on|off]', 'error')
}), }),
}, },
{
name: 'token',
args: '[add <name> | rm <name>]',
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 <name> 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 <name> | rm <name>]', 'error')
}),
},
{ {
name: 'vault', name: 'vault',
group: 'vault', group: 'vault',
+6 -2
View File
@@ -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 } this.flash = { text, kind }
if (kind === 'info') { if (kind === 'info' && !keep) {
const current = this.flash const current = this.flash
setTimeout(() => { setTimeout(() => {
if (this.flash === current) this.flash = null if (this.flash === current) this.flash = null