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
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 <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 a folder, `:cd <name|..|/>` walks it and `:mv <folder|..|/>` 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": "<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
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 |
+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.
/// `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
+1
View File
@@ -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)),
+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.
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),
+57 -4
View File
@@ -635,13 +635,25 @@ pub async fn create_card(
Json(input): Json<CardInput>,
) -> AppResult<Json<Card>> {
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();
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<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(
+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 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<AppState> {
.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<AppState> {
"/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,
+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>,
/// Logins to Proton from the workspace, slowed apart from the rest.
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.
pub vault: Arc<Vault>,
/// The Proton Drive mirror. Always present; dormant until it has a session.
+22
View File
@@ -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<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)
/** 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 <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',
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 }
if (kind === 'info') {
if (kind === 'info' && !keep) {
const current = this.flash
setTimeout(() => {
if (this.flash === current) this.flash = null