mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
a42f9074a2
commit
f629f5af97
13 files changed
+758
-9
No files matched your search
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 })))
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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' }),
|
||||||
}
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user