about summary refs log tree commit diff
path: root/kittybox-rs/src/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'kittybox-rs/src/frontend')
-rw-r--r--kittybox-rs/src/frontend/mod.rs536
-rw-r--r--kittybox-rs/src/frontend/onboarding.rs142
2 files changed, 330 insertions, 348 deletions
diff --git a/kittybox-rs/src/frontend/mod.rs b/kittybox-rs/src/frontend/mod.rs
index b87f9c6..51db2e1 100644
--- a/kittybox-rs/src/frontend/mod.rs
+++ b/kittybox-rs/src/frontend/mod.rs
@@ -1,18 +1,25 @@
-use std::convert::TryInto;
-use crate::database::Storage;
+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 futures_util::TryFutureExt;
-use warp::{http::StatusCode, Filter, host::Authority, path::FullPath};
-
+use std::convert::TryInto;
+use tracing::{debug, error};
 //pub mod login;
+pub mod onboarding;
 
-#[allow(unused_imports)]
-use kittybox_templates::{ErrorPage, MainPage, OnboardingPage, Template, POSTS_PER_PAGE};
+use kittybox_templates::{
+    Entry, ErrorPage, Feed, MainPage, Template, VCard, POSTS_PER_PAGE,
+};
 
 pub use kittybox_util::IndiewebEndpoints;
 
-#[derive(Deserialize)]
-struct QueryParams {
+#[derive(Debug, Deserialize)]
+pub struct QueryParams {
     after: Option<String>,
 }
 
@@ -42,8 +49,8 @@ impl FrontendError {
     }
 }
 
-impl From<crate::database::StorageError> for FrontendError {
-    fn from(err: crate::database::StorageError) -> Self {
+impl From<StorageError> for FrontendError {
+    fn from(err: StorageError) -> Self {
         Self {
             msg: "Database error".to_string(),
             source: Some(Box::new(err)),
@@ -66,8 +73,6 @@ impl std::fmt::Display for FrontendError {
     }
 }
 
-impl warp::reject::Reject for FrontendError {}
-
 async fn get_post_from_database<S: Storage>(
     db: &S,
     url: &str,
@@ -105,309 +110,169 @@ async fn get_post_from_database<S: Storage>(
     }
 }
 
-#[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))
+#[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)) => {
+            // 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,
+                    endpoints: None, // XXX this will be deprecated soon anyway
+                    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,
+                        endpoints: None, // XXX this will be deprecated soon anyway
+                        feeds: channels,
+                        user,
+                        content: ErrorPage {
+                            code: err.code(),
+                            msg: Some(err.msg().to_string()),
+                        }
+                        .to_string(),
+                    }
+                    .to_string(),
+                )
             }
-        })
-        .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)?;
+#[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();
 
-                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
+    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,
+                    endpoints: None, // XXX this will be deprecated soon anyway
+                    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,
+                    endpoints: None,
+                    feeds: channels,
+                    user,
+                    content: ErrorPage {
+                        code: err.code(),
+                        msg: Some(err.msg().to_owned()),
                     }
-                })?;
-                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)
-        })
-
+                    .to_string(),
+                }
+                .to_string(),
+            )
+        }
+    }
 }
 
 static STYLE_CSS: &[u8] = include_bytes!("./style.css");
@@ -416,44 +281,19 @@ 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
+static MIME_PLAIN: &str = "text/plain";
+
+pub async fn statics(Path(name): Path<String>) -> 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),
+        _ => (
+            StatusCode::NOT_FOUND,
+            [(CONTENT_TYPE, MIME_PLAIN)],
+            "not found".as_bytes(),
+        ),
     }
 }
-
-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())
-                     }
-                 })))
-}
diff --git a/kittybox-rs/src/frontend/onboarding.rs b/kittybox-rs/src/frontend/onboarding.rs
new file mode 100644
index 0000000..18def1d
--- /dev/null
+++ b/kittybox-rs/src/frontend/onboarding.rs
@@ -0,0 +1,142 @@
+use kittybox_templates::{ErrorPage, Template, OnboardingPage};
+use crate::database::{Storage, Settings};
+use axum::{
+    Json,
+    extract::{Host, Extension},
+    http::StatusCode,
+    response::{Html, IntoResponse},
+};
+use serde::Deserialize;
+use tracing::{debug, error};
+
+use super::FrontendError;
+
+pub async fn get() -> Html<String> {
+    Html(Template {
+        title: "Kittybox - Onboarding",
+        blog_name: "Kittybox",
+        feeds: vec![],
+        endpoints: None,
+        user: None,
+        content: OnboardingPage {}.to_string()
+    }.to_string())
+}
+
+#[derive(Deserialize, Debug)]
+struct OnboardingFeed {
+    slug: String,
+    name: String,
+}
+
+#[derive(Deserialize, Debug)]
+pub 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()
+    }
+}
+
+#[tracing::instrument(skip(db, http))]
+async fn onboard<D: Storage + 'static>(
+    db: D, user_uid: url::Url, data: OnboardingData, http: reqwest::Client
+) -> Result<(), FrontendError> {
+    // Create a user to pass to the backend
+    // At this point the site belongs to nobody, so it is safe to do
+    let user = crate::indieauth::User::new(
+        user_uid.as_str(),
+        "https://kittybox.fireburn.ru/",
+        "create"
+    );
+
+    if data.user["type"][0] != "h-card" || data.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"
+        ))
+    }
+
+    db.set_setting(Settings::SiteName, user.me.as_str(), &data.blog_name)
+        .await
+        .map_err(FrontendError::from)?;
+
+    let (_, hcard) = {
+        let mut hcard = data.user;
+        hcard["properties"]["uid"] = serde_json::json!([&user_uid]);
+        crate::micropub::normalize_mf2(hcard, &user)
+    };
+    db.put_post(&hcard, user_uid.as_str()).await.map_err(FrontendError::from)?;
+
+    debug!("Creating feeds...");
+    for feed in data.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(
+            serde_json::json!({
+                "type": ["h-feed"],
+                "properties": {"name": [feed.name], "mp-slug": [feed.slug]}
+            }),
+            &user,
+        );
+
+        db.put_post(&feed, user_uid.as_str()).await.map_err(FrontendError::from)?;
+    }
+    let (uid, post) = crate::micropub::normalize_mf2(data.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(())
+}
+
+pub async fn post<D: Storage + 'static>(
+    Extension(db): Extension<D>,
+    Host(host): Host,
+    Json(data): Json<OnboardingData>,
+    Extension(http): Extension<reqwest::Client>
+) -> axum::response::Response {
+    let user_uid = format!("https://{}/", host.as_str());
+
+    if db.post_exists(&user_uid).await.unwrap() {
+        IntoResponse::into_response((
+            StatusCode::FOUND,
+            [("Location", "/")]
+        ))
+    } else {
+        match onboard(db, user_uid.parse().unwrap(), data, http).await {
+            Ok(()) => IntoResponse::into_response((
+                StatusCode::FOUND,
+                [("Location", "/")]
+            )),
+            Err(err) => {
+                error!("Onboarding error: {}", err);
+                IntoResponse::into_response((
+                    err.code(),
+                    Html(Template {
+                        title: "Kittybox - Onboarding",
+                        blog_name: "Kittybox",
+                        feeds: vec![],
+                        endpoints: None,
+                        user: None,
+                        content: ErrorPage {
+                            code: err.code(),
+                            msg: Some(err.msg().to_string()),
+                        }.to_string(),
+                    }.to_string())
+                ))
+            }
+        }
+    }
+}