diff options
Diffstat (limited to 'src/frontend/mod.rs')
-rw-r--r-- | src/frontend/mod.rs | 461 |
1 files changed, 188 insertions, 273 deletions
diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index c0452f3..ffeb9de 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -1,24 +1,24 @@ +#![warn(clippy::todo)] use std::convert::TryInto; - use crate::database::Storage; -use crate::ApplicationState; -use log::{error, info}; use serde::{Deserialize, Serialize}; -use tide::{Next, Request, Response, Result, StatusCode}; +use futures_util::TryFutureExt; +use warp::{http::StatusCode, Filter, host::Authority, path::FullPath}; static POSTS_PER_PAGE: usize = 20; -pub mod login; +//pub mod login; mod templates; +#[allow(unused_imports)] use templates::{ErrorPage, MainPage, OnboardingPage, Template}; #[derive(Clone, Serialize, Deserialize)] pub struct IndiewebEndpoints { - authorization_endpoint: String, - token_endpoint: String, - webmention: Option<String>, - microsub: Option<String>, + pub authorization_endpoint: String, + pub token_endpoint: String, + pub webmention: Option<String>, + pub microsub: Option<String>, } #[derive(Deserialize)] @@ -32,6 +32,7 @@ struct FrontendError { source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>, code: StatusCode, } + impl FrontendError { pub fn with_code<C>(code: C, msg: &str) -> Self where @@ -40,7 +41,7 @@ impl FrontendError { Self { msg: msg.to_string(), source: None, - code: code.try_into().unwrap_or(StatusCode::InternalServerError), + code: code.try_into().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), } } pub fn msg(&self) -> &str { @@ -50,15 +51,17 @@ impl FrontendError { self.code } } + impl From<crate::database::StorageError> for FrontendError { fn from(err: crate::database::StorageError) -> Self { Self { msg: "Database error".to_string(), source: Some(Box::new(err)), - code: StatusCode::InternalServerError, + code: StatusCode::INTERNAL_SERVER_ERROR, } } } + impl std::error::Error for FrontendError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { self.source @@ -66,12 +69,15 @@ impl std::error::Error for FrontendError { .map(|e| e.as_ref() as &(dyn std::error::Error + 'static)) } } + impl std::fmt::Display for FrontendError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.msg) } } +impl warp::reject::Reject for FrontendError {} + async fn get_post_from_database<S: Storage>( db: &S, url: &str, @@ -85,7 +91,7 @@ async fn get_post_from_database<S: Storage>( Ok(result) => match result { Some(post) => Ok(post), None => Err(FrontendError::with_code( - StatusCode::NotFound, + StatusCode::NOT_FOUND, "Post not found in the database", )), }, @@ -94,12 +100,12 @@ async fn get_post_from_database<S: Storage>( // TODO: Authentication if user.is_some() { Err(FrontendError::with_code( - StatusCode::Forbidden, + StatusCode::FORBIDDEN, "User authenticated AND forbidden to access this resource", )) } else { Err(FrontendError::with_code( - StatusCode::Unauthorized, + StatusCode::UNAUTHORIZED, "User needs to authenticate themselves", )) } @@ -109,12 +115,14 @@ async fn get_post_from_database<S: Storage>( } } +#[allow(dead_code)] #[derive(Deserialize)] struct OnboardingFeed { slug: String, name: String, } +#[allow(dead_code)] #[derive(Deserialize)] struct OnboardingData { user: serde_json::Value, @@ -123,7 +131,7 @@ struct OnboardingData { feeds: Vec<OnboardingFeed>, } -pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result { +/*pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result { use serde_json::json; log::debug!("Entering onboarding receiver..."); @@ -213,279 +221,186 @@ pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S Ok(Response::builder(201).header("Location", "/").build()) } +*/ -pub async fn coffee<S: Storage>(_: Request<ApplicationState<S>>) -> Result { - Err(FrontendError::with_code( - StatusCode::ImATeapot, - "Someone asked this website to brew them some coffee...", - ) - .into()) +fn request_uri() -> impl Filter<Extract = (String,), Error = warp::Rejection> + Copy { + crate::util::require_host() + .and(warp::path::full()) + .map(|host: Authority, path: FullPath| "https://".to_owned() + host.as_str() + path.as_str()) } -pub async fn mainpage<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result { - // This cannot error out as the URL must be valid. Or there is something horribly wrong - // and we shouldn't serve this request anyway. - <dyn AsMut<tide::http::Request>>::as_mut(&mut req) - .url_mut() - .set_scheme("https") - .unwrap(); - let backend = &req.state().storage; - let query = req.query::<QueryParams>()?; - let authorization_endpoint = req.state().authorization_endpoint.to_string(); - let token_endpoint = req.state().token_endpoint.to_string(); - let user: Option<String> = req.session().get("user"); - - #[cfg(any(not(debug_assertions), test))] - let url = req.url(); - #[cfg(all(debug_assertions, not(test)))] - let url = url::Url::parse("https://localhost:8080/").unwrap(); - - let hcard_url = url.as_str(); - let feed_url = url.join("feeds/main").unwrap().to_string(); - - let card = get_post_from_database(backend, hcard_url, None, &user).await; - let feed = get_post_from_database(backend, &feed_url, query.after, &user).await; - - if card.is_err() && feed.is_err() { - // Uh-oh! No main feed and no h-card? Need to do onboarding. - // We can do it from inside the app without ever requesting an auth token. - let card_err = card.unwrap_err(); - let feed_err = feed.unwrap_err(); - if card_err.code == 404 { - // Yes, we definitely need some onboarding here. - Ok(Response::builder(200) - .content_type("text/html; charset=utf-8") - .body( - Template { - title: "Kittybox - Onboarding", - blog_name: "Kitty Box!", - endpoints: IndiewebEndpoints { - authorization_endpoint, - token_endpoint, - webmention: None, - microsub: None, - }, - feeds: Vec::default(), - user: None, - content: OnboardingPage {}.to_string(), - } - .to_string(), - ) - .build()) - } else { - Err(feed_err.into()) - } - } else { - Ok(Response::builder(200) - .content_type("text/html; charset=utf-8") - .body( - Template { - title: &format!("{} - Main page", url.host().unwrap().to_string()), - blog_name: &backend - .get_setting("site_name", hcard_url) - .await - .unwrap_or_else(|_| "Kitty Box!".to_string()), - endpoints: IndiewebEndpoints { - authorization_endpoint, - token_endpoint, - webmention: None, - microsub: None, - }, - feeds: backend - .get_channels(hcard_url) - .await - .unwrap_or_else(|_| Vec::default()), - user, - content: MainPage { - feed: &feed?, - card: &card?, +#[forbid(clippy::unwrap_used)] +pub fn homepage<D: Storage>(db: D, endpoints: IndiewebEndpoints) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone { + let inject_db = move || db.clone(); + warp::any() + .map(inject_db.clone()) + .and(crate::util::require_host()) + .and(warp::query()) + .and_then(|db: D, host: Authority, q: QueryParams| async move { + let path = format!("https://{}/", host.to_string()); + let feed_path = format!("https://{}/feeds/main", host.to_string()); + + match tokio::try_join!( + get_post_from_database(&db, &path, None, &None), + get_post_from_database(&db, &feed_path, q.after, &None) + ) { + Ok((hcard, hfeed)) => Ok(( + Some(hcard), + Some(hfeed), + StatusCode::OK + )), + Err(err) => { + if err.code == StatusCode::NOT_FOUND { + // signal for onboarding flow + Ok((None, None, err.code)) + } else { + Err(warp::reject::custom(err)) } - .to_string(), } - .to_string(), - ) - .build()) - } -} - -pub async fn render_post<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result { - let query = req.query::<QueryParams>()?; - let authorization_endpoint = req.state().authorization_endpoint.to_string(); - let token_endpoint = req.state().token_endpoint.to_string(); - let user: Option<String> = req.session().get("user"); - - // This cannot error out as the URL must be valid. Or there is something horribly wrong - // and we shouldn't serve this request anyway. - <dyn AsMut<tide::http::Request>>::as_mut(&mut req) - .url_mut() - .set_scheme("https") - .unwrap(); - #[cfg(any(not(debug_assertions), test))] - let url = req.url(); - #[cfg(all(debug_assertions, not(test)))] - let url = url::Url::parse("https://localhost:8080/") - .unwrap() - .join(req.url().path()) - .unwrap(); - - let mut entry_url = req.url().clone(); - entry_url.set_query(None); - - let post = get_post_from_database(&req.state().storage, entry_url.as_str(), query.after, &user) - .await?; - - #[cfg(debug_assertions)] - if let Some(value) = req.header("Accept") { - log::debug!("{:?}", value); - - if value == "application/json" { - return Ok(Response::builder(200) - .content_type("application/json; charset=utf-8") - .body(post.to_string()) - .build()); - } - } - - let template: String = match post["type"][0] - .as_str() - .expect("Empty type array or invalid type") - { - "h-entry" => templates::Entry { post: &post }.to_string(), - "h-card" => templates::VCard { card: &post }.to_string(), - "h-feed" => templates::Feed { feed: &post }.to_string(), - _ => { - return Err(FrontendError::with_code( - StatusCode::InternalServerError, - "Couldn't render an unknown type", - ) - .into()) - } - }; - let origin = url.origin(); - let owner = origin.ascii_serialization() + "/"; - - Ok(Response::builder(200) - .content_type("text/html; charset=utf-8") - .body( - Template { - title: post["properties"]["name"][0] - .as_str() - .unwrap_or(&format!("Note at {}", url.host().unwrap().to_string())), - blog_name: &req - .state() - .storage - .get_setting("site_name", &owner) // XXX I'm pretty sure this is bound to cause issues with IDN-style domains - .await - .unwrap_or_else(|_| "Kitty Box!".to_string()), - endpoints: IndiewebEndpoints { - authorization_endpoint, - token_endpoint, - webmention: None, - microsub: None, + } + }) + .and(warp::any().map(move || endpoints.clone())) + .and(crate::util::require_host()) + .and(warp::any().map(inject_db)) + .then(|content: (Option<serde_json::Value>, Option<serde_json::Value>, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move { + let owner = format!("https://{}/", host.as_str()); + let blog_name = db.get_setting("site_name", &owner).await + .unwrap_or_else(|_| "Kitty Box!".to_string()); + let feeds = db.get_channels(&owner).await.unwrap_or_default(); + match content { + (Some(card), Some(feed), StatusCode::OK) => { + warp::reply::html(Template { + title: &blog_name, + blog_name: &blog_name, + endpoints, + feeds, + user: None, // TODO + content: MainPage { feed: &feed, card: &card }.to_string() + }.to_string()) }, - feeds: req - .state() - .storage - .get_channels(&owner) - .await - .unwrap_or_else(|_| Vec::default()), - user, - content: template, + _ => { + // TODO Onboarding + todo!("Onboarding flow") + } } - .to_string(), - ) - .build()) + }) } -pub struct ErrorHandlerMiddleware {} - -#[async_trait::async_trait] -impl<S> tide::Middleware<ApplicationState<S>> for ErrorHandlerMiddleware -where - S: crate::database::Storage, -{ - async fn handle( - &self, - request: Request<ApplicationState<S>>, - next: Next<'_, ApplicationState<S>>, - ) -> Result { - let authorization_endpoint = request.state().authorization_endpoint.to_string(); - let token_endpoint = request.state().token_endpoint.to_string(); - let owner = request.url().origin().ascii_serialization() + "/"; - let site_name = &request - .state() - .storage - .get_setting("site_name", &owner) - .await - .unwrap_or_else(|_| "Kitty Box!".to_string()); - let feeds = request - .state() - .storage - .get_channels(&owner) - .await - .unwrap_or_else(|_| Vec::default()); - let user: Option<String> = request.session().get("user"); - let mut res = next.run(request).await; - let mut code: Option<StatusCode> = None; - let mut msg: Option<String> = None; - if let Some(err) = res.downcast_error::<FrontendError>() { - code = Some(err.code()); - error!("Error caught while processing request: {}", err.msg()); - if err.code() == 400 { - msg = Some(err.msg().to_string()); +#[forbid(clippy::unwrap_used)] +pub fn catchall<D: Storage>(db: D, endpoints: IndiewebEndpoints) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone { + let inject_db = move || db.clone(); + warp::any() + .map(inject_db.clone()) + .and(request_uri()) + .and(warp::query()) + .and_then(|db: D, path: String, query: QueryParams| async move { + get_post_from_database(&db, &path, query.after, &None).map_err(warp::reject::custom).await + }) + // Rendering pipeline + .and_then(|post: serde_json::Value| async move { + let post_name = &post["properties"]["name"][0].as_str().to_owned(); + match post["type"][0] + .as_str() + { + Some("h-entry") => Ok(( + post_name.unwrap_or("Note").to_string(), + templates::Entry { post: &post }.to_string(), + StatusCode::OK + )), + Some("h-card") => Ok(( + post_name.unwrap_or("Contact card").to_string(), + templates::VCard { card: &post }.to_string(), + StatusCode::OK + )), + Some("h-feed") => Ok(( + post_name.unwrap_or("Feed").to_string(), + templates::Feed { feed: &post }.to_string(), + StatusCode::OK + )), + _ => Err(warp::reject::custom(FrontendError::with_code( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("Couldn't render an unknown type: {}", post["type"][0]), + ))) } - let mut err: &dyn std::error::Error = err; - while let Some(e) = err.source() { - error!("Caused by: {}", e); - err = e; + }) + .recover(|err: warp::Rejection| { + use warp::Rejection; + use futures_util::future; + if let Some(err) = err.find::<FrontendError>() { + return future::ok::<(String, String, StatusCode), Rejection>(( + format!("Error: HTTP {}", err.code().as_u16()), + ErrorPage { code: err.code(), msg: Some(err.msg().to_string()) }.to_string(), + err.code() + )); } - } - if let Some(code) = code { - res.set_status(code); - res.set_content_type("text/html; charset=utf-8"); - res.set_body( - Template { - title: "Error", - blog_name: site_name, - endpoints: IndiewebEndpoints { - authorization_endpoint, - token_endpoint, - webmention: None, - microsub: None, - }, - feeds, - user, - content: ErrorPage { code, msg }.to_string(), - } - .to_string(), - ); - } - Ok(res) - } + future::err::<(String, String, StatusCode), Rejection>(err) + }) + .unify() + .and(warp::any().map(move || endpoints.clone())) + .and(crate::util::require_host()) + .and(warp::any().map(inject_db)) + .then(|content: (String, String, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move { + let owner = format!("https://{}/", host.as_str()); + let blog_name = db.get_setting("site_name", &owner).await + .unwrap_or_else(|_| "Kitty Box!".to_string()); + let feeds = db.get_channels(&owner).await.unwrap_or_default(); + let (title, content, code) = content; + warp::reply::with_status(warp::reply::html(Template { + title: &title, + blog_name: &blog_name, + endpoints, + feeds, + user: None, // TODO + content, + }.to_string()), code) + }) + } static STYLE_CSS: &[u8] = include_bytes!("./style.css"); static ONBOARDING_JS: &[u8] = include_bytes!("./onboarding.js"); static ONBOARDING_CSS: &[u8] = include_bytes!("./onboarding.css"); -pub async fn handle_static<S: Storage>(req: Request<ApplicationState<S>>) -> Result { - Ok(match req.param("path") { - Ok("style.css") => Ok(Response::builder(200) - .content_type("text/css; charset=utf-8") - .body(STYLE_CSS) - .build()), - Ok("onboarding.js") => Ok(Response::builder(200) - .content_type("text/javascript; charset=utf-8") - .body(ONBOARDING_JS) - .build()), - Ok("onboarding.css") => Ok(Response::builder(200) - .content_type("text/css; charset=utf-8") - .body(ONBOARDING_CSS) - .build()), - Ok(_) => Err(FrontendError::with_code( - StatusCode::NotFound, - "Static file not found", - )), - Err(_) => panic!("Invalid usage of the frontend::handle_static() function"), - }?) +static MIME_JS: &str = "application/javascript"; +static MIME_CSS: &str = "text/css"; + +fn _dispatch_static(name: &str) -> Option<(&'static [u8], &'static str)> { + match name { + "style.css" => Some((STYLE_CSS, MIME_CSS)), + "onboarding.js" => Some((ONBOARDING_JS, MIME_JS)), + "onboarding.css" => Some((ONBOARDING_CSS, MIME_CSS)), + _ => None + } +} + +pub fn static_files() -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Copy { + use futures_util::future; + + warp::get() + .and(warp::path::param() + .and_then(|filename: String| { + match _dispatch_static(&filename) { + Some((buf, content_type)) => future::ok( + warp::reply::with_header( + buf, "Content-Type", content_type + ) + ), + None => future::err(warp::reject()) + } + })) + .or(warp::head() + .and(warp::path::param() + .and_then(|filename: String| { + match _dispatch_static(&filename) { + Some((buf, content_type)) => future::ok( + warp::reply::with_header( + warp::reply::with_header( + warp::reply(), "Content-Type", content_type + ), + "Content-Length", buf.len() + ) + ), + None => future::err(warp::reject()) + } + }))) } |