use tide::{Request, Response};
mod database;
mod frontend;
mod indieauth;
mod micropub;
use crate::indieauth::IndieAuthMiddleware;
#[derive(Clone)]
pub struct ApplicationState<StorageBackend>
where
StorageBackend: database::Storage + Send + Sync + 'static,
{
token_endpoint: surf::Url,
authorization_endpoint: surf::Url,
media_endpoint: Option<String>,
http_client: surf::Client,
storage: StorageBackend,
}
type App<Storage> = tide::Server<ApplicationState<Storage>>;
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,
{
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())
});
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("/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,
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>,
) {
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(),
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::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;
(b, a, r)
}
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);
return request.send().await.unwrap();
}
#[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);
}
}