diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/database/mod.rs | 150 | ||||
-rw-r--r-- | src/database/redis/mod.rs | 293 | ||||
-rw-r--r-- | src/frontend/mod.rs | 300 | ||||
-rw-r--r-- | src/indieauth.rs | 99 | ||||
-rw-r--r-- | src/lib.rs | 211 | ||||
-rw-r--r-- | src/main.rs | 20 | ||||
-rw-r--r-- | src/micropub/get.rs | 17 | ||||
-rw-r--r-- | src/micropub/mod.rs | 2 | ||||
-rw-r--r-- | src/micropub/post.rs | 624 |
9 files changed, 1188 insertions, 528 deletions
diff --git a/src/database/mod.rs b/src/database/mod.rs index 98c2cae..8579125 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,17 +1,17 @@ #![warn(missing_docs)] -use async_trait::async_trait; -use serde::{Serialize,Deserialize}; use crate::indieauth::User; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; mod redis; pub use crate::database::redis::RedisStorage; #[cfg(test)] -pub use redis::tests::{RedisInstance, get_redis_instance}; +pub use redis::tests::{get_redis_instance, RedisInstance}; #[derive(Serialize, Deserialize, PartialEq, Debug)] pub struct MicropubChannel { pub uid: String, - pub name: String + pub name: String, } #[derive(Debug, Clone, Copy)] @@ -21,14 +21,14 @@ pub enum ErrorKind { JsonParsing, NotFound, BadRequest, - Other + Other, } #[derive(Debug)] pub struct StorageError { msg: String, source: Option<Box<dyn std::error::Error>>, - kind: ErrorKind + kind: ErrorKind, } unsafe impl Send for StorageError {} unsafe impl Sync for StorageError {} @@ -38,14 +38,16 @@ impl From<StorageError> for tide::Response { ErrorKind::BadRequest => 400, ErrorKind::NotFound => 404, _ => 500, - }).body(serde_json::json!({ + }) + .body(serde_json::json!({ "error": match err.kind() { ErrorKind::BadRequest => "invalid_request", ErrorKind::NotFound => "not_found", _ => "database_error" }, "error_description": err - })).build() + })) + .build() } } impl std::error::Error for StorageError { @@ -58,7 +60,7 @@ impl From<serde_json::Error> for StorageError { Self { msg: format!("{}", err), source: Some(Box::new(err)), - kind: ErrorKind::JsonParsing + kind: ErrorKind::JsonParsing, } } } @@ -70,15 +72,18 @@ impl std::fmt::Display for StorageError { ErrorKind::PermissionDenied => write!(f, "permission denied: "), ErrorKind::NotFound => write!(f, "not found: "), ErrorKind::BadRequest => write!(f, "bad request: "), - ErrorKind::Other => write!(f, "generic storage layer error: ") + ErrorKind::Other => write!(f, "generic storage layer error: "), } { Ok(_) => write!(f, "{}", self.msg), - Err(err) => Err(err) + Err(err) => Err(err), } } } impl serde::Serialize for StorageError { - fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> { + fn serialize<S: serde::Serializer>( + &self, + serializer: S, + ) -> std::result::Result<S::Ok, S::Error> { serializer.serialize_str(&self.to_string()) } } @@ -88,7 +93,7 @@ impl StorageError { StorageError { msg: msg.to_string(), source: None, - kind + kind, } } /// Get the kind of an error. @@ -100,12 +105,11 @@ impl StorageError { } } - /// A special Result type for the Micropub backing storage. pub type Result<T> = std::result::Result<T, StorageError>; /// A storage backend for the Micropub server. -/// +/// /// Implementations should note that all methods listed on this trait MUST be fully atomic /// or lock the database so that write conflicts or reading half-written data should not occur. #[async_trait] @@ -117,21 +121,21 @@ pub trait Storage: Clone + Send + Sync { async fn get_post(&self, url: &str) -> Result<Option<serde_json::Value>>; /// Save a post to the database as an MF2-JSON structure. - /// + /// /// Note that the `post` object MUST have `post["properties"]["uid"][0]` defined. async fn put_post<'a>(&self, post: &'a serde_json::Value) -> Result<()>; /*/// Save a post and add it to the relevant feeds listed in `post["properties"]["channel"]`. - /// - /// Note that the `post` object MUST have `post["properties"]["uid"][0]` defined + /// + /// Note that the `post` object MUST have `post["properties"]["uid"][0]` defined /// and `post["properties"]["channel"]` defined, even if it's empty. async fn put_and_index_post<'a>(&mut self, post: &'a serde_json::Value) -> Result<()>;*/ - + /// Modify a post using an update object as defined in the Micropub spec. - /// + /// /// Note to implementors: the update operation MUST be atomic OR MUST lock the database /// to prevent two clients overwriting each other's changes. - /// + /// /// You can assume concurrent updates will never contradict each other, since that will be dumb. /// The last update always wins. async fn update_post<'a>(&self, url: &'a str, update: serde_json::Value) -> Result<()>; @@ -142,21 +146,27 @@ pub trait Storage: Clone + Send + Sync { /// Fetch a feed at `url` and return a an h-feed object containing /// `limit` posts after a post by url `after`, filtering the content /// in context of a user specified by `user` (or an anonymous user). - /// + /// /// Specifically, private posts that don't include the user in the audience /// will be elided from the feed, and the posts containing location and not /// specifying post["properties"]["location-visibility"][0] == "public" /// will have their location data (but not check-in data) stripped. - /// + /// /// This function is used as an optimization so the client, whatever it is, /// doesn't have to fetch posts, then realize some of them are private, and /// fetch more posts. - /// + /// /// Note for implementors: if you use streams to fetch posts in parallel /// from the database, preferably make this method use a connection pool /// to reduce overhead of creating a database connection per post for /// parallel fetching. - async fn read_feed_with_limit<'a>(&self, url: &'a str, after: &'a Option<String>, limit: usize, user: &'a Option<String>) -> Result<Option<serde_json::Value>>; + async fn read_feed_with_limit<'a>( + &self, + url: &'a str, + after: &'a Option<String>, + limit: usize, + user: &'a Option<String>, + ) -> Result<Option<serde_json::Value>>; /// Deletes a post from the database irreversibly. 'nuff said. Must be idempotent. async fn delete_post<'a>(&self, url: &'a str) -> Result<()>; @@ -170,9 +180,9 @@ pub trait Storage: Clone + Send + Sync { #[cfg(test)] mod tests { - use super::{Storage, MicropubChannel}; - use serde_json::json; use super::redis::tests::get_redis_instance; + use super::{MicropubChannel, Storage}; + use serde_json::json; async fn test_backend_basic_operations<Backend: Storage>(backend: Backend) { let post: serde_json::Value = json!({ @@ -191,23 +201,47 @@ mod tests { backend.put_post(&post).await.unwrap(); if let Ok(Some(returned_post)) = backend.get_post(&key).await { assert!(returned_post.is_object()); - assert_eq!(returned_post["type"].as_array().unwrap().len(), post["type"].as_array().unwrap().len()); - assert_eq!(returned_post["type"].as_array().unwrap(), post["type"].as_array().unwrap()); - let props: &serde_json::Map<String, serde_json::Value> = post["properties"].as_object().unwrap(); + assert_eq!( + returned_post["type"].as_array().unwrap().len(), + post["type"].as_array().unwrap().len() + ); + assert_eq!( + returned_post["type"].as_array().unwrap(), + post["type"].as_array().unwrap() + ); + let props: &serde_json::Map<String, serde_json::Value> = + post["properties"].as_object().unwrap(); for key in props.keys() { - assert_eq!(returned_post["properties"][key].as_array().unwrap(), post["properties"][key].as_array().unwrap()) + assert_eq!( + returned_post["properties"][key].as_array().unwrap(), + post["properties"][key].as_array().unwrap() + ) } - } else { panic!("For some reason the backend did not return the post.") } + } else { + panic!("For some reason the backend did not return the post.") + } // Check the alternative URL - it should return the same post if let Ok(Some(returned_post)) = backend.get_post(&alt_url).await { assert!(returned_post.is_object()); - assert_eq!(returned_post["type"].as_array().unwrap().len(), post["type"].as_array().unwrap().len()); - assert_eq!(returned_post["type"].as_array().unwrap(), post["type"].as_array().unwrap()); - let props: &serde_json::Map<String, serde_json::Value> = post["properties"].as_object().unwrap(); + assert_eq!( + returned_post["type"].as_array().unwrap().len(), + post["type"].as_array().unwrap().len() + ); + assert_eq!( + returned_post["type"].as_array().unwrap(), + post["type"].as_array().unwrap() + ); + let props: &serde_json::Map<String, serde_json::Value> = + post["properties"].as_object().unwrap(); for key in props.keys() { - assert_eq!(returned_post["properties"][key].as_array().unwrap(), post["properties"][key].as_array().unwrap()) + assert_eq!( + returned_post["properties"][key].as_array().unwrap(), + post["properties"][key].as_array().unwrap() + ) } - } else { panic!("For some reason the backend did not return the post.") } + } else { + panic!("For some reason the backend did not return the post.") + } } async fn test_backend_get_channel_list<Backend: Storage>(backend: Backend) { @@ -221,33 +255,61 @@ mod tests { "children": [] }); backend.put_post(&feed).await.unwrap(); - let chans = backend.get_channels(&crate::indieauth::User::new("https://fireburn.ru/", "https://quill.p3k.io/", "create update media")).await.unwrap(); + let chans = backend + .get_channels(&crate::indieauth::User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + )) + .await + .unwrap(); assert_eq!(chans.len(), 1); - assert_eq!(chans[0], MicropubChannel { uid: "https://fireburn.ru/feeds/main".to_string(), name: "Main Page".to_string() }); + assert_eq!( + chans[0], + MicropubChannel { + uid: "https://fireburn.ru/feeds/main".to_string(), + name: "Main Page".to_string() + } + ); } async fn test_backend_settings<Backend: Storage>(backend: Backend) { - backend.set_setting("site_name", "https://fireburn.ru/", "Vika's Hideout").await.unwrap(); - assert_eq!(backend.get_setting("site_name", "https://fireburn.ru/").await.unwrap(), "Vika's Hideout"); + backend + .set_setting("site_name", "https://fireburn.ru/", "Vika's Hideout") + .await + .unwrap(); + assert_eq!( + backend + .get_setting("site_name", "https://fireburn.ru/") + .await + .unwrap(), + "Vika's Hideout" + ); } #[async_std::test] async fn test_redis_storage_basic_operations() { let redis_instance = get_redis_instance().await; - let backend = super::RedisStorage::new(redis_instance.uri().to_string()).await.unwrap(); + let backend = super::RedisStorage::new(redis_instance.uri().to_string()) + .await + .unwrap(); test_backend_basic_operations(backend).await; } #[async_std::test] async fn test_redis_storage_channel_list() { let redis_instance = get_redis_instance().await; - let backend = super::RedisStorage::new(redis_instance.uri().to_string()).await.unwrap(); + let backend = super::RedisStorage::new(redis_instance.uri().to_string()) + .await + .unwrap(); test_backend_get_channel_list(backend).await; } #[async_std::test] async fn test_redis_settings() { let redis_instance = get_redis_instance().await; - let backend = super::RedisStorage::new(redis_instance.uri().to_string()).await.unwrap(); + let backend = super::RedisStorage::new(redis_instance.uri().to_string()) + .await + .unwrap(); test_backend_settings(backend).await; } } diff --git a/src/database/redis/mod.rs b/src/database/redis/mod.rs index 352cece..5a6b70d 100644 --- a/src/database/redis/mod.rs +++ b/src/database/redis/mod.rs @@ -1,20 +1,20 @@ use async_trait::async_trait; +use futures::stream; use futures_util::FutureExt; use futures_util::StreamExt; -use futures::stream; use lazy_static::lazy_static; use log::error; +use mobc::Pool; use mobc_redis::redis; use mobc_redis::redis::AsyncCommands; -use serde_json::json; -use mobc::Pool; use mobc_redis::RedisConnectionManager; +use serde_json::json; -use crate::database::{Storage, Result, StorageError, ErrorKind, MicropubChannel}; +use crate::database::{ErrorKind, MicropubChannel, Result, Storage, StorageError}; use crate::indieauth::User; struct RedisScripts { - edit_post: redis::Script + edit_post: redis::Script, } impl From<mobc_redis::redis::RedisError> for StorageError { @@ -22,7 +22,7 @@ impl From<mobc_redis::redis::RedisError> for StorageError { Self { msg: format!("{}", err), source: Some(Box::new(err)), - kind: ErrorKind::Backend + kind: ErrorKind::Backend, } } } @@ -31,7 +31,7 @@ impl From<mobc::Error<mobc_redis::redis::RedisError>> for StorageError { Self { msg: format!("{}", err), source: Some(Box::new(err)), - kind: ErrorKind::Backend + kind: ErrorKind::Backend, } } } @@ -64,17 +64,40 @@ fn filter_post(mut post: serde_json::Value, user: &'_ Option<String>) -> Option< })); } let empty_vec: Vec<serde_json::Value> = vec![]; - let author = post["properties"]["author"].as_array().unwrap_or(&empty_vec).iter().map(|i| i.as_str().unwrap().to_string()); - let visibility = post["properties"]["visibility"][0].as_str().unwrap_or("public"); - let mut audience = author.chain(post["properties"]["audience"].as_array().unwrap_or(&empty_vec).iter().map(|i| i.as_str().unwrap().to_string())); - if (visibility == "private" && !audience.any(|i| Some(i) == *user)) || (visibility == "protected" && user.is_none()) { - return None + let author = post["properties"]["author"] + .as_array() + .unwrap_or(&empty_vec) + .iter() + .map(|i| i.as_str().unwrap().to_string()); + let visibility = post["properties"]["visibility"][0] + .as_str() + .unwrap_or("public"); + let mut audience = author.chain( + post["properties"]["audience"] + .as_array() + .unwrap_or(&empty_vec) + .iter() + .map(|i| i.as_str().unwrap().to_string()), + ); + if (visibility == "private" && !audience.any(|i| Some(i) == *user)) + || (visibility == "protected" && user.is_none()) + { + return None; } if post["properties"]["location"].is_array() { - let location_visibility = post["properties"]["location-visibility"][0].as_str().unwrap_or("private"); - let mut author = post["properties"]["author"].as_array().unwrap_or(&empty_vec).iter().map(|i| i.as_str().unwrap().to_string()); + let location_visibility = post["properties"]["location-visibility"][0] + .as_str() + .unwrap_or("private"); + let mut author = post["properties"]["author"] + .as_array() + .unwrap_or(&empty_vec) + .iter() + .map(|i| i.as_str().unwrap().to_string()); if location_visibility == "private" && !author.any(|i| Some(i) == *user) { - post["properties"].as_object_mut().unwrap().remove("location"); + post["properties"] + .as_object_mut() + .unwrap() + .remove("location"); } } Some(post) @@ -84,12 +107,16 @@ fn filter_post(mut post: serde_json::Value, user: &'_ Option<String>) -> Option< impl Storage for RedisStorage { async fn get_setting<'a>(&self, setting: &'a str, user: &'a str) -> Result<String> { let mut conn = self.redis.get().await?; - Ok(conn.hget::<String, &str, String>(format!("settings_{}", user), setting).await?) + Ok(conn + .hget::<String, &str, String>(format!("settings_{}", user), setting) + .await?) } async fn set_setting<'a>(&self, setting: &'a str, user: &'a str, value: &'a str) -> Result<()> { let mut conn = self.redis.get().await?; - Ok(conn.hset::<String, &str, &str, ()>(format!("settings_{}", user), setting, value).await?) + Ok(conn + .hset::<String, &str, &str, ()>(format!("settings_{}", user), setting, value) + .await?) } async fn delete_post<'a>(&self, url: &'a str) -> Result<()> { @@ -101,41 +128,63 @@ impl Storage for RedisStorage { let mut conn = self.redis.get().await?; Ok(conn.hexists::<&str, &str, bool>(&"posts", url).await?) } - + async fn get_post(&self, url: &str) -> Result<Option<serde_json::Value>> { let mut conn = self.redis.get().await?; - match conn.hget::<&str, &str, Option<String>>(&"posts", url).await? { + match conn + .hget::<&str, &str, Option<String>>(&"posts", url) + .await? + { Some(val) => { let parsed = serde_json::from_str::<serde_json::Value>(&val)?; if let Some(new_url) = parsed["see_other"].as_str() { - match conn.hget::<&str, &str, Option<String>>(&"posts", new_url).await? { + match conn + .hget::<&str, &str, Option<String>>(&"posts", new_url) + .await? + { Some(val) => Ok(Some(serde_json::from_str::<serde_json::Value>(&val)?)), - None => Ok(None) + None => Ok(None), } } else { Ok(Some(parsed)) } - }, - None => Ok(None) + } + None => Ok(None), } } async fn get_channels(&self, user: &User) -> Result<Vec<MicropubChannel>> { let mut conn = self.redis.get().await?; - let channels = conn.smembers::<String, Vec<String>>("channels_".to_string() + user.me.as_str()).await?; + let channels = conn + .smembers::<String, Vec<String>>("channels_".to_string() + user.me.as_str()) + .await?; // TODO: use streams here instead of this weird thing... how did I even write this?! - Ok(futures_util::future::join_all(channels.iter() - .map(|channel| self.get_post(channel) - .map(|result| result.unwrap()) - .map(|post: Option<serde_json::Value>| { - if let Some(post) = post { - Some(MicropubChannel { - uid: post["properties"]["uid"][0].as_str().unwrap().to_string(), - name: post["properties"]["name"][0].as_str().unwrap().to_string() - }) - } else { None } + Ok(futures_util::future::join_all( + channels + .iter() + .map(|channel| { + self.get_post(channel).map(|result| result.unwrap()).map( + |post: Option<serde_json::Value>| { + if let Some(post) = post { + Some(MicropubChannel { + uid: post["properties"]["uid"][0].as_str().unwrap().to_string(), + name: post["properties"]["name"][0] + .as_str() + .unwrap() + .to_string(), + }) + } else { + None + } + }, + ) }) - ).collect::<Vec<_>>()).await.into_iter().filter_map(|chan| chan).collect::<Vec<_>>()) + .collect::<Vec<_>>(), + ) + .await + .into_iter() + .filter_map(|chan| chan) + .collect::<Vec<_>>()) } async fn put_post<'a>(&self, post: &'a serde_json::Value) -> Result<()> { @@ -143,72 +192,122 @@ impl Storage for RedisStorage { let key: &str; match post["properties"]["uid"][0].as_str() { Some(uid) => key = uid, - None => return Err(StorageError::new(ErrorKind::BadRequest, "post doesn't have a UID")) + None => { + return Err(StorageError::new( + ErrorKind::BadRequest, + "post doesn't have a UID", + )) + } } - conn.hset::<&str, &str, String, ()>(&"posts", key, post.to_string()).await?; + conn.hset::<&str, &str, String, ()>(&"posts", key, post.to_string()) + .await?; if post["properties"]["url"].is_array() { - for url in post["properties"]["url"].as_array().unwrap().iter().map(|i| i.as_str().unwrap().to_string()) { + for url in post["properties"]["url"] + .as_array() + .unwrap() + .iter() + .map(|i| i.as_str().unwrap().to_string()) + { if url != key { - conn.hset::<&str, &str, String, ()>(&"posts", &url, json!({"see_other": key}).to_string()).await?; + conn.hset::<&str, &str, String, ()>( + &"posts", + &url, + json!({ "see_other": key }).to_string(), + ) + .await?; } } } - if post["type"].as_array().unwrap().iter().any(|i| i == "h-feed") { + if post["type"] + .as_array() + .unwrap() + .iter() + .any(|i| i == "h-feed") + { // This is a feed. Add it to the channels array if it's not already there. - conn.sadd::<String, &str, ()>("channels_".to_string() + post["properties"]["author"][0].as_str().unwrap(), key).await? + conn.sadd::<String, &str, ()>( + "channels_".to_string() + post["properties"]["author"][0].as_str().unwrap(), + key, + ) + .await? } Ok(()) } - async fn read_feed_with_limit<'a>(&self, url: &'a str, after: &'a Option<String>, limit: usize, user: &'a Option<String>) -> Result<Option<serde_json::Value>> { + async fn read_feed_with_limit<'a>( + &self, + url: &'a str, + after: &'a Option<String>, + limit: usize, + user: &'a Option<String>, + ) -> Result<Option<serde_json::Value>> { let mut conn = self.redis.get().await?; let mut feed; - match conn.hget::<&str, &str, Option<String>>(&"posts", url).await? { + match conn + .hget::<&str, &str, Option<String>>(&"posts", url) + .await? + { Some(post) => feed = serde_json::from_str::<serde_json::Value>(&post)?, - None => return Ok(None) + None => return Ok(None), } if feed["see_other"].is_string() { - match conn.hget::<&str, &str, Option<String>>(&"posts", feed["see_other"].as_str().unwrap()).await? { + match conn + .hget::<&str, &str, Option<String>>(&"posts", feed["see_other"].as_str().unwrap()) + .await? + { Some(post) => feed = serde_json::from_str::<serde_json::Value>(&post)?, - None => return Ok(None) + None => return Ok(None), } } if let Some(post) = filter_post(feed, user) { feed = post } else { - return Err(StorageError::new(ErrorKind::PermissionDenied, "specified user cannot access this post")) + return Err(StorageError::new( + ErrorKind::PermissionDenied, + "specified user cannot access this post", + )); } if feed["children"].is_array() { let children = feed["children"].as_array().unwrap(); let posts_iter: Box<dyn std::iter::Iterator<Item = String> + Send>; // TODO: refactor this to apply the skip on the &mut iterator if let Some(after) = after { - posts_iter = Box::new(children.iter().map(|i| i.as_str().unwrap().to_string()).skip_while(move |i| i != after).skip(1)); + posts_iter = Box::new( + children + .iter() + .map(|i| i.as_str().unwrap().to_string()) + .skip_while(move |i| i != after) + .skip(1), + ); } else { posts_iter = Box::new(children.iter().map(|i| i.as_str().unwrap().to_string())); } let posts = stream::iter(posts_iter) .map(|url| async move { match self.redis.get().await { - Ok(mut conn) => match conn.hget::<&str, &str, Option<String>>("posts", &url).await { - Ok(post) => match post { - Some(post) => match serde_json::from_str::<serde_json::Value>(&post) { - Ok(post) => Some(post), - Err(err) => { - let err = StorageError::from(err); - error!("{}", err); - panic!("{}", err) + Ok(mut conn) => { + match conn.hget::<&str, &str, Option<String>>("posts", &url).await { + Ok(post) => match post { + Some(post) => { + match serde_json::from_str::<serde_json::Value>(&post) { + Ok(post) => Some(post), + Err(err) => { + let err = StorageError::from(err); + error!("{}", err); + panic!("{}", err) + } + } } + // Happens because of a broken link (result of an improper deletion?) + None => None, }, - // Happens because of a broken link (result of an improper deletion?) - None => None, - }, - Err(err) => { - let err = StorageError::from(err); - error!("{}", err); - panic!("{}", err) + Err(err) => { + let err = StorageError::from(err); + error!("{}", err); + panic!("{}", err) + } } - }, + } // TODO: Instead of causing a panic, investigate how can you fail the whole stream // Somehow fuse it maybe? Err(err) => { @@ -227,14 +326,20 @@ impl Storage for RedisStorage { // Hack to unwrap the Option and sieve out broken links // Broken links return None, and Stream::filter_map skips all Nones. .filter_map(|post: Option<serde_json::Value>| async move { post }) - .filter_map(|post| async move { - filter_post(post, user) - }) + .filter_map(|post| async move { filter_post(post, user) }) .take(limit); // TODO: Instead of catching panics, find a way to make the whole stream fail with Result<Vec<serde_json::Value>> - match std::panic::AssertUnwindSafe(posts.collect::<Vec<serde_json::Value>>()).catch_unwind().await { + match std::panic::AssertUnwindSafe(posts.collect::<Vec<serde_json::Value>>()) + .catch_unwind() + .await + { Ok(posts) => feed["children"] = json!(posts), - Err(_) => return Err(StorageError::new(ErrorKind::Other, "Unknown error encountered while assembling feed, see logs for more info")) + Err(_) => { + return Err(StorageError::new( + ErrorKind::Other, + "Unknown error encountered while assembling feed, see logs for more info", + )) + } } } return Ok(Some(feed)); @@ -242,39 +347,56 @@ impl Storage for RedisStorage { async fn update_post<'a>(&self, mut url: &'a str, update: serde_json::Value) -> Result<()> { let mut conn = self.redis.get().await?; - if !conn.hexists::<&str, &str, bool>("posts", url).await.unwrap() { - return Err(StorageError::new(ErrorKind::NotFound, "can't edit a non-existent post")) + if !conn + .hexists::<&str, &str, bool>("posts", url) + .await + .unwrap() + { + return Err(StorageError::new( + ErrorKind::NotFound, + "can't edit a non-existent post", + )); } - let post: serde_json::Value = serde_json::from_str(&conn.hget::<&str, &str, String>("posts", url).await?)?; + let post: serde_json::Value = + serde_json::from_str(&conn.hget::<&str, &str, String>("posts", url).await?)?; if let Some(new_url) = post["see_other"].as_str() { url = new_url } - Ok(SCRIPTS.edit_post.key("posts").arg(url).arg(update.to_string()).invoke_async::<_, ()>(&mut conn as &mut redis::aio::Connection).await?) + Ok(SCRIPTS + .edit_post + .key("posts") + .arg(url) + .arg(update.to_string()) + .invoke_async::<_, ()>(&mut conn as &mut redis::aio::Connection) + .await?) } } - impl RedisStorage { /// Create a new RedisDatabase that will connect to Redis at `redis_uri` to store data. pub async fn new(redis_uri: String) -> Result<Self> { match redis::Client::open(redis_uri) { - Ok(client) => Ok(Self { redis: Pool::builder().max_open(20).build(RedisConnectionManager::new(client)) }), - Err(e) => Err(e.into()) + Ok(client) => Ok(Self { + redis: Pool::builder() + .max_open(20) + .build(RedisConnectionManager::new(client)), + }), + Err(e) => Err(e.into()), } } } #[cfg(test)] pub mod tests { + use mobc_redis::redis; use std::process; use std::time::Duration; - use mobc_redis::redis; pub struct RedisInstance { // We just need to hold on to it so it won't get dropped and remove the socket _tempdir: tempdir::TempDir, uri: String, - child: std::process::Child + child: std::process::Child, } impl Drop for RedisInstance { fn drop(&mut self) { @@ -292,11 +414,14 @@ pub mod tests { let socket = tempdir.path().join("redis.sock"); let redis_child = process::Command::new("redis-server") .current_dir(&tempdir) - .arg("--port").arg("0") - .arg("--unixsocket").arg(&socket) + .arg("--port") + .arg("0") + .arg("--unixsocket") + .arg(&socket) .stdout(process::Stdio::null()) .stderr(process::Stdio::null()) - .spawn().expect("Failed to spawn Redis"); + .spawn() + .expect("Failed to spawn Redis"); println!("redis+unix:///{}", socket.to_str().unwrap()); let uri = format!("redis+unix:///{}", socket.to_str().unwrap()); // There should be a slight delay, we need to wait for Redis to spin up @@ -317,7 +442,9 @@ pub mod tests { } return RedisInstance { - uri, child: redis_child, _tempdir: tempdir - } + uri, + child: redis_child, + _tempdir: tempdir, + }; } } diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index c92619b..eefc257 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -1,17 +1,17 @@ -use serde::{Serialize, Deserialize}; -use tide::{Request, Response, Result, StatusCode, Next}; -use log::{info,error}; -use crate::ApplicationState; use crate::database::Storage; +use crate::ApplicationState; +use log::{error, info}; +use serde::{Deserialize, Serialize}; +use tide::{Next, Request, Response, Result, StatusCode}; static POSTS_PER_PAGE: usize = 20; mod templates { - use log::error; - use http_types::StatusCode; - use ellipse::Ellipse; - use chrono; use super::IndiewebEndpoints; + use chrono; + use ellipse::Ellipse; + use http_types::StatusCode; + use log::error; /// Return a pretty location specifier from a geo: URI. fn decode_geo_uri(uri: &str) -> String { @@ -21,12 +21,12 @@ mod templates { let lat = parts.next().unwrap(); let lon = parts.next().unwrap(); // TODO - format them as proper latitude and longitude - return format!("{}, {}", lat, lon) + return format!("{}, {}", lat, lon); } else { - return uri.to_string() + return uri.to_string(); } } else { - return uri.to_string() + return uri.to_string(); } } @@ -124,7 +124,7 @@ mod templates { div.form_group { label[for="hcard_name"] { "Your name" } input#hcard_name[name="hcard_name", placeholder="Your name"]; - small { + small { "No need to write the name as in your passport, this is not a legal document " "- just write how you want to be called on the network. This name will be also " "shown whenever you leave a comment on someone else's post using your website." @@ -165,7 +165,7 @@ mod templates { small { "A little bit of introduction. Just one paragraph, and note, you can't use HTML here (yet)." } // TODO: HTML e-note instead of p-note } - + // TODO: u-photo upload - needs media endpoint cooperation div.switch_card_buttons { @@ -438,7 +438,7 @@ mod templates { @if card["properties"]["photo"][0].is_string() { img."u-photo"[src=card["properties"]["photo"][0].as_str().unwrap()]; } - h1 { + h1 { a."u-url"."u-uid"."p-name"[href=card["properties"]["uid"][0].as_str().unwrap()] { @card["properties"]["name"][0].as_str().unwrap() } @@ -508,7 +508,7 @@ mod templates { } } @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == super::POSTS_PER_PAGE { - a[rel="prev", href=feed["properties"]["uid"][0].as_str().unwrap().to_string() + a[rel="prev", href=feed["properties"]["uid"][0].as_str().unwrap().to_string() + "?after=" + feed["children"][super::POSTS_PER_PAGE - 1]["properties"]["uid"][0].as_str().unwrap()] { "Older posts" } @@ -521,8 +521,8 @@ mod templates { #dynamicstuff { p { "This section will provide interesting statistics or tidbits about my life in this exact moment (with maybe a small delay)." } p { "It will probably require JavaScript to self-update, but I promise to keep this widget lightweight and open-source!" } - p { small { - "JavaScript isn't a menace, stop fearing it or I will switch to WebAssembly " + p { small { + "JavaScript isn't a menace, stop fearing it or I will switch to WebAssembly " "and knock your nico-nico-kneecaps so fast with its speed you won't even notice that... " small { "omae ha mou shindeiru" } // NANI?!!! @@ -557,7 +557,7 @@ mod templates { StatusCode::ImATeapot => { p { "Wait, do you seriously expect my website to brew you coffee? It's not a coffee machine!" } - p { + p { small { "I could brew you some coffee tho if we meet one day... " small { i { "i-it's nothing personal, I just like brewing coffee, b-baka!!!~ >.<!" } } } } @@ -565,51 +565,61 @@ mod templates { _ => { p { "It seems like you have found an error. Not to worry, it has already been logged." } } } P { "For now, may I suggest to visit " a[href="/"] {"the main page"} " of this website?" } - + } } } -use templates::{Template,ErrorPage,MainPage,OnboardingPage}; +use templates::{ErrorPage, MainPage, OnboardingPage, Template}; #[derive(Clone, Serialize, Deserialize)] pub struct IndiewebEndpoints { authorization_endpoint: String, token_endpoint: String, webmention: Option<String>, - microsub: Option<String> + microsub: Option<String>, } #[derive(Deserialize)] struct QueryParams { - after: Option<String> + after: Option<String>, } #[derive(Debug)] struct FrontendError { msg: String, source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>, - code: StatusCode + code: StatusCode, } impl FrontendError { pub fn with_code(code: StatusCode, msg: &str) -> Self { - Self { msg: msg.to_string(), source: None, code } + Self { + msg: msg.to_string(), + source: None, + code, + } + } + pub fn msg(&self) -> &str { + &self.msg + } + pub fn code(&self) -> StatusCode { + self.code } - pub fn msg(&self) -> &str { &self.msg } - pub fn code(&self) -> StatusCode { self.code } } impl From<crate::database::StorageError> for FrontendError { fn from(err: crate::database::StorageError) -> Self { Self { msg: "Database error".to_string(), source: Some(Box::new(err)), - code: StatusCode::InternalServerError + code: StatusCode::InternalServerError, } } } impl std::error::Error for FrontendError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - self.source.as_ref().map(|e| e.as_ref() as &(dyn std::error::Error + 'static)) + self.source + .as_ref() + .map(|e| e.as_ref() as &(dyn std::error::Error + 'static)) } } impl std::fmt::Display for FrontendError { @@ -618,30 +628,47 @@ impl std::fmt::Display for FrontendError { } } -async fn get_post_from_database<S: Storage>(db: &S, url: &str, after: Option<String>, user: &Option<String>) -> std::result::Result<serde_json::Value, FrontendError> { - match db.read_feed_with_limit(url, &after, POSTS_PER_PAGE, user).await { +async fn get_post_from_database<S: Storage>( + db: &S, + url: &str, + after: Option<String>, + user: &Option<String>, +) -> std::result::Result<serde_json::Value, FrontendError> { + match db + .read_feed_with_limit(url, &after, POSTS_PER_PAGE, user) + .await + { Ok(result) => match result { Some(post) => Ok(post), - None => Err(FrontendError::with_code(StatusCode::NotFound, "Post not found in the database")) + None => Err(FrontendError::with_code( + StatusCode::NotFound, + "Post not found in the database", + )), }, Err(err) => match err.kind() { crate::database::ErrorKind::PermissionDenied => { // TODO: Authentication if user.is_some() { - Err(FrontendError::with_code(StatusCode::Forbidden, "User authenticated AND forbidden to access this resource")) + Err(FrontendError::with_code( + StatusCode::Forbidden, + "User authenticated AND forbidden to access this resource", + )) } else { - Err(FrontendError::with_code(StatusCode::Unauthorized, "User needs to authenticate themselves")) + Err(FrontendError::with_code( + StatusCode::Unauthorized, + "User needs to authenticate themselves", + )) } } - _ => Err(err.into()) - } + _ => Err(err.into()), + }, } } #[derive(Deserialize)] struct OnboardingFeed { slug: String, - name: String + name: String, } #[derive(Deserialize)] @@ -649,7 +676,7 @@ struct OnboardingData { user: serde_json::Value, first_post: serde_json::Value, blog_name: String, - feeds: Vec<OnboardingFeed> + feeds: Vec<OnboardingFeed>, } pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result { @@ -663,16 +690,24 @@ pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S let me = url::Url::parse("http://localhost:8080/").unwrap(); if let Ok(_) = get_post_from_database(backend, me.as_str(), None, &None).await { - Err(FrontendError::with_code(StatusCode::Forbidden, "Onboarding is over. Are you trying to take over somebody's website?!"))? + Err(FrontendError::with_code( + StatusCode::Forbidden, + "Onboarding is over. Are you trying to take over somebody's website?!", + ))? } info!("Onboarding new user: {}", me); let user = crate::indieauth::User::new(me.as_str(), "https://kittybox.fireburn.ru/", "create"); - backend.set_setting("site_name", user.me.as_str(), &body.blog_name).await?; + backend + .set_setting("site_name", user.me.as_str(), &body.blog_name) + .await?; if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" { - Err(FrontendError::with_code(StatusCode::BadRequest, "user and first_post should be h-card and h-entry"))? + Err(FrontendError::with_code( + StatusCode::BadRequest, + "user and first_post should be h-card and h-entry", + ))? } info!("Validated body.user and body.first_post as microformats2"); @@ -680,7 +715,7 @@ pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S let hentry = body.first_post; // Ensure the h-card's UID is set to the main page, so it will be fetchable. - hcard["properties"]["uid"] = json!([ me.as_str() ]); + hcard["properties"]["uid"] = json!([me.as_str()]); // Normalize the h-card - note that it should preserve the UID we set here. let (_, hcard) = crate::micropub::normalize_mf2(hcard, &user); // The h-card is written directly - all the stuff in the Micropub's @@ -690,10 +725,13 @@ pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S backend.put_post(&hcard).await?; for feed in body.feeds { - let (_, feed) = crate::micropub::normalize_mf2(json!({ - "type": ["h-feed"], - "properties": {"name": [feed.name], "mp-slug": [feed.slug]} - }), &user); + let (_, feed) = crate::micropub::normalize_mf2( + json!({ + "type": ["h-feed"], + "properties": {"name": [feed.name], "mp-slug": [feed.slug]} + }), + &user, + ); backend.put_post(&feed).await?; } @@ -707,8 +745,11 @@ pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S } pub async fn coffee<S: Storage>(_: Request<ApplicationState<S>>) -> Result { - Err(FrontendError::with_code(StatusCode::ImATeapot, "Someone asked this website to brew them some coffee..."))?; - return Ok(Response::builder(500).build()) // unreachable + Err(FrontendError::with_code( + StatusCode::ImATeapot, + "Someone asked this website to brew them some coffee...", + ))?; + return Ok(Response::builder(500).build()); // unreachable } pub async fn mainpage<S: Storage>(req: Request<ApplicationState<S>>) -> Result { @@ -726,7 +767,7 @@ pub async fn mainpage<S: Storage>(req: Request<ApplicationState<S>>) -> Result { info!("Request at {}", url); let hcard_url = url.as_str(); let feed_url = url.join("feeds/main").unwrap().to_string(); - + let card = get_post_from_database(backend, hcard_url, None, &user).await; let feed = get_post_from_database(backend, &feed_url, query.after, &user).await; @@ -737,34 +778,51 @@ pub async fn mainpage<S: Storage>(req: Request<ApplicationState<S>>) -> Result { let feed_err = feed.unwrap_err(); if card_err.code == 404 { // Yes, we definitely need some onboarding here. - Ok(Response::builder(200).content_type("text/html; charset=utf-8").body(Template { - title: "Kittybox - Onboarding", - blog_name: "Kitty Box!", - endpoints: IndiewebEndpoints { - authorization_endpoint, token_endpoint, - webmention: None, microsub: None - }, - content: OnboardingPage {}.to_string() - }.to_string()).build()) + Ok(Response::builder(200) + .content_type("text/html; charset=utf-8") + .body( + Template { + title: "Kittybox - Onboarding", + blog_name: "Kitty Box!", + endpoints: IndiewebEndpoints { + authorization_endpoint, + token_endpoint, + webmention: None, + microsub: None, + }, + content: OnboardingPage {}.to_string(), + } + .to_string(), + ) + .build()) } else { Err(feed_err)? } } else { Ok(Response::builder(200) .content_type("text/html; charset=utf-8") - .body(Template { - title: &format!("{} - Main page", url.host().unwrap().to_string()), - blog_name: &backend.get_setting("site_name", &url.host().unwrap().to_string()).await.unwrap_or_else(|_| "Kitty Box!".to_string()), - endpoints: IndiewebEndpoints { - authorization_endpoint, token_endpoint, - webmention: None, microsub: None - }, - content: MainPage { - feed: &feed?, - card: &card? - }.to_string() - }.to_string() - ).build()) + .body( + Template { + title: &format!("{} - Main page", url.host().unwrap().to_string()), + blog_name: &backend + .get_setting("site_name", &url.host().unwrap().to_string()) + .await + .unwrap_or_else(|_| "Kitty Box!".to_string()), + endpoints: IndiewebEndpoints { + authorization_endpoint, + token_endpoint, + webmention: None, + microsub: None, + }, + content: MainPage { + feed: &feed?, + card: &card?, + } + .to_string(), + } + .to_string(), + ) + .build()) } } @@ -777,41 +835,73 @@ pub async fn render_post<S: Storage>(req: Request<ApplicationState<S>>) -> Resul #[cfg(any(not(debug_assertions), test))] let url = req.url(); #[cfg(all(debug_assertions, not(test)))] - let url = url::Url::parse("http://localhost:8080/").unwrap().join(req.url().path()).unwrap(); - - let post = get_post_from_database(&req.state().storage, url.as_str(), query.after, &user).await?; - - let template: String = match post["type"][0].as_str().expect("Empty type array or invalid type") { + let url = url::Url::parse("http://localhost:8080/") + .unwrap() + .join(req.url().path()) + .unwrap(); + + let post = + get_post_from_database(&req.state().storage, url.as_str(), query.after, &user).await?; + + let template: String = match post["type"][0] + .as_str() + .expect("Empty type array or invalid type") + { "h-entry" => templates::Entry { post: &post }.to_string(), "h-card" => templates::VCard { card: &post }.to_string(), "h-feed" => templates::Feed { feed: &post }.to_string(), - _ => Err(FrontendError::with_code(StatusCode::InternalServerError, "Couldn't render an unknown type"))? + _ => Err(FrontendError::with_code( + StatusCode::InternalServerError, + "Couldn't render an unknown type", + ))?, }; Ok(Response::builder(200) .content_type("text/html; charset=utf-8") - .body(Template { - title: post["properties"]["name"][0].as_str().unwrap_or(&format!("Note at {}", url.host().unwrap().to_string())), - blog_name: &req.state().storage.get_setting("site_name", &url.host().unwrap().to_string()).await.unwrap_or_else(|_| "Kitty Box!".to_string()), - endpoints: IndiewebEndpoints { - authorization_endpoint, token_endpoint, - webmention: None, microsub: None - }, - content: template - }.to_string() - ).build()) + .body( + Template { + title: post["properties"]["name"][0] + .as_str() + .unwrap_or(&format!("Note at {}", url.host().unwrap().to_string())), + blog_name: &req + .state() + .storage + .get_setting("site_name", &url.host().unwrap().to_string()) + .await + .unwrap_or_else(|_| "Kitty Box!".to_string()), + endpoints: IndiewebEndpoints { + authorization_endpoint, + token_endpoint, + webmention: None, + microsub: None, + }, + content: template, + } + .to_string(), + ) + .build()) } pub struct ErrorHandlerMiddleware {} #[async_trait::async_trait] -impl<S> tide::Middleware<ApplicationState<S>> for ErrorHandlerMiddleware where - S: crate::database::Storage +impl<S> tide::Middleware<ApplicationState<S>> for ErrorHandlerMiddleware +where + S: crate::database::Storage, { - async fn handle(&self, request: Request<ApplicationState<S>>, next: Next<'_, ApplicationState<S>>) -> Result { + async fn handle( + &self, + request: Request<ApplicationState<S>>, + next: Next<'_, ApplicationState<S>>, + ) -> Result { let authorization_endpoint = request.state().authorization_endpoint.to_string(); let token_endpoint = request.state().token_endpoint.to_string(); - let site_name = &request.state().storage.get_setting("site_name", &request.url().host().unwrap().to_string()).await.unwrap_or_else(|_| "Kitty Box!".to_string()); + let site_name = &request + .state() + .storage + .get_setting("site_name", &request.url().host().unwrap().to_string()) + .await + .unwrap_or_else(|_| "Kitty Box!".to_string()); let mut res = next.run(request).await; let mut code: Option<StatusCode> = None; if let Some(err) = res.downcast_error::<FrontendError>() { @@ -826,15 +916,20 @@ impl<S> tide::Middleware<ApplicationState<S>> for ErrorHandlerMiddleware where if let Some(code) = code { res.set_status(code); res.set_content_type("text/html; charset=utf-8"); - res.set_body(Template { - title: "Error", - blog_name: site_name, - endpoints: IndiewebEndpoints { - authorization_endpoint, token_endpoint, - webmention: None, microsub: None - }, - content: ErrorPage { code }.to_string() - }.to_string()); + res.set_body( + Template { + title: "Error", + blog_name: site_name, + endpoints: IndiewebEndpoints { + authorization_endpoint, + token_endpoint, + webmention: None, + microsub: None, + }, + content: ErrorPage { code }.to_string(), + } + .to_string(), + ); } Ok(res) } @@ -858,7 +953,10 @@ pub async fn handle_static<S: Storage>(req: Request<ApplicationState<S>>) -> Res .content_type("text/css; charset=utf-8") .body(ONBOARDING_CSS) .build()), - Ok(_) => Err(FrontendError::with_code(StatusCode::NotFound, "Static file not found")), - Err(_) => panic!("Invalid usage of the frontend::handle_static() function") + Ok(_) => Err(FrontendError::with_code( + StatusCode::NotFound, + "Static file not found", + )), + Err(_) => panic!("Invalid usage of the frontend::handle_static() function"), }?) } diff --git a/src/indieauth.rs b/src/indieauth.rs index 27a70a1..f6bac04 100644 --- a/src/indieauth.rs +++ b/src/indieauth.rs @@ -1,11 +1,11 @@ use async_trait::async_trait; #[allow(unused_imports)] -use log::{error,info}; -use url::Url; +use log::{error, info}; +use std::sync::Arc; use tide::prelude::*; #[allow(unused_imports)] -use tide::{Request, Response, Next, Result}; -use std::sync::Arc; +use tide::{Next, Request, Response, Result}; +use url::Url; use crate::database; use crate::ApplicationState; @@ -14,7 +14,7 @@ use crate::ApplicationState; pub struct User { pub me: Url, pub client_id: Url, - scope: String + scope: String, } impl User { @@ -28,23 +28,40 @@ impl User { Self { me: Url::parse(me).unwrap(), client_id: Url::parse(client_id).unwrap(), - scope: scope.to_string() + scope: scope.to_string(), } } } #[cfg(any(not(debug_assertions), test))] -async fn get_token_data(token: String, token_endpoint: &http_types::Url, http_client: &surf::Client) -> (http_types::StatusCode, Option<User>) { - match http_client.get(token_endpoint).header("Authorization", token).header("Accept", "application/json").send().await { +async fn get_token_data( + token: String, + token_endpoint: &http_types::Url, + http_client: &surf::Client, +) -> (http_types::StatusCode, Option<User>) { + match http_client + .get(token_endpoint) + .header("Authorization", token) + .header("Accept", "application/json") + .send() + .await + { Ok(mut resp) => { if resp.status() == 200 { match resp.body_json::<User>().await { Ok(user) => { - info!("Token endpoint request successful. Validated user: {}", user.me); + info!( + "Token endpoint request successful. Validated user: {}", + user.me + ); (resp.status(), Some(user)) - }, + } Err(err) => { - error!("Token endpoint parsing error (HTTP status {}): {}", resp.status(), err); + error!( + "Token endpoint parsing error (HTTP status {}): {}", + resp.status(), + err + ); (http_types::StatusCode::InternalServerError, None) } } @@ -63,7 +80,7 @@ async fn get_token_data(token: String, token_endpoint: &http_types::Url, http_cl pub struct IndieAuthMiddleware { #[allow(dead_code)] // it's not really dead since it's only dead in debug scope cache: Arc<retainer::Cache<String, User>>, - monitor_task: Option<async_std::task::JoinHandle<()>> + monitor_task: Option<async_std::task::JoinHandle<()>>, } impl IndieAuthMiddleware { /// Create a new instance of IndieAuthMiddleware. @@ -74,12 +91,19 @@ impl IndieAuthMiddleware { pub fn new() -> Self { let cache: Arc<retainer::Cache<String, User>> = Arc::new(retainer::Cache::new()); let cache_clone = cache.clone(); - let task = async_std::task::spawn(async move { cache_clone.monitor(4, 0.1, std::time::Duration::from_secs(30)).await }); + let task = async_std::task::spawn(async move { + cache_clone + .monitor(4, 0.1, std::time::Duration::from_secs(30)) + .await + }); #[cfg(all(debug_assertions, not(test)))] error!("ATTENTION: You are running in debug mode. NO REQUESTS TO TOKEN ENDPOINT WILL BE MADE. YOU WILL BE PROCEEDING WITH DEBUG USER CREDENTIALS. DO NOT RUN LIKE THIS IN PRODUCTION."); - Self { cache, monitor_task: Some(task) } + Self { + cache, + monitor_task: Some(task), + } } } impl Drop for IndieAuthMiddleware { @@ -96,7 +120,8 @@ impl Drop for IndieAuthMiddleware { // (it is safe tho cuz None is no nullptr and dereferencing it doesn't cause unsafety) // (could cause a VERY FUNNY race condition to occur though // if you tried to refer to the value in another thread!) - let task = std::mem::take(&mut self.monitor_task).expect("Dropped IndieAuthMiddleware TWICE? Impossible!"); + let task = std::mem::take(&mut self.monitor_task) + .expect("Dropped IndieAuthMiddleware TWICE? Impossible!"); // Then cancel the task, using another task to request cancellation. // Because apparently you can't run async code from Drop... // This should drop the last reference for the [`cache`], @@ -105,27 +130,41 @@ impl Drop for IndieAuthMiddleware { } } #[async_trait] -impl<B> tide::Middleware<ApplicationState<B>> for IndieAuthMiddleware where - B: database::Storage + Send + Sync + Clone +impl<B> tide::Middleware<ApplicationState<B>> for IndieAuthMiddleware +where + B: database::Storage + Send + Sync + Clone, { - #[cfg(all(not(test), debug_assertions))] - async fn handle(&self, mut req: Request<ApplicationState<B>>, next: Next<'_, ApplicationState<B>>) -> Result { - req.set_ext(User::new("http://localhost:8080/", "https://curl.haxx.se/","create update delete undelete media")); + async fn handle( + &self, + mut req: Request<ApplicationState<B>>, + next: Next<'_, ApplicationState<B>>, + ) -> Result { + req.set_ext(User::new( + "http://localhost:8080/", + "https://curl.haxx.se/", + "create update delete undelete media", + )); Ok(next.run(req).await) } #[cfg(any(not(debug_assertions), test))] - async fn handle(&self, mut req: Request<ApplicationState<B>>, next: Next<'_, ApplicationState<B>>) -> Result { + async fn handle( + &self, + mut req: Request<ApplicationState<B>>, + next: Next<'_, ApplicationState<B>>, + ) -> Result { let header = req.header("Authorization"); match header { None => { // TODO: move that to the request handling functions // or make a middleware that refuses to accept unauthenticated requests - Ok(Response::builder(401).body(json!({ - "error": "unauthorized", - "error_description": "Please provide an access token." - })).build()) - }, + Ok(Response::builder(401) + .body(json!({ + "error": "unauthorized", + "error_description": "Please provide an access token." + })) + .build()) + } Some(value) => { let endpoint = &req.state().token_endpoint; let http_client = &req.state().http_client; @@ -177,9 +216,13 @@ mod tests { use super::*; #[test] fn user_scopes_are_checkable() { - let user = User::new("https://fireburn.ru/", "https://quill.p3k.io/", "create update media"); + let user = User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + ); assert!(user.check_scope("create")); assert!(!user.check_scope("delete")); } -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 27adc1a..d1bff68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,22 +1,22 @@ use tide::{Request, Response}; mod database; +mod frontend; mod indieauth; mod micropub; -mod frontend; use crate::indieauth::IndieAuthMiddleware; #[derive(Clone)] pub struct ApplicationState<StorageBackend> where - StorageBackend: database::Storage + Send + Sync + 'static + StorageBackend: database::Storage + Send + Sync + 'static, { token_endpoint: surf::Url, authorization_endpoint: surf::Url, media_endpoint: Option<String>, http_client: surf::Client, - storage: StorageBackend + storage: StorageBackend, } type App<Storage> = tide::Server<ApplicationState<Storage>>; @@ -25,70 +25,105 @@ static MICROPUB_CLIENT: &[u8] = include_bytes!("./index.html"); fn equip_app<Storage>(mut app: App<Storage>) -> App<Storage> where - Storage: database::Storage + Send + Sync + Clone + Storage: database::Storage + Send + Sync + Clone, { - app.at("/micropub").with(IndieAuthMiddleware::new()) + app.at("/micropub") + .with(IndieAuthMiddleware::new()) .get(micropub::get_handler) .post(micropub::post_handler); // The Micropub client. It'll start small, but could grow into something full-featured! app.at("/micropub/client").get(|_: Request<_>| async move { - Ok(Response::builder(200).body(MICROPUB_CLIENT).content_type("text/html").build()) + Ok(Response::builder(200) + .body(MICROPUB_CLIENT) + .content_type("text/html") + .build()) }); - app.at("/").with(frontend::ErrorHandlerMiddleware {}) + app.at("/") + .with(frontend::ErrorHandlerMiddleware {}) .get(frontend::mainpage) .post(frontend::onboarding_receiver); - app.at("/static/*path").with(frontend::ErrorHandlerMiddleware {}).get(frontend::handle_static); - app.at("/*path").with(frontend::ErrorHandlerMiddleware {}).get(frontend::render_post); - app.at("/coffee").with(frontend::ErrorHandlerMiddleware {}).get(frontend::coffee); + app.at("/static/*path") + .with(frontend::ErrorHandlerMiddleware {}) + .get(frontend::handle_static); + app.at("/*path") + .with(frontend::ErrorHandlerMiddleware {}) + .get(frontend::render_post); + app.at("/coffee") + .with(frontend::ErrorHandlerMiddleware {}) + .get(frontend::coffee); app.at("/health").get(|_| async { Ok("OK") }); app } -pub async fn get_app_with_redis(token_endpoint: surf::Url, authorization_endpoint: surf::Url, redis_uri: String, media_endpoint: Option<String>) -> App<database::RedisStorage> { - let app = tide::with_state(ApplicationState { - token_endpoint, media_endpoint, +pub async fn get_app_with_redis( + token_endpoint: surf::Url, + authorization_endpoint: surf::Url, + redis_uri: String, + media_endpoint: Option<String>, +) -> App<database::RedisStorage> { + let app = tide::with_state(ApplicationState { + token_endpoint, + media_endpoint, authorization_endpoint, storage: database::RedisStorage::new(redis_uri).await.unwrap(), http_client: surf::Client::new(), }); - + equip_app(app) } #[cfg(test)] -pub async fn get_app_with_test_redis(token_endpoint: surf::Url) -> (database::RedisInstance, database::RedisStorage, App<database::RedisStorage>) { +pub async fn get_app_with_test_redis( + token_endpoint: surf::Url, +) -> ( + database::RedisInstance, + database::RedisStorage, + App<database::RedisStorage>, +) { use surf::Url; let redis_instance = database::get_redis_instance().await; - let backend = database::RedisStorage::new(redis_instance.uri().to_string()).await.unwrap(); + let backend = database::RedisStorage::new(redis_instance.uri().to_string()) + .await + .unwrap(); let app = tide::with_state(ApplicationState { - token_endpoint, media_endpoint: None, + token_endpoint, + media_endpoint: None, authorization_endpoint: Url::parse("https://indieauth.com/auth").unwrap(), storage: backend.clone(), http_client: surf::Client::new(), }); - return (redis_instance, backend, equip_app(app)) + return (redis_instance, backend, equip_app(app)); } #[cfg(test)] #[allow(unused_variables)] mod tests { use super::*; + use database::Storage; + use mockito::mock; use serde_json::json; use tide_testing::TideTestingExt; - use mockito::mock; - use database::Storage; // Helpers - async fn create_app() -> (database::RedisStorage, App<database::RedisStorage>, database::RedisInstance) { + async fn create_app() -> ( + database::RedisStorage, + App<database::RedisStorage>, + database::RedisInstance, + ) { //get_app_with_memory_for_testing(surf::Url::parse(&*mockito::server_url()).unwrap()).await - let (r, b, a) = get_app_with_test_redis(surf::Url::parse(&*mockito::server_url()).unwrap()).await; + let (r, b, a) = + get_app_with_test_redis(surf::Url::parse(&*mockito::server_url()).unwrap()).await; (b, a, r) } - async fn post_json(app: &App<database::RedisStorage>, json: serde_json::Value) -> surf::Response { - let request = app.post("/micropub") + async fn post_json( + app: &App<database::RedisStorage>, + json: serde_json::Value, + ) -> surf::Response { + let request = app + .post("/micropub") .header("Authorization", "Bearer test") .header("Content-Type", "application/json") .body(json); @@ -105,22 +140,30 @@ mod tests { let (db, app, _r) = create_app().await; - let response = post_json(&app, json!({ - "type": ["h-entry"], - "properties": { - "content": ["Fake news about Aaron Parecki!"], - "uid": ["https://aaronparecki.com/posts/fake-news"] - } - })).await; + let response = post_json( + &app, + json!({ + "type": ["h-entry"], + "properties": { + "content": ["Fake news about Aaron Parecki!"], + "uid": ["https://aaronparecki.com/posts/fake-news"] + } + }), + ) + .await; assert_eq!(response.status(), 403); - let response = post_json(&app, json!({ - "type": ["h-entry"], - "properties": { - "content": ["More fake news about Aaron Parecki!"], - "url": ["https://aaronparecki.com/posts/more-fake-news"] - } - })).await; + let response = post_json( + &app, + json!({ + "type": ["h-entry"], + "properties": { + "content": ["More fake news about Aaron Parecki!"], + "url": ["https://aaronparecki.com/posts/more-fake-news"] + } + }), + ) + .await; assert_eq!(response.status(), 403); let response = post_json(&app, json!({ @@ -143,9 +186,12 @@ mod tests { let (db, app, _r) = create_app().await; - let response: serde_json::Value = app.get("/micropub?q=config") + let response: serde_json::Value = app + .get("/micropub?q=config") .header("Authorization", "test") - .recv_json().await.unwrap(); + .recv_json() + .await + .unwrap(); assert!(!response["q"].as_array().unwrap().is_empty()); } @@ -159,9 +205,12 @@ mod tests { let (db, app, _r) = create_app().await; - let response: surf::Response = app.get("/micropub?q=config") + let response: surf::Response = app + .get("/micropub?q=config") .header("Authorization", "test") - .send().await.unwrap(); + .send() + .await + .unwrap(); assert_eq!(response.status(), 401); } @@ -184,17 +233,27 @@ mod tests { let (storage, app, _r) = create_app().await; - let request: surf::RequestBuilder = app.post("/micropub") + let request: surf::RequestBuilder = app + .post("/micropub") .header("Authorization", "Bearer test") .header("Content-Type", "application/x-www-form-urlencoded") .body("h=entry&content=something%20interesting&category[]=test&category[]=stuff"); let mut response: surf::Response = request.send().await.unwrap(); - println!("{:#}", response.body_json::<serde_json::Value>().await.unwrap()); + println!( + "{:#}", + response.body_json::<serde_json::Value>().await.unwrap() + ); assert!(response.status() == 201 || response.status() == 202); let uid = response.header("Location").unwrap().last().to_string(); // Assume the post is in the database at this point. let post = storage.get_post(&uid).await.unwrap().unwrap(); - assert_eq!(post["properties"]["content"][0]["html"].as_str().unwrap().trim(), "<p>something interesting</p>"); + assert_eq!( + post["properties"]["content"][0]["html"] + .as_str() + .unwrap() + .trim(), + "<p>something interesting</p>" + ); } #[async_std::test] @@ -207,35 +266,63 @@ mod tests { let (storage, app, _r) = create_app().await; - let mut response = post_json(&app, json!({ - "type": ["h-entry"], - "properties": { - "content": ["This is content!"] - } - })).await; - println!("{:#}", response.body_json::<serde_json::Value>().await.unwrap()); + let mut response = post_json( + &app, + json!({ + "type": ["h-entry"], + "properties": { + "content": ["This is content!"] + } + }), + ) + .await; + println!( + "{:#}", + response.body_json::<serde_json::Value>().await.unwrap() + ); assert!(response.status() == 201 || response.status() == 202); let uid = response.header("Location").unwrap().last().to_string(); // Assume the post is in the database at this point. let post = storage.get_post(&uid).await.unwrap().unwrap(); - assert_eq!(post["properties"]["content"][0]["html"].as_str().unwrap().trim(), "<p>This is content!</p>"); - let feed = storage.get_post("https://fireburn.ru/feeds/main").await.unwrap().unwrap(); + assert_eq!( + post["properties"]["content"][0]["html"] + .as_str() + .unwrap() + .trim(), + "<p>This is content!</p>" + ); + let feed = storage + .get_post("https://fireburn.ru/feeds/main") + .await + .unwrap() + .unwrap(); assert_eq!(feed["children"].as_array().unwrap().len(), 1); assert_eq!(feed["children"][0].as_str().unwrap(), uid); let first_uid = uid; // Test creation of a second post - let mut response = post_json(&app, json!({ - "type": ["h-entry"], - "properties": { - "content": ["#moar content for you!"] - } - })).await; - println!("{:#}", response.body_json::<serde_json::Value>().await.unwrap()); + let mut response = post_json( + &app, + json!({ + "type": ["h-entry"], + "properties": { + "content": ["#moar content for you!"] + } + }), + ) + .await; + println!( + "{:#}", + response.body_json::<serde_json::Value>().await.unwrap() + ); assert!(response.status() == 201 || response.status() == 202); let uid = response.header("Location").unwrap().last().to_string(); // Assume the post is in the database at this point. //println!("Keys in database: {:?}", storage.mapping.read().await.keys()); - let new_feed = storage.get_post("https://fireburn.ru/feeds/main").await.unwrap().unwrap(); + let new_feed = storage + .get_post("https://fireburn.ru/feeds/main") + .await + .unwrap() + .unwrap(); println!("{}", new_feed["children"]); assert_eq!(new_feed["children"].as_array().unwrap().len(), 2); assert_eq!(new_feed["children"][0].as_str().unwrap(), uid); diff --git a/src/main.rs b/src/main.rs index ce654df..1b333a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ +use kittybox_micropub as micropub; +use log::{debug, error, info}; use std::env; -use log::{error,info,debug}; use surf::Url; -use kittybox_micropub as micropub; #[async_std::main] async fn main() -> Result<(), std::io::Error> { @@ -16,7 +16,7 @@ async fn main() -> Result<(), std::io::Error> { Ok(val) => { debug!("Redis connection: {}", val); redis_uri = val - }, + } Err(_) => { error!("REDIS_URI is not set, cannot find a database"); std::process::exit(1); @@ -50,7 +50,7 @@ async fn main() -> Result<(), std::io::Error> { std::process::exit(1) } } - }, + } Err(_) => { error!("AUTHORIZATION_ENDPOINT is not set, will not be able to confirm token and ID requests using IndieAuth!"); std::process::exit(1) @@ -59,7 +59,15 @@ async fn main() -> Result<(), std::io::Error> { let media_endpoint: Option<String> = env::var("MEDIA_ENDPOINT").ok(); - let host = env::var("SERVE_AT").ok().unwrap_or_else(|| "0.0.0.0:8080".to_string()); - let app = micropub::get_app_with_redis(token_endpoint, authorization_endpoint, redis_uri, media_endpoint).await; + let host = env::var("SERVE_AT") + .ok() + .unwrap_or_else(|| "0.0.0.0:8080".to_string()); + let app = micropub::get_app_with_redis( + token_endpoint, + authorization_endpoint, + redis_uri, + media_endpoint, + ) + .await; app.listen(host).await } diff --git a/src/micropub/get.rs b/src/micropub/get.rs index e106883..525bf12 100644 --- a/src/micropub/get.rs +++ b/src/micropub/get.rs @@ -1,23 +1,26 @@ -use tide::prelude::{Deserialize, json}; -use tide::{Request, Response, Result}; -use crate::ApplicationState; -use crate::database::{MicropubChannel,Storage}; +use crate::database::{MicropubChannel, Storage}; use crate::indieauth::User; +use crate::ApplicationState; +use tide::prelude::{json, Deserialize}; +use tide::{Request, Response, Result}; #[derive(Deserialize)] struct QueryOptions { q: String, - url: Option<String> + url: Option<String>, } pub async fn get_handler<Backend>(req: Request<ApplicationState<Backend>>) -> Result where - Backend: Storage + Send + Sync + Backend: Storage + Send + Sync, { let user = req.ext::<User>().unwrap(); let backend = &req.state().storage; let media_endpoint = &req.state().media_endpoint; - let query = req.query::<QueryOptions>().unwrap_or(QueryOptions { q: "".to_string(), url: None }); + let query = req.query::<QueryOptions>().unwrap_or(QueryOptions { + q: "".to_string(), + url: None, + }); match &*query.q { "config" => { let channels: Vec<MicropubChannel>; diff --git a/src/micropub/mod.rs b/src/micropub/mod.rs index 9bc553c..68a3134 100644 --- a/src/micropub/mod.rs +++ b/src/micropub/mod.rs @@ -2,5 +2,5 @@ pub mod get; pub mod post; pub use get::get_handler; +pub use post::normalize_mf2; pub use post::post_handler; -pub use post::normalize_mf2; \ No newline at end of file diff --git a/src/micropub/post.rs b/src/micropub/post.rs index 6183906..b3fe4ee 100644 --- a/src/micropub/post.rs +++ b/src/micropub/post.rs @@ -1,17 +1,17 @@ +use crate::database::Storage; +use crate::indieauth::User; +use crate::ApplicationState; +use chrono::prelude::*; use core::iter::Iterator; -use std::str::FromStr; -use std::convert::TryInto; -use log::{warn, error}; use futures::stream; use futures::StreamExt; -use chrono::prelude::*; use http_types::Mime; +use log::{error, warn}; +use newbase60::num_to_sxg; +use std::convert::TryInto; +use std::str::FromStr; use tide::prelude::json; use tide::{Request, Response, Result}; -use newbase60::num_to_sxg; -use crate::ApplicationState; -use crate::database::{Storage}; -use crate::indieauth::User; static DEFAULT_CHANNEL_PATH: &str = "/feeds/main"; static DEFAULT_CHANNEL_NAME: &str = "Main feed"; @@ -43,8 +43,9 @@ fn get_folder_from_type(post_type: &str) -> String { "h-card" => "vcards/", "h-event" => "events/", "h-food" => "food/", - _ => "posts/" - }).to_string() + _ => "posts/", + }) + .to_string() } pub fn normalize_mf2(mut body: serde_json::Value, user: &User) -> (String, serde_json::Value) { @@ -63,34 +64,32 @@ pub fn normalize_mf2(mut body: serde_json::Value, user: &User) -> (String, serde // Do not attempt to recover the information. // Do not pass GO. Do not collect $200. let curtime: DateTime<Local> = Local::now(); - body["properties"]["published"] = serde_json::Value::Array(vec![ - serde_json::Value::String(curtime.to_rfc3339()) - ]); + body["properties"]["published"] = + serde_json::Value::Array(vec![serde_json::Value::String(curtime.to_rfc3339())]); published = chrono::DateTime::from(curtime); } } } else { // Set the datetime. let curtime: DateTime<Local> = Local::now(); - body["properties"]["published"] = serde_json::Value::Array(vec![ - serde_json::Value::String(curtime.to_rfc3339()) - ]); + body["properties"]["published"] = + serde_json::Value::Array(vec![serde_json::Value::String(curtime.to_rfc3339())]); published = chrono::DateTime::from(curtime); } match body["properties"]["uid"][0].as_str() { None => { let uid = serde_json::Value::String( me.join( - &(folder.clone() + &num_to_sxg(published.timestamp_millis().try_into().unwrap())) - ).unwrap().to_string()); + &(folder.clone() + + &num_to_sxg(published.timestamp_millis().try_into().unwrap())), + ) + .unwrap() + .to_string(), + ); body["properties"]["uid"] = serde_json::Value::Array(vec![uid.clone()]); match body["properties"]["url"].as_array_mut() { - Some(array) => { - array.push(uid) - } - None => { - body["properties"]["url"] = body["properties"]["uid"].clone() - } + Some(array) => array.push(uid), + None => body["properties"]["url"] = body["properties"]["uid"].clone(), } } Some(uid_str) => { @@ -101,14 +100,13 @@ pub fn normalize_mf2(mut body: serde_json::Value, user: &User) -> (String, serde array.push(serde_json::Value::String(uid)) } } - None => { - body["properties"]["url"] = body["properties"]["uid"].clone() - } + None => body["properties"]["url"] = body["properties"]["uid"].clone(), } } } if let Some(slugs) = body["properties"]["mp-slug"].as_array() { - let new_urls = slugs.iter() + let new_urls = slugs + .iter() .map(|i| i.as_str().unwrap_or("")) .filter(|i| i != &"") .map(|i| me.join(&((&folder).clone() + i)).unwrap().to_string()) @@ -147,15 +145,25 @@ pub fn normalize_mf2(mut body: serde_json::Value, user: &User) -> (String, serde } // TODO: maybe highlight #hashtags? // Find other processing to do and insert it here - return (body["properties"]["uid"][0].as_str().unwrap().to_string(), body) + return ( + body["properties"]["uid"][0].as_str().unwrap().to_string(), + body, + ); } -pub async fn new_post<S: Storage>(req: Request<ApplicationState<S>>, body: serde_json::Value) -> Result { +pub async fn new_post<S: Storage>( + req: Request<ApplicationState<S>>, + body: serde_json::Value, +) -> Result { // First, check for rights. let user = req.ext::<User>().unwrap(); let storage = &req.state().storage; if !user.check_scope("create") { - return error_json!(401, "invalid_scope", "Not enough privileges to post. Try a token with a \"create\" scope instead.") + return error_json!( + 401, + "invalid_scope", + "Not enough privileges to post. Try a token with a \"create\" scope instead." + ); } let (uid, post) = normalize_mf2(body, user); @@ -163,29 +171,54 @@ pub async fn new_post<S: Storage>(req: Request<ApplicationState<S>>, body: serde // This software might also be used in a multi-user setting // where several users or identities share one Micropub server // (maybe a family website or a shitpost sideblog?) - if post["properties"]["url"].as_array().unwrap().iter().any(|url| !url.as_str().unwrap().starts_with(user.me.as_str())) - || !post["properties"]["uid"][0].as_str().unwrap().starts_with(user.me.as_str()) - || post["properties"]["channel"].as_array().unwrap().iter().any(|url| !url.as_str().unwrap().starts_with(user.me.as_str())) + if post["properties"]["url"] + .as_array() + .unwrap() + .iter() + .any(|url| !url.as_str().unwrap().starts_with(user.me.as_str())) + || !post["properties"]["uid"][0] + .as_str() + .unwrap() + .starts_with(user.me.as_str()) + || post["properties"]["channel"] + .as_array() + .unwrap() + .iter() + .any(|url| !url.as_str().unwrap().starts_with(user.me.as_str())) { - return error_json!(403, "forbidden", "You're trying to post to someone else's website...") + return error_json!( + 403, + "forbidden", + "You're trying to post to someone else's website..." + ); } - match storage.post_exists(&uid).await { - Ok(exists) => if exists { - return error_json!(409, "already_exists", format!("A post with the exact same UID already exists in the database: {}", uid)) - }, - Err(err) => return Ok(err.into()) + Ok(exists) => { + if exists { + return error_json!( + 409, + "already_exists", + format!( + "A post with the exact same UID already exists in the database: {}", + uid + ) + ); + } + } + Err(err) => return Ok(err.into()), } if let Err(err) = storage.put_post(&post).await { - return error_json!(500, "database_error", format!("{}", err)) + return error_json!(500, "database_error", format!("{}", err)); } // It makes sense to use a loop here, because you wouldn't post to a hundred channels at once // Mostly one or two, and even those ones will be the ones picked for you by software for channel in post["properties"]["channel"] - .as_array().unwrap().iter() + .as_array() + .unwrap() + .iter() .map(|i| i.as_str().unwrap_or("").to_string()) .filter(|i| !i.is_empty()) .collect::<Vec<_>>() @@ -193,22 +226,44 @@ pub async fn new_post<S: Storage>(req: Request<ApplicationState<S>>, body: serde let default_channel = user.me.join(DEFAULT_CHANNEL_PATH).unwrap().to_string(); let vcards_channel = user.me.join(CONTACTS_CHANNEL_PATH).unwrap().to_string(); match storage.post_exists(&channel).await { - Ok(exists) => if exists { - if let Err(err) = storage.update_post(&channel, json!({ - "add": { - "children": [uid] + Ok(exists) => { + if exists { + if let Err(err) = storage + .update_post( + &channel, + json!({ + "add": { + "children": [uid] + } + }), + ) + .await + { + return error_json!( + 500, + "database_error", + format!( + "Couldn't insert post into the channel due to a database error: {}", + err + ) + ); } - })).await { - return error_json!(500, "database_error", format!("Couldn't insert post into the channel due to a database error: {}", err)) - } - } else if channel == default_channel || channel == vcards_channel { - if let Err(err) = create_feed(storage, &uid, &channel, &user).await { - return error_json!(500, "database_error", format!("Couldn't save feed: {}", err)) + } else if channel == default_channel || channel == vcards_channel { + if let Err(err) = create_feed(storage, &uid, &channel, &user).await { + return error_json!( + 500, + "database_error", + format!("Couldn't save feed: {}", err) + ); + } + } else { + warn!( + "Ignoring request to post to a non-existent feed: {}", + channel + ); } - } else { - warn!("Ignoring request to post to a non-existent feed: {}", channel); - }, - Err(err) => return error_json!(500, "database_error", err) + } + Err(err) => return error_json!(500, "database_error", err), } } // END WRITE BOUNDARY @@ -222,26 +277,39 @@ pub async fn new_post<S: Storage>(req: Request<ApplicationState<S>>, body: serde .build()); } -async fn create_feed(storage: &impl Storage, uid: &str, channel: &str, user: &User) -> crate::database::Result<()> { +async fn create_feed( + storage: &impl Storage, + uid: &str, + channel: &str, + user: &User, +) -> crate::database::Result<()> { let path = url::Url::parse(channel).unwrap().path().to_string(); let (name, slug) = if path == DEFAULT_CHANNEL_PATH { (DEFAULT_CHANNEL_NAME, "main") } else if path == CONTACTS_CHANNEL_PATH { (CONTACTS_CHANNEL_NAME, "vcards") - } else { panic!("Tried to create an unknown default feed!"); }; - - let (_, feed) = normalize_mf2(json!({ - "type": ["h-feed"], - "properties": { - "name": [name], - "mp-slug": [slug], - }, - "children": [uid] - }), &user); + } else { + panic!("Tried to create an unknown default feed!"); + }; + + let (_, feed) = normalize_mf2( + json!({ + "type": ["h-feed"], + "properties": { + "name": [name], + "mp-slug": [slug], + }, + "children": [uid] + }), + &user, + ); storage.put_post(&feed).await } -async fn post_process_new_post<S: Storage>(req: Request<ApplicationState<S>>, post: serde_json::Value) { +async fn post_process_new_post<S: Storage>( + req: Request<ApplicationState<S>>, + post: serde_json::Value, +) { // TODO: Post-processing the post (aka second write pass) // - [-] Download rich reply contexts // - [-] Syndicate the post if requested, add links to the syndicated copies @@ -262,11 +330,9 @@ async fn post_process_new_post<S: Storage>(req: Request<ApplicationState<S>>, po for prop in &["in-reply-to", "like-of", "repost-of", "bookmark-of"] { if let Some(array) = post["properties"][prop].as_array() { contextually_significant_posts.extend( - array.iter() - .filter_map(|v| v.as_str() - .and_then(|v| surf::Url::parse(v).ok() - ) - ) + array + .iter() + .filter_map(|v| v.as_str().and_then(|v| surf::Url::parse(v).ok())), ); } } @@ -275,26 +341,28 @@ async fn post_process_new_post<S: Storage>(req: Request<ApplicationState<S>>, po contextually_significant_posts.dedup(); // 1.3. Fetch the posts with their bodies and save them in a new Vec<(surf::Url, String)> - let posts_with_bodies: Vec<(surf::Url, String)> = stream::iter(contextually_significant_posts.into_iter()) - .filter_map(|v: surf::Url| async move { - if let Ok(res) = http.get(&v).send().await { - if res.status() != 200 { - return None + let posts_with_bodies: Vec<(surf::Url, String)> = + stream::iter(contextually_significant_posts.into_iter()) + .filter_map(|v: surf::Url| async move { + if let Ok(res) = http.get(&v).send().await { + if res.status() != 200 { + return None; + } else { + return Some((v, res)); + } } else { - return Some((v, res)) + return None; } - } else { - return None - } - }) - .filter_map(|(v, mut res): (surf::Url, surf::Response)| async move { - if let Ok(body) = res.body_string().await { - return Some((v, body)) - } else { - return None - } - }) - .collect().await; + }) + .filter_map(|(v, mut res): (surf::Url, surf::Response)| async move { + if let Ok(body) = res.body_string().await { + return Some((v, body)); + } else { + return None; + } + }) + .collect() + .await; // 1.4. Parse the bodies and include them in relevant places on the MF2 struct // This requires an MF2 parser, and there are none for Rust at the moment. // @@ -303,24 +371,32 @@ async fn post_process_new_post<S: Storage>(req: Request<ApplicationState<S>>, po // 2. Syndicate the post let syndicated_copies: Vec<serde_json::Value>; if let Some(syndication_targets) = post["properties"]["syndicate-to"].as_array() { - syndicated_copies = stream::iter(syndication_targets.into_iter() - .filter_map(|v| v.as_str()) - .filter_map(|t| surf::Url::parse(t).ok()) - .collect::<Vec<_>>().into_iter() - .map(|_t: surf::Url| async move { - // TODO: Define supported syndication methods - // and syndicate the endpoint there - // Possible ideas: - // - indieweb.xyz (might need a lot of space for the buttons though, investigate proposing grouping syndication targets) - // - news.indieweb.org (IndieNews - needs a category linking to #indienews) - // - Twitter via brid.gy (do I really need Twitter syndication tho?) - if false { - Some("") - } else { - None - } - }) - ).buffer_unordered(3).filter_map(|v| async move { v }).map(|v| serde_json::Value::String(v.to_string())).collect::<Vec<_>>().await; + syndicated_copies = stream::iter( + syndication_targets + .into_iter() + .filter_map(|v| v.as_str()) + .filter_map(|t| surf::Url::parse(t).ok()) + .collect::<Vec<_>>() + .into_iter() + .map(|_t: surf::Url| async move { + // TODO: Define supported syndication methods + // and syndicate the endpoint there + // Possible ideas: + // - indieweb.xyz (might need a lot of space for the buttons though, investigate proposing grouping syndication targets) + // - news.indieweb.org (IndieNews - needs a category linking to #indienews) + // - Twitter via brid.gy (do I really need Twitter syndication tho?) + if false { + Some("") + } else { + None + } + }), + ) + .buffer_unordered(3) + .filter_map(|v| async move { v }) + .map(|v| serde_json::Value::String(v.to_string())) + .collect::<Vec<_>>() + .await; } else { syndicated_copies = vec![] } @@ -363,35 +439,67 @@ async fn post_process_new_post<S: Storage>(req: Request<ApplicationState<S>>, po // TODO: Replace this function once the MF2 parser is ready // A compliant parser's output format includes rels, // we could just find a Webmention one in there - let pattern = easy_scraper::Pattern::new(r#"<link href="{url}" rel="webmention">"#).expect("Pattern for webmentions couldn't be parsed"); + let pattern = easy_scraper::Pattern::new(r#"<link href="{url}" rel="webmention">"#) + .expect("Pattern for webmentions couldn't be parsed"); let matches = pattern.matches(&body); - if matches.is_empty() { return None } + if matches.is_empty() { + return None; + } let endpoint = &matches[0]["url"]; - if let Ok(endpoint) = url.join(endpoint) { Some((url, endpoint)) } else { None } + if let Ok(endpoint) = url.join(endpoint) { + Some((url, endpoint)) + } else { + None + } }) .map(|(target, endpoint)| async move { - let response = http.post(&endpoint) + let response = http + .post(&endpoint) .content_type("application/x-www-form-urlencoded") .body( - serde_urlencoded::to_string(vec![("source", source), ("target", &target.to_string())]) - .expect("Couldn't construct webmention form") - ).send().await; + serde_urlencoded::to_string(vec![ + ("source", source), + ("target", &target.to_string()), + ]) + .expect("Couldn't construct webmention form"), + ) + .send() + .await; match response { - Ok(response) => if response.status() == 200 || response.status() == 201 || response.status() == 202 { - Ok(()) - } else { - error!("Sending webmention for {} to {} failed: Endpoint replied with HTTP {}", target, endpoint, response.status()); - Err(()) + Ok(response) => { + if response.status() == 200 + || response.status() == 201 + || response.status() == 202 + { + Ok(()) + } else { + error!( + "Sending webmention for {} to {} failed: Endpoint replied with HTTP {}", + target, + endpoint, + response.status() + ); + Err(()) + } } Err(err) => { - error!("Sending webmention for {} to {} failed: {}", target, endpoint, err); + error!( + "Sending webmention for {} to {} failed: {}", + target, endpoint, err + ); Err(()) } } - }).buffer_unordered(3).collect::<Vec<_>>().await; + }) + .buffer_unordered(3) + .collect::<Vec<_>>() + .await; } -async fn process_json<S: Storage>(req: Request<ApplicationState<S>>, body: serde_json::Value) -> Result { +async fn process_json<S: Storage>( + req: Request<ApplicationState<S>>, + body: serde_json::Value, +) -> Result { let is_action = body["action"].is_string() && body["url"].is_string(); if is_action { // This could be an update, a deletion or an undeletion request. @@ -402,37 +510,51 @@ async fn process_json<S: Storage>(req: Request<ApplicationState<S>>, body: serde match action { "delete" => { if !user.check_scope("delete") { - return error_json!(401, "insufficient_scope", "You need a `delete` scope to delete posts.") + return error_json!( + 401, + "insufficient_scope", + "You need a `delete` scope to delete posts." + ); } if let Err(error) = req.state().storage.delete_post(&url).await { - return Ok(error.into()) + return Ok(error.into()); } return Ok(Response::builder(200).build()); - }, + } "update" => { if !user.check_scope("update") { - return error_json!(401, "insufficient_scope", "You need an `update` scope to update posts.") + return error_json!( + 401, + "insufficient_scope", + "You need an `update` scope to update posts." + ); } if let Err(error) = req.state().storage.update_post(&url, body.clone()).await { - return Ok(error.into()) + return Ok(error.into()); } else { - return Ok(Response::builder(204).build()) + return Ok(Response::builder(204).build()); } - }, - _ => { - return error_json!(400, "invalid_request", "This action is not supported.") } + _ => return error_json!(400, "invalid_request", "This action is not supported."), } } else if body["type"][0].is_string() { // This is definitely an h-entry or something similar. Check if it has properties? if body["properties"].is_object() { // Ok, this is definitely a new h-entry. Let's save it. - return new_post(req, body).await + return new_post(req, body).await; } else { - return error_json!(400, "invalid_request", "This MF2-JSON object has a type, but not properties. This makes no sense to post.") + return error_json!( + 400, + "invalid_request", + "This MF2-JSON object has a type, but not properties. This makes no sense to post." + ); } } else { - return error_json!(400, "invalid_request", "Try sending MF2-structured data or an object with an \"action\" and \"url\" keys.") + return error_json!( + 400, + "invalid_request", + "Try sending MF2-structured data or an object with an \"action\" and \"url\" keys." + ); } } @@ -440,12 +562,15 @@ fn convert_form_to_mf2_json(form: Vec<(String, String)>) -> serde_json::Value { let mut mf2 = json!({"type": [], "properties": {}}); for (k, v) in form { if k == "h" { - mf2["type"].as_array_mut().unwrap().push(json!("h-".to_string() + &v)); + mf2["type"] + .as_array_mut() + .unwrap() + .push(json!("h-".to_string() + &v)); } else if k != "access_token" { let key = k.strip_suffix("[]").unwrap_or(&k); match mf2["properties"][key].as_array_mut() { Some(prop) => prop.push(json!(v)), - None => mf2["properties"][key] = json!([v]) + None => mf2["properties"][key] = json!([v]), } } } @@ -455,33 +580,50 @@ fn convert_form_to_mf2_json(form: Vec<(String, String)>) -> serde_json::Value { mf2 } -async fn process_form<S: Storage>(req: Request<ApplicationState<S>>, form: Vec<(String, String)>) -> Result { +async fn process_form<S: Storage>( + req: Request<ApplicationState<S>>, + form: Vec<(String, String)>, +) -> Result { if let Some((_, v)) = form.iter().find(|(k, _)| k == "action") { if v == "delete" { let user = req.ext::<User>().unwrap(); if !user.check_scope("delete") { - return error_json!(401, "insufficient_scope", "You cannot delete posts without a `delete` scope.") + return error_json!( + 401, + "insufficient_scope", + "You cannot delete posts without a `delete` scope." + ); } match form.iter().find(|(k, _)| k == "url") { Some((_, url)) => { if let Err(error) = req.state().storage.delete_post(&url).await { - return error_json!(500, "database_error", error) + return error_json!(500, "database_error", error); } - return Ok(Response::builder(200).build()) - }, - None => return error_json!(400, "invalid_request", "Please provide an `url` to delete.") + return Ok(Response::builder(200).build()); + } + None => { + return error_json!( + 400, + "invalid_request", + "Please provide an `url` to delete." + ) + } } } else { - return error_json!(400, "invalid_request", "This action is not supported in form-encoded mode. (JSON requests support more actions, use JSON!)") + return error_json!(400, "invalid_request", "This action is not supported in form-encoded mode. (JSON requests support more actions, use JSON!)"); } } - + let mf2 = convert_form_to_mf2_json(form); if mf2["properties"].as_object().unwrap().keys().len() > 0 { return new_post(req, mf2).await; } - return error_json!(400, "invalid_request", "Try sending h=entry&content=something%20interesting"); + return error_json!( + 400, + "invalid_request", + "Try sending h=entry&content=something%20interesting" + ); } pub async fn post_handler<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result { @@ -489,29 +631,31 @@ pub async fn post_handler<S: Storage>(mut req: Request<ApplicationState<S>>) -> Some(value) => { if value == Mime::from_str("application/json").unwrap() { match req.body_json::<serde_json::Value>().await { - Ok(parsed) => { - return process_json(req, parsed).await - }, - Err(err) => return error_json!( - 400, "invalid_request", - format!("Parsing JSON failed: {:?}", err) - ) + Ok(parsed) => return process_json(req, parsed).await, + Err(err) => { + return error_json!( + 400, + "invalid_request", + format!("Parsing JSON failed: {:?}", err) + ) + } } } else if value == Mime::from_str("application/x-www-form-urlencoded").unwrap() { match req.body_form::<Vec<(String, String)>>().await { - Ok(parsed) => { - return process_form(req, parsed).await - }, - Err(err) => return error_json!( - 400, "invalid_request", - format!("Parsing form failed: {:?}", err) - ) + Ok(parsed) => return process_form(req, parsed).await, + Err(err) => { + return error_json!( + 400, + "invalid_request", + format!("Parsing form failed: {:?}", err) + ) + } } } else { return error_json!( 415, "unsupported_media_type", "What's this? Try sending JSON instead. (urlencoded form also works but is less cute)" - ) + ); } } _ => { @@ -538,9 +682,22 @@ mod tests { } }); - let (uid, normalized) = normalize_mf2(mf2.clone(), &User::new("https://fireburn.ru/", "https://quill.p3k.io/", "create update media")); - assert_eq!(normalized["properties"]["uid"][0], mf2["properties"]["uid"][0], "UID was replaced"); - assert_eq!(normalized["properties"]["uid"][0], uid, "Returned post location doesn't match UID"); + let (uid, normalized) = normalize_mf2( + mf2.clone(), + &User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + ), + ); + assert_eq!( + normalized["properties"]["uid"][0], mf2["properties"]["uid"][0], + "UID was replaced" + ); + assert_eq!( + normalized["properties"]["uid"][0], uid, + "Returned post location doesn't match UID" + ); } #[test] @@ -548,7 +705,7 @@ mod tests { use serde_urlencoded::from_str; assert_eq!( - convert_form_to_mf2_json(from_str("h=entry&content=something%20interesting").unwrap()), + convert_form_to_mf2_json(from_str("h=entry&content=something%20interesting").unwrap()), json!({ "type": ["h-entry"], "properties": { @@ -567,16 +724,64 @@ mod tests { } }); - let (uid, post) = normalize_mf2(mf2, &User::new("https://fireburn.ru/", "https://quill.p3k.io/", "create update media")); - assert_eq!(post["properties"]["published"].as_array().expect("post['published'] is undefined").len(), 1, "Post doesn't have a published time"); - DateTime::parse_from_rfc3339(post["properties"]["published"][0].as_str().unwrap()).expect("Couldn't parse date from rfc3339"); - assert!(post["properties"]["url"].as_array().expect("post['url'] is undefined").len() > 0, "Post doesn't have any URLs"); - assert_eq!(post["properties"]["uid"].as_array().expect("post['uid'] is undefined").len(), 1, "Post doesn't have a single UID"); - assert_eq!(post["properties"]["uid"][0], uid, "UID of a post and its supposed location don't match"); - assert!(uid.starts_with("https://fireburn.ru/posts/"), "The post namespace is incorrect"); - assert_eq!(post["properties"]["content"][0]["html"].as_str().expect("Post doesn't have a rich content object").trim(), "<p>This is content!</p>", "Parsed Markdown content doesn't match expected HTML"); - assert_eq!(post["properties"]["channel"][0], "https://fireburn.ru/feeds/main", "Post isn't posted to the main channel"); - assert_eq!(post["properties"]["author"][0], "https://fireburn.ru/", "Post author is unknown"); + let (uid, post) = normalize_mf2( + mf2, + &User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + ), + ); + assert_eq!( + post["properties"]["published"] + .as_array() + .expect("post['published'] is undefined") + .len(), + 1, + "Post doesn't have a published time" + ); + DateTime::parse_from_rfc3339(post["properties"]["published"][0].as_str().unwrap()) + .expect("Couldn't parse date from rfc3339"); + assert!( + post["properties"]["url"] + .as_array() + .expect("post['url'] is undefined") + .len() + > 0, + "Post doesn't have any URLs" + ); + assert_eq!( + post["properties"]["uid"] + .as_array() + .expect("post['uid'] is undefined") + .len(), + 1, + "Post doesn't have a single UID" + ); + assert_eq!( + post["properties"]["uid"][0], uid, + "UID of a post and its supposed location don't match" + ); + assert!( + uid.starts_with("https://fireburn.ru/posts/"), + "The post namespace is incorrect" + ); + assert_eq!( + post["properties"]["content"][0]["html"] + .as_str() + .expect("Post doesn't have a rich content object") + .trim(), + "<p>This is content!</p>", + "Parsed Markdown content doesn't match expected HTML" + ); + assert_eq!( + post["properties"]["channel"][0], "https://fireburn.ru/feeds/main", + "Post isn't posted to the main channel" + ); + assert_eq!( + post["properties"]["author"][0], "https://fireburn.ru/", + "Post author is unknown" + ); } #[test] @@ -589,15 +794,27 @@ mod tests { }, }); - let (_, post) = normalize_mf2(mf2, &User::new("https://fireburn.ru/", "https://quill.p3k.io/", "create update media")); - assert!(post["properties"]["url"] - .as_array() - .unwrap() - .iter() - .map(|i| i.as_str().unwrap()) - .any(|i| i == "https://fireburn.ru/posts/hello-post"), - "Didn't found an URL pointing to the location expected by the mp-slug semantics"); - assert!(post["properties"]["mp-slug"].as_array().is_none(), "mp-slug wasn't deleted from the array!") + let (_, post) = normalize_mf2( + mf2, + &User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + ), + ); + assert!( + post["properties"]["url"] + .as_array() + .unwrap() + .iter() + .map(|i| i.as_str().unwrap()) + .any(|i| i == "https://fireburn.ru/posts/hello-post"), + "Didn't found an URL pointing to the location expected by the mp-slug semantics" + ); + assert!( + post["properties"]["mp-slug"].as_array().is_none(), + "mp-slug wasn't deleted from the array!" + ) } #[test] @@ -610,16 +827,31 @@ mod tests { } }); - let (uid, post) = normalize_mf2(mf2, &User::new("https://fireburn.ru/", "https://quill.p3k.io/", "create update media")); - assert_eq!(post["properties"]["uid"][0], uid, "UID of a post and its supposed location don't match"); + let (uid, post) = normalize_mf2( + mf2, + &User::new( + "https://fireburn.ru/", + "https://quill.p3k.io/", + "create update media", + ), + ); + assert_eq!( + post["properties"]["uid"][0], uid, + "UID of a post and its supposed location don't match" + ); assert_eq!(post["properties"]["author"][0], "https://fireburn.ru/"); - assert!(post["properties"]["url"] - .as_array() - .unwrap() - .iter() - .map(|i| i.as_str().unwrap()) - .any(|i| i == "https://fireburn.ru/feeds/main"), - "Didn't found an URL pointing to the location expected by the mp-slug semantics"); - assert!(post["properties"]["mp-slug"].as_array().is_none(), "mp-slug wasn't deleted from the array!") + assert!( + post["properties"]["url"] + .as_array() + .unwrap() + .iter() + .map(|i| i.as_str().unwrap()) + .any(|i| i == "https://fireburn.ru/feeds/main"), + "Didn't found an URL pointing to the location expected by the mp-slug semantics" + ); + assert!( + post["properties"]["mp-slug"].as_array().is_none(), + "mp-slug wasn't deleted from the array!" + ) } -} \ No newline at end of file +} |