use crate::database::{Storage, StorageError}; use axum::{ extract::{Host, Path, Query}, http::{StatusCode, Uri}, response::IntoResponse, Extension, }; use futures_util::FutureExt; use serde::Deserialize; use std::convert::TryInto; use tracing::{debug, error}; //pub mod login; pub mod onboarding; use kittybox_templates::{Entry, ErrorPage, Feed, MainPage, Template, VCard, POSTS_PER_PAGE}; #[derive(Debug, Deserialize)] pub 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: 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) } } 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()), }, } } #[tracing::instrument(skip(db))] pub async fn homepage( Host(host): Host, Query(query): Query, Extension(db): Extension, ) -> impl IntoResponse { let user = None; // TODO authentication let path = format!("https://{}/", host); let feed_path = format!("https://{}/feeds/main", host); match tokio::try_join!( get_post_from_database(&db, &path, None, &user), get_post_from_database(&db, &feed_path, query.after, &user) ) { Ok((hcard, hfeed)) => { // Here, we know those operations can't really fail // (or it'll be a transient failure that will show up on // other requests anyway if it's serious...) // // btw is it more efficient to fetch these in parallel? let (blogname, channels) = tokio::join!( db.get_setting(crate::database::Settings::SiteName, &path) .map(|i| i.unwrap_or_else(|_| "Kittybox".to_owned())), db.get_channels(&path).map(|i| i.unwrap_or_default()) ); // Render the homepage ( StatusCode::OK, [( axum::http::header::CONTENT_TYPE, r#"text/html; charset="utf-8""#, )], Template { title: &blogname, blog_name: &blogname, feeds: channels, user, content: MainPage { feed: &hfeed, card: &hcard, } .to_string(), } .to_string(), ) } Err(err) => { if err.code == StatusCode::NOT_FOUND { debug!("Transferring to onboarding..."); // Transfer to onboarding ( StatusCode::FOUND, [(axum::http::header::LOCATION, "/.kittybox/onboarding")], String::default(), ) } else { error!("Error while fetching h-card and/or h-feed: {}", err); // Return the error let (blogname, channels) = tokio::join!( db.get_setting(crate::database::Settings::SiteName, &path) .map(|i| i.unwrap_or_else(|_| "Kittybox".to_owned())), db.get_channels(&path).map(|i| i.unwrap_or_default()) ); ( err.code(), [( axum::http::header::CONTENT_TYPE, r#"text/html; charset="utf-8""#, )], Template { title: &blogname, blog_name: &blogname, feeds: channels, user, content: ErrorPage { code: err.code(), msg: Some(err.msg().to_string()), } .to_string(), } .to_string(), ) } } } } #[tracing::instrument(skip(db))] pub async fn catchall( Extension(db): Extension, Host(host): Host, Query(query): Query, uri: Uri, ) -> impl IntoResponse { let user = None; // TODO authentication let path = url::Url::parse(&format!("https://{}/", host)) .unwrap() .join(uri.path()) .unwrap(); match get_post_from_database(&db, path.as_str(), query.after, &user).await { Ok(post) => { let (blogname, channels) = tokio::join!( db.get_setting(crate::database::Settings::SiteName, &host) .map(|i| i.unwrap_or_else(|_| "Kittybox".to_owned())), db.get_channels(&host).map(|i| i.unwrap_or_default()) ); // Render the homepage ( StatusCode::OK, [( axum::http::header::CONTENT_TYPE, r#"text/html; charset="utf-8""#, )], Template { title: &blogname, blog_name: &blogname, feeds: channels, user, content: match post.pointer("/type/0").and_then(|i| i.as_str()) { Some("h-entry") => Entry { post: &post }.to_string(), Some("h-feed") => Feed { feed: &post }.to_string(), Some("h-card") => VCard { card: &post }.to_string(), unknown => { unimplemented!("Template for MF2-JSON type {:?}", unknown) } }, } .to_string(), ) } Err(err) => { let (blogname, channels) = tokio::join!( db.get_setting(crate::database::Settings::SiteName, &host) .map(|i| i.unwrap_or_else(|_| "Kittybox".to_owned())), db.get_channels(&host).map(|i| i.unwrap_or_default()) ); ( err.code(), [( axum::http::header::CONTENT_TYPE, r#"text/html; charset="utf-8""#, )], Template { title: &blogname, blog_name: &blogname, feeds: channels, user, content: ErrorPage { code: err.code(), msg: Some(err.msg().to_owned()), } .to_string(), } .to_string(), ) } } } const STYLE_CSS: &[u8] = include_bytes!("./style.css"); // XXX const path handling is ugly, and concat!() doesn't take // constants, only literals... how annoying! // // This might break compiling on inferior operating systems that use // backslashes as their path separator const ONBOARDING_JS: &[u8] = include_bytes!(concat!( env!("OUT_DIR"), "/", "kittybox_js", "/", "onboarding.js" )); const ONBOARDING_CSS: &[u8] = include_bytes!("./onboarding.css"); const INDIEAUTH_JS: &[u8] = include_bytes!(concat!( env!("OUT_DIR"), "/", "kittybox_js", "/", "indieauth.js" )); const MIME_JS: &str = "application/javascript"; const MIME_CSS: &str = "text/css"; const MIME_PLAIN: &str = "text/plain"; pub async fn statics(Path(name): Path) -> impl IntoResponse { use axum::http::header::CONTENT_TYPE; match name.as_str() { "style.css" => (StatusCode::OK, [(CONTENT_TYPE, MIME_CSS)], STYLE_CSS), "onboarding.js" => (StatusCode::OK, [(CONTENT_TYPE, MIME_JS)], ONBOARDING_JS), "onboarding.css" => (StatusCode::OK, [(CONTENT_TYPE, MIME_CSS)], ONBOARDING_CSS), "indieauth.js" => (StatusCode::OK, [(CONTENT_TYPE, MIME_JS)], INDIEAUTH_JS), _ => ( StatusCode::NOT_FOUND, [(CONTENT_TYPE, MIME_PLAIN)], "not found".as_bytes(), ), } }