diff options
Diffstat (limited to 'src/micropub/mod.rs')
-rw-r--r-- | src/micropub/mod.rs | 326 |
1 files changed, 195 insertions, 131 deletions
diff --git a/src/micropub/mod.rs b/src/micropub/mod.rs index 8505ae5..5e11033 100644 --- a/src/micropub/mod.rs +++ b/src/micropub/mod.rs @@ -1,26 +1,26 @@ use std::collections::HashMap; +use std::sync::Arc; use url::Url; use util::NormalizedPost; -use std::sync::Arc; use crate::database::{MicropubChannel, Storage, StorageError}; use crate::indieauth::backend::AuthBackend; use crate::indieauth::User; use crate::micropub::util::form_to_mf2_json; -use axum::extract::{FromRef, Query, State}; use axum::body::Body as BodyStream; +use axum::extract::{FromRef, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; use axum_extra::extract::Host; use axum_extra::headers::ContentType; -use axum::response::{IntoResponse, Response}; use axum_extra::TypedHeader; -use axum::http::StatusCode; +use kittybox_indieauth::{Scope, TokenData}; +use kittybox_util::micropub::{Error as MicropubError, ErrorKind, QueryType}; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::sync::Mutex; use tokio::task::JoinSet; use tracing::{debug, error, info, warn}; -use kittybox_indieauth::{Scope, TokenData}; -use kittybox_util::micropub::{Error as MicropubError, ErrorKind, QueryType}; #[derive(Serialize, Deserialize, Debug)] pub struct MicropubQuery { @@ -35,7 +35,7 @@ impl From<StorageError> for MicropubError { crate::database::ErrorKind::NotFound => ErrorKind::NotFound, _ => ErrorKind::InternalServerError, }, - format!("backend error: {}", err) + format!("backend error: {}", err), ) } } @@ -59,7 +59,8 @@ fn populate_reply_context( array .iter() .map(|i| { - let mut item = i.as_str() + let mut item = i + .as_str() .and_then(|i| i.parse::<Url>().ok()) .and_then(|url| ctxs.get(&url)) .and_then(|ctx| ctx.mf2["items"].get(0)) @@ -69,7 +70,12 @@ fn populate_reply_context( if item.is_object() && (i != &item) { if let Some(props) = item["properties"].as_object_mut() { // Fixup the item: if it lacks a URL, add one. - if !props.get("url").and_then(serde_json::Value::as_array).map(|a| !a.is_empty()).unwrap_or(false) { + if !props + .get("url") + .and_then(serde_json::Value::as_array) + .map(|a| !a.is_empty()) + .unwrap_or(false) + { props.insert("url".to_owned(), json!([i.as_str()])); } } @@ -145,11 +151,14 @@ async fn background_processing<D: 'static + Storage>( .get("webmention") .and_then(|i| i.first().cloned()); - dbg!(Some((url.clone(), FetchedPostContext { - url, - mf2: serde_json::to_value(mf2).unwrap(), - webmention - }))) + dbg!(Some(( + url.clone(), + FetchedPostContext { + url, + mf2: serde_json::to_value(mf2).unwrap(), + webmention + } + ))) }) .collect::<HashMap<Url, FetchedPostContext>>() .await @@ -161,7 +170,11 @@ async fn background_processing<D: 'static + Storage>( }; for prop in context_props { if let Some(json) = populate_reply_context(&mf2, prop, &post_contexts) { - update.replace.as_mut().unwrap().insert(prop.to_owned(), json); + update + .replace + .as_mut() + .unwrap() + .insert(prop.to_owned(), json); } } if !update.replace.as_ref().unwrap().is_empty() { @@ -250,7 +263,7 @@ pub(crate) async fn _post<D: 'static + Storage>( if !user.check_scope(&Scope::Create) { return Err(MicropubError::from_static( ErrorKind::InvalidScope, - "Not enough privileges - try acquiring the \"create\" scope." + "Not enough privileges - try acquiring the \"create\" scope.", )); } @@ -264,7 +277,7 @@ pub(crate) async fn _post<D: 'static + Storage>( { return Err(MicropubError::from_static( ErrorKind::Forbidden, - "You're posting to a website that's not yours." + "You're posting to a website that's not yours.", )); } @@ -272,7 +285,7 @@ pub(crate) async fn _post<D: 'static + Storage>( if db.post_exists(&uid).await? { return Err(MicropubError::from_static( ErrorKind::AlreadyExists, - "UID clash was detected, operation aborted." + "UID clash was detected, operation aborted.", )); } // Save the post @@ -309,13 +322,18 @@ pub(crate) async fn _post<D: 'static + Storage>( } } - let reply = - IntoResponse::into_response((StatusCode::ACCEPTED, [("Location", uid.as_str())])); + let reply = IntoResponse::into_response((StatusCode::ACCEPTED, [("Location", uid.as_str())])); #[cfg(not(tokio_unstable))] - let _ = jobset.lock().await.spawn(background_processing(db, mf2, http)); + let _ = jobset + .lock() + .await + .spawn(background_processing(db, mf2, http)); #[cfg(tokio_unstable)] - let _ = jobset.lock().await.build_task() + let _ = jobset + .lock() + .await + .build_task() .name(format!("Kittybox background processing for post {}", uid.as_str()).as_str()) .spawn(background_processing(db, mf2, http)); @@ -333,7 +351,7 @@ enum ActionType { #[serde(untagged)] pub enum MicropubPropertyDeletion { Properties(Vec<String>), - Values(HashMap<String, Vec<serde_json::Value>>) + Values(HashMap<String, Vec<serde_json::Value>>), } #[derive(Serialize, Deserialize)] struct MicropubFormAction { @@ -347,7 +365,7 @@ pub struct MicropubAction { url: String, #[serde(flatten)] #[serde(skip_serializing_if = "Option::is_none")] - update: Option<MicropubUpdate> + update: Option<MicropubUpdate>, } #[derive(Serialize, Deserialize, Debug, Default)] @@ -362,39 +380,43 @@ pub struct MicropubUpdate { impl MicropubUpdate { pub fn check_validity(&self) -> Result<(), MicropubError> { if let Some(add) = &self.add { - if add.iter().map(|(k, _)| k.as_str()).any(|k| { - k.to_lowercase().as_str() == "uid" - }) { + if add + .iter() + .map(|(k, _)| k.as_str()) + .any(|k| k.to_lowercase().as_str() == "uid") + { return Err(MicropubError::from_static( ErrorKind::InvalidRequest, - "Update cannot modify the post UID" + "Update cannot modify the post UID", )); } } if let Some(replace) = &self.replace { - if replace.iter().map(|(k, _)| k.as_str()).any(|k| { - k.to_lowercase().as_str() == "uid" - }) { + if replace + .iter() + .map(|(k, _)| k.as_str()) + .any(|k| k.to_lowercase().as_str() == "uid") + { return Err(MicropubError::from_static( ErrorKind::InvalidRequest, - "Update cannot modify the post UID" + "Update cannot modify the post UID", )); } } let iter = match &self.delete { Some(MicropubPropertyDeletion::Properties(keys)) => { Some(Box::new(keys.iter().map(|k| k.as_str())) as Box<dyn Iterator<Item = &str>>) - }, + } Some(MicropubPropertyDeletion::Values(map)) => { Some(Box::new(map.iter().map(|(k, _)| k.as_str())) as Box<dyn Iterator<Item = &str>>) - }, + } None => None, }; if let Some(mut iter) = iter { if iter.any(|k| k.to_lowercase().as_str() == "uid") { return Err(MicropubError::from_static( ErrorKind::InvalidRequest, - "Update cannot modify the post UID" + "Update cannot modify the post UID", )); } } @@ -412,8 +434,9 @@ impl MicropubUpdate { } else if let Some(MicropubPropertyDeletion::Values(ref delete)) = self.delete { if let Some(props) = post["properties"].as_object_mut() { for (key, values) in delete { - if let Some(prop) = props.get_mut(key).and_then(serde_json::Value::as_array_mut) { - prop.retain(|v| { values.iter().all(|i| i != v) }) + if let Some(prop) = props.get_mut(key).and_then(serde_json::Value::as_array_mut) + { + prop.retain(|v| values.iter().all(|i| i != v)) } } } @@ -428,7 +451,10 @@ impl MicropubUpdate { if let Some(add) = self.add { if let Some(props) = post["properties"].as_object_mut() { for (key, value) in add { - if let Some(prop) = props.get_mut(&key).and_then(serde_json::Value::as_array_mut) { + if let Some(prop) = props + .get_mut(&key) + .and_then(serde_json::Value::as_array_mut) + { prop.extend_from_slice(value.as_slice()); } else { props.insert(key, serde_json::Value::Array(value)); @@ -445,7 +471,7 @@ impl From<MicropubFormAction> for MicropubAction { Self { action: a.action, url: a.url, - update: None + update: None, } } } @@ -458,10 +484,12 @@ async fn post_action<D: Storage, A: AuthBackend>( ) -> Result<(), MicropubError> { let uri = match action.url.parse::<hyper::Uri>() { Ok(uri) => uri, - Err(err) => return Err(MicropubError::new( - ErrorKind::InvalidRequest, - format!("url parsing error: {}", err) - )) + Err(err) => { + return Err(MicropubError::new( + ErrorKind::InvalidRequest, + format!("url parsing error: {}", err), + )) + } }; if uri.authority().unwrap() @@ -475,7 +503,7 @@ async fn post_action<D: Storage, A: AuthBackend>( { return Err(MicropubError::from_static( ErrorKind::Forbidden, - "Don't tamper with others' posts!" + "Don't tamper with others' posts!", )); } @@ -484,7 +512,7 @@ async fn post_action<D: Storage, A: AuthBackend>( if !user.check_scope(&Scope::Delete) { return Err(MicropubError::from_static( ErrorKind::InvalidScope, - "You need a \"delete\" scope for this." + "You need a \"delete\" scope for this.", )); } @@ -494,7 +522,7 @@ async fn post_action<D: Storage, A: AuthBackend>( if !user.check_scope(&Scope::Update) { return Err(MicropubError::from_static( ErrorKind::InvalidScope, - "You need an \"update\" scope for this." + "You need an \"update\" scope for this.", )); } @@ -503,7 +531,7 @@ async fn post_action<D: Storage, A: AuthBackend>( } else { return Err(MicropubError::from_static( ErrorKind::InvalidRequest, - "Update request is not set." + "Update request is not set.", )); }; @@ -555,7 +583,7 @@ async fn dispatch_body( } else { Err(MicropubError::from_static( ErrorKind::InvalidRequest, - "Invalid JSON object passed." + "Invalid JSON object passed.", )) } } else if content_type == ContentType::form_url_encoded() { @@ -566,7 +594,7 @@ async fn dispatch_body( } else { Err(MicropubError::from_static( ErrorKind::InvalidRequest, - "Invalid form-encoded data. Try h=entry&content=Hello!" + "Invalid form-encoded data. Try h=entry&content=Hello!", )) } } else { @@ -605,7 +633,10 @@ pub(crate) async fn post<D: Storage + 'static, A: AuthBackend>( #[tracing::instrument(skip(db))] pub(crate) async fn query<D: Storage, A: AuthBackend>( State(db): State<D>, - query: Result<Query<MicropubQuery>, <Query<MicropubQuery> as axum::extract::FromRequestParts<()>>::Rejection>, + query: Result< + Query<MicropubQuery>, + <Query<MicropubQuery> as axum::extract::FromRequestParts<()>>::Rejection, + >, Host(host): Host, user: User<A>, ) -> axum::response::Response { @@ -616,8 +647,9 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>( } else { return MicropubError::from_static( ErrorKind::InvalidRequest, - "Invalid query provided. Try ?q=config to see what you can do." - ).into_response(); + "Invalid query provided. Try ?q=config to see what you can do.", + ) + .into_response(); }; if axum::http::Uri::try_from(user.me.as_str()) @@ -630,7 +662,7 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>( ErrorKind::NotAuthorized, "This website doesn't belong to you.", ) - .into_response(); + .into_response(); } // TODO: consider replacing by `user.me.authority()`? @@ -644,7 +676,7 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>( ErrorKind::InternalServerError, format!("Error fetching channels: {}", err), ) - .into_response() + .into_response() } }; @@ -654,35 +686,36 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>( QueryType::Config, QueryType::Channel, QueryType::SyndicateTo, - QueryType::Category + QueryType::Category, ], channels: Some(channels), syndicate_to: None, media_endpoint: Some(user.me.join("/.kittybox/media").unwrap()), other: { let mut map = std::collections::HashMap::new(); - map.insert("kittybox_authority".to_string(), serde_json::Value::String(user.me.to_string())); + map.insert( + "kittybox_authority".to_string(), + serde_json::Value::String(user.me.to_string()), + ); map - } + }, }) - .into_response() + .into_response() } QueryType::Source => { match query.url { - Some(url) => { - match db.get_post(&url).await { - Ok(some) => match some { - Some(post) => axum::response::Json(&post).into_response(), - None => MicropubError::from_static( - ErrorKind::NotFound, - "The specified MF2 object was not found in database.", - ) - .into_response(), - }, - Err(err) => MicropubError::from(err).into_response(), - } - } + Some(url) => match db.get_post(&url).await { + Ok(some) => match some { + Some(post) => axum::response::Json(&post).into_response(), + None => MicropubError::from_static( + ErrorKind::NotFound, + "The specified MF2 object was not found in database.", + ) + .into_response(), + }, + Err(err) => MicropubError::from(err).into_response(), + }, None => { // Here, one should probably attempt to query at least the main feed and collect posts // Using a pre-made query function can't be done because it does unneeded filtering @@ -691,7 +724,7 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>( ErrorKind::InvalidRequest, "Querying for post list is not implemented yet.", ) - .into_response() + .into_response() } } } @@ -701,46 +734,45 @@ pub(crate) async fn query<D: Storage, A: AuthBackend>( ErrorKind::InternalServerError, format!("error fetching channels: backend error: {}", err), ) - .into_response(), + .into_response(), }, QueryType::SyndicateTo => { axum::response::Json(json!({ "syndicate-to": [] })).into_response() - }, + } QueryType::Category => { let categories = match db.categories(user_domain).await { Ok(categories) => categories, Err(err) => { return MicropubError::new( ErrorKind::InternalServerError, - format!("error fetching categories: backend error: {}", err) - ).into_response() + format!("error fetching categories: backend error: {}", err), + ) + .into_response() } }; axum::response::Json(json!({ "categories": categories })).into_response() - }, - QueryType::Unknown(q) => return MicropubError::new( - ErrorKind::InvalidRequest, - format!("Invalid query: {}", q) - ).into_response(), + } + QueryType::Unknown(q) => { + return MicropubError::new(ErrorKind::InvalidRequest, format!("Invalid query: {}", q)) + .into_response() + } } } - pub fn router<A, S, St: Send + Sync + Clone + 'static>() -> axum::routing::MethodRouter<St> where S: Storage + FromRef<St> + 'static, A: AuthBackend + FromRef<St>, reqwest_middleware::ClientWithMiddleware: FromRef<St>, - Arc<Mutex<JoinSet<()>>>: FromRef<St> + Arc<Mutex<JoinSet<()>>>: FromRef<St>, { axum::routing::get(query::<S, A>) .post(post::<S, A>) - .layer::<_, _>(tower_http::cors::CorsLayer::new() - .allow_methods([ - axum::http::Method::GET, - axum::http::Method::POST, - ]) - .allow_origin(tower_http::cors::Any)) + .layer::<_, _>( + tower_http::cors::CorsLayer::new() + .allow_methods([axum::http::Method::GET, axum::http::Method::POST]) + .allow_origin(tower_http::cors::Any), + ) } #[cfg(test)] @@ -765,16 +797,19 @@ impl MicropubQuery { mod tests { use std::sync::Arc; - use crate::{database::Storage, micropub::{util::NormalizedPost, MicropubError}}; + use crate::{ + database::Storage, + micropub::{util::NormalizedPost, MicropubError}, + }; use bytes::Bytes; use futures::StreamExt; use serde_json::json; use tokio::sync::Mutex; use super::FetchedPostContext; - use kittybox_indieauth::{Scopes, Scope, TokenData}; use axum::extract::State; use axum_extra::extract::Host; + use kittybox_indieauth::{Scope, Scopes, TokenData}; #[test] fn test_populate_reply_context() { @@ -801,16 +836,27 @@ mod tests { } }); let fetched_ctx_url: url::Url = "https://fireburn.ru/posts/example".parse().unwrap(); - let reply_contexts = vec![(fetched_ctx_url.clone(), FetchedPostContext { - url: fetched_ctx_url.clone(), - mf2: json!({ "items": [test_ctx] }), - webmention: None, - })].into_iter().collect(); + let reply_contexts = vec![( + fetched_ctx_url.clone(), + FetchedPostContext { + url: fetched_ctx_url.clone(), + mf2: json!({ "items": [test_ctx] }), + webmention: None, + }, + )] + .into_iter() + .collect(); let like_of = super::populate_reply_context(&mf2, "like-of", &reply_contexts).unwrap(); - assert_eq!(like_of[0]["properties"]["content"], test_ctx["properties"]["content"]); - assert_eq!(like_of[0]["properties"]["url"][0].as_str().unwrap(), reply_contexts[&fetched_ctx_url].url.as_str()); + assert_eq!( + like_of[0]["properties"]["content"], + test_ctx["properties"]["content"] + ); + assert_eq!( + like_of[0]["properties"]["url"][0].as_str().unwrap(), + reply_contexts[&fetched_ctx_url].url.as_str() + ); assert_eq!(like_of[1], already_expanded_reply_ctx); assert_eq!(like_of[2], "https://fireburn.ru/posts/non-existent"); @@ -830,20 +876,21 @@ mod tests { me: "https://localhost:8080/".parse().unwrap(), client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), scope: Scopes::new(vec![Scope::Profile]), - iat: None, exp: None + iat: None, + exp: None, }; let NormalizedPost { id, post } = super::normalize_mf2(post, &user); let err = super::_post( - &user, id, post, db.clone(), - reqwest_middleware::ClientWithMiddleware::new( - reqwest::Client::new(), - Box::default() - ), - Arc::new(Mutex::new(tokio::task::JoinSet::new())) + &user, + id, + post, + db.clone(), + reqwest_middleware::ClientWithMiddleware::new(reqwest::Client::new(), Box::default()), + Arc::new(Mutex::new(tokio::task::JoinSet::new())), ) - .await - .unwrap_err(); + .await + .unwrap_err(); assert_eq!(err.error, super::ErrorKind::InvalidScope); @@ -866,21 +913,27 @@ mod tests { let user = TokenData { me: "https://aaronparecki.com/".parse().unwrap(), client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), - scope: Scopes::new(vec![Scope::Profile, Scope::Create, Scope::Update, Scope::Media]), - iat: None, exp: None + scope: Scopes::new(vec![ + Scope::Profile, + Scope::Create, + Scope::Update, + Scope::Media, + ]), + iat: None, + exp: None, }; let NormalizedPost { id, post } = super::normalize_mf2(post, &user); let err = super::_post( - &user, id, post, db.clone(), - reqwest_middleware::ClientWithMiddleware::new( - reqwest::Client::new(), - Box::default() - ), - Arc::new(Mutex::new(tokio::task::JoinSet::new())) + &user, + id, + post, + db.clone(), + reqwest_middleware::ClientWithMiddleware::new(reqwest::Client::new(), Box::default()), + Arc::new(Mutex::new(tokio::task::JoinSet::new())), ) - .await - .unwrap_err(); + .await + .unwrap_err(); assert_eq!(err.error, super::ErrorKind::Forbidden); @@ -902,20 +955,21 @@ mod tests { me: "https://localhost:8080/".parse().unwrap(), client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), scope: Scopes::new(vec![Scope::Profile, Scope::Create]), - iat: None, exp: None + iat: None, + exp: None, }; let NormalizedPost { id, post } = super::normalize_mf2(post, &user); let res = super::_post( - &user, id, post, db.clone(), - reqwest_middleware::ClientWithMiddleware::new( - reqwest::Client::new(), - Box::default() - ), - Arc::new(Mutex::new(tokio::task::JoinSet::new())) + &user, + id, + post, + db.clone(), + reqwest_middleware::ClientWithMiddleware::new(reqwest::Client::new(), Box::default()), + Arc::new(Mutex::new(tokio::task::JoinSet::new())), ) - .await - .unwrap(); + .await + .unwrap(); assert!(res.headers().contains_key("Location")); let location = res.headers().get("Location").unwrap(); @@ -938,10 +992,17 @@ mod tests { TokenData { me: "https://fireburn.ru/".parse().unwrap(), client_id: "https://kittybox.fireburn.ru/".parse().unwrap(), - scope: Scopes::new(vec![Scope::Profile, Scope::Create, Scope::Update, Scope::Media]), - iat: None, exp: None - }, std::marker::PhantomData - ) + scope: Scopes::new(vec![ + Scope::Profile, + Scope::Create, + Scope::Update, + Scope::Media, + ]), + iat: None, + exp: None, + }, + std::marker::PhantomData, + ), ) .await; @@ -954,7 +1015,10 @@ mod tests { .into_iter() .map(Result::unwrap) .by_ref() - .fold(Vec::new(), |mut a, i| { a.extend(i); a}); + .fold(Vec::new(), |mut a, i| { + a.extend(i); + a + }); let json: MicropubError = serde_json::from_slice(&body as &[u8]).unwrap(); assert_eq!(json.error, super::ErrorKind::NotAuthorized); } |