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<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<StorageError> 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["properties"]["uid"][0]))]
pub fn filter_post(
mut post: serde_json::Value,
user: Option<&url::Url>,
) -> Option<serde_json::Value> {
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<serde_json::Value> = 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::<Vec<url::Url>>();
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::<Vec<serde_json::Value>>()
);
},
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::<Vec<serde_json::Value>>()
);
},
serde_json::Value::Null => {},
other => post["children"] = other
}
Some(post)
}
async fn get_post_from_database<S: Storage>(
db: &S,
url: &str,
after: Option<String>,
user: Option<&url::Url>,
) -> std::result::Result<(serde_json::Value, Option<String>), 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<D: Storage>(
Host(host): Host,
Query(query): Query<QueryParams>,
State(db): State<D>,
session: Option<crate::Session>
) -> 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::<crate::database::settings::SiteName>(&hcard_url)
.map(Result::unwrap_or_default),
db.get_setting::<crate::database::settings::Webring>(&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::<crate::database::settings::SiteName>(&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<D: Storage>(
State(db): State<D>,
Host(host): Host,
Query(query): Query<QueryParams>,
session: Option<crate::Session>,
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::<crate::database::settings::SiteName>(&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::<chrono::FixedOffset>::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::<crate::database::settings::SiteName>(&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()
}
}
}