// Our own struct to collect the pieces of an HTTP request before we
// pass it on to reqwests. Allows incremental adding of request parameters.
-struct Req {
+pub struct Req {
method: reqwest::Method,
url_suffix: String,
parameters: Vec<(String, String)>,
}
impl Req {
- fn get(url_suffix: &str) -> Self {
+ pub fn get(url_suffix: &str) -> Self {
Req {
method: reqwest::Method::GET,
url_suffix: url_suffix.to_owned(),
}
}
- fn post(url_suffix: &str) -> Self {
+ pub fn post(url_suffix: &str) -> Self {
Req {
method: reqwest::Method::POST,
url_suffix: url_suffix.to_owned(),
}
}
- fn param<T>(mut self, key: &str, value: T) -> Self
+ pub fn param<T>(mut self, key: &str, value: T) -> Self
where T: ReqParam
{
self.parameters.push((key.to_owned(), value.param_value()));
self
}
+
+ pub fn url(&self, base_url: &str) -> Result<(String, Url), ClientError> {
+ let urlstr = base_url.to_owned() + &self.url_suffix;
+ let parsed = if self.parameters.is_empty() {
+ Url::parse(&urlstr)
+ } else {
+ Url::parse_with_params(&urlstr, self.parameters.iter())
+ };
+ let url = match parsed {
+ Ok(url) => Ok(url),
+ Err(e) => Err(ClientError::UrlParseError(
+ urlstr.clone(), e.to_string())),
+ }?;
+ Ok((urlstr, url))
+ }
+
+ pub fn build(self, base_url: &str, client: &reqwest::blocking::Client,
+ bearer_token: Option<&str>) ->
+ Result<(String, reqwest::blocking::RequestBuilder), ClientError>
+ {
+ let (urlstr, url) = self.url(base_url)?;
+ let req = client.request(self.method, url);
+ let req = match bearer_token {
+ Some(tok) => req.bearer_auth(tok),
+ None => req,
+ };
+ Ok((urlstr, req))
+ }
}
-trait ReqParam {
+pub trait ReqParam {
fn param_value(self) -> String;
}
Initial, Past, Future
}
+pub fn reqwest_client() -> Result<reqwest::blocking::Client, ClientError> {
+ // We turn off cross-site HTTP redirections in our client
+ // objects. That's because in general it doesn't do any good
+ // for a Mastodon API endpoint to redirect to another host:
+ // most requests have to be authenticated, and if you're
+ // redirected to another host, that host (by normal reqwest
+ // policy for cross-site redirections) won't receive your auth
+ // header. So we might as well reject the redirection in the
+ // first place.
+ //
+ // An exception is when setting up streaming connections,
+ // because those _can_ redirect to another host - but in that
+ // case, the target host can be known in advance (via the
+ // streaming URL in the instance configuration), and also, we
+ // still can't let _reqwest_ quietly follow the redirection,
+ // because it will strip off the auth. So we follow it
+ // ourselves manually, vet the target URL, and put the auth
+ // back on.
+ let no_xsite = reqwest::redirect::Policy::custom(|attempt| {
+ if attempt.previous().len() > 10 {
+ attempt.error("too many redirects")
+ } else if let Some(prev_url) = attempt.previous().last() {
+ let next_url = attempt.url();
+ if (prev_url.host(), prev_url.port()) !=
+ (next_url.host(), next_url.port())
+ {
+ // Stop and pass the 3xx response back to the
+ // caller, rather than throwing a fatal error.
+ // That way, the streaming setup can implement its
+ // special case.
+ attempt.stop()
+ } else {
+ attempt.follow()
+ }
+ } else {
+ panic!("confusing redirect with no previous URLs!");
+ }
+ });
+
+ let client = reqwest::blocking::Client::builder()
+ .redirect(no_xsite)
+ .build()?;
+ Ok(client)
+}
+
impl Client {
pub fn new(cfgloc: &ConfigLocation) -> Result<Self, ClientError> {
Ok(Client {
auth: AuthConfig::load(cfgloc)?,
- client: Self::build_client()?,
+ client: reqwest_client()?,
accounts: HashMap::new(),
statuses: HashMap::new(),
notifications: HashMap::new(),
})
}
- fn build_client() -> Result<reqwest::blocking::Client, ClientError>
- {
- // We turn off cross-site HTTP redirections in our client
- // objects. That's because in general it doesn't do any good
- // for a Mastodon API endpoint to redirect to another host:
- // most requests have to be authenticated, and if you're
- // redirected to another host, that host (by normal reqwest
- // policy for cross-site redirections) won't receive your auth
- // header. So we might as well reject the redirection in the
- // first place.
- //
- // An exception is when setting up streaming connections,
- // because those _can_ redirect to another host - but in that
- // case, the target host can be known in advance (via the
- // streaming URL in the instance configuration), and also, we
- // still can't let _reqwest_ quietly follow the redirection,
- // because it will strip off the auth. So we follow it
- // ourselves manually, vet the target URL, and put the auth
- // back on.
- let no_xsite = reqwest::redirect::Policy::custom(|attempt| {
- if attempt.previous().len() > 10 {
- attempt.error("too many redirects")
- } else if let Some(prev_url) = attempt.previous().last() {
- let next_url = attempt.url();
- if (prev_url.host(), prev_url.port()) !=
- (next_url.host(), next_url.port())
- {
- // Stop and pass the 3xx response back to the
- // caller, rather than throwing a fatal error.
- // That way, the streaming setup can implement its
- // special case.
- attempt.stop()
- } else {
- attempt.follow()
- }
- } else {
- panic!("confusing redirect with no previous URLs!");
- }
- });
-
- let client = reqwest::blocking::Client::builder()
- .redirect(no_xsite)
- .build()?;
- Ok(client)
- }
-
pub fn set_writable(&mut self, permit: bool) {
self.permit_write = permit;
}
"Non-GET request attempted in readonly mode".to_string()));
}
- let urlstr = self.auth.instance_url.clone() + "/api/" +
- &req.url_suffix;
- let parsed = if req.parameters.is_empty() {
- Url::parse(&urlstr)
- } else {
- Url::parse_with_params(&urlstr, req.parameters.iter())
- };
- let url = match parsed {
- Ok(url) => Ok(url),
- Err(e) => Err(ClientError::UrlParseError(
- urlstr.clone(), e.to_string())),
- }?;
-
- Ok((urlstr, client.request(req.method, url)
- .bearer_auth(&self.auth.user_token)))
+ let base_url = self.auth.instance_url.to_owned() + "/api/";
+ req.build(&base_url, client, Some(&self.auth.user_token))
}
fn api_request(&self, req: Req) ->
};
let method = req.method.clone(); // to reuse for redirects below
- let client = Self::build_client()?;
+ let client = reqwest_client()?;
let (url, mut req) = self.api_request_cl(&client, req)?;
let mut rsp = req.send()?;
if rsp.status().is_redirection() {
pub mod types;
+pub mod login;
pub mod auth;
pub mod config;
pub mod html;
--- /dev/null
+use reqwest::Url;
+use std::io::Write;
+
+use super::auth::AuthConfig;
+use super::client::{reqwest_client, Req, ClientError};
+use super::types::{Account, Application, Instance, Token};
+
+struct Login {
+ instance_url: String,
+ client: reqwest::blocking::Client,
+}
+
+enum AppTokenType<'a> {
+ ClientCredentials,
+ AuthorizationCode(&'a str),
+}
+use AppTokenType::*;
+
+const REDIRECT_MAGIC_STRING: &'static str = "urn:ietf:wg:oauth:2.0:oob";
+
+impl Login {
+ fn new(instance_url: &str) -> Result<Self, ClientError> {
+ Ok(Login {
+ instance_url: instance_url.to_owned(),
+ client: reqwest_client()?,
+ })
+ }
+
+ fn register_client(&self) -> Result<Application, ClientError> {
+ let (url, req) = Req::post("/api/v1/apps")
+ .param("redirect_uris", REDIRECT_MAGIC_STRING)
+ .param("client_name", "Mastodonochrome")
+ .param("scopes", "read write push")
+ .param("website", "https://www.chiark.greenend.org.uk/~sgtatham/mastodonochrome/")
+ .build(&self.instance_url, &self.client, None)?;
+ let rsp = req.send()?;
+ let rspstatus = rsp.status();
+ if !rspstatus.is_success() {
+ Err(ClientError::UrlError(url.clone(), rspstatus.to_string()))
+ } else {
+ let app: Application = match serde_json::from_str(&rsp.text()?) {
+ Ok(app) => Ok(app),
+ Err(e) => Err(ClientError::UrlError(
+ url.clone(), e.to_string())),
+ }?;
+ Ok(app)
+ }
+ }
+
+ fn get_token(&self, app: &Application, toktype: AppTokenType) ->
+ Result<Token, ClientError>
+ {
+ let client_id = match &app.client_id {
+ Some(id) => Ok(id),
+ None => Err(ClientError::InternalError("registering application did not return a client id".to_owned())),
+ }?;
+ let client_secret = match &app.client_secret {
+ Some(id) => Ok(id),
+ None => Err(ClientError::InternalError("registering application did not return a client secret".to_owned())),
+ }?;
+
+ let req = Req::post("/oauth/token")
+ .param("redirect_uri", REDIRECT_MAGIC_STRING)
+ .param("client_id", client_id)
+ .param("client_secret", client_secret);
+ let req = match toktype {
+ ClientCredentials => req
+ .param("grant_type", "client_credentials"),
+ AuthorizationCode(code) => req
+ .param("grant_type", "authorization_code")
+ .param("code", code)
+ .param("scope", "read write push"),
+ };
+ let (url, req) = req.build(&self.instance_url, &self.client, None)?;
+ let rsp = req.send()?;
+ let rspstatus = rsp.status();
+ if !rspstatus.is_success() {
+ Err(ClientError::UrlError(url.clone(), rspstatus.to_string()))
+ } else {
+ let tok: Token = match serde_json::from_str(&rsp.text()?) {
+ Ok(tok) => Ok(tok),
+ Err(e) => Err(ClientError::UrlError(
+ url.clone(), e.to_string())),
+ }?;
+ Ok(tok)
+ }
+ }
+
+ fn get<T: for<'a> serde::Deserialize<'a>>(&self, path: &str, token: &str) ->
+ Result<T, ClientError>
+ {
+ let (url, req) = Req::get(path)
+ .build(&self.instance_url, &self.client, Some(token))?;
+ let rsp = req.send()?;
+ let rspstatus = rsp.status();
+ if !rspstatus.is_success() {
+ Err(ClientError::UrlError(url.clone(), rspstatus.to_string()))
+ } else {
+ let tok: T = match serde_json::from_str(&rsp.text()?) {
+ Ok(tok) => Ok(tok),
+ Err(e) => Err(ClientError::UrlError(
+ url.clone(), e.to_string())),
+ }?;
+ Ok(tok)
+ }
+ }
+
+ fn get_auth_url(&self, app: &Application) -> Result<String, ClientError> {
+ let client_id = match &app.client_id {
+ Some(id) => Ok(id),
+ None => Err(ClientError::InternalError("registering application did not return a client id".to_owned())),
+ }?;
+
+ let (_urlstr, url) = Req::get("/oauth/authorize")
+ .param("redirect_uri", REDIRECT_MAGIC_STRING)
+ .param("client_id", client_id)
+ .param("scope", "read write push")
+ .param("response_type", "code")
+ .url(&self.instance_url)?;
+ Ok(url.to_string())
+ }
+}
+
+pub fn login(instance_url: &str) -> Result<(), ClientError> {
+ // Ergonomics: parse the URL, adding a default https:// scheme if
+ // it's just a bare hostname
+ let urlstr = match instance_url.find('/') {
+ Some(_) => instance_url.to_owned(),
+ None => format!("https://{instance_url}"),
+ };
+ let url = match Url::parse(&urlstr) {
+ Ok(url) => Ok(url),
+ Err(e) => Err(ClientError::UrlParseError(
+ urlstr.clone(), e.to_string())),
+ }?;
+ let instance_url = url.as_str().trim_end_matches('/');
+
+ let login = Login::new(instance_url)?;
+
+ // Register the client and get its details
+ let app = login.register_client()?;
+ let app_token = login.get_token(&app, ClientCredentials)?;
+ let _app: Application = login.get("/api/v1/apps/verify_credentials",
+ &app_token.access_token)?;
+
+ // Get the URL the user will have to visit
+ let url = login.get_auth_url(&app)?;
+
+ // Print it
+ println!("Log in to the website {instance_url}/");
+ println!("and then visit this URL to authorise Mastodonochrome:");
+ println!("");
+ println!("{}", url);
+ println!("");
+ println!("If you click \"Authorise\" on that page, you should receive a response code.");
+ print!("Enter that code here: ");
+ std::io::stdout().flush().unwrap(); // FIXME
+
+ // Read the user's response
+ let code = {
+ let mut buf = String::new();
+ std::io::stdin().read_line(&mut buf).unwrap(); // FIXME
+ buf
+ };
+ let code = (&code).trim_end();
+
+ // Use that code to get the final user access token
+ let user_token = login.get_token(&app, AuthorizationCode(code))?;
+ let account: Account = login.get("/api/v1/accounts/verify_credentials",
+ &user_token.access_token)?;
+ let instance: Instance = login.get("/api/v2/instance",
+ &user_token.access_token)?;
+
+ println!("");
+ println!("Successfully logged in as {}@{}", account.id, instance.domain);
+
+ // Save everything!
+ let auth = AuthConfig {
+ account_id: account.id,
+ username: account.username,
+ instance_url: instance_url.to_owned(),
+ instance_domain: instance.domain,
+ client_id: app.client_id.unwrap(),
+ client_secret: app.client_secret.unwrap(),
+ user_token: user_token.access_token,
+ };
+ println!("JSON: {}", serde_json::to_string_pretty(&auth).unwrap());
+
+ Ok(())
+}
use std::fmt::Display;
use std::process::ExitCode;
+use mastodonochrome::client::ClientError;
use mastodonochrome::config::{ConfigLocation, ConfigError};
use mastodonochrome::tui::{Tui, TuiError};
+use mastodonochrome::login::login;
#[derive(Parser, Debug)]
struct Args {
#[arg(short, long)]
config: Option<std::path::PathBuf>,
- // Read-only mode: the client prevents accidental posting.
+ /// Read-only mode: the client prevents accidental posting.
#[arg(short, long, action(clap::ArgAction::SetTrue))]
readonly: bool,
+
+ /// Log in to a server, instead of running the main user interface.
+ /// Provide the top-level URL of the instance website.
+ #[arg(long, conflicts_with("readonly"))]
+ login: Option<String>,
}
#[derive(Debug)]
impl TopLevelErrorCandidate for ConfigError {}
impl TopLevelErrorCandidate for TuiError {}
+impl TopLevelErrorCandidate for ClientError {}
impl TopLevelErrorCandidate for clap::error::Error {
// clap prints its own "error: "
fn get_prefix() -> String { "".to_owned() }
None => ConfigLocation::default()?,
Some(dir) => ConfigLocation::from_pathbuf(dir),
};
- Tui::run(&cfgloc, cli.readonly)?;
+ match cli.login {
+ None => Tui::run(&cfgloc, cli.readonly)?,
+ Some(ref server) => login(server)?,
+ }
Ok(())
}
pub struct Application {
pub name: String,
pub website: Option<String>,
+ pub client_id: Option<String>,
+ pub client_secret: Option<String>,
+}
+
+#[derive(Deserialize, Debug, Clone)]
+pub struct Token {
+ pub access_token: String,
+ pub token_type: String,
+ pub scope: String,
+ pub created_at: u64, // Unix timestamp
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, EnumIter)]