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:
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+4351
File diff suppressed because it is too large
Load Diff
+27
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(¬e.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(¬e).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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¬e)),
|
||||
};
|
||||
if resp_tx.send(resp).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
(req_tx, resp_rx)
|
||||
}
|
||||
Reference in New Issue
Block a user