about summary refs log tree commit diff
path: root/kittybox-rs/src/frontend/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'kittybox-rs/src/frontend/mod.rs')
-rw-r--r--kittybox-rs/src/frontend/mod.rs459
1 files changed, 459 insertions, 0 deletions
diff --git a/kittybox-rs/src/frontend/mod.rs b/kittybox-rs/src/frontend/mod.rs
new file mode 100644
index 0000000..b87f9c6
--- /dev/null
+++ b/kittybox-rs/src/frontend/mod.rs
@@ -0,0 +1,459 @@
+use std::convert::TryInto;
+use crate::database::Storage;
+use serde::Deserialize;
+use futures_util::TryFutureExt;
+use warp::{http::StatusCode, Filter, host::Authority, path::FullPath};
+
+//pub mod login;
+
+#[allow(unused_imports)]
+use kittybox_templates::{ErrorPage, MainPage, OnboardingPage, Template, POSTS_PER_PAGE};
+
+pub use kittybox_util::IndiewebEndpoints;
+
+#[derive(Deserialize)]
+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<crate::database::StorageError> for FrontendError {
+    fn from(err: crate::database::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)
+    }
+}
+
+impl warp::reject::Reject for FrontendError {}
+
+async fn get_post_from_database<S: Storage>(
+    db: &S,
+    url: &str,
+    after: Option<String>,
+    user: &Option<String>,
+) -> std::result::Result<serde_json::Value, FrontendError> {
+    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()),
+        },
+    }
+}
+
+#[allow(dead_code)]
+#[derive(Deserialize)]
+struct OnboardingFeed {
+    slug: String,
+    name: String,
+}
+
+#[allow(dead_code)]
+#[derive(Deserialize)]
+struct OnboardingData {
+    user: serde_json::Value,
+    first_post: serde_json::Value,
+    #[serde(default = "OnboardingData::default_blog_name")]
+    blog_name: String,
+    feeds: Vec<OnboardingFeed>,
+}
+
+impl OnboardingData {
+    fn default_blog_name() -> String {
+        "Kitty Box!".to_owned()
+    }
+}
+
+/*pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
+    use serde_json::json;
+
+    log::debug!("Entering onboarding receiver...");
+
+    // This cannot error out as the URL must be valid. Or there is something horribly wrong
+    // and we shouldn't serve this request anyway.
+    <dyn AsMut<tide::http::Request>>::as_mut(&mut req)
+        .url_mut()
+        .set_scheme("https")
+        .unwrap();
+
+    log::debug!("Parsing the body...");
+    let body = req.body_json::<OnboardingData>().await?;
+    log::debug!("Body parsed!");
+    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();
+
+    log::debug!("me value: {:?}", me);
+
+    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");
+
+    log::debug!("Setting the site name to {}", &body.blog_name);
+    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.
+    log::debug!("Saving the h-card...");
+    backend.put_post(&hcard, me.as_str()).await?;
+
+    log::debug!("Creating feeds...");
+    for feed in body.feeds {
+        if feed.name.is_empty() || feed.slug.is_empty() {
+            continue;
+        };
+        log::debug!("Creating feed {} with slug {}", &feed.name, &feed.slug);
+        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?;
+    }
+    log::debug!("Saving the h-entry...");
+    // 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())
+}
+*/
+
+fn request_uri() -> impl Filter<Extract = (String,), Error = warp::Rejection> + Copy {
+    crate::util::require_host()
+        .and(warp::path::full())
+        .map(|host: Authority, path: FullPath| "https://".to_owned() + host.as_str() + path.as_str())
+}
+
+#[forbid(clippy::unwrap_used)]
+pub fn homepage<D: Storage>(db: D, endpoints: IndiewebEndpoints) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
+    let inject_db = move || db.clone();
+    warp::any()
+        .map(inject_db.clone())
+        .and(crate::util::require_host())
+        .and(warp::query())
+        .and_then(|db: D, host: Authority, q: QueryParams| async move {
+            let path = format!("https://{}/", host);
+            let feed_path = format!("https://{}/feeds/main", host);
+
+            match tokio::try_join!(
+                get_post_from_database(&db, &path, None, &None),
+                get_post_from_database(&db, &feed_path, q.after, &None)
+            ) {
+                Ok((hcard, hfeed)) => Ok((
+                    Some(hcard),
+                    Some(hfeed),
+                    StatusCode::OK
+                )),
+                Err(err) => {
+                    if err.code == StatusCode::NOT_FOUND {
+                        // signal for onboarding flow
+                        Ok((None, None, err.code))
+                    } else {
+                        Err(warp::reject::custom(err))
+                    }
+                }
+            }
+        })
+        .and(warp::any().map(move || endpoints.clone()))
+        .and(crate::util::require_host())
+        .and(warp::any().map(inject_db))
+        .then(|content: (Option<serde_json::Value>, Option<serde_json::Value>, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move {
+            let owner = format!("https://{}/", host.as_str());
+            let blog_name = db.get_setting(crate::database::Settings::SiteName, &owner).await
+                .unwrap_or_else(|_| "Kitty Box!".to_string());
+            let feeds = db.get_channels(&owner).await.unwrap_or_default();
+            match content {
+                (Some(card), Some(feed), StatusCode::OK) => {
+                    Box::new(warp::reply::html(Template {
+                        title: &blog_name,
+                        blog_name: &blog_name,
+                        endpoints: Some(endpoints),
+                        feeds,
+                        user: None, // TODO
+                        content: MainPage { feed: &feed, card: &card }.to_string()
+                    }.to_string())) as Box<dyn warp::Reply>
+                },
+                (None, None, StatusCode::NOT_FOUND) => {
+                    // TODO Onboarding
+                    Box::new(warp::redirect::found(
+                        hyper::Uri::from_static("/onboarding")
+                    )) as Box<dyn warp::Reply>
+                }
+                _ => unreachable!()
+            }
+        })
+}
+
+pub fn onboarding<D: 'static + Storage>(
+    db: D,
+    endpoints: IndiewebEndpoints,
+    http: reqwest::Client
+) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
+    let inject_db = move || db.clone();
+    warp::get()
+        .map(move || warp::reply::html(Template {
+            title: "Kittybox - Onboarding",
+            blog_name: "Kittybox",
+            endpoints: Some(endpoints.clone()),
+            feeds: vec![],
+            user: None,
+            content: OnboardingPage {}.to_string()
+        }.to_string()))
+        .or(warp::post()
+            .and(crate::util::require_host())
+            .and(warp::any().map(inject_db))
+            .and(warp::body::json::<OnboardingData>())
+            .and(warp::any().map(move || http.clone()))
+            .and_then(|host: warp::host::Authority, db: D, body: OnboardingData, http: reqwest::Client| async move {
+                let user_uid = format!("https://{}/", host.as_str());
+                if db.post_exists(&user_uid).await.map_err(FrontendError::from)? {
+                    
+                    return Ok(warp::redirect(hyper::Uri::from_static("/")));
+                }
+                let user = crate::indieauth::User::new(&user_uid, "https://kittybox.fireburn.ru/", "create");
+                if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" {
+                    return Err(FrontendError::with_code(StatusCode::BAD_REQUEST, "user and first_post should be an h-card and an h-entry").into());
+                }
+                db.set_setting(crate::database::Settings::SiteName, user.me.as_str(), &body.blog_name)
+                    .await
+                    .map_err(FrontendError::from)?;
+
+                let (_, hcard) = {
+                    let mut hcard = body.user;
+                    hcard["properties"]["uid"] = serde_json::json!([&user_uid]);
+                    crate::micropub::normalize_mf2(hcard, &user)
+                };
+                db.put_post(&hcard, &user_uid).await.map_err(FrontendError::from)?;
+                let (uid, post) = crate::micropub::normalize_mf2(body.first_post, &user);
+                crate::micropub::_post(user, uid, post, db, http).await.map_err(|e| {
+                    FrontendError {
+                        msg: "Error while posting the first post".to_string(),
+                        source: Some(Box::new(e)),
+                        code: StatusCode::INTERNAL_SERVER_ERROR
+                    }
+                })?;
+                Ok::<_, warp::Rejection>(warp::redirect(hyper::Uri::from_static("/")))
+            }))
+        
+}
+
+#[forbid(clippy::unwrap_used)]
+pub fn catchall<D: Storage>(db: D, endpoints: IndiewebEndpoints) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
+    let inject_db = move || db.clone();
+    warp::any()
+        .map(inject_db.clone())
+        .and(request_uri())
+        .and(warp::query())
+        .and_then(|db: D, path: String, query: QueryParams| async move {
+            get_post_from_database(&db, &path, query.after, &None).map_err(warp::reject::custom).await
+        })
+        // Rendering pipeline
+        .and_then(|post: serde_json::Value| async move {
+            let post_name = &post["properties"]["name"][0].as_str().to_owned();
+            match post["type"][0]
+                .as_str()
+            {
+                Some("h-entry") => Ok((
+                    post_name.unwrap_or("Note").to_string(),
+                    kittybox_templates::Entry { post: &post }.to_string(),
+                    StatusCode::OK
+                )),
+                Some("h-card") => Ok((
+                    post_name.unwrap_or("Contact card").to_string(),
+                    kittybox_templates::VCard { card: &post }.to_string(),
+                    StatusCode::OK
+                )),
+                Some("h-feed") => Ok((
+                    post_name.unwrap_or("Feed").to_string(),
+                    kittybox_templates::Feed { feed: &post }.to_string(),
+                    StatusCode::OK
+                )),
+                _ => Err(warp::reject::custom(FrontendError::with_code(
+                    StatusCode::INTERNAL_SERVER_ERROR,
+                    &format!("Couldn't render an unknown type: {}", post["type"][0]),
+                )))
+            }
+        })
+        .recover(|err: warp::Rejection| {
+            use warp::Rejection;
+            use futures_util::future;
+            if let Some(err) = err.find::<FrontendError>() {
+                return future::ok::<(String, String, StatusCode), Rejection>((
+                    format!("Error: HTTP {}", err.code().as_u16()),
+                    ErrorPage { code: err.code(), msg: Some(err.msg().to_string()) }.to_string(),
+                    err.code()
+                ));
+            }
+            future::err::<(String, String, StatusCode), Rejection>(err)
+        })
+        .unify()
+        .and(warp::any().map(move || endpoints.clone()))
+        .and(crate::util::require_host())
+        .and(warp::any().map(inject_db))
+        .then(|content: (String, String, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move {
+            let owner = format!("https://{}/", host.as_str());
+            let blog_name = db.get_setting(crate::database::Settings::SiteName, &owner).await
+                .unwrap_or_else(|_| "Kitty Box!".to_string());
+            let feeds = db.get_channels(&owner).await.unwrap_or_default();
+            let (title, content, code) = content;
+            warp::reply::with_status(warp::reply::html(Template {
+                title: &title,
+                blog_name: &blog_name,
+                endpoints: Some(endpoints),
+                feeds,
+                user: None, // TODO
+                content,
+            }.to_string()), code)
+        })
+
+}
+
+static STYLE_CSS: &[u8] = include_bytes!("./style.css");
+static ONBOARDING_JS: &[u8] = include_bytes!("./onboarding.js");
+static ONBOARDING_CSS: &[u8] = include_bytes!("./onboarding.css");
+
+static MIME_JS: &str = "application/javascript";
+static MIME_CSS: &str = "text/css";
+
+fn _dispatch_static(name: &str) -> Option<(&'static [u8], &'static str)> {
+    match name {
+        "style.css" => Some((STYLE_CSS, MIME_CSS)),
+        "onboarding.js" => Some((ONBOARDING_JS, MIME_JS)),
+        "onboarding.css" => Some((ONBOARDING_CSS, MIME_CSS)),
+        _ => None
+    }
+}
+
+pub fn static_files() -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Copy {
+    use futures_util::future;
+
+    warp::get()
+        .and(warp::path::param()
+             .and_then(|filename: String| {
+                 match _dispatch_static(&filename) {
+                     Some((buf, content_type)) => future::ok(
+                         warp::reply::with_header(
+                             buf, "Content-Type", content_type
+                         )
+                     ),
+                     None => future::err(warp::reject())
+                 }
+             }))
+        .or(warp::head()
+            .and(warp::path::param()
+                 .and_then(|filename: String| {
+                     match _dispatch_static(&filename) {
+                         Some((buf, content_type)) => future::ok(
+                             warp::reply::with_header(
+                                 warp::reply::with_header(
+                                     warp::reply(), "Content-Type", content_type
+                                 ),
+                                 "Content-Length", buf.len()
+                             )
+                         ),
+                         None => future::err(warp::reject())
+                     }
+                 })))
+}