use crate::database::{Storage, StorageError}; use axum::{ extract::{Host, Query, State}, http::{StatusCode, Uri}, response::IntoResponse, }; use axum_extra::headers::HeaderMapExt; use futures_util::FutureExt; use serde::Deserialize; use std::convert::TryInto; use tracing::{debug, error}; //pub mod login; pub mod onboarding; use kittybox_frontend_renderer::{ Entry, Feed, VCard, ErrorPage, Template, MainPage, POSTS_PER_PAGE }; pub use kittybox_frontend_renderer::assets::statics; #[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)?; if let Some(err) = std::error::Error::source(&self) { write!(f, ": {}", err)?; } Ok(()) } } /// Filter the post according to the value of `user`. /// /// Anonymous users cannot view private posts and protected locations; /// Logged-in users can only view private posts targeted at them; /// Logged-in users can't view private location data #[tracing::instrument(skip(post), fields(post = %post))] pub fn filter_post( mut post: serde_json::Value, user: Option<&url::Url>, ) -> Option { if post["properties"]["deleted"][0].is_string() { tracing::debug!("Deleted post; returning tombstone instead"); return Some(serde_json::json!({ "type": post["type"], "properties": { "deleted": post["properties"]["deleted"] } })); } let empty_vec: Vec = vec![]; let author_list = post["properties"]["author"] .as_array() .unwrap_or(&empty_vec) .iter() .map(|i| -> &str { match i { serde_json::Value::String(ref author) => author.as_str(), mf2 => mf2["properties"]["uid"][0].as_str().unwrap() } }) .map(|i| i.parse().unwrap()) .collect::>(); let visibility = post["properties"]["visibility"][0] .as_str() .unwrap_or("public"); let audience = { let mut audience = author_list.clone(); audience.extend(post["properties"]["audience"] .as_array() .unwrap_or(&empty_vec) .iter() .map(|i| i.as_str().unwrap().parse().unwrap())); audience }; tracing::debug!("post audience = {:?}", audience); if (visibility == "private" && !audience.iter().any(|i| Some(i) == user)) || (visibility == "protected" && user.is_none()) { return None; } if post["properties"]["location"].is_array() { let location_visibility = post["properties"]["location-visibility"][0] .as_str() .unwrap_or("private"); tracing::debug!("Post contains location, location privacy = {}", location_visibility); let mut author = post["properties"]["author"] .as_array() .unwrap_or(&empty_vec) .iter() .map(|i| i.as_str().unwrap().parse().unwrap()); if (location_visibility == "private" && !author.any(|i| Some(&i) == user)) || (location_visibility == "protected" && user.is_none()) { post["properties"] .as_object_mut() .unwrap() .remove("location"); } } match post["properties"]["author"].take() { serde_json::Value::Array(children) => { post["properties"]["author"] = serde_json::Value::Array( children .into_iter() .filter_map(|post| if post.is_string() { Some(post) } else { filter_post(post, user) }) .collect::>() ); }, serde_json::Value::Null => {}, other => post["properties"]["author"] = other } match post["children"].take() { serde_json::Value::Array(children) => { post["children"] = serde_json::Value::Array( children .into_iter() .filter_map(|post| filter_post(post, user)) .collect::>() ); }, serde_json::Value::Null => {}, other => post["children"] = other } Some(post) } async fn get_post_from_database( db: &S, url: &str, after: Option, user: Option<&url::Url>, ) -> std::result::Result<(serde_json::Value, Option), FrontendError> { match db .read_feed_with_cursor(url, after.as_deref(), POSTS_PER_PAGE, user) .await { Ok(result) => match result { Some((post, cursor)) => match filter_post(post, user) { Some(post) => Ok((post, cursor)), None => { // 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", )) } } } 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, State(db): State, session: Option ) -> impl IntoResponse { // This is stupid, but there is no other way. let hcard_url: url::Url = format!("https://{}/", host).parse().unwrap(); let feed_path = format!("https://{}/feeds/main", host); let mut headers = axum::http::HeaderMap::new(); headers.insert( axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static(r#"text/html; charset="utf-8""#), ); let user = session.as_deref().map(|s| &s.me); match tokio::try_join!( get_post_from_database(&db, hcard_url.as_str(), None, user), get_post_from_database(&db, &feed_path, query.after, user) ) { Ok(((hcard, _), (hfeed, cursor))) => { // 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, webring, channels) = tokio::join!( db.get_setting::(&hcard_url) .map(Result::unwrap_or_default), db.get_setting::(&hcard_url) .map(Result::unwrap_or_default), db.get_channels(&hcard_url).map(|i| i.unwrap_or_default()) ); if user.is_some() { headers.insert( axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("private") ); } // Render the homepage ( StatusCode::OK, headers, Template { title: blogname.as_ref(), blog_name: blogname.as_ref(), feeds: channels, user: session.as_deref(), content: MainPage { feed: &hfeed, card: &hcard, cursor: cursor.as_deref(), webring: crate::database::settings::Setting::into_inner(webring) } .to_string(), } .to_string(), ).into_response() } 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(), ).into_response() } else { error!("Error while fetching h-card and/or h-feed: {}", err); // Return the error let (blogname, channels) = tokio::join!( db.get_setting::(&hcard_url) .map(Result::unwrap_or_default), db.get_channels(&hcard_url).map(|i| i.unwrap_or_default()) ); ( err.code(), headers, Template { title: blogname.as_ref(), blog_name: blogname.as_ref(), feeds: channels, user: session.as_deref(), content: ErrorPage { code: err.code(), msg: Some(err.msg().to_string()), } .to_string(), } .to_string(), ).into_response() } } } } #[tracing::instrument(skip(db))] pub async fn catchall( State(db): State, Host(host): Host, Query(query): Query, session: Option, uri: Uri, ) -> impl IntoResponse { let user: Option<&url::Url> = session.as_deref().map(|p| &p.me); let host = url::Url::parse(&format!("https://{}/", host)).unwrap(); let path = host .clone() .join(uri.path()) .unwrap(); match get_post_from_database(&db, path.as_str(), query.after, user).await { Ok((post, cursor)) => { let (blogname, channels) = tokio::join!( db.get_setting::(&host) .map(Result::unwrap_or_default), db.get_channels(&host).map(|i| i.unwrap_or_default()) ); let mut headers = axum::http::HeaderMap::new(); headers.insert( axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static(r#"text/html; charset="utf-8""#), ); if user.is_some() { headers.insert( axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("private") ); } if post["type"][0].as_str() == Some("h-entry") { let last_modified = post["properties"]["updated"] .as_array() .and_then(|v| v.last()) .or_else(|| post["properties"]["published"] .as_array() .and_then(|v| v.last()) ) .and_then(serde_json::Value::as_str) .and_then(|dt| chrono::DateTime::::parse_from_rfc3339(dt).ok()); if let Some(last_modified) = last_modified { headers.typed_insert( axum_extra::headers::LastModified::from( std::time::SystemTime::from(last_modified) ) ); } } // Render the homepage ( StatusCode::OK, headers, Template { title: blogname.as_ref(), blog_name: blogname.as_ref(), feeds: channels, user: session.as_deref(), content: match post.pointer("/type/0").and_then(|i| i.as_str()) { Some("h-entry") => Entry { post: &post, from_feed: false, }.to_string(), Some("h-feed") => Feed { feed: &post, cursor: cursor.as_deref() }.to_string(), Some("h-card") => VCard { card: &post }.to_string(), unknown => { unimplemented!("Template for MF2-JSON type {:?}", unknown) } }, } .to_string(), ).into_response() } Err(err) => { let (blogname, channels) = tokio::join!( db.get_setting::(&host) .map(Result::unwrap_or_default), 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.as_ref(), blog_name: blogname.as_ref(), feeds: channels, user: session.as_deref(), content: ErrorPage { code: err.code(), msg: Some(err.msg().to_owned()), } .to_string(), } .to_string(), ).into_response() } } }