about summary refs log tree commit diff
path: root/kittybox-rs/src/frontend
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 /kittybox-rs/src/frontend
parent26c2b79f6a6380ae3224e9309b9f3352f5717bd7 (diff)
downloadkittybox-0617663b249f9ca488e5de652108b17d67fbaf45.tar.zst
Moved the entire Kittybox tree into the root
Diffstat (limited to 'kittybox-rs/src/frontend')
-rw-r--r--kittybox-rs/src/frontend/login.rs333
-rw-r--r--kittybox-rs/src/frontend/mod.rs404
-rw-r--r--kittybox-rs/src/frontend/onboarding.rs181
3 files changed, 0 insertions, 918 deletions
diff --git a/kittybox-rs/src/frontend/login.rs b/kittybox-rs/src/frontend/login.rs
deleted file mode 100644
index c693899..0000000
--- a/kittybox-rs/src/frontend/login.rs
+++ /dev/null
@@ -1,333 +0,0 @@
-use http_types::Mime;
-use log::{debug, error};
-use rand::Rng;
-use serde::{Deserialize, Serialize};
-use sha2::{Digest, Sha256};
-use std::convert::TryInto;
-use std::str::FromStr;
-
-use crate::frontend::templates::Template;
-use crate::frontend::{FrontendError, IndiewebEndpoints};
-use crate::{database::Storage, ApplicationState};
-use kittybox_frontend_renderer::LoginPage;
-
-pub async fn form<S: Storage>(req: Request<ApplicationState<S>>) -> Result {
-    let owner = req.url().origin().ascii_serialization() + "/";
-    let storage = &req.state().storage;
-    let authorization_endpoint = req.state().authorization_endpoint.to_string();
-    let token_endpoint = req.state().token_endpoint.to_string();
-    let blog_name = storage
-        .get_setting("site_name", &owner)
-        .await
-        .unwrap_or_else(|_| "Kitty Box!".to_string());
-    let feeds = storage.get_channels(&owner).await.unwrap_or_default();
-
-    Ok(Response::builder(200)
-        .body(
-            Template {
-                title: "Sign in with IndieAuth",
-                blog_name: &blog_name,
-                endpoints: IndiewebEndpoints {
-                    authorization_endpoint,
-                    token_endpoint,
-                    webmention: None,
-                    microsub: None,
-                },
-                feeds,
-                user: req.session().get("user"),
-                content: LoginPage {}.to_string(),
-            }
-            .to_string(),
-        )
-        .content_type("text/html; charset=utf-8")
-        .build())
-}
-
-#[derive(Serialize, Deserialize)]
-struct LoginForm {
-    url: String,
-}
-
-#[derive(Serialize, Deserialize)]
-struct IndieAuthClientState {
-    /// A random value to protect from CSRF attacks.
-    nonce: String,
-    /// The user's initial "me" value.
-    me: String,
-    /// Authorization endpoint used.
-    authorization_endpoint: String,
-}
-
-#[derive(Serialize, Deserialize)]
-struct IndieAuthRequestParams {
-    response_type: String,         // can only have "code". TODO make an enum
-    client_id: String,             // always a URL. TODO consider making a URL
-    redirect_uri: surf::Url,       // callback URI for IndieAuth
-    state: String, // CSRF protection, should include randomness and be passed through
-    code_challenge: String, // base64-encoded PKCE challenge
-    code_challenge_method: String, // usually "S256". TODO make an enum
-    scope: Option<String>, // oAuth2 scopes to grant,
-    me: surf::Url, // User's entered profile URL
-}
-
-/// Handle login requests. Find the IndieAuth authorization endpoint and redirect to it.
-pub async fn handler<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
-    let content_type = req.content_type();
-    if content_type.is_none() {
-        return Err(FrontendError::with_code(400, "Use the login form, Luke.").into());
-    }
-    if content_type.unwrap() != Mime::from_str("application/x-www-form-urlencoded").unwrap() {
-        return Err(
-            FrontendError::with_code(400, "Login form results must be a urlencoded form").into(),
-        );
-    }
-
-    let form = req.body_form::<LoginForm>().await?; // FIXME check if it returns 400 or 500 on error
-    let homepage_uri = surf::Url::parse(&form.url)?;
-    let http = &req.state().http_client;
-
-    let mut fetch_response = http.get(&homepage_uri).send().await?;
-    if fetch_response.status() != 200 {
-        return Err(FrontendError::with_code(
-            500,
-            "Error fetching your authorization endpoint. Check if your website's okay.",
-        )
-        .into());
-    }
-
-    let mut authorization_endpoint: Option<surf::Url> = None;
-    if let Some(links) = fetch_response.header("Link") {
-        // NOTE: this is the same Link header parser used in src/micropub/post.rs:459.
-        // One should refactor it to a function to use independently and improve later
-        for link in links.iter().flat_map(|i| i.as_str().split(',')) {
-            debug!("Trying to match {} as authorization_endpoint", link);
-            let mut split_link = link.split(';');
-
-            match split_link.next() {
-                Some(uri) => {
-                    if let Some(uri) = uri.strip_prefix('<').and_then(|uri| uri.strip_suffix('>')) {
-                        debug!("uri: {}", uri);
-                        for prop in split_link {
-                            debug!("prop: {}", prop);
-                            let lowercased = prop.to_ascii_lowercase();
-                            let trimmed = lowercased.trim();
-                            if trimmed == "rel=\"authorization_endpoint\""
-                                || trimmed == "rel=authorization_endpoint"
-                            {
-                                if let Ok(endpoint) = homepage_uri.join(uri) {
-                                    debug!(
-                                        "Found authorization endpoint {} for user {}",
-                                        endpoint,
-                                        homepage_uri.as_str()
-                                    );
-                                    authorization_endpoint = Some(endpoint);
-                                    break;
-                                }
-                            }
-                        }
-                    }
-                }
-                None => continue,
-            }
-        }
-    }
-    // If the authorization_endpoint is still not found after the Link parsing gauntlet,
-    // bring out the big guns and parse HTML to find it.
-    if authorization_endpoint.is_none() {
-        let body = fetch_response.body_string().await?;
-        let pattern =
-            easy_scraper::Pattern::new(r#"<link rel="authorization_endpoint" href="{{url}}">"#)
-                .expect("Cannot parse the pattern for authorization_endpoint");
-        let matches = pattern.matches(&body);
-        debug!("Matches for authorization_endpoint in HTML: {:?}", matches);
-        if !matches.is_empty() {
-            if let Ok(endpoint) = homepage_uri.join(&matches[0]["url"]) {
-                debug!(
-                    "Found authorization endpoint {} for user {}",
-                    endpoint,
-                    homepage_uri.as_str()
-                );
-                authorization_endpoint = Some(endpoint)
-            }
-        }
-    };
-    // If even after this the authorization endpoint is still not found, bail out.
-    if authorization_endpoint.is_none() {
-        error!(
-            "Couldn't find authorization_endpoint for {}",
-            homepage_uri.as_str()
-        );
-        return Err(FrontendError::with_code(
-            400,
-            "Your website doesn't support the IndieAuth protocol.",
-        )
-        .into());
-    }
-    let mut authorization_endpoint: surf::Url = authorization_endpoint.unwrap();
-    let mut rng = rand::thread_rng();
-    let state: String = data_encoding::BASE64URL.encode(
-        serde_urlencoded::to_string(IndieAuthClientState {
-            nonce: (0..8)
-                .map(|_| {
-                    let idx = rng.gen_range(0..INDIEAUTH_PKCE_CHARSET.len());
-                    INDIEAUTH_PKCE_CHARSET[idx] as char
-                })
-                .collect(),
-            me: homepage_uri.to_string(),
-            authorization_endpoint: authorization_endpoint.to_string(),
-        })?
-        .as_bytes(),
-    );
-    // PKCE code generation
-    let code_verifier: String = (0..128)
-        .map(|_| {
-            let idx = rng.gen_range(0..INDIEAUTH_PKCE_CHARSET.len());
-            INDIEAUTH_PKCE_CHARSET[idx] as char
-        })
-        .collect();
-    let mut hasher = Sha256::new();
-    hasher.update(code_verifier.as_bytes());
-    let code_challenge: String = data_encoding::BASE64URL.encode(&hasher.finalize());
-
-    authorization_endpoint.set_query(Some(&serde_urlencoded::to_string(
-        IndieAuthRequestParams {
-            response_type: "code".to_string(),
-            client_id: req.url().origin().ascii_serialization(),
-            redirect_uri: req.url().join("login/callback")?,
-            state: state.clone(),
-            code_challenge,
-            code_challenge_method: "S256".to_string(),
-            scope: Some("profile".to_string()),
-            me: homepage_uri,
-        },
-    )?));
-
-    let cookies = vec![
-        format!(
-            r#"indieauth_state="{}"; Same-Site: None; Secure; Max-Age: 600"#,
-            state
-        ),
-        format!(
-            r#"indieauth_code_verifier="{}"; Same-Site: None; Secure; Max-Age: 600"#,
-            code_verifier
-        ),
-    ];
-
-    let cookie_header = cookies
-        .iter()
-        .map(|i| -> http_types::headers::HeaderValue { (i as &str).try_into().unwrap() })
-        .collect::<Vec<_>>();
-
-    Ok(Response::builder(302)
-        .header("Location", authorization_endpoint.to_string())
-        .header("Set-Cookie", &*cookie_header)
-        .build())
-}
-
-const INDIEAUTH_PKCE_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\
-                                        abcdefghijklmnopqrstuvwxyz\
-                                        1234567890-._~";
-
-#[derive(Deserialize)]
-struct IndieAuthCallbackResponse {
-    code: Option<String>,
-    error: Option<String>,
-    error_description: Option<String>,
-    #[allow(dead_code)]
-    error_uri: Option<String>,
-    // This needs to be further decoded to receive state back and will always be present
-    state: String,
-}
-
-impl IndieAuthCallbackResponse {
-    fn is_successful(&self) -> bool {
-        self.code.is_some()
-    }
-}
-
-#[derive(Serialize, Deserialize)]
-struct IndieAuthCodeRedeem {
-    grant_type: String,
-    code: String,
-    client_id: String,
-    redirect_uri: String,
-    code_verifier: String,
-}
-
-#[derive(Serialize, Deserialize)]
-struct IndieWebProfile {
-    name: Option<String>,
-    url: Option<String>,
-    email: Option<String>,
-    photo: Option<String>,
-}
-
-#[derive(Serialize, Deserialize)]
-struct IndieAuthResponse {
-    me: String,
-    scope: Option<String>,
-    access_token: Option<String>,
-    token_type: Option<String>,
-    profile: Option<IndieWebProfile>,
-}
-
-/// Handle IndieAuth parameters, fetch the final h-card and redirect the user to the homepage.
-pub async fn callback<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
-    let params: IndieAuthCallbackResponse = req.query()?;
-    let http: &surf::Client = &req.state().http_client;
-    let origin = req.url().origin().ascii_serialization();
-
-    if req.cookie("indieauth_state").unwrap().value() != params.state {
-        return Err(FrontendError::with_code(400, "The state doesn't match. A possible CSRF attack was prevented. Please try again later.").into());
-    }
-    let state: IndieAuthClientState =
-        serde_urlencoded::from_bytes(&data_encoding::BASE64URL.decode(params.state.as_bytes())?)?;
-
-    if !params.is_successful() {
-        return Err(FrontendError::with_code(
-            400,
-            &format!(
-                "The authorization endpoint indicated a following error: {:?}: {:?}",
-                &params.error, &params.error_description
-            ),
-        )
-        .into());
-    }
-
-    let authorization_endpoint = surf::Url::parse(&state.authorization_endpoint).unwrap();
-    let mut code_response = http
-        .post(authorization_endpoint)
-        .body_string(serde_urlencoded::to_string(IndieAuthCodeRedeem {
-            grant_type: "authorization_code".to_string(),
-            code: params.code.unwrap().to_string(),
-            client_id: origin.to_string(),
-            redirect_uri: origin + "/login/callback",
-            code_verifier: req
-                .cookie("indieauth_code_verifier")
-                .unwrap()
-                .value()
-                .to_string(),
-        })?)
-        .header("Content-Type", "application/x-www-form-urlencoded")
-        .header("Accept", "application/json")
-        .send()
-        .await?;
-
-    if code_response.status() != 200 {
-        return Err(FrontendError::with_code(
-            code_response.status(),
-            &format!(
-                "Authorization endpoint returned an error when redeeming the code: {}",
-                code_response.body_string().await?
-            ),
-        )
-        .into());
-    }
-
-    let json: IndieAuthResponse = code_response.body_json().await?;
-    let session = req.session_mut();
-    session.insert("user", &json.me)?;
-
-    // TODO redirect to the page user came from
-    Ok(Response::builder(302).header("Location", "/").build())
-}
diff --git a/kittybox-rs/src/frontend/mod.rs b/kittybox-rs/src/frontend/mod.rs
deleted file mode 100644
index 7a43532..0000000
--- a/kittybox-rs/src/frontend/mod.rs
+++ /dev/null
@@ -1,404 +0,0 @@
-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(),
-            )
-        }
-    }
-}
diff --git a/kittybox-rs/src/frontend/onboarding.rs b/kittybox-rs/src/frontend/onboarding.rs
deleted file mode 100644
index e44e866..0000000
--- a/kittybox-rs/src/frontend/onboarding.rs
+++ /dev/null
@@ -1,181 +0,0 @@
-use std::sync::Arc;
-
-use crate::database::{settings, Storage};
-use axum::{
-    extract::{Extension, Host},
-    http::StatusCode,
-    response::{Html, IntoResponse},
-    Json,
-};
-use kittybox_frontend_renderer::{ErrorPage, OnboardingPage, Template};
-use serde::Deserialize;
-use tokio::{task::JoinSet, sync::Mutex};
-use tracing::{debug, error};
-
-use super::FrontendError;
-
-pub async fn get() -> Html<String> {
-    Html(
-        Template {
-            title: "Kittybox - Onboarding",
-            blog_name: "Kittybox",
-            feeds: vec![],
-            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,
-    jobset: Arc<Mutex<JoinSet<()>>>,
-) -> Result<(), FrontendError> {
-    // Create a user to pass to the backend
-    // At this point the site belongs to nobody, so it is safe to do
-    tracing::debug!("Creating user...");
-    let user = kittybox_indieauth::TokenData {
-        me: user_uid.clone(),
-        client_id: "https://kittybox.fireburn.ru/".parse().unwrap(),
-        scope: kittybox_indieauth::Scopes::new(vec![kittybox_indieauth::Scope::Create]),
-        iat: None, exp: None
-    };
-    tracing::debug!("User data: {:?}", user);
-
-    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",
-        ));
-    }
-
-    tracing::debug!("Setting settings...");
-    let user_domain = format!(
-        "{}{}",
-        user.me.host_str().unwrap(),
-        user.me.port()
-            .map(|port| format!(":{}", port))
-            .unwrap_or_default()
-    );
-    db.set_setting::<settings::SiteName>(&user_domain, data.blog_name.to_owned())
-        .await
-        .map_err(FrontendError::from)?;
-
-    db.set_setting::<settings::Webring>(&user_domain, false)
-        .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_domain.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;
-        };
-        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);
-    tracing::debug!("Posting first post {}...", uid);
-    crate::micropub::_post(&user, uid, post, db, http, jobset)
-        .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,
-    Extension(http): Extension<reqwest::Client>,
-    Extension(jobset): Extension<Arc<Mutex<JoinSet<()>>>>,
-    Json(data): Json<OnboardingData>,
-) -> 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, jobset).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![],
-                            user: None,
-                            content: ErrorPage {
-                                code: err.code(),
-                                msg: Some(err.msg().to_string()),
-                            }
-                            .to_string(),
-                        }
-                        .to_string(),
-                    ),
-                ))
-            }
-        }
-    }
-}
-
-pub fn router<S: Storage + 'static>(
-    database: S,
-    http: reqwest::Client,
-    jobset: Arc<Mutex<JoinSet<()>>>,
-) -> axum::routing::MethodRouter {
-    axum::routing::get(get)
-        .post(post::<S>)
-        .layer::<_, _, std::convert::Infallible>(axum::Extension(database))
-        .layer::<_, _, std::convert::Infallible>(axum::Extension(http))
-        .layer(axum::Extension(jobset))
-}