diff options
Diffstat (limited to 'kittybox-rs/src/frontend/mod.rs')
-rw-r--r-- | kittybox-rs/src/frontend/mod.rs | 459 |
1 files changed, 459 insertions, 0 deletions
diff --git a/kittybox-rs/src/frontend/mod.rs b/kittybox-rs/src/frontend/mod.rs new file mode 100644 index 0000000..b87f9c6 --- /dev/null +++ b/kittybox-rs/src/frontend/mod.rs @@ -0,0 +1,459 @@ +use std::convert::TryInto; +use crate::database::Storage; +use serde::Deserialize; +use futures_util::TryFutureExt; +use warp::{http::StatusCode, Filter, host::Authority, path::FullPath}; + +//pub mod login; + +#[allow(unused_imports)] +use kittybox_templates::{ErrorPage, MainPage, OnboardingPage, Template, POSTS_PER_PAGE}; + +pub use kittybox_util::IndiewebEndpoints; + +#[derive(Deserialize)] +struct QueryParams { + after: Option<String>, +} + +#[derive(Debug)] +struct FrontendError { + msg: String, + 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 + C: TryInto<StatusCode>, + { + 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<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::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<S: Storage>( + db: &S, + url: &str, + after: Option<String>, + user: &Option<String>, +) -> std::result::Result<serde_json::Value, FrontendError> { + 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<OnboardingFeed>, +} + +impl OnboardingData { + fn default_blog_name() -> String { + "Kitty Box!".to_owned() + } +} + +/*pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S>>) -> 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. + <dyn AsMut<tide::http::Request>>::as_mut(&mut req) + .url_mut() + .set_scheme("https") + .unwrap(); + + log::debug!("Parsing the body..."); + let body = req.body_json::<OnboardingData>().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<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()) +} + +#[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); + let feed_path = format!("https://{}/feeds/main", host); + + 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<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(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<dyn warp::Reply> + }, + (None, None, StatusCode::NOT_FOUND) => { + // TODO Onboarding + Box::new(warp::redirect::found( + hyper::Uri::from_static("/onboarding") + )) as Box<dyn warp::Reply> + } + _ => unreachable!() + } + }) +} + +pub fn onboarding<D: 'static + Storage>( + db: D, + endpoints: IndiewebEndpoints, + http: reqwest::Client +) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + 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::<OnboardingData>()) + .and(warp::any().map(move || http.clone())) + .and_then(|host: warp::host::Authority, db: D, body: OnboardingData, http: reqwest::Client| 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<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(), + kittybox_templates::Entry { post: &post }.to_string(), + StatusCode::OK + )), + Some("h-card") => Ok(( + post_name.unwrap_or("Contact card").to_string(), + kittybox_templates::VCard { card: &post }.to_string(), + StatusCode::OK + )), + Some("h-feed") => Ok(( + post_name.unwrap_or("Feed").to_string(), + kittybox_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::<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() + )); + } + 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<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()) + } + }))) +} |