#[allow(unused_imports)] use warp::Filter; pub mod metrics; /// Database abstraction layer for Kittybox, allowing the CMS to work with any kind of database. pub mod database; pub mod micropub; pub mod indieauth; //pub mod frontend; /*use crate::indieauth::IndieAuthMiddleware; use crate::micropub::CORSMiddleware;*/ pub mod rejections { #[derive(Debug)] pub struct UnacceptableContentType; impl warp::reject::Reject for UnacceptableContentType {} #[derive(Debug)] pub struct HostHeaderUnset; impl warp::reject::Reject for HostHeaderUnset {} } pub static MICROPUB_CLIENT: &[u8] = include_bytes!("./index.html"); pub mod util { use warp::{Filter, host::Authority}; use super::rejections; pub fn require_host() -> impl Filter + Copy { warp::host::optional() .and_then(|authority: Option| async move { authority.ok_or_else(|| warp::reject::custom(rejections::HostHeaderUnset)) }) } pub fn template( template: R ) -> impl warp::Reply where R: markup::Render + std::fmt::Display { warp::reply::html(template.to_string()) } pub fn parse_accept() -> impl Filter + Copy { warp::header::value("Accept").and_then(|accept: warp::http::HeaderValue| async move { let mut accept: http_types::content::Accept = { // This is unneccesarily complicated because I want to reuse some http-types parsing // and http-types has constructor for Headers private so I need to construct // a mock Request to reason about headers... this is so dumb wtf let bytes: &[u8] = accept.as_bytes(); let value = http_types::headers::HeaderValue::from_bytes(bytes.to_vec()).unwrap(); let values: http_types::headers::HeaderValues = vec![value].into(); let mut request = http_types::Request::new(http_types::Method::Get, "http://example.com/"); request.append_header("Accept".parse::().unwrap(), &values); http_types::content::Accept::from_headers(&request).unwrap().unwrap() }; // This code is INCREDIBLY dumb, honestly... // why did I even try to use it? // TODO vendor this stuff in so I can customize it match accept.negotiate(&[ "text/html; encoding=\"utf-8\"".into(), "application/json; encoding=\"utf-8\"".into(), "text/html".into(), "application/json".into(), ]) { Ok(mime) => { Ok(http_types::Mime::from(mime.value().as_str())) }, Err(err) => { log::error!("Content-Type negotiation error: {:?}, accepting: {:?}", err, accept); Err(warp::reject::custom(rejections::UnacceptableContentType)) } } }) } mod tests { #[tokio::test] async fn test_require_host_with_host() { use super::require_host; let filter = require_host(); let res = warp::test::request() .path("/") .header("Host", "localhost:8080") .filter(&filter) .await .unwrap(); assert_eq!(res, "localhost:8080"); } #[tokio::test] async fn test_require_host_no_host() { use super::require_host; let filter = require_host(); let res = warp::test::request() .path("/") .filter(&filter) .await; assert!(res.is_err()); } } } // fn equip_app(mut app: App) -> App // where // Storage: database::Storage + Send + Sync + Clone, // { // app.at("/micropub") // .with(CORSMiddleware {}) // .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()) // }); // app.at("/") // .with(CORSMiddleware {}) // .with(frontend::ErrorHandlerMiddleware {}) // .get(frontend::mainpage) // .post(frontend::onboarding_receiver); // app.at("/login") // .with(frontend::ErrorHandlerMiddleware {}) // .get(frontend::login::form) // .post(frontend::login::handler); // app.at("/login/callback") // .with(frontend::ErrorHandlerMiddleware {}) // .get(frontend::login::callback); // 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); // // TODO make sure the health check actually checks the backend or something // // otherwise it'll get false-negatives for application faults like resource // // exhaustion // app.at("/health").get(|_| async { Ok("OK") }); // app.at("/metrics").get(metrics::gather); // app.with(metrics::InstrumentationMiddleware {}); // app.with( // tide::sessions::SessionMiddleware::new( // tide::sessions::CookieStore::new(), // app.state().cookie_secret.as_bytes(), // ) // .with_cookie_name("kittybox_session") // .without_save_unchanged(), // ); // app // } /*#[cfg(feature="redis")] pub async fn get_app_with_redis( token_endpoint: surf::Url, authorization_endpoint: surf::Url, redis_uri: String, media_endpoint: Option, internal_token: Option, ) -> App { let app = tide::with_state(ApplicationState { token_endpoint, media_endpoint, authorization_endpoint, internal_token, 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_file( token_endpoint: surf::Url, ) -> ( tempdir::TempDir, database::FileStorage, App, ) { use surf::Url; let tempdir = tempdir::TempDir::new("file").expect("Failed to create tempdir"); let backend = database::FileStorage::new(tempdir.path().to_path_buf()) .await .unwrap(); let app = tide::with_state(ApplicationState { token_endpoint, media_endpoint: None, authorization_endpoint: Url::parse("https://indieauth.com/auth").unwrap(), storage: backend.clone(), internal_token: None, cookie_secret: "1234567890abcdefghijklmnopqrstuvwxyz".to_string(), http_client: surf::Client::new(), }); (tempdir, backend, equip_app(app)) } #[cfg(all(redis, test))] pub async fn get_app_with_test_redis( token_endpoint: surf::Url, ) -> ( database::RedisInstance, database::RedisStorage, App, ) { use surf::Url; let redis_instance = database::get_redis_instance().await; let backend = database::RedisStorage::new(redis_instance.uri().to_string()) .await .unwrap(); let app = tide::with_state(ApplicationState { token_endpoint, media_endpoint: None, authorization_endpoint: Url::parse("https://indieauth.com/auth").unwrap(), storage: backend.clone(), internal_token: None, http_client: surf::Client::new(), }); (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; // Helpers async fn create_app() -> ( database::FileStorage, App, tempdir::TempDir, ) { //get_app_with_memory_for_testing(surf::Url::parse(&*mockito::server_url()).unwrap()).await let (r, b, a) = get_app_with_test_file(surf::Url::parse(&*mockito::server_url()).unwrap()).await; (b, a, r) } async fn post_json( app: &App, json: serde_json::Value, ) -> surf::Response { let request = app .post("/micropub") .header("Authorization", "Bearer test") .header("Content-Type", "application/json") .body(json); return request.send().await.unwrap(); } #[async_std::test] async fn test_no_deletion_of_others_posts() { 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, _r) = create_app().await; let mut response = post_json( &app, json!({ "type": ["h-entry"], "properties": { "content": ["This is content!"] } }), ) .await; println!( "{:#}", response.body_json::().await.unwrap() ); assert!(response.status() == 201 || response.status() == 202); let uid = response.header("Location").unwrap().last().to_string(); drop(_m); let _m = mock("GET", "/") .with_status(200) .with_header("Content-Type", "application/json") .with_body(r#"{"me": "https://aaronparecki.com/", "client_id": "https://quill.p3k.io/", "scope": "create update delete media"}"#) .create(); let mut response = app .post("/micropub") .header("Authorization", "Bearer awoo") .header("Content-Type", "application/json") .body(json!({ "action": "delete", "url": uid })) .send() .await .unwrap(); println!("{}", response.body_string().await.unwrap()); assert_eq!(response.status(), 403); } #[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, _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; 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; // Should be posted successfully, but... assert!(response.status() == 201 || response.status() == 202); // ...won't be available on a foreign URL assert!(db .get_post("https://aaronparecki.com/posts/more-fake-news") .await .unwrap() .is_none()); let response = post_json(&app, 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"] } })).await; 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, _r) = 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, _r) = 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, _r) = 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, _r) = 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, _r) = create_app().await; let mut response = post_json( &app, json!({ "type": ["h-entry"], "properties": { "content": ["This is content!"] } }), ) .await; 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 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::().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); } }*/