about summary refs log tree commit diff
path: root/kittybox-rs/src/frontend
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2022-07-07 00:32:33 +0300
committerVika <vika@fireburn.ru>2022-07-07 00:36:39 +0300
commit7f23ec84bc05c236c1bf40c2f0d72412af711516 (patch)
treef0ba64804fffce29a8f04e5b6c76f9863de81dd2 /kittybox-rs/src/frontend
parent5cfac54aa4fb3c207ea2cbbeccd4571fa204a09b (diff)
downloadkittybox-7f23ec84bc05c236c1bf40c2f0d72412af711516.tar.zst
treewide: rewrite using Axum
Axum has streaming bodies and allows to write simpler code. It also
helps enforce stronger types and looks much more neat.

This allows me to progress on the media endpoint and add streaming
reads and writes to the MediaStore trait.

Metrics are temporarily not implemented. Everything else was
preserved, and the tests still pass, after adjusting for new calling
conventions.

TODO: create method routers for protocol endpoints
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())
+                ))
+            }
+        }
+    }
+}