diff options
Diffstat (limited to 'src/lib.rs')
-rw-r--r-- | src/lib.rs | 406 |
1 files changed, 246 insertions, 160 deletions
diff --git a/src/lib.rs b/src/lib.rs index fd4b51c..84379cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,16 @@ -use gettextrs::*; use adw::prelude::*; -use libsecret::prelude::{RetrievableExtManual, RetrievableExt}; -use relm4::{actions::{RelmAction, RelmActionGroup}, gtk, loading_widgets::LoadingWidgets, prelude::{AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController, ComponentController, Controller}, AsyncComponentSender, Component, RelmWidgetExt}; +use gettextrs::*; +use libsecret::prelude::{RetrievableExt, RetrievableExtManual}; +use relm4::{ + actions::{RelmAction, RelmActionGroup}, + gtk, + loading_widgets::LoadingWidgets, + prelude::{ + AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController, + ComponentController, Controller, + }, + AsyncComponentSender, Component, RelmWidgetExt, +}; pub mod icons { include!(concat!(env!("OUT_DIR"), "/icons.rs")); @@ -11,29 +20,30 @@ pub mod components { pub(crate) mod smart_summary; #[cfg(feature = "smart-summary")] pub(crate) use smart_summary::{ - SmartSummaryButton, Output as SmartSummaryOutput, Input as SmartSummaryInput + Input as SmartSummaryInput, Output as SmartSummaryOutput, SmartSummaryButton, }; pub(crate) mod post_editor; - pub(crate) use post_editor::{ - PostEditor, Input as PostEditorInput - }; + pub(crate) use post_editor::{Input as PostEditorInput, PostEditor}; pub(crate) mod tag_pill; // pub(crate) use tag_pill::{TagPill, TagPillDelete} pub mod signin; - pub use signin::{SignIn, Output as SignInOutput, Error as SignInError}; + pub use signin::{Error as SignInError, Output as SignInOutput, SignIn}; pub mod preferences; pub use preferences::Preferences; } -use components::{post_editor::{Post, PostConversionSettings}, PostEditorInput}; +use components::{ + post_editor::{Post, PostConversionSettings}, + PostEditorInput, +}; use soup::prelude::SessionExt; -pub mod secrets; pub mod micropub; +pub mod secrets; pub mod util; pub const APPLICATION_ID: &str = env!("APP_ID"); pub const CLIENT_ID_STR: &str = "https://kittybox.fireburn.ru/bowl/"; @@ -53,7 +63,11 @@ pub struct App { } impl App { - async fn authorize(schema: &libsecret::Schema, http: soup::Session, data: Box<components::SignInOutput>) -> Result<micropub::Client, glib::Error> { + async fn authorize( + schema: &libsecret::Schema, + http: soup::Session, + data: Box<components::SignInOutput>, + ) -> Result<micropub::Client, glib::Error> { let mut attributes = std::collections::HashMap::new(); let me = data.me.to_string(); let _micropub = data.micropub.to_string(); @@ -62,7 +76,8 @@ impl App { attributes.insert(secrets::TOKEN_KIND, secrets::ACCESS_TOKEN); attributes.insert(secrets::MICROPUB, _micropub.as_str()); attributes.insert(secrets::SCOPE, scope.as_str()); - let exp = data.expires_in + let exp = data + .expires_in .as_ref() .map(std::time::Duration::as_secs) .as_ref() @@ -76,13 +91,15 @@ impl App { attributes.clone(), Some(libsecret::COLLECTION_DEFAULT), &gettext!("Micropub access token for {}", &data.me), - &data.access_token - ).await { - Ok(()) => {}, + &data.access_token, + ) + .await + { + Ok(()) => {} Err(err) => { log::error!("Failed to store access token to the secret store: {}", err); - return Err(err) - }, + return Err(err); + } } if let Some(refresh_token) = data.refresh_token.as_deref() { attributes.insert(secrets::TOKEN_KIND, secrets::REFRESH_TOKEN); @@ -92,28 +109,42 @@ impl App { attributes, Some(libsecret::COLLECTION_DEFAULT), &format!("Micropub refresh token for {}", &data.me), - refresh_token - ).await { - Ok(()) => {}, + refresh_token, + ) + .await + { + Ok(()) => {} Err(err) => { log::error!("Failed to store refresh token to the secret store: {}", err); - return Err(err) - }, + return Err(err); + } } } Ok(micropub::Client::new( - http.clone(), data.micropub.clone(), data.access_token.clone(), me + http.clone(), + data.micropub.clone(), + data.access_token.clone(), + me, )) } - async fn refresh_token(schema: &libsecret::Schema, http: soup::Session, me: String) -> Result<Option<micropub::Client>, glib::Error> { - let mut retrievables = libsecret::password_search_future(Some(schema), { - let mut attrs = std::collections::HashMap::default(); - attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::REFRESH_TOKEN); - attrs.insert(crate::secrets::ME, &me); - attrs - }, libsecret::SearchFlags::ALL).await?; + async fn refresh_token( + schema: &libsecret::Schema, + http: soup::Session, + me: String, + ) -> Result<Option<micropub::Client>, glib::Error> { + let mut retrievables = libsecret::password_search_future( + Some(schema), + { + let mut attrs = std::collections::HashMap::default(); + attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::REFRESH_TOKEN); + attrs.insert(crate::secrets::ME, &me); + attrs + }, + libsecret::SearchFlags::ALL, + ) + .await?; if retrievables.is_empty() { Ok(None) @@ -127,7 +158,9 @@ impl App { .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let token = retrievable.retrieve_secret_future().await? + let token = retrievable + .retrieve_secret_future() + .await? .unwrap() .text() .unwrap() @@ -135,30 +168,40 @@ impl App { let url = glib::Uri::parse(me.as_str(), glib::UriFlags::SCHEME_NORMALIZE)?; - let (metadata, micropub_uri) = match crate::components::signin::get_metadata(http.clone(), url).await { - Ok(res) => res, - Err(err) => { - tracing::warn!("failed to fetch metadata to refresh an expired token: {}", err); - return Ok(None) - } - }; + let (metadata, micropub_uri) = + match crate::components::signin::get_metadata(http.clone(), url).await { + Ok(res) => res, + Err(err) => { + tracing::warn!( + "failed to fetch metadata to refresh an expired token: {}", + err + ); + return Ok(None); + } + }; let grant = kittybox_indieauth::GrantRequest::RefreshToken { - refresh_token: token, client_id: CLIENT_ID_STR.parse().unwrap(), scope: None + refresh_token: token, + client_id: CLIENT_ID_STR.parse().unwrap(), + scope: None, }; - let url = glib::Uri::parse(metadata.token_endpoint.as_str(), glib::UriFlags::NONE).unwrap(); + let url = glib::Uri::parse(metadata.token_endpoint.as_str(), glib::UriFlags::NONE) + .unwrap(); let msg = soup::Message::from_uri("POST", &url); let headers = msg.request_headers().unwrap(); headers.append("Accept", "application/json"); msg.set_request_body_from_bytes( Some("application/x-www-form-urlencoded"), Some(&glib::Bytes::from_owned( - serde_urlencoded::to_string(grant).unwrap().into_bytes() - )) + serde_urlencoded::to_string(grant).unwrap().into_bytes(), + )), ); - match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await { + match http + .send_and_read_future(&msg, glib::Priority::DEFAULT) + .await + { Ok(body) if msg.status() == soup::Status::Ok => { match serde_json::from_slice::<kittybox_indieauth::GrantResponse>(&body) { Ok(kittybox_indieauth::GrantResponse::ProfileUrl(_)) => unreachable!(), @@ -170,81 +213,96 @@ impl App { state: _, expires_in, profile, - refresh_token + refresh_token, }) => { if refresh_token.is_some() { // Get rid of the old refresh token. - let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await; + let _ = + libsecret::password_clear_future(Some(schema), attrs_ref) + .await; }; let micropub = Self::authorize( - schema, http, Box::new(components::SignInOutput { - me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE).unwrap(), + schema, + http, + Box::new(components::SignInOutput { + me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE) + .unwrap(), scope: scope.unwrap_or_else(components::SignIn::scopes), micropub: micropub_uri, - userinfo: metadata.userinfo_endpoint - .map(|u| glib::Uri::parse( - u.as_str(), glib::UriFlags::NONE - ).unwrap()), + userinfo: metadata.userinfo_endpoint.map(|u| { + glib::Uri::parse(u.as_str(), glib::UriFlags::NONE) + .unwrap() + }), access_token, refresh_token, expires_in: expires_in.map(std::time::Duration::from_secs), profile, - }) - ).await?; + }), + ) + .await?; - return Ok(Some(micropub)) - }, + return Ok(Some(micropub)); + } Err(err) => { tracing::warn!("failed to refresh token for {}: failed to parse grant response: {}", me, err); - return Ok(None) - }, - } - }, - Ok(body) => { - match serde_json::from_slice::<kittybox_indieauth::Error>(&body) { - Ok(err) => { - tracing::warn!("failed to refresh token for {}: token endpoint error: {}", me, err); - continue; - }, - Err(err) => { - tracing::warn!("failed to refresh token for {}: error parsing token endpoint error: {}", me, err); - tracing::warn!("token endpoint response verbatim follows:\n{}", String::from_utf8_lossy(&body)); - return Ok(None) + return Ok(None); } } + } + Ok(body) => match serde_json::from_slice::<kittybox_indieauth::Error>(&body) { + Ok(err) => { + tracing::warn!( + "failed to refresh token for {}: token endpoint error: {}", + me, + err + ); + continue; + } + Err(err) => { + tracing::warn!("failed to refresh token for {}: error parsing token endpoint error: {}", me, err); + tracing::warn!( + "token endpoint response verbatim follows:\n{}", + String::from_utf8_lossy(&body) + ); + return Ok(None); + } }, Err(err) => return Err(err), }; - } unreachable!() } } - async fn revoke_token(http: soup::Session, me: String, token: String) -> Result<Option<()>, components::SignInError> { - let url = glib::Uri::parse( - me.as_str(), - glib::UriFlags::SCHEME_NORMALIZE - )?; + async fn revoke_token( + http: soup::Session, + me: String, + token: String, + ) -> Result<Option<()>, components::SignInError> { + let url = glib::Uri::parse(me.as_str(), glib::UriFlags::SCHEME_NORMALIZE)?; let (metadata, _) = crate::components::signin::get_metadata(http.clone(), url).await?; let endpoint = match metadata.revocation_endpoint { Some(endpoint) => match metadata.revocation_endpoint_auth_methods_supported { - Some(methods) => if methods.iter().any(|i| matches!(i, kittybox_indieauth::RevocationEndpointAuthMethod::None)) { - glib::Uri::parse(endpoint.as_str(), glib::UriFlags::NONE).unwrap() - } else { - tracing::warn!("couldn't revoke token: revocation endpoint doesn't support unauthenticated requests\n(Note: this is a violation of the IndieAuth specification)"); - return Ok(None) - }, + Some(methods) => { + if methods.iter().any(|i| { + matches!(i, kittybox_indieauth::RevocationEndpointAuthMethod::None) + }) { + glib::Uri::parse(endpoint.as_str(), glib::UriFlags::NONE).unwrap() + } else { + tracing::warn!("couldn't revoke token: revocation endpoint doesn't support unauthenticated requests\n(Note: this is a violation of the IndieAuth specification)"); + return Ok(None); + } + } None => { tracing::warn!("couldn't revoke token: revocation endpoint doesn't support unauthenticated requests\n(Note: this is a violation of the IndieAuth specification)"); - return Ok(None) + return Ok(None); } }, None => { tracing::warn!("couldn't revoke token: revocation endpoint not found"); - return Ok(None) + return Ok(None); } }; let msg = soup::Message::from_uri("POST", &endpoint); @@ -253,37 +311,55 @@ impl App { msg.set_request_body_from_bytes( Some("application/x-www-form-urlencoded"), Some(&glib::Bytes::from_owned( - serde_urlencoded::to_string( - kittybox_indieauth::TokenRevocationRequest { token } - ).unwrap().into_bytes() - )) + serde_urlencoded::to_string(kittybox_indieauth::TokenRevocationRequest { token }) + .unwrap() + .into_bytes(), + )), ); - match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await { - Ok(_) if msg.status() == soup::Status::Ok => { - Ok(Some(())) - }, + match http + .send_and_read_future(&msg, glib::Priority::DEFAULT) + .await + { + Ok(_) if msg.status() == soup::Status::Ok => Ok(Some(())), Ok(body) => { - tracing::warn!("couldn't revoke token: revocation endpoint returned non-200: {:?}", msg.status()); + tracing::warn!( + "couldn't revoke token: revocation endpoint returned non-200: {:?}", + msg.status() + ); match serde_json::from_slice::<kittybox_indieauth::Error>(&body) { Ok(err) => tracing::warn!("revocation endpoint returned an error: {}", err), - Err(_) => tracing::warn!("couldn't parse revocation endpoint error, response verbatim follows:\n{}", String::from_utf8_lossy(&body)) + Err(_) => tracing::warn!( + "couldn't parse revocation endpoint error, response verbatim follows:\n{}", + String::from_utf8_lossy(&body) + ), } Ok(None) - }, + } Err(err) => { - tracing::warn!("couldn't revoke token: error contacting revocation endpoint: {:?}", err); + tracing::warn!( + "couldn't revoke token: error contacting revocation endpoint: {:?}", + err + ); Err(err.into()) } } } - async fn get_login_state(schema: &libsecret::Schema, http: soup::Session) -> Result<Option<micropub::Client>, glib::Error> { - let mut retrievables = libsecret::password_search_future(Some(schema), { - let mut attrs = std::collections::HashMap::default(); - attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::ACCESS_TOKEN); - attrs - }, libsecret::SearchFlags::ALL).await?; + async fn get_login_state( + schema: &libsecret::Schema, + http: soup::Session, + ) -> Result<Option<micropub::Client>, glib::Error> { + let mut retrievables = libsecret::password_search_future( + Some(schema), + { + let mut attrs = std::collections::HashMap::default(); + attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::ACCESS_TOKEN); + attrs + }, + libsecret::SearchFlags::ALL, + ) + .await?; if retrievables.is_empty() { Ok(None) @@ -298,26 +374,27 @@ impl App { .collect(); let micropub_uri = match attrs .get(crate::secrets::MICROPUB) - .and_then(|v| glib::Uri::parse( - v, glib::UriFlags::NONE - ).ok()) { - Some(uri) => uri, - None => { - let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await; - continue - }, - }; + .and_then(|v| glib::Uri::parse(v, glib::UriFlags::NONE).ok()) + { + Some(uri) => uri, + None => { + let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await; + continue; + } + }; let me = attrs.get(crate::secrets::ME).unwrap().to_string(); let micropub = crate::micropub::Client::new( http.clone(), micropub_uri, - retrievable.retrieve_secret_future().await? + retrievable + .retrieve_secret_future() + .await? .unwrap() .text() .unwrap() .to_string(), - me.clone() + me.clone(), ); // Skip the token if we can't access ?q=config @@ -327,21 +404,22 @@ impl App { // Token may have expired. See if we have a refresh token and renew. let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await; - match Self::refresh_token( - schema, http.clone(), - me.clone() - ).await { + match Self::refresh_token(schema, http.clone(), me.clone()).await { Ok(None) => continue, Err(err) => { - tracing::warn!("error refreshing Micropub token for {}: {}", &me, err); - continue - }, - Ok(Some(micropub)) => return Ok(Some(micropub)) + tracing::warn!( + "error refreshing Micropub token for {}: {}", + &me, + err + ); + continue; + } + Ok(Some(micropub)) => return Ok(Some(micropub)), } } } - return Ok(Some(micropub)) + return Ok(Some(micropub)); } Ok(None) @@ -461,12 +539,17 @@ impl AsyncComponent for App { ) -> AsyncComponentParts<Self> { let secret_schema = crate::secrets::get_schema(); let http = soup::Session::builder() - .user_agent(concat!(env!("CARGO_PKG_NAME"),"/",env!("CARGO_PKG_VERSION")," ")) + .user_agent(concat!( + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION"), + " " + )) .build(); - let state = App::get_login_state( - &secret_schema, http.clone() - ).await.unwrap(); + let state = App::get_login_state(&secret_schema, http.clone()) + .await + .unwrap(); let model = App { submit_busy_guard: None, @@ -487,13 +570,13 @@ impl AsyncComponent for App { .forward(sender.input_sender(), Self::Input::PostEditor) }, signin: components::SignIn::builder() - .launch((glib::Uri::parse( - CLIENT_ID_STR, glib::UriFlags::NONE - ).unwrap(), http)) - .forward( - sender.input_sender(), - |o| Self::Input::Authorize(Box::new(o)) - ) + .launch(( + glib::Uri::parse(CLIENT_ID_STR, glib::UriFlags::NONE).unwrap(), + http, + )) + .forward(sender.input_sender(), |o| { + Self::Input::Authorize(Box::new(o)) + }), }; let widgets = view_output!(); @@ -504,20 +587,18 @@ impl AsyncComponent for App { App::about().present(weak_window.upgrade().as_ref()); }); let weak_window = window.downgrade(); - let preferences_action: RelmAction<PreferencesAction> = RelmAction::new_stateless(move |_| { - // This could be built as an action that sends an input to open preferences. - // - // But I find this an acceptable alternative. - let mut prefs = components::Preferences::builder() - .launch(()) - .detach(); - - prefs.emit(weak_window.upgrade().map(|w| w.upcast())); - prefs.detach_runtime(); - }); - let sign_out_action: RelmAction<SignOutAction> = RelmAction::new_stateless(move |_| { - input_sender.emit(Input::SignOut) - }); + let preferences_action: RelmAction<PreferencesAction> = + RelmAction::new_stateless(move |_| { + // This could be built as an action that sends an input to open preferences. + // + // But I find this an acceptable alternative. + let mut prefs = components::Preferences::builder().launch(()).detach(); + + prefs.emit(weak_window.upgrade().map(|w| w.upcast())); + prefs.detach_runtime(); + }); + let sign_out_action: RelmAction<SignOutAction> = + RelmAction::new_stateless(move |_| input_sender.emit(Input::SignOut)); let mut action_group: RelmActionGroup<AppActionGroup> = RelmActionGroup::new(); action_group.add_action(about_action); action_group.add_action(preferences_action); @@ -527,12 +608,11 @@ impl AsyncComponent for App { AsyncComponentParts { model, widgets } } - async fn update( &mut self, message: Self::Input, _sender: AsyncComponentSender<Self>, - _root: &Self::Root + _root: &Self::Root, ) { match message { Input::SignOut => { @@ -540,37 +620,43 @@ impl AsyncComponent for App { let _ = libsecret::password_clear_future( Some(&self.secret_schema), Default::default(), - ).await; - let _ = Self::revoke_token( - self.http.clone(), micropub.me, micropub.access_token - ).await; + ) + .await; + let _ = + Self::revoke_token(self.http.clone(), micropub.me, micropub.access_token) + .await; } - }, + } Input::Authorize(data) => { - if let Ok(micropub) = Self::authorize(&self.secret_schema, self.http.clone(), data).await { + if let Ok(micropub) = + Self::authorize(&self.secret_schema, self.http.clone(), data).await + { self.micropub = Some(micropub); } - }, + } Input::SubmitButtonPressed => { if self.micropub.is_some() { self.submit_busy_guard = Some(relm4::main_adw_application().mark_busy()); // TODO: too easy to deadlock here, refactor to take a channel self.post_editor.emit(PostEditorInput::Submit); }; - }, + } Input::PostEditor(None) => { self.submit_busy_guard = None; } Input::PostEditor(Some(post)) => { if let Some(micropub) = self.micropub.as_ref() { let mf2 = post.into_mf2(PostConversionSettings { - send_html_directly: self.settings.get("send-html-directly") + send_html_directly: self.settings.get("send-html-directly"), }); - log::debug!("Submitting post: {:#}", serde_json::to_string(&mf2).unwrap()); + log::debug!( + "Submitting post: {:#}", + serde_json::to_string(&mf2).unwrap() + ); match micropub.send_post(mf2).await { Ok(uri) => { self.post_editor.emit(PostEditorInput::SubmitDone(uri)); - }, + } Err(err) => { log::warn!("Error sending post: {}", err); self.post_editor.emit(PostEditorInput::SubmitError(err)); @@ -578,7 +664,7 @@ impl AsyncComponent for App { } } self.submit_busy_guard = None; - }, + } } } } |