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