use crate::database::Storage; use crate::ApplicationState; use log::{error, info}; use serde::{Deserialize, Serialize}; use tide::{Next, Request, Response, Result, StatusCode}; static POSTS_PER_PAGE: usize = 20; mod templates; use templates::{ErrorPage, MainPage, OnboardingPage, Template}; #[derive(Clone, Serialize, Deserialize)] pub struct IndiewebEndpoints { authorization_endpoint: String, token_endpoint: String, webmention: Option, 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: StatusCode, msg: &str) -> Self { Self { msg: msg.to_string(), source: None, code, } } 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::InternalServerError, } } } 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) } } 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::NotFound, "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()), }, } } #[derive(Deserialize)] struct OnboardingFeed { slug: String, name: String, } #[derive(Deserialize)] struct OnboardingData { user: serde_json::Value, first_post: serde_json::Value, blog_name: String, feeds: Vec, } pub async fn onboarding_receiver(mut req: Request>) -> Result { use serde_json::json; // 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(); let body = req.body_json::().await?; 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(); 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"); 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. backend.put_post(&hcard, me.as_str()).await?; for feed in body.feeds { 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?; } // 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()) } pub async fn coffee(_: Request>) -> Result { Err(FrontendError::with_code( StatusCode::ImATeapot, "Someone asked this website to brew them some coffee...", ) .into()) } pub async fn mainpage(mut req: Request>) -> 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. >::as_mut(&mut req) .url_mut() .set_scheme("https") .unwrap(); let backend = &req.state().storage; let query = req.query::()?; let authorization_endpoint = req.state().authorization_endpoint.to_string(); let token_endpoint = req.state().token_endpoint.to_string(); let user: Option = None; #[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, }, 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, }, content: MainPage { feed: &feed?, card: &card?, } .to_string(), } .to_string(), ) .build()) } } pub async fn render_post(mut req: Request>) -> Result { let query = req.query::()?; let authorization_endpoint = req.state().authorization_endpoint.to_string(); let token_endpoint = req.state().token_endpoint.to_string(); let user: Option = None; // 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(); #[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?; 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(); 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", &(origin.ascii_serialization() + "/")) // 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, }, content: template, } .to_string(), ) .build()) } pub struct ErrorHandlerMiddleware {} #[async_trait::async_trait] impl tide::Middleware> for ErrorHandlerMiddleware where S: crate::database::Storage, { async fn handle( &self, request: Request>, next: Next<'_, ApplicationState>, ) -> Result { let authorization_endpoint = request.state().authorization_endpoint.to_string(); let token_endpoint = request.state().token_endpoint.to_string(); let site_name = &request .state() .storage .get_setting("site_name", &(request.url().origin().ascii_serialization().clone() + "/")) .await .unwrap_or_else(|_| "Kitty Box!".to_string()); let mut res = next.run(request).await; let mut code: Option = None; if let Some(err) = res.downcast_error::() { code = Some(err.code()); error!("Error caught while processing request: {}", err.msg()); let mut err: &dyn std::error::Error = err; while let Some(e) = err.source() { error!("Caused by: {}", e); err = e; } } 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, }, content: ErrorPage { code }.to_string(), } .to_string(), ); } Ok(res) } } 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(req: Request>) -> 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"), }?) }