From 08c09aaa055c05228855eed8cded9fdfe4939c0f Mon Sep 17 00:00:00 2001 From: Vika Date: Tue, 4 May 2021 17:05:51 +0300 Subject: Initial commit Working features: - Sending posts from the database - Reading posts from the database - Responding with MF2-JSON (only in debug mode!) - Not locking the database when not needed - All database actions are atomic (except for a small race where UIDs can clash, but that's not gonna happen often) TODOs: - Send webmentions - Send syndication requests - Send WebSub notifications - Make tombstones for deleted posts (update adding dt-deleted) - Rich reply contexts (possibly on the frontend part?) - Frontend? - Fix UID race Code maintenance TODOs: - Split the database module - Finish implementing the in-memory test database - Make RedisDatabase unit tests launch their own Redis instances (see redis-rs/tests/support/mod.rs for more info) - Write more unit-tests!!! --- src/lib.rs | 276 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 src/lib.rs (limited to 'src/lib.rs') diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..459ad23 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,276 @@ +#[cfg(debug_assertions)] +use log::info; +#[cfg(debug_assertions)] +use serde::Deserialize; +use tide::{Request, Response}; + +mod database; +mod indieauth; +mod micropub; + +use crate::indieauth::check_auth; +use crate::micropub::{get_handler,post_handler}; + +#[derive(Clone)] +pub struct ApplicationState +where + StorageBackend: database::Storage + Send + Sync + 'static +{ + token_endpoint: surf::Url, + media_endpoint: Option, + http_client: surf::Client, + storage: StorageBackend +} + +type App = tide::Server>; + +static INDEX_PAGE: &[u8] = include_bytes!("./index.html"); + +#[cfg(debug_assertions)] +#[derive(Deserialize)] +struct Mf2JsonQuery { + url: String, + limit: usize, + user: Option, + after: Option +} + +fn equip_app(mut app: App) -> App +where + Storage: database::Storage + Send + Sync + Clone +{ + app.at("/").get(|_: Request<_>| async move { + Ok(Response::builder(200).body(INDEX_PAGE).content_type("text/html").build()) + }); + app.at("/micropub").with(check_auth).get(get_handler).post(post_handler); + #[cfg(debug_assertions)] + info!("Outfitting app with the debug function"); + #[cfg(debug_assertions)] + app.at("/mf2-json").get(|req: Request>| async move { + info!("DEBUG FUNCTION: Reading MF2-JSON"); + let backend = &req.state().storage; + let query = req.query::()?; + match backend.read_feed_with_limit(&query.url, &query.after, query.limit, &query.user).await { + Ok(result) => match result { + Some(post) => Ok(Response::builder(200).body(post).build()), + None => Ok(Response::builder(404).build()) + }, + Err(err) => match err.kind() { + database::ErrorKind::PermissionDenied => { + if let Some(_) = query.user { + Ok(Response::builder(403).build()) + } else { + Ok(Response::builder(401).build()) + } + } + _ => Ok(Response::builder(500).body(serde_json::json!({"error": "database_error", "error_description": format!("{}", err)})).build()) + } + } + }); + + return app +} + +pub async fn get_app_with_redis(token_endpoint: surf::Url, redis_uri: String, media_endpoint: Option) -> App { + let app = tide::with_state(ApplicationState { + token_endpoint, media_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_memory_for_testing(token_endpoint: surf::Url) -> (database::MemoryStorage, App) { + let database = database::MemoryStorage::new(); + let app = tide::with_state(ApplicationState { + token_endpoint, media_endpoint: None, + storage: database.clone(), + http_client: surf::Client::new(), + }); + + return (database, equip_app(app)) +} + +#[cfg(test)] +#[allow(unused_variables,unused_imports)] +mod tests { + use super::*; + use serde_json::json; + use tide_testing::TideTestingExt; + use crate::database::Storage; + use mockito::mock; + + async fn create_app() -> (database::MemoryStorage, App) { + get_app_with_memory_for_testing(surf::Url::parse(&*mockito::server_url()).unwrap()).await + } + #[async_std::test] + async fn test_no_posting_to_others_websites() { + let _m = mock("GET", "/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(r#"{"me": "https://fireburn.ru", "client_id": "https://quill.p3k.io/", "scope": "create update media"}"#) + .create(); + + let (db, app) = create_app().await; + + let request: surf::RequestBuilder = app.post("/micropub") + .header("Authorization", "Bearer test") + .header("Content-Type", "application/json") + .body(json!({ + "type": ["h-entry"], + "properties": { + "content": ["Fake news about Aaron Parecki!"], + "uid": ["https://aaronparecki.com/posts/fake-news"] + } + })); + let response = request.send().await.unwrap(); + assert_eq!(response.status(), 403); + + let request: surf::RequestBuilder = app.post("/micropub") + .header("Authorization", "Bearer test") + .header("Content-Type", "application/json") + .body(json!({ + "type": ["h-entry"], + "properties": { + "content": ["More fake news about Aaron Parecki!"], + "url": ["https://aaronparecki.com/posts/more-fake-news"] + } + })); + let response = request.send().await.unwrap(); + assert_eq!(response.status(), 403); + + let request: surf::RequestBuilder = app.post("/micropub") + .header("Authorization", "Bearer test") + .header("Content-Type", "application/json") + .body(json!({ + "type": ["h-entry"], + "properties": { + "content": ["Sneaky advertisement designed to creep into someone else's feed! Buy whatever I'm promoting!"], + "channel": ["https://aaronparecki.com/feeds/main"] + } + })); + let response = request.send().await.unwrap(); + assert_eq!(response.status(), 403); + } + + #[async_std::test] + async fn test_successful_authorization() { + let _m = mock("GET", "/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(r#"{"me": "https://fireburn.ru", "client_id": "https://quill.p3k.io/", "scope": "create update media"}"#) + .create(); + + let (db, app) = create_app().await; + + let response: serde_json::Value = app.get("/micropub?q=config") + .header("Authorization", "test") + .recv_json().await.unwrap(); + assert!(!response["q"].as_array().unwrap().is_empty()); + } + + #[async_std::test] + async fn test_unsuccessful_authorization() { + let _m = mock("GET", "/") + .with_status(400) + .with_header("Content-Type", "application/json") + .with_body(r#"{"error":"unauthorized","error_description":"A valid access token is required."}"#) + .create(); + + let (db, app) = create_app().await; + + let response: surf::Response = app.get("/micropub?q=config") + .header("Authorization", "test") + .send().await.unwrap(); + assert_eq!(response.status(), 401); + } + + #[async_std::test] + async fn test_no_auth_header() { + let (db, app) = create_app().await; + + let request: surf::RequestBuilder = app.get("/micropub?q=config"); + let response: surf::Response = request.send().await.unwrap(); + assert_eq!(response.status(), 401); + } + + #[async_std::test] + async fn test_create_post_form_encoded() { + let _m = mock("GET", "/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(r#"{"me": "https://fireburn.ru", "client_id": "https://quill.p3k.io/", "scope": "create update media"}"#) + .create(); + + let (storage, app) = create_app().await; + + 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::().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(), "

something interesting

"); + } + + #[async_std::test] + async fn test_create_post_json() { + let _m = mock("GET", "/") + .with_status(200) + .with_header("Content-Type", "application/json") + .with_body(r#"{"me": "https://fireburn.ru", "client_id": "https://quill.p3k.io/", "scope": "create update media"}"#) + .create(); + + let (storage, app) = create_app().await; + + let request: surf::RequestBuilder = app.post("/micropub") + .header("Authorization", "Bearer test") + .header("Content-Type", "application/json") + .body(json!({ + "type": ["h-entry"], + "properties": { + "content": ["This is content!"] + } + })); + let mut response: surf::Response = request.send().await.unwrap(); + println!("{:#}", response.body_json::().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(), "

This is content!

"); + 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 request: surf::RequestBuilder = app.post("/micropub") + .header("Authorization", "Bearer test") + .header("Content-Type", "application/json") + .body(json!({ + "type": ["h-entry"], + "properties": { + "content": ["#moar content for you!"] + } + })); + + let first_uid = uid; + + let mut response: surf::Response = request.send().await.unwrap(); + println!("{:#}", response.body_json::().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(); + println!("{}", new_feed["children"]); + assert_eq!(new_feed["children"].as_array().unwrap().len(), 2); + assert_eq!(new_feed["children"][0].as_str().unwrap(), uid); + assert_eq!(new_feed["children"][1].as_str().unwrap(), first_uid); + } +} -- cgit 1.4.1