about summary refs log tree commit diff
path: root/src/frontend
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2022-05-24 17:18:30 +0300
committerVika <vika@fireburn.ru>2022-05-24 17:18:30 +0300
commit5610a5f0bf1a9df02bd3d5b55e2cdebef2440360 (patch)
tree8394bcf1dcc204043d7adeb8dde2e2746977606e /src/frontend
parent2f93873122b47e42f7ee1c38f1f04d052a63599c (diff)
downloadkittybox-5610a5f0bf1a9df02bd3d5b55e2cdebef2440360.tar.zst
flake.nix: reorganize
 - Kittybox's source code is moved to a subfolder
   - This improves build caching by Nix since it doesn't take changes
     to other files into account
 - Package and test definitions were spun into separate files
   - This makes my flake.nix much easier to navigate
   - This also makes it somewhat possible to use without flakes (but
     it is still not easy, so use flakes!)
 - Some attributes were moved in compliance with Nix 2.8's changes to
   flake schema
Diffstat (limited to 'src/frontend')
-rw-r--r--src/frontend/login.rs333
-rw-r--r--src/frontend/mod.rs459
-rw-r--r--src/frontend/onboarding.css33
-rw-r--r--src/frontend/onboarding.js87
-rw-r--r--src/frontend/style.css194
5 files changed, 0 insertions, 1106 deletions
diff --git a/src/frontend/login.rs b/src/frontend/login.rs
deleted file mode 100644
index 9665ce7..0000000
--- a/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_templates::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
deleted file mode 100644
index b87f9c6..0000000
--- a/src/frontend/mod.rs
+++ /dev/null
@@ -1,459 +0,0 @@
-use std::convert::TryInto;
-use crate::database::Storage;
-use serde::Deserialize;
-use futures_util::TryFutureExt;
-use warp::{http::StatusCode, Filter, host::Authority, path::FullPath};
-
-//pub mod login;
-
-#[allow(unused_imports)]
-use kittybox_templates::{ErrorPage, MainPage, OnboardingPage, Template, POSTS_PER_PAGE};
-
-pub use kittybox_util::IndiewebEndpoints;
-
-#[derive(Deserialize)]
-struct QueryParams {
-    after: Option<String>,
-}
-
-#[derive(Debug)]
-struct FrontendError {
-    msg: String,
-    source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
-    code: StatusCode,
-}
-
-impl FrontendError {
-    pub fn with_code<C>(code: C, msg: &str) -> Self
-    where
-        C: TryInto<StatusCode>,
-    {
-        Self {
-            msg: msg.to_string(),
-            source: None,
-            code: code.try_into().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
-        }
-    }
-    pub fn msg(&self) -> &str {
-        &self.msg
-    }
-    pub fn code(&self) -> StatusCode {
-        self.code
-    }
-}
-
-impl From<crate::database::StorageError> for FrontendError {
-    fn from(err: crate::database::StorageError) -> Self {
-        Self {
-            msg: "Database error".to_string(),
-            source: Some(Box::new(err)),
-            code: StatusCode::INTERNAL_SERVER_ERROR,
-        }
-    }
-}
-
-impl std::error::Error for FrontendError {
-    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
-        self.source
-            .as_ref()
-            .map(|e| e.as_ref() as &(dyn std::error::Error + 'static))
-    }
-}
-
-impl std::fmt::Display for FrontendError {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "{}", self.msg)
-    }
-}
-
-impl warp::reject::Reject for FrontendError {}
-
-async fn get_post_from_database<S: Storage>(
-    db: &S,
-    url: &str,
-    after: Option<String>,
-    user: &Option<String>,
-) -> std::result::Result<serde_json::Value, FrontendError> {
-    match db
-        .read_feed_with_limit(url, &after, POSTS_PER_PAGE, user)
-        .await
-    {
-        Ok(result) => match result {
-            Some(post) => Ok(post),
-            None => Err(FrontendError::with_code(
-                StatusCode::NOT_FOUND,
-                "Post not found in the database",
-            )),
-        },
-        Err(err) => match err.kind() {
-            crate::database::ErrorKind::PermissionDenied => {
-                // TODO: Authentication
-                if user.is_some() {
-                    Err(FrontendError::with_code(
-                        StatusCode::FORBIDDEN,
-                        "User authenticated AND forbidden to access this resource",
-                    ))
-                } else {
-                    Err(FrontendError::with_code(
-                        StatusCode::UNAUTHORIZED,
-                        "User needs to authenticate themselves",
-                    ))
-                }
-            }
-            _ => Err(err.into()),
-        },
-    }
-}
-
-#[allow(dead_code)]
-#[derive(Deserialize)]
-struct OnboardingFeed {
-    slug: String,
-    name: String,
-}
-
-#[allow(dead_code)]
-#[derive(Deserialize)]
-struct OnboardingData {
-    user: serde_json::Value,
-    first_post: serde_json::Value,
-    #[serde(default = "OnboardingData::default_blog_name")]
-    blog_name: String,
-    feeds: Vec<OnboardingFeed>,
-}
-
-impl OnboardingData {
-    fn default_blog_name() -> String {
-        "Kitty Box!".to_owned()
-    }
-}
-
-/*pub async fn onboarding_receiver<S: Storage>(mut req: Request<ApplicationState<S>>) -> Result {
-    use serde_json::json;
-
-    log::debug!("Entering onboarding receiver...");
-
-    // This cannot error out as the URL must be valid. Or there is something horribly wrong
-    // and we shouldn't serve this request anyway.
-    <dyn AsMut<tide::http::Request>>::as_mut(&mut req)
-        .url_mut()
-        .set_scheme("https")
-        .unwrap();
-
-    log::debug!("Parsing the body...");
-    let body = req.body_json::<OnboardingData>().await?;
-    log::debug!("Body parsed!");
-    let backend = &req.state().storage;
-
-    #[cfg(any(not(debug_assertions), test))]
-    let me = req.url();
-    #[cfg(all(debug_assertions, not(test)))]
-    let me = url::Url::parse("https://localhost:8080/").unwrap();
-
-    log::debug!("me value: {:?}", me);
-
-    if get_post_from_database(backend, me.as_str(), None, &None)
-        .await
-        .is_ok()
-    {
-        return Err(FrontendError::with_code(
-            StatusCode::Forbidden,
-            "Onboarding is over. Are you trying to take over somebody's website?!",
-        )
-        .into());
-    }
-    info!("Onboarding new user: {}", me);
-
-    let user = crate::indieauth::User::new(me.as_str(), "https://kittybox.fireburn.ru/", "create");
-
-    log::debug!("Setting the site name to {}", &body.blog_name);
-    backend
-        .set_setting("site_name", user.me.as_str(), &body.blog_name)
-        .await?;
-
-    if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" {
-        return Err(FrontendError::with_code(
-            StatusCode::BadRequest,
-            "user and first_post should be h-card and h-entry",
-        )
-        .into());
-    }
-    info!("Validated body.user and body.first_post as microformats2");
-
-    let mut hcard = body.user;
-    let hentry = body.first_post;
-
-    // Ensure the h-card's UID is set to the main page, so it will be fetchable.
-    hcard["properties"]["uid"] = json!([me.as_str()]);
-    // Normalize the h-card - note that it should preserve the UID we set here.
-    let (_, hcard) = crate::micropub::normalize_mf2(hcard, &user);
-    // The h-card is written directly - all the stuff in the Micropub's
-    // post function is just to ensure that the posts will be syndicated
-    // and inserted into proper feeds. Here, we don't have a need for this,
-    // since the h-card is DIRECTLY accessible via its own URL.
-    log::debug!("Saving the h-card...");
-    backend.put_post(&hcard, me.as_str()).await?;
-
-    log::debug!("Creating feeds...");
-    for feed in body.feeds {
-        if feed.name.is_empty() || feed.slug.is_empty() {
-            continue;
-        };
-        log::debug!("Creating feed {} with slug {}", &feed.name, &feed.slug);
-        let (_, feed) = crate::micropub::normalize_mf2(
-            json!({
-                "type": ["h-feed"],
-                "properties": {"name": [feed.name], "mp-slug": [feed.slug]}
-            }),
-            &user,
-        );
-
-        backend.put_post(&feed, me.as_str()).await?;
-    }
-    log::debug!("Saving the h-entry...");
-    // This basically puts the h-entry post through the normal creation process.
-    // We need to insert it into feeds and optionally send a notification to everywhere.
-    req.set_ext(user);
-    crate::micropub::post::new_post(req, hentry).await?;
-
-    Ok(Response::builder(201).header("Location", "/").build())
-}
-*/
-
-fn request_uri() -> impl Filter<Extract = (String,), Error = warp::Rejection> + Copy {
-    crate::util::require_host()
-        .and(warp::path::full())
-        .map(|host: Authority, path: FullPath| "https://".to_owned() + host.as_str() + path.as_str())
-}
-
-#[forbid(clippy::unwrap_used)]
-pub fn homepage<D: Storage>(db: D, endpoints: IndiewebEndpoints) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
-    let inject_db = move || db.clone();
-    warp::any()
-        .map(inject_db.clone())
-        .and(crate::util::require_host())
-        .and(warp::query())
-        .and_then(|db: D, host: Authority, q: QueryParams| async move {
-            let path = format!("https://{}/", host);
-            let feed_path = format!("https://{}/feeds/main", host);
-
-            match tokio::try_join!(
-                get_post_from_database(&db, &path, None, &None),
-                get_post_from_database(&db, &feed_path, q.after, &None)
-            ) {
-                Ok((hcard, hfeed)) => Ok((
-                    Some(hcard),
-                    Some(hfeed),
-                    StatusCode::OK
-                )),
-                Err(err) => {
-                    if err.code == StatusCode::NOT_FOUND {
-                        // signal for onboarding flow
-                        Ok((None, None, err.code))
-                    } else {
-                        Err(warp::reject::custom(err))
-                    }
-                }
-            }
-        })
-        .and(warp::any().map(move || endpoints.clone()))
-        .and(crate::util::require_host())
-        .and(warp::any().map(inject_db))
-        .then(|content: (Option<serde_json::Value>, Option<serde_json::Value>, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move {
-            let owner = format!("https://{}/", host.as_str());
-            let blog_name = db.get_setting(crate::database::Settings::SiteName, &owner).await
-                .unwrap_or_else(|_| "Kitty Box!".to_string());
-            let feeds = db.get_channels(&owner).await.unwrap_or_default();
-            match content {
-                (Some(card), Some(feed), StatusCode::OK) => {
-                    Box::new(warp::reply::html(Template {
-                        title: &blog_name,
-                        blog_name: &blog_name,
-                        endpoints: Some(endpoints),
-                        feeds,
-                        user: None, // TODO
-                        content: MainPage { feed: &feed, card: &card }.to_string()
-                    }.to_string())) as Box<dyn warp::Reply>
-                },
-                (None, None, StatusCode::NOT_FOUND) => {
-                    // TODO Onboarding
-                    Box::new(warp::redirect::found(
-                        hyper::Uri::from_static("/onboarding")
-                    )) as Box<dyn warp::Reply>
-                }
-                _ => unreachable!()
-            }
-        })
-}
-
-pub fn onboarding<D: 'static + Storage>(
-    db: D,
-    endpoints: IndiewebEndpoints,
-    http: reqwest::Client
-) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
-    let inject_db = move || db.clone();
-    warp::get()
-        .map(move || warp::reply::html(Template {
-            title: "Kittybox - Onboarding",
-            blog_name: "Kittybox",
-            endpoints: Some(endpoints.clone()),
-            feeds: vec![],
-            user: None,
-            content: OnboardingPage {}.to_string()
-        }.to_string()))
-        .or(warp::post()
-            .and(crate::util::require_host())
-            .and(warp::any().map(inject_db))
-            .and(warp::body::json::<OnboardingData>())
-            .and(warp::any().map(move || http.clone()))
-            .and_then(|host: warp::host::Authority, db: D, body: OnboardingData, http: reqwest::Client| async move {
-                let user_uid = format!("https://{}/", host.as_str());
-                if db.post_exists(&user_uid).await.map_err(FrontendError::from)? {
-                    
-                    return Ok(warp::redirect(hyper::Uri::from_static("/")));
-                }
-                let user = crate::indieauth::User::new(&user_uid, "https://kittybox.fireburn.ru/", "create");
-                if body.user["type"][0] != "h-card" || body.first_post["type"][0] != "h-entry" {
-                    return Err(FrontendError::with_code(StatusCode::BAD_REQUEST, "user and first_post should be an h-card and an h-entry").into());
-                }
-                db.set_setting(crate::database::Settings::SiteName, user.me.as_str(), &body.blog_name)
-                    .await
-                    .map_err(FrontendError::from)?;
-
-                let (_, hcard) = {
-                    let mut hcard = body.user;
-                    hcard["properties"]["uid"] = serde_json::json!([&user_uid]);
-                    crate::micropub::normalize_mf2(hcard, &user)
-                };
-                db.put_post(&hcard, &user_uid).await.map_err(FrontendError::from)?;
-                let (uid, post) = crate::micropub::normalize_mf2(body.first_post, &user);
-                crate::micropub::_post(user, uid, post, db, http).await.map_err(|e| {
-                    FrontendError {
-                        msg: "Error while posting the first post".to_string(),
-                        source: Some(Box::new(e)),
-                        code: StatusCode::INTERNAL_SERVER_ERROR
-                    }
-                })?;
-                Ok::<_, warp::Rejection>(warp::redirect(hyper::Uri::from_static("/")))
-            }))
-        
-}
-
-#[forbid(clippy::unwrap_used)]
-pub fn catchall<D: Storage>(db: D, endpoints: IndiewebEndpoints) -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Clone {
-    let inject_db = move || db.clone();
-    warp::any()
-        .map(inject_db.clone())
-        .and(request_uri())
-        .and(warp::query())
-        .and_then(|db: D, path: String, query: QueryParams| async move {
-            get_post_from_database(&db, &path, query.after, &None).map_err(warp::reject::custom).await
-        })
-        // Rendering pipeline
-        .and_then(|post: serde_json::Value| async move {
-            let post_name = &post["properties"]["name"][0].as_str().to_owned();
-            match post["type"][0]
-                .as_str()
-            {
-                Some("h-entry") => Ok((
-                    post_name.unwrap_or("Note").to_string(),
-                    kittybox_templates::Entry { post: &post }.to_string(),
-                    StatusCode::OK
-                )),
-                Some("h-card") => Ok((
-                    post_name.unwrap_or("Contact card").to_string(),
-                    kittybox_templates::VCard { card: &post }.to_string(),
-                    StatusCode::OK
-                )),
-                Some("h-feed") => Ok((
-                    post_name.unwrap_or("Feed").to_string(),
-                    kittybox_templates::Feed { feed: &post }.to_string(),
-                    StatusCode::OK
-                )),
-                _ => Err(warp::reject::custom(FrontendError::with_code(
-                    StatusCode::INTERNAL_SERVER_ERROR,
-                    &format!("Couldn't render an unknown type: {}", post["type"][0]),
-                )))
-            }
-        })
-        .recover(|err: warp::Rejection| {
-            use warp::Rejection;
-            use futures_util::future;
-            if let Some(err) = err.find::<FrontendError>() {
-                return future::ok::<(String, String, StatusCode), Rejection>((
-                    format!("Error: HTTP {}", err.code().as_u16()),
-                    ErrorPage { code: err.code(), msg: Some(err.msg().to_string()) }.to_string(),
-                    err.code()
-                ));
-            }
-            future::err::<(String, String, StatusCode), Rejection>(err)
-        })
-        .unify()
-        .and(warp::any().map(move || endpoints.clone()))
-        .and(crate::util::require_host())
-        .and(warp::any().map(inject_db))
-        .then(|content: (String, String, StatusCode), endpoints: IndiewebEndpoints, host: Authority, db: D| async move {
-            let owner = format!("https://{}/", host.as_str());
-            let blog_name = db.get_setting(crate::database::Settings::SiteName, &owner).await
-                .unwrap_or_else(|_| "Kitty Box!".to_string());
-            let feeds = db.get_channels(&owner).await.unwrap_or_default();
-            let (title, content, code) = content;
-            warp::reply::with_status(warp::reply::html(Template {
-                title: &title,
-                blog_name: &blog_name,
-                endpoints: Some(endpoints),
-                feeds,
-                user: None, // TODO
-                content,
-            }.to_string()), code)
-        })
-
-}
-
-static STYLE_CSS: &[u8] = include_bytes!("./style.css");
-static ONBOARDING_JS: &[u8] = include_bytes!("./onboarding.js");
-static ONBOARDING_CSS: &[u8] = include_bytes!("./onboarding.css");
-
-static MIME_JS: &str = "application/javascript";
-static MIME_CSS: &str = "text/css";
-
-fn _dispatch_static(name: &str) -> Option<(&'static [u8], &'static str)> {
-    match name {
-        "style.css" => Some((STYLE_CSS, MIME_CSS)),
-        "onboarding.js" => Some((ONBOARDING_JS, MIME_JS)),
-        "onboarding.css" => Some((ONBOARDING_CSS, MIME_CSS)),
-        _ => None
-    }
-}
-
-pub fn static_files() -> impl Filter<Extract = (impl warp::Reply,), Error = warp::Rejection> + Copy {
-    use futures_util::future;
-
-    warp::get()
-        .and(warp::path::param()
-             .and_then(|filename: String| {
-                 match _dispatch_static(&filename) {
-                     Some((buf, content_type)) => future::ok(
-                         warp::reply::with_header(
-                             buf, "Content-Type", content_type
-                         )
-                     ),
-                     None => future::err(warp::reject())
-                 }
-             }))
-        .or(warp::head()
-            .and(warp::path::param()
-                 .and_then(|filename: String| {
-                     match _dispatch_static(&filename) {
-                         Some((buf, content_type)) => future::ok(
-                             warp::reply::with_header(
-                                 warp::reply::with_header(
-                                     warp::reply(), "Content-Type", content_type
-                                 ),
-                                 "Content-Length", buf.len()
-                             )
-                         ),
-                         None => future::err(warp::reject())
-                     }
-                 })))
-}
diff --git a/src/frontend/onboarding.css b/src/frontend/onboarding.css
deleted file mode 100644
index 6f191b9..0000000
--- a/src/frontend/onboarding.css
+++ /dev/null
@@ -1,33 +0,0 @@
-form.onboarding > ul#progressbar > li.active {
-    font-weight: bold;
-}
-form.onboarding > ul#progressbar {
-  display: flex; list-style: none; justify-content: space-around;
-}
-
-form.onboarding > fieldset > div.switch_card_buttons {
-    display: flex;
-    justify-content: space-between;
-    width: 100%;
-}
-form.onboarding > fieldset > div.switch_card_buttons button:last-child {
-    margin-left: auto;
-}
-.form_group, .multi_input {
-    display: flex;
-    flex-direction: column;
-}
-.multi_input {
-    align-items: start;
-}
-.multi_input > input {
-    width: 100%;
-    align-self: stretch;
-}
-form.onboarding > fieldset > .form_group + * {
-    margin-top: .75rem;
-}
-form.onboarding textarea {
-    width: 100%;
-    resize: vertical;
-}
diff --git a/src/frontend/onboarding.js b/src/frontend/onboarding.js
deleted file mode 100644
index 7f9aa32..0000000
--- a/src/frontend/onboarding.js
+++ /dev/null
@@ -1,87 +0,0 @@
-const firstOnboardingCard = "intro";
-
-function switchOnboardingCard(card) {
-    Array.from(document.querySelectorAll("form.onboarding > fieldset")).map(node => {
-        if (node.id == card) {
-            node.style.display = "block";
-        } else {
-            node.style.display = "none";
-        }
-    });
-
-    Array.from(document.querySelectorAll("form.onboarding > ul#progressbar > li")).map(node => {
-        if (node.id == card) {
-            node.classList.add("active")
-        } else {
-            node.classList.remove("active")
-        }
-    })
-};
-
-window.kittybox_onboarding = {
-    switchOnboardingCard
-};
-
-document.querySelector("form.onboarding > ul#progressbar").style.display = "";
-switchOnboardingCard(firstOnboardingCard);
-
-function switchCardOnClick(event) {
-    switchOnboardingCard(event.target.dataset.card)
-}
-
-function multiInputAddMore(event) {
-    let parent = event.target.parentElement;
-    let template = event.target.parentElement.querySelector("template").content.cloneNode(true);
-    parent.prepend(template);
-}
-
-Array.from(document.querySelectorAll("form.onboarding > fieldset button.switch_card")).map(button => {
-    button.addEventListener("click", switchCardOnClick)
-})
-
-Array.from(document.querySelectorAll("form.onboarding > fieldset div.multi_input > button.add_more")).map(button => {
-    button.addEventListener("click", multiInputAddMore)
-    multiInputAddMore({ target: button });
-})
-
-const form = document.querySelector("form.onboarding");
-console.log(form);
-form.onsubmit = async (event) => {
-    console.log(event);
-    event.preventDefault();
-    const form = event.target;
-    const json = {
-        user: {
-            type: ["h-card"],
-            properties: {
-                name: [form.querySelector("#hcard_name").value],
-                pronoun: Array.from(form.querySelectorAll("#hcard_pronouns")).map(input => input.value).filter(i => i != ""),
-                url: Array.from(form.querySelectorAll("#hcard_url")).map(input => input.value).filter(i => i != ""),
-                note: [form.querySelector("#hcard_note").value]
-            }
-        },
-        first_post: {
-            type: ["h-entry"],
-            properties: {
-                content: [form.querySelector("#first_post_content").value]
-            }
-        },
-        blog_name: form.querySelector("#blog_name").value,
-        feeds: Array.from(form.querySelectorAll(".multi_input#custom_feeds > fieldset.feed")).map(form => {
-            return {
-                name: form.querySelector("#feed_name").value,
-                slug: form.querySelector("#feed_slug").value
-            }
-        }).filter(feed => feed.name == "" || feed.slug == "")
-    };
-
-    await fetch("/", {
-        method: "POST",
-        body: JSON.stringify(json),
-        headers: { "Content-Type": "application/json" }
-    }).then(response => {
-        if (response.status == 201) {
-            window.location.href = window.location.href;
-        }
-    })
-}
\ No newline at end of file
diff --git a/src/frontend/style.css b/src/frontend/style.css
deleted file mode 100644
index 109bba0..0000000
--- a/src/frontend/style.css
+++ /dev/null
@@ -1,194 +0,0 @@
-@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@500&family=Lato&display=swap');
-
-:root {
-    font-family: var(--font-normal);
-    --font-normal: 'Lato', sans-serif;
-    --font-accent: 'Caveat', cursive;
-    --type-scale: 1.250;
-
-    --primary-accent: purple;
-    --secondary-accent: gold;
-}
-* {
-    box-sizing: border-box;
-}
-body {
-    margin: 0;
-}
-h1, h2, h3, h4, h5, h6 {
-    font-family: var(--font-accent);
-}
-.titanic {
-    font-size: 3.815rem
-}
-h1, .xxxlarge {
-    margin-top: 0;
-    margin-bottom: 0;
-    font-size: 3.052rem;
-}
-h2, .xxlarge {font-size: 2.441rem;}
-h3, .xlarge {font-size: 1.953rem;}
-h4, .larger {font-size: 1.563rem;}
-h5, .large {font-size: 1.25rem;}
-h6, .normal {font-size: 1rem;}
-small, .small { font-size: 0.8em; }
-
-nav#headerbar {
-    background: var(--primary-accent);
-    color: whitesmoke;
-    border-bottom: .75rem solid var(--secondary-accent);
-    padding: .3rem;
-    vertical-align: center;
-    position: sticky;
-    top: 0;
-}
-nav#headerbar a#homepage {
-    font-weight: bolder;
-    font-family: var(--font-accent);
-    font-size: 2rem;
-}
-nav#headerbar > ul {
-    display: flex;
-    padding: inherit;
-    margin: inherit;
-    gap: .75em;
-}
-nav#headerbar > ul > li {
-    display: inline-flex;
-    flex-direction: column;
-    marker: none;
-    padding: inherit;
-    margin: inherit;
-    justify-content: center;
-}
-nav#headerbar > ul > li.shiftright {
-    margin-left: auto;
-}
-nav#headerbar a {
-    color: white;
-}
-body > main {
-    max-width: 60rem;
-    margin: auto;
-    padding: .75rem;
-}
-body > footer {
-    text-align: center;
-}
-.sidebyside {
-    display: flex;
-    flex-wrap: wrap;
-    gap: .75rem;
-    margin-top: .75rem;
-    margin-bottom: .75rem;
-}
-.sidebyside > * {
-    width: 100%;
-    margin-top: 0;
-    margin-bottom: 0;
-    border: .125rem solid black;
-    border-radius: .75rem;
-    padding: .75rem;
-    margin-top: 0 !important;
-    margin-bottom: 0 !important;
-    flex-basis: 28rem;
-    flex-grow: 1;
-}
-article > * + * {
-    margin-top: .75rem;
-}
-article > header {
-    padding-bottom: .75rem;
-    border-bottom: 1px solid gray;
-}
-article > footer {
-    border-top: 1px solid gray;
-}
-article.h-entry, article.h-feed, article.h-card, article.h-event {
-    border: 2px solid black;
-    border-radius: .75rem;
-    padding: .75rem;
-    margin-top: .75rem;
-    margin-bottom: .75rem;
-}
-.webinteractions > ul.counters {
-    display: inline-flex;
-    padding: inherit;
-    margin: inherit;
-    gap: .75em;
-    flex-wrap: wrap;
-}
-.webinteractions > ul.counters > li > .icon {
-    font-size: 1.5em;
-}
-.webinteractions > ul.counters > li {
-    display: inline-flex;
-    align-items: center;
-    gap: .5em;
-}
-article.h-entry > header.metadata ul {
-    padding-inline-start: unset;
-    margin: unset;
-}
-article.h-entry > header.metadata ul.categories {
-    flex-wrap: wrap;
-    display: inline-flex;
-    list-style-type: none;
-}
-article.h-entry > header.metadata ul.categories li {
-    display: inline;
-    margin-inline-start: unset;
-}
-article.h-entry > header.metadata ul li {
-    margin-inline-start: 2.5em;
-}
-article.h-entry .e-content pre {
-    border: 1px solid gray;
-    border-radius: 0.5em;
-    overflow-y: auto;
-    padding: 0.5em;
-}
-article.h-entry img.u-photo {
-    max-width: 80%;
-    max-height: 90vh;
-    display: block;
-    margin: auto;
-}
-article.h-entry img.u-photo + * {
-    margin-top: .75rem;
-}
-article.h-entry > header.metadata span + span::before {
-    content: " | "
-}
-li.p-category::before {
-    content: " #";
-}
-
-article.h-entry ul.categories {
-    gap: .2em;
-}
-article.h-card img.u-photo {
-    border-radius: 100%;
-    float: left;
-    height: 8rem;
-    border: 1px solid gray;
-    margin-right: .75em;
-    object-fit: cover;
-    aspect-ratio: 1;
-}
-
-.mini-h-card img {
-    height: 2em;
-    display: inline-block;
-    border: 2px solid gray;
-    border-radius: 100%;
-    margin-right: 0.5rem;
-}
-
-.mini-h-card * {
-    vertical-align: middle;
-}
-
-.mini-h-card a {
-    text-decoration: none;
-}