mod templates; pub use templates::{ErrorPage, MainPage, Template}; mod onboarding; pub use onboarding::OnboardingPage; mod indieauth; pub use indieauth::AuthorizationRequestPage; mod login; pub use login::{LoginPage, LogoutPage}; mod mf2; pub use mf2::{Entry, VCard, Feed, Food, POSTS_PER_PAGE}; pub mod admin; pub mod assets { use axum::response::{IntoResponse, Response}; use axum::extract::Path; use axum::http::StatusCode; use axum::http::header::{CONTENT_TYPE, CONTENT_ENCODING, CACHE_CONTROL}; const ASSETS: include_dir::Dir<'static> = include_dir::include_dir!("$OUT_DIR/"); const CACHE_FOR_A_DAY: &str = "max-age=86400"; const GZIP: &str = "gzip"; pub async fn statics( Path(path): Path ) -> Response { let content_type: &'static str = if path.ends_with(".js") { "application/javascript" } else if path.ends_with(".css") { "text/css" } else if path.ends_with(".html") { "text/html; charset=\"utf-8\"" } else { "application/octet-stream" }; match ASSETS.get_file(path.clone() + ".gz") { Some(file) => (StatusCode::OK, [(CONTENT_TYPE, content_type), (CONTENT_ENCODING, GZIP), (CACHE_CONTROL, CACHE_FOR_A_DAY)], file.contents()).into_response(), None => match ASSETS.get_file(path) { Some(file) => (StatusCode::OK, [(CONTENT_TYPE, content_type), (CACHE_CONTROL, CACHE_FOR_A_DAY)], file.contents()).into_response(), None => StatusCode::NOT_FOUND.into_response() } } } } #[cfg(test)] mod tests { use faker_rand::en_us::internet::Domain; use faker_rand::lorem::Word; use microformats::types::{Document, Item, PropertyValue, Url}; use rand::distributions::Distribution; use serde_json::json; enum PostType { Note, Article, ReplyTo(serde_json::Value), ReplyToLink(String), LikeOf(serde_json::Value), LikeOfLink(String), } fn gen_hcard(domain: &str) -> serde_json::Value { use faker_rand::en_us::names::FirstName; json!({ "type": ["h-card"], "properties": { "name": [rand::random::().to_string()], "photo": [format!("https://{domain}/media/me.png")], "uid": [format!("https://{domain}/")], "url": [format!("https://{domain}/")] } }) } fn gen_random_post(domain: &str, kind: PostType) -> serde_json::Value { use faker_rand::lorem::{Paragraph, Sentence}; fn html(content: Paragraph) -> serde_json::Value { json!({ "html": format!("

{}

", content), "value": content.to_string() }) } let uid = format!( "https://{domain}/posts/{}-{}-{}", rand::random::(), rand::random::(), rand::random::() ); let dt = time::OffsetDateTime::now_utc() .to_offset( time::UtcOffset::from_hms( rand::distributions::Uniform::new(-11, 12) .sample(&mut rand::thread_rng()), if rand::random::() { 0 } else { 30 }, 0 ).unwrap() ) .format(&time::format_description::well_known::Rfc3339) .unwrap(); match kind { PostType::Note => { let content = rand::random::(); json!({ "type": ["h-entry"], "properties": { "content": [html(content)], "published": [dt], "uid": [&uid], "url": [&uid], "author": [gen_hcard(domain)] } }) } PostType::Article => { let content = rand::random::(); let name = rand::random::(); json!({ "type": ["h-entry"], "properties": { "content": [html(content)], "published": [dt], "uid": [&uid], "url": [&uid], "author": [gen_hcard(domain)], "name": [name.to_string()] } }) } PostType::ReplyTo(ctx) => { let content = rand::random::(); json!({ "type": ["h-entry"], "properties": { "content": [html(content)], "published": [dt], "uid": [&uid], "url": [&uid], "author": [gen_hcard(domain)], "in-reply-to": [{ "type": ["h-cite"], "properties": ctx["properties"] }] } }) } PostType::ReplyToLink(link) => { let content = rand::random::(); json!({ "type": ["h-entry"], "properties": { "content": [html(content)], "published": [dt], "uid": [&uid], "url": [&uid], "author": [gen_hcard(domain)], "in-reply-to": [link] } }) } PostType::LikeOf(ctx) => { json!({ "type": ["h-entry"], "properties": { "published": [dt], "author": [gen_hcard(domain)], "uid": [&uid], "url": [&uid], "like-of": [{ "type": ["h-cite"], "properties": ctx["properties"] }] } }) } PostType::LikeOfLink(link) => { json!({ "type": ["h-entry"], "properties": { "published": [dt], "author": [gen_hcard(domain)], "uid": [&uid], "url": [&uid], "like-of": [link] } }) } } } fn check_dt_published(mf2: &serde_json::Value, item: &Item) { use microformats::types::temporal::Value as TemporalValue; assert!(item.properties.contains_key("published")); if let Some(PropertyValue::Temporal(TemporalValue::Timestamp(item))) = item.properties.get("published").and_then(|v| v.first()) { // Faithfully reconstruct the original datetime // I wonder why not just have an Enum that would // get you either date, time or a datetime, // potentially with an offset? let offset = item.as_offset().unwrap().data; let date = item.as_date().unwrap().data; let time = item.as_time().unwrap().data; let dt = date.with_time(time).assume_offset(offset); let expected = time::OffsetDateTime::parse( mf2["properties"]["published"][0].as_str().unwrap(), &time::format_description::well_known::Rfc3339 ).unwrap(); assert_eq!(dt, expected); } else { unreachable!() } } fn check_e_content(mf2: &serde_json::Value, item: &Item) { assert!(item.properties.contains_key("content")); if let Some(PropertyValue::Fragment(content)) = item.properties.get("content").and_then(|v| v.first()) { assert_eq!( content.html, mf2["properties"]["content"][0]["html"].as_str().unwrap() ); } else { unreachable!() } } #[test] fn test_note() { let mf2 = gen_random_post(&rand::random::().to_string(), PostType::Note); let html = crate::mf2::Entry { post: &mf2 }.to_string(); let url: Url = mf2 .pointer("/properties/uid/0") .and_then(|i| i.as_str()) .and_then(|u| u.parse().ok()) .unwrap(); let parsed: Document = microformats::from_html(&html, url.clone()).unwrap(); if let Some(item) = parsed.into_iter().find(|i| i.properties.get("url").unwrap().contains(&PropertyValue::Url(url.clone()))) { let props = &item.properties; check_e_content(&mf2, &item); check_dt_published(&mf2, &item); assert!(props.contains_key("uid")); assert!(props.contains_key("url")); assert!(props .get("url") .unwrap() .iter() .any(|i| i == props.get("uid").and_then(|v| v.first()).unwrap())); // XXX: fails because of https://gitlab.com/maxburon/microformats-parser/-/issues/7 assert!(!props.contains_key("name")); } else { unreachable!() } } #[test] fn test_article() { let mf2 = gen_random_post(&rand::random::().to_string(), PostType::Article); let html = crate::mf2::Entry { post: &mf2 }.to_string(); let url: Url = mf2 .pointer("/properties/uid/0") .and_then(|i| i.as_str()) .and_then(|u| u.parse().ok()) .unwrap(); let parsed: Document = microformats::from_html(&html, url.clone()).unwrap(); if let Some(item) = parsed.into_iter().find(|i| i.properties.get("url").unwrap().contains(&PropertyValue::Url(url.clone()))) { check_e_content(&mf2, &item); check_dt_published(&mf2, &item); assert!(item.properties.contains_key("uid")); assert!(item.properties.contains_key("url")); assert!(item .properties .get("url") .unwrap() .iter() .any(|i| i == item.properties.get("uid").and_then(|v| v.first()).unwrap())); assert!(item.properties.contains_key("name")); if let Some(PropertyValue::Plain(name)) = item.properties.get("name").and_then(|v| v.first()) { assert_eq!( name, mf2.pointer("/properties/name/0") .and_then(|v| v.as_str()) .unwrap() ); } else { panic!("Name wasn't a plain property!"); } } else { unreachable!() } } #[test] fn test_like_of() { for likeof in [ PostType::LikeOf(gen_random_post( &rand::random::().to_string(), PostType::Note, )), PostType::LikeOfLink(format!( "https://{}/posts/{}-{}-{}", &rand::random::(), &rand::random::(), &rand::random::(), &rand::random::(), )), ] { let mf2 = gen_random_post(&rand::random::().to_string(), likeof); let url: Url = mf2 .pointer("/properties/uid/0") .and_then(|i| i.as_str()) .and_then(|u| u.parse().ok()) .unwrap(); let html = crate::mf2::Entry { post: &mf2 }.to_string(); let parsed: Document = microformats::from_html(&html, url.clone()).unwrap(); if let Some(item) = parsed.items.first() { check_dt_published(&mf2, item); assert!(item.properties.contains_key("like-of")); match item.properties.get("like-of").and_then(|v| v.first()) { Some(PropertyValue::Url(url)) => { assert_eq!( url, &mf2.pointer("/properties/like-of/0") .and_then(|i| i.as_str()) .or_else(|| mf2 .pointer("/properties/like-of/0/properties/uid/0") .and_then(|i| i.as_str())) .and_then(|u| u.parse::().ok()) .unwrap() ); } Some(PropertyValue::Item(_cite)) => { todo!() } other => panic!("Unexpected value in like-of: {:?}", other), } } else { unreachable!() } } } }