#[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<Extract = (Authority,), Error = warp::Rejection> + Copy {
warp::host::optional()
.and_then(|authority: Option<Authority>| async move {
authority.ok_or_else(|| warp::reject::custom(rejections::HostHeaderUnset))
})
}
pub fn template<R>(
template: R
) -> impl warp::Reply
where
R: markup::Render + std::fmt::Display
{
warp::reply::html(template.to_string())
}
pub fn parse_accept() -> impl Filter<Extract = (http_types::Mime,), Error = warp::Rejection> + 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::<http_types::headers::HeaderName>().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<Storage>(mut app: App<Storage>) -> App<Storage>
// 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<String>,
internal_token: Option<String>,
) -> App<database::RedisStorage> {
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<database::FileStorage>,
) {
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<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 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<database::FileStorage>,
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<database::FileStorage>,
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::<serde_json::Value>().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::<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, _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()
);
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 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()
);
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);
}
}*/