use crate::database::Storage; use crate::ApplicationState; use log::{error, info}; use serde::{Deserialize, Serialize}; use tide::{Next, Request, Response, Result, StatusCode}; static POSTS_PER_PAGE: usize = 20; mod templates { use super::IndiewebEndpoints; use ellipse::Ellipse; use http_types::StatusCode; use log::error; /// Return a pretty location specifier from a geo: URI. fn decode_geo_uri(uri: &str) -> String { if let Some(part) = uri.split(':').collect::>().get(1) { if let Some(part) = part.split(';').next() { let mut parts = part.split(','); let lat = parts.next().unwrap(); let lon = parts.next().unwrap(); // TODO - format them as proper latitude and longitude return format!("{}, {}", lat, lon); } else { uri.to_string() } } else { uri.to_string() } } markup::define! { Template<'a>(title: &'a str, blog_name: &'a str, endpoints: IndiewebEndpoints, content: String) { @markup::doctype() html { head { title { @title } link[rel="preconnect", href="https://fonts.gstatic.com"]; link[rel="stylesheet", href="/static/style.css"]; meta[name="viewport", content="initial-scale=1, width=device-width"]; // TODO: link rel= for common IndieWeb APIs: webmention, microsub link[rel="micropub", href="/micropub"]; // Static, because it's built into the server itself link[rel="authorization_endpoint", href=&endpoints.authorization_endpoint]; link[rel="token_endpoint", href=&endpoints.token_endpoint]; @if endpoints.webmention.is_some() { link[rel="webmention", href=&endpoints.webmention.as_ref()]; } @if endpoints.microsub.is_some() { link[rel="microsub", href=&endpoints.microsub.as_ref()]; } } body { nav#headerbar { // TODO: Find a way to store the website name somewhere in the database // Maybe in the settings? ul { li { a#homepage[href="/"] { @blog_name } } li.shiftright { a#login[href="/login"] { "Login" } } } } main { @markup::raw(content) } } } } OnboardingPage { h1[style="text-align: center"] { "Welcome to Kittybox" } script[type="module", src="/static/onboarding.js"] {} link[rel="stylesheet", href="/static/onboarding.css"]; form.onboarding[action="/", method="POST"] { noscript { p { "Ok, let's be honest. Most of this software doesn't require JS to be enabled " "to view pages (and in some cases, even edit them if logged in)." } p { "This page is a little bit different. It uses JavaScript to provide interactive features, such as:" } ul { li { "Multiple-input questions" } li { "Answers spanning multiple fields" } li { "Preview of files being uploaded" } li { "Pretty pagination so you won't feel overwhelmed" } } p { "Sadly, it's very hard or even impossible to recreate this without any JavaScript. " "Good news though - the code is " b { "open-source AND free software" } " (under MIT (X11) or Apache-2.0 license - your choice) " "and I promise to not obfuscate it or minify it. " a[href="/static/onboarding.js"] { "Here" } "'s the link - you can try reading it so you'll be 200% sure " "it won't steal your cookies or turn your kitty into a soulless monster." @markup::raw("") } hr; p { "In other words: " b { "please enable JavaScript for this page to work properly." } small { "sorry T__T" } } } ul#progressbar[style="display: none"] { li#intro { "Introduction" } li#hcard { "Your profile" } li#settings { "Your website" } li#firstpost { "Your first post" } } fieldset#intro[style="display: none"] { legend { "Introduction" } p { "Kittybox is a CMS that can act as a member of the IndieWeb. " "IndieWeb is a global distributed social network built on top of open Web standards " "and composed of blogs around the Internet supporting these standards." } p { "There is no registration or centralized database of any sort - everyone owns their data and is responsible for it." } p { "If you're seeing this page, it looks like your configuration is correct and we can proceed with the setup." } div.switch_card_buttons { button.switch_card.next_card[type="button", "data-card"="hcard"] { "Next" } } } fieldset#hcard[style="display: none"] { legend { "Your profile" } p { "An h-card is an IndieWeb social profile, and we're gonna make you one!" } p { "Thanks to some clever markup, it will be readable by both humans and machines looking at your homepage."} p { "If you make a mistake, don't worry, you're gonna be able to edit this later." "The only mandatory field is your name." } div.form_group { label[for="hcard_name"] { "Your name" } input#hcard_name[name="hcard_name", placeholder="Your name"]; small { "No need to write the name as in your passport, this is not a legal document " "- just write how you want to be called on the network. This name will be also " "shown whenever you leave a comment on someone else's post using your website." } } div.form_group { label[for="pronouns"] { "Your pronouns" } div.multi_input#pronouns { template { input#hcard_pronouns[name="hcard_pronouns", placeholder="they/them?"]; } button.add_more[type="button", "aria-label"="Add more"] { "[+] Add more" } } small { "Write which pronouns you use for yourself. It's a free-form field " "so don't feel constrained - but keep it compact, as it'll be shown in a lot of places." } } div.form_group { label[for="urls"] { "Links to other pages of you" } div.multi_input#urls { template { input#hcard_url[name="hcard_url", placeholder="https://example.com/"]; } button.add_more[type="button", "aria-label"="Add more"] { "[+] Add more" } } small { "These URLs will help your readers find you elsewhere and will help you that whoever owns these pages owns your website too" " in case the links are mutual. So make sure to put a link to your site in your other social profiles!" } } div.form_group { label[for="hcard_note"] { "A little about yourself" } textarea#hcard_note[name="hcard_note", placeholder="Loves cooking, plants, cats, dogs and racoons."] {} small { "A little bit of introduction. Just one paragraph, and note, you can't use HTML here (yet)." } // TODO: HTML e-note instead of p-note } // TODO: u-photo upload - needs media endpoint cooperation div.switch_card_buttons { button.switch_card.prev_card[type="button", "data-card"="intro"] { "Previous" } button.switch_card.next_card[type="button", "data-card"="settings"] { "Next" } } } fieldset#settings[style="display: none"] { legend { "Your website" } p { "Ok, it's nice to know you more. Tell me about what you'll be writing and how you want to name your blog." } // TODO: site-name, saved to settings div.form_group { label[for="blog_name"] { "Your website's name"} input#blog_name[name="blog_name", placeholder="Kitty Box!"]; small { "It'll get shown in the title of your blog, in the upper left corner!" } } div.form_group { label[for="custom_feeds"] { "Custom feeds" } small { p { "You can set up custom feeds to post your stuff to. " "This is a nice way to organize stuff into huge folders, like all your trips or your quantified-self data." } p { "Feeds can be followed individually, which makes it easy for users who are interested in certain types " "of content you produce to follow your adventures in certain areas of your life without cluttering their " "readers." } p { "We will automatically create some feeds for you aside from these so you won't have to - including a main feed, " "address book (for venues you go to and people you talk about), a cookbook for your recipes and some more." // TODO: Put a link to documentation explaining feeds in more detail. } } div.multi_input#custom_feeds { template { fieldset.feed { div.form_group { label[for="feed_name"] { "Name" } input#feed_name[name="feed_name", placeholder="My cool feed"]; small { "This is a name that will identify this feed to the user. Make it short and descriptive!" } } div.form_group { label[for="feed_slug"] { "Slug" } input#feed_slug[name="feed_slug", placeholder="my-cool-feed"]; small { "This will form a pretty URL for the feed. For example: https://example.com/feeds/my-cool-feed" } } } } button.add_more[type="button", "aria-label"="Add more"] { "[+] Add More" } } } div.switch_card_buttons { button.switch_card.prev_card[type="button", "data-card"="hcard"] { "Previous" } button.switch_card.next_card[type="button", "data-card"="firstpost"] { "Next" } } } fieldset#firstpost[style="display: none"] { legend { "Your first post" } p { "Maybe you should start writing your first posts now. How about a short note?" } p { "A note is a short-form post (not unlike a tweet - but without the actual character limit) that doesn't bear a title." } p { "Consider telling more about yourself, your skills and interests in this note " @markup::raw("—") " though you're free to write anything you want. (By the way, you can use " a[href="https://daringfireball.net/projects/markdown/syntax"] { "Markdown" } " here to spice up your note!)" } textarea#first_post_content[style="width: 100%; height: 8em", placeholder="Hello! I am really excited about #IndieWeb"] {} div.switch_card_buttons { button.switch_card.prev_card[type="button", "data-card"="settings"] { "Previous" } button[type="submit"] { "Finish" } } } } } Entry<'a>(post: &'a serde_json::Value) { article."h-entry" { header.metadata { @if post["properties"]["name"][0].is_string() { h1."p-name" { @post["properties"]["name"][0].as_str().unwrap() } } div { span { a."u-url"."u-uid"[href=post["properties"]["uid"][0].as_str().unwrap()] { time."dt-published"[datetime=post["properties"]["published"][0].as_str().unwrap()] { @chrono::DateTime::parse_from_rfc3339(post["properties"]["published"][0].as_str().unwrap()) .map(|dt| dt.format("%a %b %e %T %Y").to_string()) .unwrap_or("ERROR: Couldn't parse the datetime".to_string()) } } } @if post["properties"]["category"].is_array() { span { ul.categories { "Tagged: " @for cat in post["properties"]["category"].as_array().unwrap() { li."p-category" { @cat.as_str().unwrap() } } } } } @if post["properties"]["in-reply-to"].is_array() { // TODO: Rich reply contexts - blocked on MF2 parser span { "In reply to: " ul.replyctx { @for ctx in post["properties"]["in-reply-to"].as_array().unwrap() { li { a."u-in-reply-to"[href=ctx.as_str().unwrap()] { @ctx.as_str().unwrap().truncate_ellipse(24).as_ref() } } } } } } } @if post["properties"]["location"].is_array() || post["properties"]["checkin"].is_array() { div { @if post["properties"]["checkin"].is_array() { span { "Check-in to: " @if post["properties"]["checkin"][0].is_string() { // It's a URL a."u-checkin"[href=post["properties"]["checkin"][0].as_str().unwrap()] { @post["properties"]["checkin"][0].as_str().unwrap().truncate_ellipse(24).as_ref() } } else { a."u-checkin"[href=post["properties"]["checkin"][0]["properties"]["uid"][0].as_str().unwrap()] { @post["properties"]["checkin"][0]["properties"]["name"][0].as_str().unwrap() } } } } @if post["properties"]["location"].is_array() { span { "Location: " @if post["properties"]["location"][0].is_string() { // It's a geo: URL // We need to decode it a."u-location"[href=post["properties"]["location"][0].as_str().unwrap()] { @decode_geo_uri(post["properties"]["location"][0].as_str().unwrap()) } } else { // It's an inner h-geo object a."u-location"[href=post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap()))] { // I'm a lazy bitch @decode_geo_uri(&post["properties"]["location"][0]["value"].as_str().map(|x| x.to_string()).unwrap_or(format!("geo:{},{}", post["properties"]["location"][0]["properties"]["latitude"][0].as_str().unwrap(), post["properties"]["location"][0]["properties"]["longitude"][0].as_str().unwrap()))) } } } } } } @if post["properties"]["ate"].is_array() || post["properties"]["drank"].is_array() { div { @if post["properties"]["ate"].is_array() { span { ul { "Ate:" @for food in post["properties"]["ate"].as_array().unwrap() { li { @if food.is_string() { // If this is a string, it's a URL. a."u-ate"[href=food.as_str().unwrap()] { @food.as_str().unwrap().truncate_ellipse(24).as_ref() } } else { // This is a rich food object (mm, sounds tasty! I wanna eat something tasty) a."u-ate"[href=food["properties"]["uid"][0].as_str().unwrap()] { @food["properties"]["name"][0].as_str() .unwrap_or(food["properties"]["uid"][0].as_str().unwrap().truncate_ellipse(24).as_ref()) } } } } } } } @if post["properties"]["drank"].is_array() { span { ul { "Drank:" @for food in post["properties"]["drank"].as_array().unwrap() { li { @if food.is_string() { // If this is a string, it's a URL. a."u-drank"[href=food.as_str().unwrap()] { @food.as_str().unwrap().truncate_ellipse(24).as_ref() } } else { // This is a rich food object (mm, sounds tasty! I wanna eat something tasty) a."u-drank"[href=food["properties"]["uid"][0].as_str().unwrap()] { @food["properties"]["name"][0].as_str() .unwrap_or(food["properties"]["uid"][0].as_str().unwrap().truncate_ellipse(24).as_ref()) } } } } } } } } } } @PhotoGallery { photos: post["properties"]["photo"].as_array() } @if post["properties"]["content"][0]["html"].is_string() { main."e-content" { @markup::raw(post["properties"]["content"][0]["html"].as_str().unwrap().trim()) } } @WebInteractions { post } } } PhotoGallery<'a>(photos: Option<&'a Vec>) { @if photos.is_some() { @for photo in photos.unwrap() { @if photo.is_string() { img."u-photo"[src=photo.as_str().unwrap(), loading="lazy"]; } else if photo.is_array() { @if photo["thumbnail"].is_string() { a."u-photo"[href=photo["value"].as_str().unwrap()] { img[src=photo["thumbnail"].as_str().unwrap(), loading="lazy", alt=photo["alt"].as_str().unwrap_or("")]; } } else { img."u-photo"[src=photo["value"].as_str().unwrap(), loading="lazy", alt=photo["alt"].as_str().unwrap_or("")]; } } } } } WebInteractions<'a>(post: &'a serde_json::Value) { footer.webinteractions { ul.counters { li { span.icon { "❤️" } span.counter { @post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0) } } li { span.icon { "💬" } span.counter { @post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0) } } li { span.icon { "🔄" } span.counter { @post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0) } } li { span.icon { "🔖" } span.counter { @post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0) } } } // Needs rich webmention support which may or may not depend on an MF2 parser // Might circumvent with an external parser with CORS support // why write this stuff in rust then tho /*details { summary { "Show comments and reactions" } @if post["properties"]["like"].as_array().map(|a| a.len()).unwrap_or(0) > 0 { // Show a facepile of likes for a post } @if post["properties"]["bookmark"].as_array().map(|a| a.len()).unwrap_or(0) > 0 { // Show a facepile of bookmarks for a post } @if post["properties"]["repost"].as_array().map(|a| a.len()).unwrap_or(0) > 0 { // Show a facepile of reposts for a post } @if post["properties"]["comment"].as_array().map(|a| a.len()).unwrap_or(0) > 0 { // Show all the comments recursively (so we could do Salmention with them) } }*/ } } VCard<'a>(card: &'a serde_json::Value) { article."h-card" { @if card["properties"]["photo"][0].is_string() { img."u-photo"[src=card["properties"]["photo"][0].as_str().unwrap()]; } h1 { a."u-url"."u-uid"."p-name"[href=card["properties"]["uid"][0].as_str().unwrap()] { @card["properties"]["name"][0].as_str().unwrap() } } @if card["properties"]["pronoun"].is_array() { span { "(" @for (i, pronoun) in card["properties"]["pronoun"].as_array().unwrap().iter().filter_map(|v| v.as_str()).enumerate() { span."p-pronoun" { @pronoun } // Insert commas between multiple sets of pronouns @if i < (card["properties"]["pronoun"].as_array().unwrap().len() - 1) {", "} } ")" } } p."p-note" { @card["properties"]["note"][0]["value"].as_str().unwrap_or_else(|| card["properties"]["note"][0].as_str().unwrap()) } @if card["properties"]["url"].is_array() { ul { "Can be found elsewhere at:" @for url in card["properties"]["url"].as_array().unwrap().iter().filter_map(|v| v.as_str()).filter(|v| v != &card["properties"]["uid"][0].as_str().unwrap()) { li { a."u-url"[href=url, rel="me"] { @url } } } } } } } Food<'a>(food: &'a serde_json::Value) { article."h-food" { header.metadata { h1 { a."p-name"."u-url"[href=food["properties"]["url"][0].as_str().unwrap()] { @food["properties"]["name"][0].as_str().unwrap() } } } @PhotoGallery { photos: food["properties"]["photo"].as_array() } } } Feed<'a>(feed: &'a serde_json::Value) { div."h-feed" { div.metadata { @if feed["properties"]["name"][0].is_string() { h1."p-name".titanic { a[href=feed["properties"]["uid"][0].as_str().unwrap(), rel="feed"] { @feed["properties"]["name"][0].as_str().unwrap() } } } } @if feed["children"].is_array() { @for child in feed["children"].as_array().unwrap() { @match child["type"][0].as_str().unwrap() { "h-entry" => { @Entry { post: child } } "h-feed" => { @Feed { feed: child } } "h-event" => { @{error!("Templating error: h-events aren't implemented yet");} } "h-card" => { @VCard { card: child }} something_else => { @{error!("Templating error: found a {} object that couldn't be parsed", something_else);} } } } } @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == 0 { p { "Looks like you reached the end. Wanna jump back to the " a[href=feed["properties"]["uid"][0].as_str().unwrap()] { "beginning" } "?" } } @if feed["children"].as_array().map(|a| a.len()).unwrap_or(0) == super::POSTS_PER_PAGE { a[rel="prev", href=feed["properties"]["uid"][0].as_str().unwrap().to_string() + "?after=" + feed["children"][super::POSTS_PER_PAGE - 1]["properties"]["uid"][0].as_str().unwrap()] { "Older posts" } } } } MainPage<'a>(feed: &'a serde_json::Value, card: &'a serde_json::Value) { .sidebyside { @VCard { card } #dynamicstuff { p { "This section will provide interesting statistics or tidbits about my life in this exact moment (with maybe a small delay)." } p { "It will probably require JavaScript to self-update, but I promise to keep this widget lightweight and open-source!" } p { small { "JavaScript isn't a menace, stop fearing it or I will switch to WebAssembly " "and knock your nico-nico-kneecaps so fast with its speed you won't even notice that... " small { "omae ha mou shindeiru" } @markup::raw("") } } } } @Feed { feed } } ErrorPage(code: StatusCode) { h1 { @format!("HTTP {} {}", code, code.canonical_reason()) } @match code { StatusCode::Unauthorized => { p { "Looks like you need to authenticate yourself before seeing this page. Try logging in with IndieAuth using the Login button above!" } } StatusCode::Forbidden => { p { "Looks like you're forbidden from viewing this page." } p { "This might've been caused by being banned from viewing my website" "or simply by trying to see what you're not supposed to see, " "like a private post that's not intended for you. It's ok, it happens." } } StatusCode::Gone => { p { "Looks like the page you're trying to find is gone and is never coming back." } } StatusCode::UnavailableForLegalReasons => { p { "The page is there, but I can't legally provide it to you because the censorship said so." } } StatusCode::NotFound => { p { "Looks like there's no such page. Maybe you or someone else mistyped a URL or my database experienced data loss." } } StatusCode::ImATeapot => { p { "Wait, do you seriously expect my website to brew you coffee? It's not a coffee machine!" } p { small { "I could brew you some coffee tho if we meet one day... " small { i { "i-it's nothing personal, I just like brewing coffee, b-baka!!!~ >. { p { "It seems like you have found an error. Not to worry, it has already been logged." } } } P { "For now, may I suggest to visit " a[href="/"] {"the main page"} " of this website?" } } } } use templates::{ErrorPage, MainPage, OnboardingPage, Template}; #[derive(Clone, Serialize, Deserialize)] pub struct IndiewebEndpoints { authorization_endpoint: String, token_endpoint: String, webmention: Option, microsub: Option, } #[derive(Deserialize)] struct QueryParams { after: Option, } #[derive(Debug)] struct FrontendError { msg: String, source: Option>, code: StatusCode, } impl FrontendError { pub fn with_code(code: StatusCode, msg: &str) -> Self { Self { msg: msg.to_string(), source: None, code, } } 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::InternalServerError, } } } 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) } } 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::NotFound, "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()), }, } } #[derive(Deserialize)] struct OnboardingFeed { slug: String, name: String, } #[derive(Deserialize)] struct OnboardingData { user: serde_json::Value, first_post: serde_json::Value, blog_name: String, feeds: Vec, } pub async fn onboarding_receiver(mut req: Request>) -> Result { use serde_json::json; >::as_mut(&mut req).url_mut().set_scheme("https"); let body = req.body_json::().await?; 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(); 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"); 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. backend.put_post(&hcard, me.as_str()).await?; for feed in body.feeds { 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?; } // 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()) } pub async fn coffee(_: Request>) -> Result { Err(FrontendError::with_code( StatusCode::ImATeapot, "Someone asked this website to brew them some coffee...", ) .into()) } pub async fn mainpage(mut req: Request>) -> Result { >::as_mut(&mut req).url_mut().set_scheme("https"); let backend = &req.state().storage; let query = req.query::()?; let authorization_endpoint = req.state().authorization_endpoint.to_string(); let token_endpoint = req.state().token_endpoint.to_string(); let user: Option = None; #[cfg(any(not(debug_assertions), test))] let url = req.url(); #[cfg(all(debug_assertions, not(test)))] let url = url::Url::parse("https://localhost:8080/").unwrap(); info!("Request at {}", url); let hcard_url = url.as_str(); let feed_url = url.join("feeds/main").unwrap().to_string(); let card = get_post_from_database(backend, hcard_url, None, &user).await; let feed = get_post_from_database(backend, &feed_url, query.after, &user).await; if card.is_err() && feed.is_err() { // Uh-oh! No main feed and no h-card? Need to do onboarding. // We can do it from inside the app without ever requesting an auth token. let card_err = card.unwrap_err(); let feed_err = feed.unwrap_err(); if card_err.code == 404 { // Yes, we definitely need some onboarding here. Ok(Response::builder(200) .content_type("text/html; charset=utf-8") .body( Template { title: "Kittybox - Onboarding", blog_name: "Kitty Box!", endpoints: IndiewebEndpoints { authorization_endpoint, token_endpoint, webmention: None, microsub: None, }, content: OnboardingPage {}.to_string(), } .to_string(), ) .build()) } else { Err(feed_err.into()) } } else { Ok(Response::builder(200) .content_type("text/html; charset=utf-8") .body( Template { title: &format!("{} - Main page", url.host().unwrap().to_string()), blog_name: &backend .get_setting("site_name", hcard_url) .await .unwrap_or_else(|_| "Kitty Box!".to_string()), endpoints: IndiewebEndpoints { authorization_endpoint, token_endpoint, webmention: None, microsub: None, }, content: MainPage { feed: &feed?, card: &card?, } .to_string(), } .to_string(), ) .build()) } } pub async fn render_post(mut req: Request>) -> Result { let query = req.query::()?; let authorization_endpoint = req.state().authorization_endpoint.to_string(); let token_endpoint = req.state().token_endpoint.to_string(); let user: Option = None; >::as_mut(&mut req).url_mut().set_scheme("https"); #[cfg(any(not(debug_assertions), test))] let url = req.url(); #[cfg(all(debug_assertions, not(test)))] let url = url::Url::parse("https://localhost:8080/") .unwrap() .join(req.url().path()) .unwrap(); let mut entry_url = req.url().clone(); entry_url.set_query(None); let post = get_post_from_database(&req.state().storage, entry_url.as_str(), query.after, &user).await?; let template: String = match post["type"][0] .as_str() .expect("Empty type array or invalid type") { "h-entry" => templates::Entry { post: &post }.to_string(), "h-card" => templates::VCard { card: &post }.to_string(), "h-feed" => templates::Feed { feed: &post }.to_string(), _ => { return Err(FrontendError::with_code( StatusCode::InternalServerError, "Couldn't render an unknown type", ) .into()) } }; let origin = url.origin(); Ok(Response::builder(200) .content_type("text/html; charset=utf-8") .body( Template { title: post["properties"]["name"][0] .as_str() .unwrap_or(&format!("Note at {}", url.host().unwrap().to_string())), blog_name: &req .state() .storage .get_setting("site_name", &(origin.ascii_serialization() + "/")) // XXX I'm pretty sure this is bound to cause issues with IDN-style domains .await .unwrap_or_else(|_| "Kitty Box!".to_string()), endpoints: IndiewebEndpoints { authorization_endpoint, token_endpoint, webmention: None, microsub: None, }, content: template, } .to_string(), ) .build()) } pub struct ErrorHandlerMiddleware {} #[async_trait::async_trait] impl tide::Middleware> for ErrorHandlerMiddleware where S: crate::database::Storage, { async fn handle( &self, request: Request>, next: Next<'_, ApplicationState>, ) -> Result { let authorization_endpoint = request.state().authorization_endpoint.to_string(); let token_endpoint = request.state().token_endpoint.to_string(); let site_name = &request .state() .storage .get_setting("site_name", &request.url().host().unwrap().to_string()) .await .unwrap_or_else(|_| "Kitty Box!".to_string()); let mut res = next.run(request).await; let mut code: Option = None; if let Some(err) = res.downcast_error::() { code = Some(err.code()); error!("Error caught while processing request: {}", err.msg()); let mut err: &dyn std::error::Error = err; while let Some(e) = err.source() { error!("Caused by: {}", e); err = e; } } if let Some(code) = code { res.set_status(code); res.set_content_type("text/html; charset=utf-8"); res.set_body( Template { title: "Error", blog_name: site_name, endpoints: IndiewebEndpoints { authorization_endpoint, token_endpoint, webmention: None, microsub: None, }, content: ErrorPage { code }.to_string(), } .to_string(), ); } Ok(res) } } static STYLE_CSS: &[u8] = include_bytes!("./style.css"); static ONBOARDING_JS: &[u8] = include_bytes!("./onboarding.js"); static ONBOARDING_CSS: &[u8] = include_bytes!("./onboarding.css"); pub async fn handle_static(req: Request>) -> Result { Ok(match req.param("path") { Ok("style.css") => Ok(Response::builder(200) .content_type("text/css; charset=utf-8") .body(STYLE_CSS) .build()), Ok("onboarding.js") => Ok(Response::builder(200) .content_type("text/javascript; charset=utf-8") .body(ONBOARDING_JS) .build()), Ok("onboarding.css") => Ok(Response::builder(200) .content_type("text/css; charset=utf-8") .body(ONBOARDING_CSS) .build()), Ok(_) => Err(FrontendError::with_code( StatusCode::NotFound, "Static file not found", )), Err(_) => panic!("Invalid usage of the frontend::handle_static() function"), }?) }