about summary refs log tree commit diff
path: root/src/frontend/mod.rs
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2023-07-29 21:59:56 +0300
committerVika <vika@fireburn.ru>2023-07-29 21:59:56 +0300
commit0617663b249f9ca488e5de652108b17d67fbaf45 (patch)
tree11564b6c8fa37bf9203a0a4cc1c4e9cc088cb1a5 /src/frontend/mod.rs
parent26c2b79f6a6380ae3224e9309b9f3352f5717bd7 (diff)
Moved the entire Kittybox tree into the root
Diffstat (limited to 'src/frontend/mod.rs')
-rw-r--r--src/frontend/mod.rs404
1 files changed, 404 insertions, 0 deletions
diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs
new file mode 100644
index 0000000..7a43532
--- /dev/null
+++ b/src/frontend/mod.rs
@@ -0,0 +1,404 @@
+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_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))]
+pub fn filter_post(
+    mut post: serde_json::Value,
+    user: Option<&str>,
+) -> 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()
+            }
+        }).collect::<Vec<&str>>();
+    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()));
+
+        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());
+        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<String>,
+) -> std::result::Result<(serde_json::Value, Option<String>), FrontendError> {
+    match db
+        .read_feed_with_cursor(url, after.as_deref(), POSTS_PER_PAGE, user.as_deref())
+        .await
+    {
+        Ok(result) => match result {
+            Some((post, cursor)) => match filter_post(post, user.as_deref()) {
+                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>,
+    Extension(db): Extension<D>,
+) -> 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, 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>(&host)
+                .map(Result::unwrap_or_default),
+
+                db.get_setting::<crate::database::settings::Webring>(&host)
+                .map(Result::unwrap_or_default),
+
+                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.as_ref(),
+                    blog_name: blogname.as_ref(),
+                    feeds: channels,
+                    user,
+                    content: MainPage {
+                        feed: &hfeed,
+                        card: &hcard,
+                        cursor: cursor.as_deref(),
+                        webring: crate::database::settings::Setting::into_inner(webring)
+                    }
+                    .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>(&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,
+                        content: ErrorPage {
+                            code: err.code(),
+                            msg: Some(err.msg().to_string()),
+                        }
+                        .to_string(),
+                    }
+                    .to_string(),
+                )
+            }
+        }
+    }
+}
+
+#[tracing::instrument(skip(db))]
+pub async fn catchall<D: Storage>(
+    Extension(db): Extension<D>,
+    Host(host): Host,
+    Query(query): Query<QueryParams>,
+    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, 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())
+            );
+            // Render the homepage
+            (
+                StatusCode::OK,
+                [(
+                    axum::http::header::CONTENT_TYPE,
+                    r#"text/html; charset="utf-8""#,
+                )],
+                Template {
+                    title: blogname.as_ref(),
+                    blog_name: blogname.as_ref(),
+                    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, cursor: cursor.as_deref() }.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(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,
+                    content: ErrorPage {
+                        code: err.code(),
+                        msg: Some(err.msg().to_owned()),
+                    }
+                    .to_string(),
+                }
+                .to_string(),
+            )
+        }
+    }
+}