tuipub: terminal Micropub client (IndieAuth PKCE login, compose and publish notes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Phil
2026-10-31 19:05:50 -06:00
co-authored by Claude Opus 5.5
commit d3ad760798
13 changed files with 6512 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+4351
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "tuipub"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.104"
base64 = "0.23.1"
clap = { version = "4.6.7", features = ["derive"] }
directories = "6.0.0"
keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] }
open = "5.4.5"
rand = "0.10.3"
ratatui = "0.30.2"
ratatui-textarea = "0.9.4"
reqwest = { version = "0.13.5", default-features = false, features = ["blocking", "json", "rustls", "form", "query"] }
scraper = "0.29.0"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
sha2 = "0.11.1"
thiserror = "2.0.21"
tiny_http = "0.12.0"
toml = "1.1.8"
url = "2.5.8"
[dev-dependencies]
httpmock = "0.9.0"
+61
View File
@@ -0,0 +1,61 @@
# tuipub
A terminal [Micropub](https://micropub.spec.indieweb.org/) client for posting notes to your IndieWeb site.
It logs in with [IndieAuth](https://indieauth.spec.indieweb.org/) and is built with Rust and [Ratatui](https://ratatui.rs).
It's developed against StarPunk, but sticks to the spec, so other Micropub servers should work too.
## Install
```sh
cargo install --path .
```
## Use
```sh
tuipub login example.com # opens your browser to approve access
tuipub # write and publish a note
tuipub whoami # show the account and endpoints
tuipub logout # revoke the token and forget the account
```
Running `tuipub` with no account starts the login flow first.
### Keys
| Key | Action |
| --- | --- |
| Tab / Shift-Tab | Next / previous field |
| Ctrl-S | Publish (asks to confirm) |
| Ctrl-R | Reload crosspost targets from the server |
| Ctrl-O | Open the last published post |
| Esc / Ctrl-Q | Quit (asks if there's unpublished text) |
| ↑ ↓ / j k, Space, a | Crosspost list: move, toggle, all/none |
Content is sent as written (Markdown for StarPunk). Tags are comma-separated, and a leading `#` is dropped.
## How it works
- **Discovery**: reads `rel="micropub"` and `rel="indieauth-metadata"` from your site's `Link` headers and HTML. If there's no metadata link, it falls back to `authorization_endpoint` / `token_endpoint`.
- **Login**: authorization code flow with PKCE (S256). tuipub runs a one-shot callback server on `127.0.0.1:<random port>` and uses `http://127.0.0.1:<port>/` as its `client_id`. The client ID and redirect URI share an origin, so the server needs no client registration. It checks `state` and `iss`. Scopes requested: `create media`.
- **Token storage**: the system keyring (Secret Service / gnome-keyring), keyed by your profile URL. Account settings go in `~/.config/tuipub/config.toml`.
- **Posting**: always sends a JSON body. `mp-syndicate-to` lists exactly the checked targets, and an empty list means "crosspost nowhere". If the target list can't be loaded, `mp-syndicate-to` is left out and the server's default applies (the confirm dialog says so).
### Environment
- `TUIPUB_TOKEN`: use this token instead of the keyring (e.g. one made in StarPunk's `/admin/tokens`).
- `TUIPUB_CONFIG_DIR`: use a different config directory (handy for dev servers).
## StarPunk notes
- StarPunk takes a note's title from its first line and ignores the Micropub `name` property, so the Title field has no effect there. Start the note with `# Heading` instead.
- Editing, deleting and listing posts aren't supported yet, because StarPunk's Micropub endpoint doesn't implement `update`, `delete` or `q=source` listing.
## Develop
```sh
cargo test
cargo clippy --all-targets -- -D warnings
```
To try it against a local StarPunk, start it with `DEV_MODE=true`, `SITE_URL`/`ADMIN_ME`/`DEV_ADMIN_ME=http://127.0.0.1:8099/`, then run `TUIPUB_CONFIG_DIR=/tmp/tp tuipub login 127.0.0.1:8099`. Bare `localhost` / `127.*` addresses default to `http://`.
+523
View File
@@ -0,0 +1,523 @@
//! App state and key handling. Pure logic: no terminal or network here.
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::style::{Modifier, Style};
use ratatui_textarea::{Input, Key, TextArea};
use crate::micropub::{self, Error, Note, ServerConfig, SyndicationTarget};
use crate::worker::{Request, Response};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Title,
Content,
Tags,
Syndication,
}
impl Focus {
const ORDER: [Focus; 4] = [
Focus::Title,
Focus::Content,
Focus::Tags,
Focus::Syndication,
];
fn step(self, by: isize) -> Focus {
let i = Self::ORDER.iter().position(|f| *f == self).unwrap_or(0) as isize;
Self::ORDER[(i + by).rem_euclid(Self::ORDER.len() as isize) as usize]
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Mode {
Editing,
ConfirmPublish,
Publishing,
Published(String),
ConfirmQuit,
}
/// Where the syndication target list came from.
#[derive(Debug, Clone, PartialEq)]
pub enum Targets {
Loading,
Loaded,
/// Couldn't load q=config. Publishing leaves syndication to the server default.
Failed(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Status {
Info(String),
Error(String),
}
pub struct App {
pub me: String,
pub endpoint: String,
pub title: TextArea<'static>,
pub content: TextArea<'static>,
pub tags: TextArea<'static>,
pub targets: Vec<(SyndicationTarget, bool)>,
pub targets_state: Targets,
pub target_cursor: usize,
pub focus: Focus,
pub mode: Mode,
pub status: Option<Status>,
pub last_permalink: Option<String>,
pub should_quit: bool,
/// Set when a request failed for auth reasons, so main can suggest logging in again.
pub needs_login: bool,
}
fn text_area(placeholder: &str) -> TextArea<'static> {
let mut t = TextArea::default();
t.set_placeholder_text(placeholder);
t.set_cursor_line_style(Style::default());
t
}
impl App {
pub fn new(me: String, endpoint: String) -> Self {
let mut app = App {
me,
endpoint,
title: text_area("Optional title"),
content: text_area("What's on your mind? (Markdown)"),
tags: text_area("Comma-separated, e.g. rust, indieweb"),
targets: Vec::new(),
targets_state: Targets::Loading,
target_cursor: 0,
focus: Focus::Content,
mode: Mode::Editing,
status: None,
last_permalink: None,
should_quit: false,
needs_login: false,
};
app.update_cursors();
app
}
fn update_cursors(&mut self) {
let focus = self.focus;
for (field, area) in [
(Focus::Title, &mut self.title),
(Focus::Content, &mut self.content),
(Focus::Tags, &mut self.tags),
] {
let style = if field == focus {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
area.set_cursor_style(style);
}
}
pub fn is_dirty(&self) -> bool {
[&self.title, &self.content, &self.tags]
.iter()
.any(|t| t.lines().iter().any(|l| !l.trim().is_empty()))
}
fn clear_form(&mut self) {
self.title = text_area("Optional title");
self.content = text_area("What's on your mind? (Markdown)");
self.tags = text_area("Comma-separated, e.g. rust, indieweb");
self.focus = Focus::Content;
self.update_cursors();
}
pub fn note(&self) -> Note {
let title = self.title.lines().join(" ").trim().to_string();
Note {
title: (!title.is_empty()).then_some(title),
content: self.content.lines().join("\n").trim_end().to_string(),
tags: micropub::parse_tags(&self.tags.lines().join(",")),
syndicate_to: match self.targets_state {
Targets::Loaded => Some(
self.targets
.iter()
.filter(|(_, on)| *on)
.map(|(t, _)| t.uid.clone())
.collect(),
),
_ => None,
},
}
}
/// Human summary of where the post will be crossposted.
pub fn syndication_summary(&self) -> String {
match &self.targets_state {
Targets::Loaded => {
let names: Vec<_> = self
.targets
.iter()
.filter(|(_, on)| *on)
.map(|(t, _)| t.name.as_str())
.collect();
if names.is_empty() {
"nowhere".into()
} else {
names.join(", ")
}
}
_ => "server default".into(),
}
}
pub fn handle_response(&mut self, resp: Response) {
match resp {
Response::Config(Ok(cfg)) => self.set_config(cfg),
Response::Config(Err(e)) => {
self.targets_state = Targets::Failed(e.to_string());
self.report(e);
}
Response::Published(Ok(url)) => {
self.clear_form();
self.status = Some(Status::Info(format!("Published {url}")));
self.last_permalink = Some(url.clone());
self.mode = Mode::Published(url);
}
Response::Published(Err(e)) => {
self.mode = Mode::Editing;
self.report(e);
}
}
}
fn report(&mut self, e: Error) {
if matches!(e, Error::Unauthorized(_)) {
self.needs_login = true;
}
self.status = Some(Status::Error(e.to_string()));
}
fn set_config(&mut self, cfg: ServerConfig) {
// Keep earlier choices on refresh; new targets start checked (the server's default).
let previous = std::mem::take(&mut self.targets);
self.targets = cfg
.syndicate_to
.into_iter()
.map(|t| {
let on = previous
.iter()
.find(|(p, _)| p.uid == t.uid)
.is_none_or(|(_, on)| *on);
(t, on)
})
.collect();
self.target_cursor = self.target_cursor.min(self.targets.len().saturating_sub(1));
self.targets_state = Targets::Loaded;
}
/// Handle a key press. Returns a request for the worker, if any.
pub fn handle_key(&mut self, key: KeyEvent) -> Option<Request> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match self.mode.clone() {
Mode::Publishing => None,
Mode::ConfirmPublish => {
match key.code {
KeyCode::Char('y') | KeyCode::Enter => {
self.mode = Mode::Publishing;
self.status = Some(Status::Info("Publishing…".into()));
return Some(Request::Publish(self.note()));
}
_ => self.mode = Mode::Editing,
}
None
}
Mode::ConfirmQuit => {
match key.code {
KeyCode::Char('y') => self.should_quit = true,
_ => self.mode = Mode::Editing,
}
None
}
Mode::Published(url) => {
if key.code == KeyCode::Char('o') {
self.open(&url);
}
self.mode = Mode::Editing;
None
}
Mode::Editing => self.handle_editing_key(key, ctrl),
}
}
fn open(&mut self, url: &str) {
if let Err(e) = open::that_detached(url) {
self.status = Some(Status::Error(format!("Could not open browser: {e}")));
}
}
fn handle_editing_key(&mut self, key: KeyEvent, ctrl: bool) -> Option<Request> {
match (ctrl, key.code) {
(true, KeyCode::Char('q' | 'c')) | (false, KeyCode::Esc) => {
if self.is_dirty() {
self.mode = Mode::ConfirmQuit;
} else {
self.should_quit = true;
}
return None;
}
(true, KeyCode::Char('s')) => {
if self.note().content.trim().is_empty() {
self.status = Some(Status::Error("Write something first.".into()));
} else {
self.status = None;
self.mode = Mode::ConfirmPublish;
}
return None;
}
(true, KeyCode::Char('r')) => {
self.targets_state = Targets::Loading;
self.status = Some(Status::Info("Refreshing server config…".into()));
return Some(Request::LoadConfig);
}
(true, KeyCode::Char('o')) => {
if let Some(url) = self.last_permalink.clone() {
self.open(&url);
}
return None;
}
(_, KeyCode::Tab) => {
self.focus = self.focus.step(1);
self.update_cursors();
return None;
}
(_, KeyCode::BackTab) => {
self.focus = self.focus.step(-1);
self.update_cursors();
return None;
}
_ => {}
}
match self.focus {
Focus::Content => {
self.content.input(Input::from(key));
}
Focus::Title | Focus::Tags => {
let input = Input::from(key);
// Single-line fields: Enter moves on instead of adding a line.
if input.key == Key::Enter || (input.ctrl && input.key == Key::Char('m')) {
self.focus = self.focus.step(1);
self.update_cursors();
} else {
let area = if self.focus == Focus::Title {
&mut self.title
} else {
&mut self.tags
};
area.input(input);
}
}
Focus::Syndication => self.handle_target_key(key),
}
None
}
fn handle_target_key(&mut self, key: KeyEvent) {
let n = self.targets.len();
if n == 0 {
return;
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
self.target_cursor = self.target_cursor.saturating_sub(1)
}
KeyCode::Down | KeyCode::Char('j') => {
self.target_cursor = (self.target_cursor + 1).min(n - 1)
}
KeyCode::Char(' ') | KeyCode::Enter => {
if let Some((_, on)) = self.targets.get_mut(self.target_cursor) {
*on = !*on;
}
}
KeyCode::Char('a') => {
let all_on = self.targets.iter().all(|(_, on)| *on);
for (_, on) in &mut self.targets {
*on = !all_on;
}
}
_ => {}
}
}
/// Bracketed paste: insert text into the focused field.
pub fn handle_paste(&mut self, text: &str) {
if self.mode != Mode::Editing {
return;
}
let text = text.replace("\r\n", "\n").replace('\r', "\n");
match self.focus {
Focus::Content => {
self.content.insert_str(text);
}
Focus::Title => {
self.title.insert_str(text.replace('\n', " "));
}
Focus::Tags => {
self.tags.insert_str(text.replace('\n', ","));
}
Focus::Syndication => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn typ(app: &mut App, s: &str) {
for c in s.chars() {
app.handle_key(key(KeyCode::Char(c)));
}
}
fn target(uid: &str) -> SyndicationTarget {
SyndicationTarget {
uid: uid.into(),
name: uid.to_uppercase(),
}
}
fn loaded(targets: &[&str]) -> App {
let mut app = App::new(
"https://example.com/".into(),
"https://example.com/micropub".into(),
);
app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: targets.iter().map(|t| target(t)).collect(),
})));
app
}
#[test]
fn compose_and_publish_flow() {
let mut app = loaded(&["gts", "bsky"]);
typ(&mut app, "Hello");
app.handle_key(key(KeyCode::Enter));
typ(&mut app, "world");
app.handle_key(key(KeyCode::Tab)); // -> tags
typ(&mut app, "rust, #tui");
app.handle_key(key(KeyCode::Tab)); // -> syndication
app.handle_key(key(KeyCode::Down));
app.handle_key(key(KeyCode::Char(' '))); // uncheck bsky
assert!(app.handle_key(ctrl('s')).is_none());
assert_eq!(app.mode, Mode::ConfirmPublish);
let Some(Request::Publish(note)) = app.handle_key(key(KeyCode::Char('y'))) else {
panic!("expected publish request");
};
assert_eq!(app.mode, Mode::Publishing);
assert_eq!(note.content, "Hello\nworld");
assert_eq!(note.title, None);
assert_eq!(note.tags, vec!["rust", "tui"]);
assert_eq!(note.syndicate_to, Some(vec!["gts".to_string()]));
app.handle_response(Response::Published(
Ok("https://example.com/notes/x".into()),
));
assert_eq!(
app.mode,
Mode::Published("https://example.com/notes/x".into())
);
assert!(!app.is_dirty(), "form clears after publishing");
app.handle_key(key(KeyCode::Esc));
assert_eq!(app.mode, Mode::Editing);
}
#[test]
fn title_enter_moves_to_content() {
let mut app = loaded(&[]);
app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
assert_eq!(app.focus, Focus::Title);
typ(&mut app, "My title");
app.handle_key(key(KeyCode::Enter));
assert_eq!(app.focus, Focus::Content);
assert_eq!(app.title.lines(), ["My title"]);
}
#[test]
fn empty_note_is_not_published() {
let mut app = loaded(&[]);
app.handle_key(ctrl('s'));
assert_eq!(app.mode, Mode::Editing);
assert!(matches!(app.status, Some(Status::Error(_))));
}
#[test]
fn unchecking_all_sends_empty_list_but_unknown_config_sends_none() {
let mut app = loaded(&["gts"]);
app.focus = Focus::Syndication;
app.handle_key(key(KeyCode::Char('a')));
assert_eq!(app.note().syndicate_to, Some(vec![]));
assert_eq!(app.syndication_summary(), "nowhere");
let mut app = App::new("m".into(), "e".into());
app.handle_response(Response::Config(Err(Error::NoLocation(
reqwest::StatusCode::OK,
))));
assert_eq!(app.note().syndicate_to, None);
assert_eq!(app.syndication_summary(), "server default");
}
#[test]
fn refresh_keeps_choices() {
let mut app = loaded(&["gts", "bsky"]);
app.targets[0].1 = false;
app.handle_response(Response::Config(Ok(ServerConfig {
syndicate_to: vec![target("gts"), target("bsky"), target("new")],
})));
let on: Vec<bool> = app.targets.iter().map(|(_, on)| *on).collect();
assert_eq!(on, [false, true, true]);
}
#[test]
fn quit_asks_when_dirty() {
let mut app = loaded(&[]);
typ(&mut app, "draft");
app.handle_key(key(KeyCode::Esc));
assert_eq!(app.mode, Mode::ConfirmQuit);
app.handle_key(key(KeyCode::Char('n')));
assert!(!app.should_quit);
app.handle_key(ctrl('q'));
app.handle_key(key(KeyCode::Char('y')));
assert!(app.should_quit);
let mut clean = loaded(&[]);
clean.handle_key(key(KeyCode::Esc));
assert!(clean.should_quit);
}
#[test]
fn publish_error_returns_to_editing_and_flags_login() {
let mut app = loaded(&[]);
typ(&mut app, "x");
app.handle_key(ctrl('s'));
app.handle_key(key(KeyCode::Char('y')));
app.handle_response(Response::Published(Err(Error::Unauthorized(
"expired".into(),
))));
assert_eq!(app.mode, Mode::Editing);
assert!(app.needs_login);
assert!(app.is_dirty(), "draft is kept after a failed publish");
}
#[test]
fn paste_into_single_line_fields() {
let mut app = loaded(&[]);
app.focus = Focus::Tags;
app.handle_paste("a\nb");
assert_eq!(app.note().tags, vec!["a", "b"]);
}
}
+428
View File
@@ -0,0 +1,428 @@
//! IndieAuth login: authorization code flow with PKCE and a loopback redirect.
//!
//! The client_id is `http://127.0.0.1:<port>/` and the redirect_uri is
//! `http://127.0.0.1:<port>/callback`. They share an origin, so servers accept the
//! redirect without fetching client metadata (the IndieAuth spec allows loopback clients).
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use reqwest::blocking::Client;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use url::Url;
use crate::{config::Config, discovery, token};
pub const SCOPE: &str = "create media";
const LOGIN_TIMEOUT: Duration = Duration::from_secs(300);
pub struct Pkce {
pub verifier: String,
pub challenge: String,
}
impl Pkce {
pub fn new() -> Self {
Self::from_verifier(random_token(32))
}
pub fn from_verifier(verifier: String) -> Self {
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
Pkce {
verifier,
challenge,
}
}
}
fn random_token(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
rand::fill(&mut buf[..]);
URL_SAFE_NO_PAD.encode(buf)
}
#[derive(Debug, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub me: String,
#[serde(default)]
pub scope: String,
}
#[derive(Debug, Deserialize)]
struct OAuthError {
error: String,
error_description: Option<String>,
}
pub fn http_client() -> Client {
Client::builder()
.user_agent(concat!("tuipub/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(30))
.build()
.expect("building HTTP client")
}
/// Build the authorization URL the browser is sent to.
pub fn authorization_url(
endpoint: &Url,
client_id: &str,
redirect_uri: &str,
state: &str,
pkce: &Pkce,
me: &Url,
) -> Url {
let mut url = endpoint.clone();
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", client_id)
.append_pair("redirect_uri", redirect_uri)
.append_pair("state", state)
.append_pair("code_challenge", &pkce.challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("scope", SCOPE)
.append_pair("me", me.as_str());
url
}
/// What came back on the redirect: the code, or an error from the server.
#[derive(Debug, PartialEq)]
pub enum Callback {
Code(String),
Denied(String),
}
/// Check a `/callback?...` query against the expected state and issuer.
pub fn parse_callback(query: &str, state: &str, issuer: Option<&str>) -> Result<Callback> {
let params: std::collections::HashMap<String, String> =
url::form_urlencoded::parse(query.as_bytes())
.into_owned()
.collect();
if params.get("state").map(String::as_str) != Some(state) {
bail!("the login callback had the wrong state; try logging in again");
}
if let (Some(expected), Some(got)) = (issuer, params.get("iss"))
&& expected != got
{
bail!("the login callback came from issuer {got}, expected {expected}");
}
if let Some(err) = params.get("error") {
let desc = params.get("error_description").cloned().unwrap_or_default();
return Ok(Callback::Denied(format!("{err} {desc}").trim().to_string()));
}
params
.get("code")
.cloned()
.map(Callback::Code)
.ok_or_else(|| anyhow!("the login callback had no code"))
}
const CLIENT_PAGE: &str = r#"<!doctype html><html><head><title>tuipub</title></head>
<body><div class="h-app"><span class="p-name">tuipub</span> — a terminal Micropub client</div></body></html>"#;
fn page(message: &str) -> String {
format!(
"<!doctype html><html><head><title>tuipub</title></head><body style=\"font-family:sans-serif;margin:3em\">\
<h1>tuipub</h1><p>{message}</p></body></html>"
)
}
fn respond(req: tiny_http::Request, status: u16, body: String) {
let header = tiny_http::Header::from_bytes("Content-Type", "text/html; charset=utf-8")
.expect("static header");
let _ = req.respond(
tiny_http::Response::from_string(body)
.with_status_code(status)
.with_header(header),
);
}
/// Wait for the browser to hit /callback. Answers anything else with the client page.
fn wait_for_callback(
server: &tiny_http::Server,
state: &str,
issuer: Option<&str>,
) -> Result<String> {
let deadline = Instant::now() + LOGIN_TIMEOUT;
loop {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
bail!("timed out waiting for the browser login");
}
let Some(req) = server.recv_timeout(left)? else {
continue;
};
let url = req.url().to_string();
let (path, query) = url.split_once('?').unwrap_or((&url, ""));
if path != "/callback" {
respond(req, 200, CLIENT_PAGE.to_string());
continue;
}
match parse_callback(query, state, issuer) {
Ok(Callback::Code(code)) => {
respond(
req,
200,
page("Logged in. You can close this tab and go back to the terminal."),
);
return Ok(code);
}
Ok(Callback::Denied(why)) => {
respond(
req,
400,
page("Login was not approved. You can close this tab."),
);
bail!("login was not approved: {why}");
}
Err(e) => {
// A stray or forged request: tell the browser, keep waiting for the real one.
respond(req, 400, page(&format!("{e}")));
}
}
}
}
pub fn redeem_code(
http: &Client,
token_endpoint: &Url,
code: &str,
client_id: &str,
redirect_uri: &str,
verifier: &str,
) -> Result<TokenResponse> {
let resp = http
.post(token_endpoint.clone())
.header("Accept", "application/json")
.form(&[
("grant_type", "authorization_code"),
("code", code),
("client_id", client_id),
("redirect_uri", redirect_uri),
("code_verifier", verifier),
])
.send()
.context("contacting the token endpoint")?;
let status = resp.status();
let body = resp.text().unwrap_or_default();
if !status.is_success() {
if let Ok(e) = serde_json::from_str::<OAuthError>(&body) {
bail!(
"token endpoint refused the code: {} {}",
e.error,
e.error_description.unwrap_or_default()
);
}
bail!("token endpoint returned {status}");
}
serde_json::from_str(&body).context("token endpoint sent an unexpected response")
}
/// Run the full browser login for `me_input` and save the result.
pub fn login(me_input: &str) -> Result<Config> {
let http = http_client();
let me = discovery::normalize_me(me_input)?;
eprintln!("Looking up endpoints for {me} …");
let ep = discovery::discover(&http, &me)?;
let server = tiny_http::Server::http("127.0.0.1:0")
.map_err(|e| anyhow!("could not start the login callback server: {e}"))?;
let port = server
.server_addr()
.to_ip()
.context("callback server has no IP address")?
.port();
let client_id = format!("http://127.0.0.1:{port}/");
let redirect_uri = format!("{client_id}callback");
let state = random_token(16);
let pkce = Pkce::new();
let auth_url = authorization_url(
&ep.authorization_endpoint,
&client_id,
&redirect_uri,
&state,
&pkce,
&me,
);
eprintln!("Opening your browser to log in. If it doesn't open, visit:\n\n {auth_url}\n");
if let Err(e) = open::that_detached(auth_url.as_str()) {
eprintln!("(could not open a browser: {e})");
}
let code = wait_for_callback(&server, &state, ep.issuer.as_deref())?;
let tok = redeem_code(
&http,
&ep.token_endpoint,
&code,
&client_id,
&redirect_uri,
&pkce.verifier,
)?;
// The server's `me` is authoritative. If it differs from what was typed,
// make sure it points at the same authorization server before trusting it.
let canonical = Url::parse(&tok.me).context("token endpoint returned an invalid me")?;
let ep = if canonical != me {
let other = discovery::discover(&http, &canonical)?;
if other.authorization_endpoint != ep.authorization_endpoint {
bail!("{canonical} uses a different authorization server; refusing the login");
}
other
} else {
ep
};
let cfg = Config {
me: canonical.to_string(),
micropub: ep.micropub.to_string(),
authorization_endpoint: ep.authorization_endpoint.to_string(),
token_endpoint: ep.token_endpoint.to_string(),
revocation_endpoint: ep.revocation_endpoint.map(|u| u.to_string()),
scope: tok.scope.clone(),
};
token::set(&cfg.me, &tok.access_token)?;
cfg.save()?;
eprintln!("Logged in as {} (scope: {})", cfg.me, cfg.scope);
if !cfg.scope.split_whitespace().any(|s| s == "create") {
eprintln!("Warning: the server did not grant the `create` scope, so posting will fail.");
}
Ok(cfg)
}
/// Revoke the token on the server (best effort) and forget it locally.
pub fn logout() -> Result<()> {
let Some(cfg) = Config::load()? else {
eprintln!("Not logged in.");
return Ok(());
};
if let Some(tok) = token::get(&cfg.me)? {
let http = http_client();
let result = match &cfg.revocation_endpoint {
Some(url) => http.post(url).form(&[("token", tok.as_str())]).send(),
None => http
.post(&cfg.token_endpoint)
.form(&[("action", "revoke"), ("token", tok.as_str())])
.send(),
};
match result.and_then(|r| r.error_for_status()) {
Ok(_) => eprintln!("Revoked the token on the server."),
Err(e) => eprintln!(
"Could not revoke the token on the server ({e}); removing it locally anyway."
),
}
}
token::delete(&cfg.me)?;
Config::remove()?;
eprintln!("Logged out of {}.", cfg.me);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pkce_matches_rfc7636_vector() {
let p = Pkce::from_verifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk".into());
assert_eq!(p.challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
}
#[test]
fn random_verifier_is_long_enough() {
let p = Pkce::new();
assert!(p.verifier.len() >= 43, "RFC 7636 needs 43+ chars");
assert_ne!(p.verifier, Pkce::new().verifier);
}
#[test]
fn authorization_url_has_required_params() {
let ep = Url::parse("https://example.com/auth/authorize").unwrap();
let me = Url::parse("https://example.com/").unwrap();
let pkce = Pkce::from_verifier("v".repeat(43));
let url = authorization_url(
&ep,
"http://127.0.0.1:1234/",
"http://127.0.0.1:1234/callback",
"st",
&pkce,
&me,
);
let q: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
assert_eq!(q["response_type"], "code");
assert_eq!(q["client_id"], "http://127.0.0.1:1234/");
assert_eq!(q["redirect_uri"], "http://127.0.0.1:1234/callback");
assert_eq!(q["code_challenge_method"], "S256");
assert_eq!(q["code_challenge"], pkce.challenge);
assert_eq!(q["scope"], SCOPE);
assert_eq!(q["state"], "st");
}
#[test]
fn callback_checks_state_and_issuer() {
let iss = Some("https://example.com/");
assert_eq!(
parse_callback(
"code=abc&state=s1&iss=https%3A%2F%2Fexample.com%2F",
"s1",
iss
)
.unwrap(),
Callback::Code("abc".into())
);
assert!(parse_callback("code=abc&state=wrong", "s1", iss).is_err());
assert!(
parse_callback(
"code=abc&state=s1&iss=https%3A%2F%2Fevil.example%2F",
"s1",
iss
)
.is_err()
);
assert!(matches!(
parse_callback("error=access_denied&state=s1", "s1", None).unwrap(),
Callback::Denied(_)
));
assert!(parse_callback("state=s1", "s1", None).is_err());
}
#[test]
fn redeem_code_posts_form_and_reports_errors() {
use httpmock::prelude::*;
let server = MockServer::start();
let ok = server.mock(|when, then| {
when.method(POST)
.path("/token")
.form_urlencoded_tuple("grant_type", "authorization_code")
.form_urlencoded_tuple("code", "good")
.form_urlencoded_tuple("code_verifier", "ver");
then.status(200).json_body(serde_json::json!({
"access_token": "tok", "token_type": "Bearer",
"scope": "create media", "me": "https://example.com/"
}));
});
server.mock(|when, then| {
when.method(POST)
.path("/token")
.form_urlencoded_tuple("code", "bad");
then.status(400).json_body(serde_json::json!({
"error": "invalid_grant", "error_description": "expired"
}));
});
let ep = Url::parse(&server.url("/token")).unwrap();
let http = Client::new();
let tok = redeem_code(&http, &ep, "good", "c", "r", "ver").unwrap();
assert_eq!(tok.access_token, "tok");
assert_eq!(tok.scope, "create media");
ok.assert();
let err = redeem_code(&http, &ep, "bad", "c", "r", "ver")
.unwrap_err()
.to_string();
assert!(
err.contains("invalid_grant") && err.contains("expired"),
"{err}"
);
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Account settings stored in ~/.config/tuipub/config.toml.
use std::{fs, path::PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Config {
/// The user's canonical profile URL, as returned by the token endpoint.
pub me: String,
pub micropub: String,
pub authorization_endpoint: String,
pub token_endpoint: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revocation_endpoint: Option<String>,
#[serde(default)]
pub scope: String,
}
/// Directory for config.toml. `TUIPUB_CONFIG_DIR` overrides it (handy for tests and dev servers).
pub fn dir() -> Result<PathBuf> {
if let Some(d) = std::env::var_os("TUIPUB_CONFIG_DIR") {
return Ok(PathBuf::from(d));
}
directories::ProjectDirs::from("", "", "tuipub")
.map(|p| p.config_dir().to_path_buf())
.context("could not work out a config directory")
}
fn path() -> Result<PathBuf> {
Ok(dir()?.join("config.toml"))
}
impl Config {
pub fn load() -> Result<Option<Config>> {
let path = path()?;
match fs::read_to_string(&path) {
Ok(text) => Ok(Some(
toml::from_str(&text).with_context(|| format!("reading {}", path.display()))?,
)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
}
}
pub fn save(&self) -> Result<()> {
let path = path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, toml::to_string_pretty(self)?)
.with_context(|| format!("writing {}", path.display()))
}
pub fn remove() -> Result<()> {
match fs::remove_file(path()?) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
}
}
}
+293
View File
@@ -0,0 +1,293 @@
//! Endpoint discovery from the user's profile URL.
//!
//! Looks at HTTP `Link` headers first, then `<link rel>` / `<a rel>` in the HTML,
//! as the IndieAuth and Micropub specs describe. Prefers the `indieauth-metadata`
//! document and falls back to the older `authorization_endpoint` / `token_endpoint` rels.
use anyhow::{Context, Result, bail};
use reqwest::blocking::Client;
use scraper::{Html, Selector};
use serde::Deserialize;
use url::Url;
#[derive(Debug, Clone, PartialEq)]
pub struct Endpoints {
pub micropub: Url,
pub authorization_endpoint: Url,
pub token_endpoint: Url,
pub revocation_endpoint: Option<Url>,
pub issuer: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Metadata {
issuer: Option<String>,
authorization_endpoint: String,
token_endpoint: String,
revocation_endpoint: Option<String>,
}
/// Turn what the user typed ("example.com") into a profile URL ("https://example.com/").
pub fn normalize_me(input: &str) -> Result<Url> {
let input = input.trim();
let with_scheme = if input.contains("://") {
input.to_string()
} else if ["localhost", "127.", "[::1]"]
.iter()
.any(|p| input.starts_with(p))
{
// Local dev servers rarely have TLS.
format!("http://{input}")
} else {
format!("https://{input}")
};
let url = Url::parse(&with_scheme).with_context(|| format!("not a valid URL: {input}"))?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
bail!("profile URL must be http(s) with a host: {input}");
}
Ok(url)
}
/// All `(rel, url)` pairs from a `Link` header value, resolved against `base`.
pub fn parse_link_header(value: &str, base: &Url) -> Vec<(String, Url)> {
let mut out = Vec::new();
let mut rest = value;
while let Some(start) = rest.find('<') {
let Some(end) = rest[start..].find('>') else {
break;
};
let target = &rest[start + 1..start + end];
rest = &rest[start + end + 1..];
// A link's params run until the next '<' (the trailing ',' is ignored below).
let params = &rest[..rest.find('<').unwrap_or(rest.len())];
for param in params.split(';') {
let Some((k, v)) = param.split_once('=') else {
continue;
};
if !k.trim().eq_ignore_ascii_case("rel") {
continue;
}
let Ok(url) = base.join(target) else { continue };
let v = v.trim().trim_end_matches(',').trim().trim_matches('"');
for rel in v.split_whitespace() {
out.push((rel.to_ascii_lowercase(), url.clone()));
}
}
}
out
}
/// All `(rel, url)` pairs from `<link>` and `<a>` elements, in document order.
pub fn parse_html_links(html: &str, base: &Url) -> Vec<(String, Url)> {
let doc = Html::parse_document(html);
let base = Selector::parse("base[href]")
.ok()
.and_then(|s| {
doc.select(&s)
.next()?
.value()
.attr("href")
.map(str::to_string)
})
.and_then(|href| base.join(&href).ok())
.unwrap_or_else(|| base.clone());
let sel = Selector::parse("link[rel][href], a[rel][href]").expect("valid selector");
let mut out = Vec::new();
for el in doc.select(&sel) {
let (Some(rel), Some(href)) = (el.value().attr("rel"), el.value().attr("href")) else {
continue;
};
let Ok(url) = base.join(href) else { continue };
for r in rel.split_whitespace() {
out.push((r.to_ascii_lowercase(), url.clone()));
}
}
out
}
fn first(links: &[(String, Url)], rel: &str) -> Option<Url> {
links.iter().find(|(r, _)| r == rel).map(|(_, u)| u.clone())
}
pub fn discover(http: &Client, me: &Url) -> Result<Endpoints> {
let resp = http
.get(me.clone())
.header("Accept", "text/html")
.send()
.with_context(|| format!("fetching {me}"))?
.error_for_status()
.with_context(|| format!("fetching {me}"))?;
let final_url = resp.url().clone();
let mut links = Vec::new();
for value in resp.headers().get_all("link") {
if let Ok(v) = value.to_str() {
links.extend(parse_link_header(v, &final_url));
}
}
let body = resp.text().unwrap_or_default();
links.extend(parse_html_links(&body, &final_url));
let micropub = first(&links, "micropub").with_context(|| {
format!("{me} does not advertise a Micropub endpoint (rel=\"micropub\")")
})?;
if let Some(meta_url) = first(&links, "indieauth-metadata") {
let meta: Metadata = http
.get(meta_url.clone())
.header("Accept", "application/json")
.send()
.and_then(|r| r.error_for_status())
.with_context(|| format!("fetching IndieAuth metadata {meta_url}"))?
.json()
.with_context(|| format!("parsing IndieAuth metadata {meta_url}"))?;
return Ok(Endpoints {
micropub,
authorization_endpoint: meta_url.join(&meta.authorization_endpoint)?,
token_endpoint: meta_url.join(&meta.token_endpoint)?,
revocation_endpoint: meta
.revocation_endpoint
.and_then(|u| meta_url.join(&u).ok()),
issuer: meta.issuer,
});
}
Ok(Endpoints {
micropub,
authorization_endpoint: first(&links, "authorization_endpoint").with_context(|| {
format!("{me} does not advertise an IndieAuth authorization endpoint")
})?,
token_endpoint: first(&links, "token_endpoint")
.with_context(|| format!("{me} does not advertise an IndieAuth token endpoint"))?,
revocation_endpoint: None,
issuer: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Url {
Url::parse("https://example.com/about/").unwrap()
}
#[test]
fn normalizes_bare_domain() {
assert_eq!(
normalize_me("127.0.0.1:8099").unwrap().as_str(),
"http://127.0.0.1:8099/"
);
assert_eq!(
normalize_me("example.com").unwrap().as_str(),
"https://example.com/"
);
assert_eq!(
normalize_me(" http://example.com/me ").unwrap().as_str(),
"http://example.com/me"
);
assert!(normalize_me("ftp://example.com").is_err());
}
#[test]
fn link_header_multiple_links_and_rels() {
let v = r#"<https://example.com/micropub>; rel="micropub", </.well-known/oauth-authorization-server>; rel="indieauth-metadata me""#;
let links = parse_link_header(v, &base());
assert_eq!(
first(&links, "micropub").unwrap().as_str(),
"https://example.com/micropub"
);
assert_eq!(
first(&links, "indieauth-metadata").unwrap().as_str(),
"https://example.com/.well-known/oauth-authorization-server"
);
assert!(first(&links, "me").is_some());
}
#[test]
fn link_header_unquoted_rel_and_relative() {
let links = parse_link_header("<../auth>; rel=authorization_endpoint", &base());
assert_eq!(
first(&links, "authorization_endpoint").unwrap().as_str(),
"https://example.com/auth"
);
}
#[test]
fn html_links_resolve_and_keep_order() {
let html = r#"<html><head>
<link rel="micropub" href="/micropub">
<link rel="authorization_endpoint" href="https://auth.example/a">
<link rel="token_endpoint" href="https://auth.example/t">
</head><body><a rel="micropub" href="/second">x</a></body></html>"#;
let links = parse_html_links(html, &base());
assert_eq!(
first(&links, "micropub").unwrap().as_str(),
"https://example.com/micropub"
);
assert_eq!(
first(&links, "token_endpoint").unwrap().as_str(),
"https://auth.example/t"
);
}
#[test]
fn header_links_win_over_html() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/");
then.status(200)
.header("Link", "</header-micropub>; rel=\"micropub\"")
.header("Content-Type", "text/html")
.body(
r#"<link rel="micropub" href="/html-micropub">
<link rel="indieauth-metadata" href="/meta">"#,
);
});
server.mock(|when, then| {
when.method(GET).path("/meta");
then.status(200).json_body(serde_json::json!({
"issuer": server.url("/"),
"authorization_endpoint": "/auth/authorize",
"token_endpoint": "/auth/token",
"revocation_endpoint": "/auth/revoke"
}));
});
let me = Url::parse(&server.url("/")).unwrap();
let ep = discover(&Client::new(), &me).unwrap();
assert_eq!(ep.micropub.path(), "/header-micropub");
assert_eq!(ep.authorization_endpoint.path(), "/auth/authorize");
assert_eq!(ep.token_endpoint.path(), "/auth/token");
assert_eq!(ep.revocation_endpoint.unwrap().path(), "/auth/revoke");
assert_eq!(ep.issuer.as_deref(), Some(server.url("/").as_str()));
}
#[test]
fn falls_back_to_legacy_rels() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/");
then.status(200).body(
r#"<link rel="micropub" href="/mp"><link rel="authorization_endpoint" href="/a">
<link rel="token_endpoint" href="/t">"#,
);
});
let ep = discover(&Client::new(), &Url::parse(&server.url("/")).unwrap()).unwrap();
assert_eq!(ep.token_endpoint.path(), "/t");
assert!(ep.revocation_endpoint.is_none());
}
#[test]
fn missing_micropub_is_an_error() {
use httpmock::prelude::*;
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/");
then.status(200).body("<html></html>");
});
let err = discover(&Client::new(), &Url::parse(&server.url("/")).unwrap()).unwrap_err();
assert!(err.to_string().contains("Micropub"));
}
}
+146
View File
@@ -0,0 +1,146 @@
mod app;
mod auth;
mod config;
mod discovery;
mod micropub;
mod token;
mod ui;
mod worker;
use std::io::{self, BufRead, Write};
use std::time::Duration;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use ratatui::crossterm::{
event::{self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind},
execute,
};
use url::Url;
use crate::{app::App, config::Config, worker::Request};
/// A terminal Micropub client for posting notes to your IndieWeb site.
#[derive(Parser)]
#[command(version, about)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
/// Log in to your site with IndieAuth (opens a browser)
Login {
/// Your site's URL, e.g. example.com
me: Option<String>,
},
/// Revoke the token and forget the account
Logout,
/// Show the logged-in account and endpoints
Whoami,
}
fn prompt(question: &str) -> Result<String> {
eprint!("{question}");
io::stderr().flush()?;
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
let line = line.trim().to_string();
anyhow::ensure!(!line.is_empty(), "no URL entered");
Ok(line)
}
fn login(me: Option<String>) -> Result<Config> {
let me = match me {
Some(m) => m,
None => match Config::load()? {
Some(cfg) => cfg.me,
None => prompt("Your site URL: ")?,
},
};
auth::login(&me)
}
/// The saved account and a token for it, logging in first if needed.
fn session() -> Result<(Config, String)> {
let cfg = match Config::load()? {
Some(cfg) => cfg,
None => {
eprintln!("No account yet. Let's log in.");
login(None)?
}
};
if let Some(tok) = token::get(&cfg.me)? {
return Ok((cfg, tok));
}
eprintln!("No saved token for {}. Logging in again.", cfg.me);
let cfg = auth::login(&cfg.me)?;
let tok = token::get(&cfg.me)?.context("login finished but no token was saved")?;
Ok((cfg, tok))
}
fn run_tui() -> Result<()> {
let (cfg, tok) = session()?;
let endpoint = Url::parse(&cfg.micropub).context("invalid Micropub endpoint in config")?;
let client = micropub::Client::new(auth::http_client(), endpoint, tok);
let (tx, rx) = worker::spawn(client);
tx.send(Request::LoadConfig).ok();
let mut app = App::new(cfg.me.clone(), cfg.micropub.clone());
let mut terminal = ratatui::init();
execute!(io::stdout(), EnableBracketedPaste).ok();
let result = (|| -> Result<()> {
while !app.should_quit {
terminal.draw(|f| ui::draw(f, &mut app))?;
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
if let Some(req) = app.handle_key(key) {
tx.send(req).context("background worker stopped")?;
}
}
Event::Paste(text) => app.handle_paste(&text),
_ => {}
}
}
while let Ok(resp) = rx.try_recv() {
app.handle_response(resp);
}
}
Ok(())
})();
execute!(io::stdout(), DisableBracketedPaste).ok();
ratatui::restore();
if app.needs_login {
eprintln!("The server rejected the token. Run `tuipub login` to log in again.");
}
result
}
fn main() -> Result<()> {
match Cli::parse().command {
None => run_tui(),
Some(Command::Login { me }) => login(me).map(|_| ()),
Some(Command::Logout) => auth::logout(),
Some(Command::Whoami) => {
match Config::load()? {
Some(cfg) => {
let has_token = token::get(&cfg.me)?.is_some();
println!("me: {}", cfg.me);
println!("micropub: {}", cfg.micropub);
println!("token: {}", if has_token { "saved" } else { "missing" });
println!("scope: {}", cfg.scope);
println!(
"config: {}",
config::dir()?.join("config.toml").display()
);
}
None => println!("Not logged in. Run `tuipub login <your site>`."),
}
Ok(())
}
}
}
+332
View File
@@ -0,0 +1,332 @@
//! Micropub client: `q=config` and create.
use reqwest::{StatusCode, blocking::Client as Http};
use serde::Deserialize;
use serde_json::{Value, json};
use url::Url;
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct SyndicationTarget {
pub uid: String,
pub name: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ServerConfig {
#[serde(rename = "syndicate-to", default)]
pub syndicate_to: Vec<SyndicationTarget>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Note {
pub title: Option<String>,
pub content: String,
pub tags: Vec<String>,
/// `None` leaves syndication to the server's default. `Some(vec![])` means none.
pub syndicate_to: Option<Vec<String>>,
}
impl Note {
/// Micropub JSON create body. JSON (not form encoding) is the only way to send
/// an empty `mp-syndicate-to`, which tells the server "crosspost nowhere".
pub fn to_json(&self) -> Value {
let mut props = serde_json::Map::new();
props.insert("content".into(), json!([self.content]));
if let Some(t) = self.title.as_deref().filter(|t| !t.is_empty()) {
props.insert("name".into(), json!([t]));
}
if !self.tags.is_empty() {
props.insert("category".into(), json!(self.tags));
}
let mut body = json!({ "type": ["h-entry"], "properties": props });
if let Some(targets) = &self.syndicate_to {
body["mp-syndicate-to"] = json!(targets);
}
body
}
}
/// Split "a, #b,, a" into ["a", "b"].
pub fn parse_tags(input: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for tag in input.split(',') {
let tag = tag.trim().trim_start_matches('#').trim();
if !tag.is_empty() && !out.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
out.push(tag.to_string());
}
}
out
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("not authorized: {0}. Run `tuipub login` again.")]
Unauthorized(String),
#[error("{error}: {description}")]
Server {
status: StatusCode,
error: String,
description: String,
},
#[error("server returned {0} without a Location header")]
NoLocation(StatusCode),
#[error("network error: {0}")]
Http(#[from] reqwest::Error),
}
#[derive(Deserialize)]
struct ErrorBody {
error: String,
#[serde(default)]
error_description: String,
}
pub struct Client {
http: Http,
endpoint: Url,
token: String,
}
impl Client {
pub fn new(http: Http, endpoint: Url, token: String) -> Self {
Client {
http,
endpoint,
token,
}
}
fn check(resp: reqwest::blocking::Response) -> Result<reqwest::blocking::Response, Error> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let body = resp.text().unwrap_or_default();
let (error, description) = match serde_json::from_str::<ErrorBody>(&body) {
Ok(b) => (b.error, b.error_description),
Err(_) => (
status.canonical_reason().unwrap_or("error").to_string(),
body.chars().take(200).collect(),
),
};
if status == StatusCode::UNAUTHORIZED
|| status == StatusCode::FORBIDDEN
|| error == "insufficient_scope"
{
let why = if description.is_empty() {
error
} else {
description
};
return Err(Error::Unauthorized(why));
}
Err(Error::Server {
status,
error,
description,
})
}
fn query(&self, q: &str) -> Result<reqwest::blocking::Response, Error> {
let mut url = self.endpoint.clone();
url.query_pairs_mut().append_pair("q", q);
let resp = self
.http
.get(url)
.bearer_auth(&self.token)
.header("Accept", "application/json")
.send()?;
Self::check(resp)
}
pub fn config(&self) -> Result<ServerConfig, Error> {
let mut cfg: ServerConfig = self.query("config")?.json()?;
// syndicate-to is optional in q=config; some servers only answer q=syndicate-to.
if cfg.syndicate_to.is_empty()
&& let Ok(resp) = self.query("syndicate-to")
&& let Ok(s) = resp.json::<ServerConfig>()
{
cfg.syndicate_to = s.syndicate_to;
}
Ok(cfg)
}
/// Publish a note. Returns the post's permalink.
pub fn create(&self, note: &Note) -> Result<String, Error> {
let resp = self
.http
.post(self.endpoint.clone())
.bearer_auth(&self.token)
.header("Accept", "application/json")
.json(&note.to_json())
.send()?;
let resp = Self::check(resp)?;
let status = resp.status();
let location = resp
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.ok_or(Error::NoLocation(status))?;
Ok(self
.endpoint
.join(location)
.map(|u| u.to_string())
.unwrap_or_else(|_| location.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use httpmock::prelude::*;
fn client(server: &MockServer) -> Client {
Client::new(
Http::new(),
Url::parse(&server.url("/micropub")).unwrap(),
"tok".into(),
)
}
#[test]
fn tags_are_trimmed_and_deduped() {
assert_eq!(
parse_tags(" rust, #tui,,Rust , indieweb "),
vec!["rust", "tui", "indieweb"]
);
assert!(parse_tags(" , ").is_empty());
}
#[test]
fn note_json_shape() {
let note = Note {
title: Some("Hi".into()),
content: "Hello **world**".into(),
tags: vec!["a".into()],
syndicate_to: Some(vec![]),
};
assert_eq!(
note.to_json(),
json!({
"type": ["h-entry"],
"properties": {"content": ["Hello **world**"], "name": ["Hi"], "category": ["a"]},
"mp-syndicate-to": []
})
);
let bare = Note {
content: "x".into(),
title: Some(String::new()),
..Default::default()
};
assert_eq!(
bare.to_json(),
json!({"type": ["h-entry"], "properties": {"content": ["x"]}})
);
}
#[test]
fn config_reads_targets() {
let server = MockServer::start();
let m = server.mock(|when, then| {
when.method(GET)
.path("/micropub")
.query_param("q", "config")
.header("authorization", "Bearer tok");
then.status(200).json_body(json!({
"media-endpoint": "https://example.com/micropub/media",
"syndicate-to": [{"uid": "gotosocial", "name": "GoToSocial"}],
"post-types": []
}));
});
let cfg = client(&server).config().unwrap();
m.assert();
assert_eq!(
cfg.syndicate_to,
vec![SyndicationTarget {
uid: "gotosocial".into(),
name: "GoToSocial".into()
}]
);
}
#[test]
fn config_falls_back_to_syndicate_to_query() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/micropub")
.query_param("q", "config");
then.status(200).json_body(json!({}));
});
server.mock(|when, then| {
when.method(GET)
.path("/micropub")
.query_param("q", "syndicate-to");
then.status(200)
.json_body(json!({"syndicate-to": [{"uid": "x", "name": "X"}]}));
});
assert_eq!(client(&server).config().unwrap().syndicate_to.len(), 1);
}
#[test]
fn create_posts_json_and_resolves_location() {
let server = MockServer::start();
let note = Note {
content: "hello".into(),
syndicate_to: Some(vec!["gts".into()]),
..Default::default()
};
let m = server.mock(|when, then| {
when.method(POST)
.path("/micropub")
.header("authorization", "Bearer tok")
.json_body(note.to_json());
then.status(201).header("Location", "/notes/hello");
});
let url = client(&server).create(&note).unwrap();
m.assert();
assert_eq!(url, server.url("/notes/hello"));
}
#[test]
fn create_maps_errors() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(POST).path("/micropub");
then.status(403).json_body(json!({"error": "insufficient_scope", "error_description": "Token lacks create scope"}));
});
let err = client(&server)
.create(&Note {
content: "x".into(),
..Default::default()
})
.unwrap_err();
assert!(
matches!(err, Error::Unauthorized(ref d) if d.contains("create scope")),
"{err}"
);
let server = MockServer::start();
server.mock(|when, then| {
when.method(POST).path("/micropub");
then.status(400).json_body(
json!({"error": "invalid_request", "error_description": "Content is required"}),
);
});
let err = client(&server).create(&Note::default()).unwrap_err();
assert_eq!(err.to_string(), "invalid_request: Content is required");
let server = MockServer::start();
server.mock(|when, then| {
when.method(POST).path("/micropub");
then.status(201);
});
let err = client(&server)
.create(&Note {
content: "x".into(),
..Default::default()
})
.unwrap_err();
assert!(matches!(err, Error::NoLocation(_)));
}
}
+36
View File
@@ -0,0 +1,36 @@
//! Access token storage in the system keyring (Secret Service on Linux).
//! `TUIPUB_TOKEN` overrides the keyring, e.g. for a token pasted from the server's admin page.
use anyhow::{Context, Result};
const SERVICE: &str = "tuipub";
fn entry(me: &str) -> Result<keyring::Entry> {
keyring::Entry::new(SERVICE, me).context("opening the system keyring")
}
pub fn get(me: &str) -> Result<Option<String>> {
if let Ok(t) = std::env::var("TUIPUB_TOKEN")
&& !t.is_empty()
{
return Ok(Some(t));
}
match entry(me)?.get_password() {
Ok(t) => Ok(Some(t)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(e).context("reading the token from the system keyring"),
}
}
pub fn set(me: &str, token: &str) -> Result<()> {
entry(me)?
.set_password(token)
.context("saving the token to the system keyring")
}
pub fn delete(me: &str) -> Result<()> {
match entry(me)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e).context("removing the token from the system keyring"),
}
}
+219
View File
@@ -0,0 +1,219 @@
//! Rendering.
use ratatui::{
Frame,
layout::{Constraint, Flex, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, BorderType, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use crate::app::{App, Focus, Mode, Status, Targets};
const ACCENT: Color = Color::Cyan;
fn field_block(title: &str, focused: bool) -> Block<'static> {
let block = Block::bordered().title(format!(" {title} "));
if focused {
block
.border_type(BorderType::Thick)
.border_style(Style::default().fg(ACCENT))
.title_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD))
} else {
block.border_style(Style::default().fg(Color::DarkGray))
}
}
pub fn draw(f: &mut Frame, app: &mut App) {
let targets_height = match app.targets_state {
Targets::Loaded if !app.targets.is_empty() => (app.targets.len() as u16 + 2).min(8),
_ => 3,
};
let [header, title, content, tags, targets, status, help] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(5),
Constraint::Length(3),
Constraint::Length(targets_height),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(f.area());
f.render_widget(
Line::from(vec![
Span::styled(
" tuipub ",
Style::default().fg(Color::Black).bg(ACCENT).bold(),
),
Span::raw(" "),
Span::styled(app.me.clone(), Style::default().bold()),
Span::styled(
format!(" via {}", app.endpoint),
Style::default().fg(Color::DarkGray),
),
]),
header,
);
app.title
.set_block(field_block("Title", app.focus == Focus::Title));
f.render_widget(&app.title, title);
let chars: usize = app.content.lines().iter().map(|l| l.chars().count()).sum();
app.content.set_block(field_block(
&format!("Note · {chars} chars"),
app.focus == Focus::Content,
));
f.render_widget(&app.content, content);
app.tags
.set_block(field_block("Tags", app.focus == Focus::Tags));
f.render_widget(&app.tags, tags);
draw_targets(f, app, targets);
let status_line = match &app.status {
Some(Status::Info(s)) => Line::styled(format!(" {s}"), Style::default().fg(Color::Green)),
Some(Status::Error(s)) => Line::styled(format!(" {s}"), Style::default().fg(Color::Red)),
None => Line::raw(""),
};
f.render_widget(status_line, status);
let help_text = match app.focus {
Focus::Syndication => {
" ↑↓ move · space toggle · a all/none · Tab next · Ctrl-S publish · Esc quit"
}
_ => " Tab next field · Ctrl-S publish · Ctrl-R refresh · Ctrl-O open last post · Esc quit",
};
f.render_widget(
Line::styled(help_text, Style::default().fg(Color::DarkGray)),
help,
);
match &app.mode {
Mode::Editing => {}
Mode::ConfirmPublish => popup(
f,
"Publish?",
vec![
Line::from(vec![
Span::raw("Post to "),
Span::styled(app.me.clone(), Style::default().bold()),
]),
Line::from(vec![
Span::raw("Crosspost: "),
Span::styled(app.syndication_summary(), Style::default().fg(ACCENT)),
]),
Line::raw(""),
Line::styled(
"y / Enter publish · any other key cancel",
Style::default().fg(Color::DarkGray),
),
],
),
Mode::Publishing => popup(f, "Publishing", vec![Line::raw("Sending to your site…")]),
Mode::Published(url) => popup(
f,
"Published",
vec![
Line::styled(url.clone(), Style::default().fg(ACCENT).underlined()),
Line::raw(""),
Line::styled(
"o open in browser · any other key new note",
Style::default().fg(Color::DarkGray),
),
],
),
Mode::ConfirmQuit => popup(
f,
"Quit?",
vec![
Line::raw("This note hasn't been published and will be lost."),
Line::raw(""),
Line::styled(
"y quit · any other key keep editing",
Style::default().fg(Color::DarkGray),
),
],
),
}
}
fn draw_targets(f: &mut Frame, app: &App, area: Rect) {
let focused = app.focus == Focus::Syndication;
let block = field_block("Crosspost to", focused);
match &app.targets_state {
Targets::Loading => f.render_widget(
Paragraph::new("Loading targets…")
.fg(Color::DarkGray)
.block(block),
area,
),
Targets::Failed(_) => f.render_widget(
Paragraph::new("Couldn't load targets; the server's default applies (Ctrl-R to retry)")
.fg(Color::Yellow)
.block(block),
area,
),
Targets::Loaded if app.targets.is_empty() => f.render_widget(
Paragraph::new("This server has no crosspost targets")
.fg(Color::DarkGray)
.block(block),
area,
),
Targets::Loaded => {
let items: Vec<ListItem> = app
.targets
.iter()
.map(|(t, on)| {
let mark = if *on { "[x] " } else { "[ ] " };
ListItem::new(Line::from(vec![
Span::styled(
mark,
Style::default().fg(if *on { Color::Green } else { Color::DarkGray }),
),
Span::raw(t.name.clone()),
]))
})
.collect();
let mut list = List::new(items).block(block);
if focused {
list = list.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
}
let mut state = ListState::default().with_selected(Some(app.target_cursor));
f.render_stateful_widget(list, area, &mut state);
}
}
}
fn popup(f: &mut Frame, title: &str, lines: Vec<Line<'static>>) {
let width = lines
.iter()
.map(|l| l.width() as u16)
.max()
.unwrap_or(20)
.max(30)
+ 4;
let height = lines.len() as u16 + 2;
let area = centered(f.area(), width, height);
f.render_widget(Clear, area);
f.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: false }).block(
Block::bordered()
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(ACCENT))
.title(format!(" {title} "))
.title_style(Style::default().bold()),
),
area,
);
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let [row] = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.areas(area);
let [cell] = Layout::horizontal([Constraint::Length(width.min(area.width))])
.flex(Flex::Center)
.areas(row);
cell
}
+33
View File
@@ -0,0 +1,33 @@
//! Runs Micropub requests on a background thread so the UI never blocks.
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use crate::micropub::{Client, Error, Note, ServerConfig};
pub enum Request {
LoadConfig,
Publish(Note),
}
pub enum Response {
Config(Result<ServerConfig, Error>),
Published(Result<String, Error>),
}
pub fn spawn(client: Client) -> (Sender<Request>, Receiver<Response>) {
let (req_tx, req_rx) = mpsc::channel::<Request>();
let (resp_tx, resp_rx) = mpsc::channel::<Response>();
thread::spawn(move || {
for req in req_rx {
let resp = match req {
Request::LoadConfig => Response::Config(client.config()),
Request::Publish(note) => Response::Published(client.create(&note)),
};
if resp_tx.send(resp).is_err() {
break;
}
}
});
(req_tx, resp_rx)
}