use std::convert::TryInto; use crate::database::Storage; use serde::{Deserialize, Serialize}; use futures_util::TryFutureExt; use warp::{http::StatusCode, Filter, host::Authority, path::FullPath}; static POSTS_PER_PAGE: usize = 20; //pub mod login; mod templates; #[allow(unused_imports)] use templates::{ErrorPage, MainPage, OnboardingPage, Template}; #[derive(Clone, Serialize, Deserialize)] pub struct IndiewebEndpoints { pub authorization_endpoint: String, pub token_endpoint: String, pub webmention: Option, pub microsub: Option, } #[derive(Deserialize)] struct QueryParams { after: Option, } #[derive(Debug)] struct FrontendError { msg: String, source: Option>, code: StatusCode, } impl FrontendError { pub fn with_code(code: C, msg: &str) -> Self where C: TryInto, { Self { msg: msg.to_string(), source: None, code: code.try_into().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), } } pub fn msg(&self) -> &str { &self.msg } pub fn code(&self) -> StatusCode { self.code } } impl From for FrontendError { fn from(err: crate::database::StorageError) -> Self { Self { msg: "Database error".to_string(), source: Some(Box::new(err)), code: StatusCode::INTERNAL_SERVER_ERROR, } } } impl std::error::Error for FrontendError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { self.source .as_ref() .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( db: &S, url: &str, after: Option, user: &Option, ) -> std::result::Result { match db .read_feed_with_limit(url, &after, POSTS_PER_PAGE, user) .await { Ok(result) => match result { Some(post) => Ok(post), None => Err(FrontendError::with_code( StatusCode::NOT_FOUND, "Post not found in the database", )), }, Err(err) => match err.kind() { crate::database::ErrorKind::PermissionDenied => { // TODO: Authentication if user.is_some() { Err(FrontendError::with_code( StatusCode::FORBIDDEN, "User authenticated AND forbidden to access this resource", )) } else { Err(FrontendError::with_code( StatusCode::UNAUTHORIZED, "User needs to authenticate themselves", )) } } _ => Err(err.into()), }, } } #[allow(dead_code)] #[derive(Deserialize)] struct OnboardingFeed { slug: String, name: String, } #[allow(dead_code)] #[derive(Deserialize)] struct OnboardingData { user: serde_json::Value, first_post: serde_json::Value, #[serde(default = "OnboardingData::default_blog_name")] blog_name: String, feeds: Vec, } impl OnboardingData { fn default_blog_name() -> String { "Kitty Box!".to_owned() } } /*pub async fn onboarding_receiver(mut req: Request>) -> Result { use serde_json::json; log::debug!("Entering onboarding receiver..."); // This cannot error out as the URL must be valid. Or there is something horribly wrong // and we shouldn't serve this request anyway. >::as_mut(&mut req) .url_mut() .set_scheme("https") .unwrap(); log::debug!("Parsing the body..."); let body = req.body_json::().await?; log::debug!("Body parsed!"); let backend = &req.state().storage; #[cfg(any(not(debug_assertions), test))] let me = req.url(); #[cfg(all(debug_assertions, not(test)))] let me = url::Url::parse("https://localhost:8080/").unwrap(); log::debug!("me value: {:?}", me); if get_post_from_database(backend, me.as_str(), None, &None) .await .is_ok() { return Err(FrontendError::with_code( StatusCode::Forbidden, "Onboarding is over. Are you trying to take over somebody's website?!", ) .into()); } info!("Onboarding new user: {}", me); let user = crate::indieauth::User::new(me.as_str(), "https://kittybox.fireburn.ru/", "create"); log::debug!("Setting the site name to {}", &body.blog_name); backend .set_setting("site_name", user.me.as_str(), &body.blog_name) .await?; if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" { return Err(FrontendError::with_code( StatusCode::BadRequest, "user and first_post should be h-card and h-entry", ) .into()); } info!("Validated body.user and body.first_post as microformats2"); let mut hcard = body.user; let hentry = body.first_post; // Ensure the h-card's UID is set to the main page, so it will be fetchable. hcard["properties"]["uid"] = json!([me.as_str()]); // Normalize the h-card - note that it should preserve the UID we set here. let (_, hcard) = crate::micropub::normalize_mf2(hcard, &user); // The h-card is written directly - all the stuff in the Micropub's // post function is just to ensure that the posts will be syndicated // and inserted into proper feeds. Here, we don't have a need for this, // since the h-card is DIRECTLY accessible via its own URL. log::debug!("Saving the h-card..."); backend.put_post(&hcard, me.as_str()).await?; log::debug!("Creating feeds..."); for feed in body.feeds { if feed.name.is_empty() || feed.slug.is_empty() { continue; }; log::debug!("Creating feed {} with slug {}", &feed.name, &feed.slug); let (_, feed) = crate::micropub::normalize_mf2( json!({ "type": ["h-feed"], "properties": {"name": [feed.name], "mp-slug": [feed.slug]} }), &user, ); backend.put_post(&feed, me.as_str()).await?; } log::debug!("Saving the h-entry..."); // This basically puts the h-entry post through the normal creation process. // We need to insert it into feeds and optionally send a notification to everywhere. req.set_ext(user); crate::micropub::post::new_post(req, hentry).await?; Ok(Response::builder(201).header("Location", "/").build()) } */ fn request_uri() -> impl Filter + Copy { crate::util::require_host() .and(warp::path::full()) .map(|host: Authority, path: FullPath| "https://".to_owned() + host.as_str() + path.as_str()) } #[forbid(clippy::unwrap_used)] pub fn homepage(db: D, endpoints: IndiewebEndpoints) -> impl Filter + 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)) } } } }) .and(warp::any().map(move || endpoints.clone())) .and(crate::util::require_host()) .and(warp::any().map(inject_db)) .then(|content: (Option, Option, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move { let owner = format!("https://{}/", host.as_str()); let blog_name = db.get_setting(crate::database::Settings::SiteName, &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) => { Box::new(warp::reply::html(Template { title: &blog_name, blog_name: &blog_name, endpoints: Some(endpoints), feeds, user: None, // TODO content: MainPage { feed: &feed, card: &card }.to_string() }.to_string())) as Box }, (None, None, StatusCode::NOT_FOUND) => { // TODO Onboarding Box::new(warp::redirect::found( hyper::Uri::from_static("/onboarding") )) as Box } _ => unreachable!() } }) } pub fn onboarding( db: D, endpoints: IndiewebEndpoints, http: hyper::Client ) -> impl Filter + Clone { let inject_db = move || db.clone(); warp::get() .map(move || warp::reply::html(Template { title: "Kittybox - Onboarding", blog_name: "Kittybox", endpoints: Some(endpoints.clone()), feeds: vec![], user: None, content: OnboardingPage {}.to_string() }.to_string())) .or(warp::post() .and(crate::util::require_host()) .and(warp::any().map(inject_db)) .and(warp::body::json::()) .and(warp::any().map(move || http.clone())) .and_then(|host: warp::host::Authority, db: D, body: OnboardingData, http: _| async move { let user_uid = format!("https://{}/", host.as_str()); if db.post_exists(&user_uid).await.map_err(FrontendError::from)? { return Ok(warp::redirect(hyper::Uri::from_static("/"))); } let user = crate::indieauth::User::new(&user_uid, "https://kittybox.fireburn.ru/", "create"); if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" { return Err(FrontendError::with_code(StatusCode::BAD_REQUEST, "user and first_post should be an h-card and an h-entry").into()); } db.set_setting(crate::database::Settings::SiteName, user.me.as_str(), &body.blog_name) .await .map_err(FrontendError::from)?; let (_, hcard) = { let mut hcard = body.user; hcard["properties"]["uid"] = serde_json::json!([&user_uid]); crate::micropub::normalize_mf2(hcard, &user) }; db.put_post(&hcard, &user_uid).await.map_err(FrontendError::from)?; let (uid, post) = crate::micropub::normalize_mf2(body.first_post, &user); crate::micropub::_post(user, uid, post, db, http).await.map_err(|e| { FrontendError { msg: "Error while posting the first post".to_string(), source: Some(Box::new(e)), code: StatusCode::INTERNAL_SERVER_ERROR } })?; Ok::<_, warp::Rejection>(warp::redirect(hyper::Uri::from_static("/"))) })) } #[forbid(clippy::unwrap_used)] pub fn catchall(db: D, endpoints: IndiewebEndpoints) -> impl Filter + 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]), ))) } }) .recover(|err: warp::Rejection| { use warp::Rejection; use futures_util::future; if let Some(err) = err.find::() { 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() )); } 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(crate::database::Settings::SiteName, &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: Some(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"); 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 + 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()) } }))) }