about summary refs log tree commit diff
path: root/src/lib.rs
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2021-05-04 17:05:51 +0300
committerVika <vika@fireburn.ru>2021-05-04 17:07:25 +0300
commit08c09aaa055c05228855eed8cded9fdfe4939c0f (patch)
tree792ba1d2a3b3af7a837135aa90620d8f689d7ebd /src/lib.rs
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!!!
Diffstat (limited to 'src/lib.rs')
-rw-r--r--src/lib.rs276
1 files changed, 276 insertions, 0 deletions
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<StorageBackend>
+where
+    StorageBackend: database::Storage + Send + Sync + 'static
+{
+    token_endpoint: surf::Url,
+    media_endpoint: Option<String>,
+    http_client: surf::Client,
+    storage: StorageBackend
+}
+
+type App<Storage> = tide::Server<ApplicationState<Storage>>;
+
+static INDEX_PAGE: &[u8] = include_bytes!("./index.html");
+
+#[cfg(debug_assertions)]
+#[derive(Deserialize)]
+struct Mf2JsonQuery {
+    url: String,
+    limit: usize,
+    user: Option<String>,
+    after: Option<String>
+}
+
+fn equip_app<Storage>(mut app: App<Storage>) -> App<Storage>
+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<ApplicationState<Storage>>| async move {
+        info!("DEBUG FUNCTION: Reading MF2-JSON");
+        let backend = &req.state().storage;
+        let query = req.query::<Mf2JsonQuery>()?;
+        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<String>) -> App<database::RedisStorage> {
+    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<database::MemoryStorage>) {
+    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<database::MemoryStorage>) {
+        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::<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>");
+    }
+
+    #[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::<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!(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::<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();
+        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);
+    }
+}