From 5610a5f0bf1a9df02bd3d5b55e2cdebef2440360 Mon Sep 17 00:00:00 2001 From: Vika Date: Tue, 24 May 2022 17:18:30 +0300 Subject: 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 --- kittybox-rs/src/frontend/login.rs | 333 +++++++++++++++++++++++ kittybox-rs/src/frontend/mod.rs | 459 ++++++++++++++++++++++++++++++++ kittybox-rs/src/frontend/onboarding.css | 33 +++ kittybox-rs/src/frontend/onboarding.js | 87 ++++++ kittybox-rs/src/frontend/style.css | 194 ++++++++++++++ 5 files changed, 1106 insertions(+) create mode 100644 kittybox-rs/src/frontend/login.rs create mode 100644 kittybox-rs/src/frontend/mod.rs create mode 100644 kittybox-rs/src/frontend/onboarding.css create mode 100644 kittybox-rs/src/frontend/onboarding.js create mode 100644 kittybox-rs/src/frontend/style.css (limited to 'kittybox-rs/src/frontend') diff --git a/kittybox-rs/src/frontend/login.rs b/kittybox-rs/src/frontend/login.rs new file mode 100644 index 0000000..9665ce7 --- /dev/null +++ b/kittybox-rs/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_templates::LoginPage; + +pub async fn form(req: Request>) -> 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, // 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(mut req: Request>) -> 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::().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 = 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#""#) + .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::>(); + + 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, + error: Option, + error_description: Option, + #[allow(dead_code)] + error_uri: Option, + // 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, + url: Option, + email: Option, + photo: Option, +} + +#[derive(Serialize, Deserialize)] +struct IndieAuthResponse { + me: String, + scope: Option, + access_token: Option, + token_type: Option, + profile: Option, +} + +/// Handle IndieAuth parameters, fetch the final h-card and redirect the user to the homepage. +pub async fn callback(mut req: Request>) -> 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: {:?}: {:?}", + ¶ms.error, ¶ms.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 new file mode 100644 index 0000000..b87f9c6 --- /dev/null +++ b/kittybox-rs/src/frontend/mod.rs @@ -0,0 +1,459 @@ +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, +} + +#[derive(Debug)] +struct FrontendError { + msg: String, + source: Option>, + code: StatusCode, +} + +impl FrontendError { + pub fn with_code(code: C, msg: &str) -> Self + where + C: TryInto, + { + 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 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( + db: &S, + url: &str, + after: Option, + user: &Option, +) -> std::result::Result { + 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, +} + +impl OnboardingData { + fn default_blog_name() -> String { + "Kitty Box!".to_owned() + } +} + +/*pub async fn onboarding_receiver(mut req: Request>) -> 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. + >::as_mut(&mut req) + .url_mut() + .set_scheme("https") + .unwrap(); + + log::debug!("Parsing the body..."); + let body = req.body_json::().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 + 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(db: D, endpoints: IndiewebEndpoints) -> impl Filter + 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, Option, 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 + }, + (None, None, StatusCode::NOT_FOUND) => { + // TODO Onboarding + Box::new(warp::redirect::found( + hyper::Uri::from_static("/onboarding") + )) as Box + } + _ => unreachable!() + } + }) +} + +pub fn onboarding( + db: D, + endpoints: IndiewebEndpoints, + http: reqwest::Client +) -> impl Filter + 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::()) + .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(db: D, endpoints: IndiewebEndpoints) -> impl Filter + 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::() { + 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 + Copy { + use futures_util::future; + + warp::get() + .and(warp::path::param() + .and_then(|filename: String| { + match _dispatch_static(&filename) { + Some((buf, content_type)) => future::ok( + warp::reply::with_header( + buf, "Content-Type", content_type + ) + ), + None => future::err(warp::reject()) + } + })) + .or(warp::head() + .and(warp::path::param() + .and_then(|filename: String| { + match _dispatch_static(&filename) { + Some((buf, content_type)) => future::ok( + warp::reply::with_header( + warp::reply::with_header( + warp::reply(), "Content-Type", content_type + ), + "Content-Length", buf.len() + ) + ), + None => future::err(warp::reject()) + } + }))) +} diff --git a/kittybox-rs/src/frontend/onboarding.css b/kittybox-rs/src/frontend/onboarding.css new file mode 100644 index 0000000..6f191b9 --- /dev/null +++ b/kittybox-rs/src/frontend/onboarding.css @@ -0,0 +1,33 @@ +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/kittybox-rs/src/frontend/onboarding.js b/kittybox-rs/src/frontend/onboarding.js new file mode 100644 index 0000000..7f9aa32 --- /dev/null +++ b/kittybox-rs/src/frontend/onboarding.js @@ -0,0 +1,87 @@ +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/kittybox-rs/src/frontend/style.css b/kittybox-rs/src/frontend/style.css new file mode 100644 index 0000000..109bba0 --- /dev/null +++ b/kittybox-rs/src/frontend/style.css @@ -0,0 +1,194 @@ +@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; +} -- cgit 1.4.1