about summary refs log tree commit diff
path: root/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 /src/frontend
parent26c2b79f6a6380ae3224e9309b9f3352f5717bd7 (diff)
downloadkittybox-0617663b249f9ca488e5de652108b17d67fbaf45.tar.zst
Moved the entire Kittybox tree into the root
Diffstat (limited to 'src/frontend')
-rw-r--r--src/frontend/login.rs333
-rw-r--r--src/frontend/mod.rs404
-rw-r--r--src/frontend/onboarding.rs181
3 files changed, 918 insertions, 0 deletions
diff --git a/src/frontend/login.rs b/src/frontend/login.rs
new file mode 100644
index 0000000..c693899
--- /dev/null
+++ b/src/frontend/login.rs
@@ -0,0 +1,333 @@
+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/src/frontend/mod.rs b/src/frontend/mod.rs
new file mode 100644
index 0000000..7a43532
--- /dev/null
+++ b/src/frontend/mod.rs
@@ -0,0 +1,404 @@
+use crate::database::{Storage, StorageError};
+use axum::{
+    extract::{Host, Path, Query},
+    http::{StatusCode, Uri},
+    response::IntoResponse,
+    Extension,
+};
+use futures_util::FutureExt;
+use serde::Deserialize;
+use std::convert::TryInto;
+use tracing::{debug, error};
+//pub mod login;
+pub mod onboarding;
+
+use kittybox_frontend_renderer::{
+    Entry, Feed, VCard,
+    ErrorPage, Template, MainPage,
+    POSTS_PER_PAGE
+};
+pub use kittybox_frontend_renderer::assets::statics;
+
+#[derive(Debug, Deserialize)]
+pub struct QueryParams {
+    after: Option<String>,
+}
+
+#[derive(Debug)]
+struct FrontendError {
+    msg: String,
+    source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
+    code: StatusCode,
+}
+
+impl FrontendError {
+    pub fn with_code<C>(code: C, msg: &str) -> Self
+    where
+        C: TryInto<StatusCode>,
+    {
+        Self {
+            msg: msg.to_string(),
+            source: None,
+            code: code.try_into().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
+        }
+    }
+    pub fn msg(&self) -> &str {
+        &self.msg
+    }
+    pub fn code(&self) -> StatusCode {
+        self.code
+    }
+}
+
+impl From<StorageError> for FrontendError {
+    fn from(err: StorageError) -> Self {
+        Self {
+            msg: "Database error".to_string(),
+            source: Some(Box::new(err)),
+            code: StatusCode::INTERNAL_SERVER_ERROR,
+        }
+    }
+}
+
+impl std::error::Error for FrontendError {
+    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+        self.source
+            .as_ref()
+            .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
+    }
+}
+
+impl std::fmt::Display for FrontendError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.msg)?;
+        if let Some(err) = std::error::Error::source(&self) {
+            write!(f, ": {}", err)?;
+        }
+
+        Ok(())
+    }
+}
+
+/// Filter the post according to the value of `user`.
+///
+/// Anonymous users cannot view private posts and protected locations;
+/// Logged-in users can only view private posts targeted at them;
+/// Logged-in users can't view private location data
+#[tracing::instrument(skip(post), fields(post = %post))]
+pub fn filter_post(
+    mut post: serde_json::Value,
+    user: Option<&str>,
+) -> Option<serde_json::Value> {
+    if post["properties"]["deleted"][0].is_string() {
+        tracing::debug!("Deleted post; returning tombstone instead");
+        return Some(serde_json::json!({
+            "type": post["type"],
+            "properties": {
+                "deleted": post["properties"]["deleted"]
+            }
+        }));
+    }
+    let empty_vec: Vec<serde_json::Value> = vec![];
+    let author_list = post["properties"]["author"]
+        .as_array()
+        .unwrap_or(&empty_vec)
+        .iter()
+        .map(|i| -> &str {
+            match i {
+                serde_json::Value::String(ref author) => author.as_str(),
+                mf2 => mf2["properties"]["uid"][0].as_str().unwrap()
+            }
+        }).collect::<Vec<&str>>();
+    let visibility = post["properties"]["visibility"][0]
+        .as_str()
+        .unwrap_or("public");
+    let audience = {
+        let mut audience = author_list.clone();
+        audience.extend(post["properties"]["audience"]
+            .as_array()
+            .unwrap_or(&empty_vec)
+            .iter()
+            .map(|i| i.as_str().unwrap()));
+
+        audience
+    };
+    tracing::debug!("post audience = {:?}", audience);
+    if (visibility == "private" && !audience.iter().any(|i| Some(*i) == user))
+        || (visibility == "protected" && user.is_none())
+    {
+        return None;
+    }
+    if post["properties"]["location"].is_array() {
+        let location_visibility = post["properties"]["location-visibility"][0]
+            .as_str()
+            .unwrap_or("private");
+        tracing::debug!("Post contains location, location privacy = {}", location_visibility);
+        let mut author = post["properties"]["author"]
+            .as_array()
+            .unwrap_or(&empty_vec)
+            .iter()
+            .map(|i| i.as_str().unwrap());
+        if (location_visibility == "private" && !author.any(|i| Some(i) == user))
+            || (location_visibility == "protected" && user.is_none())
+        {
+            post["properties"]
+                .as_object_mut()
+                .unwrap()
+                .remove("location");
+        }
+    }
+
+    match post["properties"]["author"].take() {
+        serde_json::Value::Array(children) => {
+            post["properties"]["author"] = serde_json::Value::Array(
+                children
+                    .into_iter()
+                    .filter_map(|post| if post.is_string() {
+                        Some(post)
+                    } else {
+                        filter_post(post, user)
+                    })
+                    .collect::<Vec<serde_json::Value>>()
+            );
+        },
+        serde_json::Value::Null => {},
+        other => post["properties"]["author"] = other
+    }
+
+    match post["children"].take() {
+        serde_json::Value::Array(children) => {
+            post["children"] = serde_json::Value::Array(
+                children
+                    .into_iter()
+                    .filter_map(|post| filter_post(post, user))
+                    .collect::<Vec<serde_json::Value>>()
+            );
+        },
+        serde_json::Value::Null => {},
+        other => post["children"] = other
+    }
+    Some(post)
+}
+
+async fn get_post_from_database<S: Storage>(
+    db: &S,
+    url: &str,
+    after: Option<String>,
+    user: &Option<String>,
+) -> std::result::Result<(serde_json::Value, Option<String>), FrontendError> {
+    match db
+        .read_feed_with_cursor(url, after.as_deref(), POSTS_PER_PAGE, user.as_deref())
+        .await
+    {
+        Ok(result) => match result {
+            Some((post, cursor)) => match filter_post(post, user.as_deref()) {
+                Some(post) => Ok((post, cursor)),
+                None => {
+                    // TODO: Authentication
+                    if user.is_some() {
+                        Err(FrontendError::with_code(
+                            StatusCode::FORBIDDEN,
+                            "User authenticated AND forbidden to access this resource",
+                        ))
+                    } else {
+                        Err(FrontendError::with_code(
+                            StatusCode::UNAUTHORIZED,
+                            "User needs to authenticate themselves",
+                        ))
+                    }
+                }
+            }
+            None => Err(FrontendError::with_code(
+                StatusCode::NOT_FOUND,
+                "Post not found in the database",
+            )),
+        },
+        Err(err) => match err.kind() {
+            crate::database::ErrorKind::PermissionDenied => {
+                // TODO: Authentication
+                if user.is_some() {
+                    Err(FrontendError::with_code(
+                        StatusCode::FORBIDDEN,
+                        "User authenticated AND forbidden to access this resource",
+                    ))
+                } else {
+                    Err(FrontendError::with_code(
+                        StatusCode::UNAUTHORIZED,
+                        "User needs to authenticate themselves",
+                    ))
+                }
+            }
+            _ => Err(err.into()),
+        },
+    }
+}
+
+#[tracing::instrument(skip(db))]
+pub async fn homepage<D: Storage>(
+    Host(host): Host,
+    Query(query): Query<QueryParams>,
+    Extension(db): Extension<D>,
+) -> impl IntoResponse {
+    let user = None; // TODO authentication
+    let path = format!("https://{}/", host);
+    let feed_path = format!("https://{}/feeds/main", host);
+
+    match tokio::try_join!(
+        get_post_from_database(&db, &path, None, &user),
+        get_post_from_database(&db, &feed_path, query.after, &user)
+    ) {
+        Ok(((hcard, _), (hfeed, cursor))) => {
+            // Here, we know those operations can't really fail
+            // (or it'll be a transient failure that will show up on
+            // other requests anyway if it's serious...)
+            //
+            // btw is it more efficient to fetch these in parallel?
+            let (blogname, webring, channels) = tokio::join!(
+                db.get_setting::<crate::database::settings::SiteName>(&host)
+                .map(Result::unwrap_or_default),
+
+                db.get_setting::<crate::database::settings::Webring>(&host)
+                .map(Result::unwrap_or_default),
+
+                db.get_channels(&host).map(|i| i.unwrap_or_default())
+            );
+            // Render the homepage
+            (
+                StatusCode::OK,
+                [(
+                    axum::http::header::CONTENT_TYPE,
+                    r#"text/html; charset="utf-8""#,
+                )],
+                Template {
+                    title: blogname.as_ref(),
+                    blog_name: blogname.as_ref(),
+                    feeds: channels,
+                    user,
+                    content: MainPage {
+                        feed: &hfeed,
+                        card: &hcard,
+                        cursor: cursor.as_deref(),
+                        webring: crate::database::settings::Setting::into_inner(webring)
+                    }
+                    .to_string(),
+                }
+                .to_string(),
+            )
+        }
+        Err(err) => {
+            if err.code == StatusCode::NOT_FOUND {
+                debug!("Transferring to onboarding...");
+                // Transfer to onboarding
+                (
+                    StatusCode::FOUND,
+                    [(axum::http::header::LOCATION, "/.kittybox/onboarding")],
+                    String::default(),
+                )
+            } else {
+                error!("Error while fetching h-card and/or h-feed: {}", err);
+                // Return the error
+                let (blogname, channels) = tokio::join!(
+                    db.get_setting::<crate::database::settings::SiteName>(&host)
+                    .map(Result::unwrap_or_default),
+
+                    db.get_channels(&host).map(|i| i.unwrap_or_default())
+                );
+
+                (
+                    err.code(),
+                    [(
+                        axum::http::header::CONTENT_TYPE,
+                        r#"text/html; charset="utf-8""#,
+                    )],
+                    Template {
+                        title: blogname.as_ref(),
+                        blog_name: blogname.as_ref(),
+                        feeds: channels,
+                        user,
+                        content: ErrorPage {
+                            code: err.code(),
+                            msg: Some(err.msg().to_string()),
+                        }
+                        .to_string(),
+                    }
+                    .to_string(),
+                )
+            }
+        }
+    }
+}
+
+#[tracing::instrument(skip(db))]
+pub async fn catchall<D: Storage>(
+    Extension(db): Extension<D>,
+    Host(host): Host,
+    Query(query): Query<QueryParams>,
+    uri: Uri,
+) -> impl IntoResponse {
+    let user = None; // TODO authentication
+    let path = url::Url::parse(&format!("https://{}/", host))
+        .unwrap()
+        .join(uri.path())
+        .unwrap();
+
+    match get_post_from_database(&db, path.as_str(), query.after, &user).await {
+        Ok((post, cursor)) => {
+            let (blogname, channels) = tokio::join!(
+                db.get_setting::<crate::database::settings::SiteName>(&host)
+                .map(Result::unwrap_or_default),
+
+                db.get_channels(&host).map(|i| i.unwrap_or_default())
+            );
+            // Render the homepage
+            (
+                StatusCode::OK,
+                [(
+                    axum::http::header::CONTENT_TYPE,
+                    r#"text/html; charset="utf-8""#,
+                )],
+                Template {
+                    title: blogname.as_ref(),
+                    blog_name: blogname.as_ref(),
+                    feeds: channels,
+                    user,
+                    content: match post.pointer("/type/0").and_then(|i| i.as_str()) {
+                        Some("h-entry") => Entry { post: &post }.to_string(),
+                        Some("h-feed") => Feed { feed: &post, cursor: cursor.as_deref() }.to_string(),
+                        Some("h-card") => VCard { card: &post }.to_string(),
+                        unknown => {
+                            unimplemented!("Template for MF2-JSON type {:?}", unknown)
+                        }
+                    },
+                }
+                .to_string(),
+            )
+        }
+        Err(err) => {
+            let (blogname, channels) = tokio::join!(
+                db.get_setting::<crate::database::settings::SiteName>(&host)
+                .map(Result::unwrap_or_default),
+
+                db.get_channels(&host).map(|i| i.unwrap_or_default())
+            );
+            (
+                err.code(),
+                [(
+                    axum::http::header::CONTENT_TYPE,
+                    r#"text/html; charset="utf-8""#,
+                )],
+                Template {
+                    title: blogname.as_ref(),
+                    blog_name: blogname.as_ref(),
+                    feeds: channels,
+                    user,
+                    content: ErrorPage {
+                        code: err.code(),
+                        msg: Some(err.msg().to_owned()),
+                    }
+                    .to_string(),
+                }
+                .to_string(),
+            )
+        }
+    }
+}
diff --git a/src/frontend/onboarding.rs b/src/frontend/onboarding.rs
new file mode 100644
index 0000000..e44e866
--- /dev/null
+++ b/src/frontend/onboarding.rs
@@ -0,0 +1,181 @@
+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))
+}