From f00159f53b3774601500ec65345791311ff6efa1 Mon Sep 17 00:00:00 2001 From: cel 🌸 Date: Tue, 30 Jan 2024 16:16:05 +0000 Subject: migrate to poem and askama --- src/atom.rs | 142 +++++++++++++++++++++++++++++ src/blog.rs | 186 ++++++++++++++++++++++++++++++++++++++ src/error.rs | 88 +++++++++++++----- src/main.rs | 212 ++++++++++++++++++++----------------------- src/posts.rs | 62 +++++++++++++ src/posts/article.rs | 0 src/posts/mod.rs | 228 ----------------------------------------------- src/posts/syndication.rs | 96 -------------------- src/templates.rs | 55 ++++++++++++ src/utils.rs | 3 + 10 files changed, 613 insertions(+), 459 deletions(-) create mode 100644 src/atom.rs create mode 100644 src/blog.rs create mode 100644 src/posts.rs delete mode 100644 src/posts/article.rs delete mode 100644 src/posts/mod.rs delete mode 100644 src/posts/syndication.rs create mode 100644 src/templates.rs create mode 100644 src/utils.rs (limited to 'src') diff --git a/src/atom.rs b/src/atom.rs new file mode 100644 index 0000000..4410edb --- /dev/null +++ b/src/atom.rs @@ -0,0 +1,142 @@ +use atom_syndication::{ + Category, Content as AtomContent, Entry as AtomEntry, Feed, Generator, Link, Person, Text, + TextType, +}; +use chrono::{DateTime, Utc}; + +use crate::posts::Post; + +pub struct Context { + /// the page for which the feed is being generated for + pub page_title: String, + /// the human-readable page the feed is for + pub page_url: String, + /// the url to the atom xml document + pub self_url: String, + /// current site language + pub lang: String, +} + +pub async fn atom(ctx: Context, entries: Vec

) -> Feed { + let mut authors = Vec::new(); + let me = Person { + name: "cel".to_owned(), + email: Some("cel@blos.sm".to_owned()), + uri: Some("/contact".to_owned()), + }; + authors.push(me.clone()); + let page_link = Link { + href: ctx.page_url.clone(), + rel: "alternate".into(), + hreflang: Some(ctx.lang.clone()), + mime_type: Some("text/html".into()), + title: Some(ctx.page_title.clone()), + length: None, + }; + let home_link = Link { + // TODO?: localisation + href: "/".into(), + rel: "via".into(), + hreflang: Some(ctx.lang.clone()), + mime_type: Some("text/html".into()), + // TODO: localisation + title: Some("cel's garden".into()), + length: None, + }; + let self_link = Link { + href: ctx.self_url.into(), + rel: "self".into(), + hreflang: Some(ctx.lang.clone()), + mime_type: Some("application/atom+xml".into()), + // TODO: localisation + title: Some("atom feed".into()), + length: None, + }; + let mut links = Vec::new(); + links.push(page_link); + links.push(home_link); + links.push(self_link); + let mut feed = Feed { + title: Text { + value: ctx.page_title, + base: None, + lang: Some(ctx.lang.clone()), + r#type: TextType::Text, + }, + id: ctx.page_url, + updated: entries + .clone() + .into_iter() + .fold(DateTime::::MIN_UTC, |acc, entry| { + let updated_at = entry.updated_at().to_owned(); + if updated_at > acc { + updated_at + } else { + acc + } + }) + .into(), + authors: authors.clone(), + categories: Vec::new(), + contributors: authors.clone(), + generator: Some(Generator { + value: "blossom".into(), + uri: Some("https://bunny.garden/cel/blossom".into()), + version: None, + }), + icon: Some("/favicon.ico".into()), + links: links.clone(), + logo: Some("/logo.png".into()), + entries: Vec::new(), + // TODO: determine base url from lang + base: Some("https://en.blos.sm/".into()), + lang: Some(ctx.lang), + ..Default::default() + }; + for entry in entries { + // TODO: localisation: url from lang + filtering entries translated or not + let categories = entry + .tags() + .into_iter() + .map(|category| Category { + term: category.clone(), + scheme: None, + label: Some(category.clone()), + }) + .collect(); + let published = Some(entry.published_at().to_owned().into()); + let title = if let Some(title) = entry.subject() { + title.to_owned() + } else { + entry.content()[..30].to_owned() + "..." + }; + let entry = AtomEntry { + title: Text { + value: title, + base: None, + lang: Some(entry.lang().to_owned()), + r#type: TextType::Text, + }, + id: entry.link(), + updated: entry.updated_at().to_owned().into(), + authors: authors.clone(), + categories, + contributors: authors.clone(), + links: links.clone(), + published, + rights: None, + source: None, + summary: None, + content: Some(AtomContent { + base: None, + lang: Some(entry.lang().to_owned()), + value: Some(entry.content().to_owned()), + src: Some(entry.link()), + content_type: Some("html".to_string()), + }), + ..Default::default() + }; + feed.entries.push(entry); + } + feed +} diff --git a/src/blog.rs b/src/blog.rs new file mode 100644 index 0000000..22cee05 --- /dev/null +++ b/src/blog.rs @@ -0,0 +1,186 @@ +use std::path::Path; + +use markdown::{mdast::Node, CompileOptions, Constructs, Options, ParseOptions}; +use serde::Deserialize; +use tokio::{fs, io::AsyncReadExt}; + +use chrono::{DateTime, Utc}; + +use crate::{ + error::BlossomError, + posts::{Post, PostType}, + Result, +}; + +static ARTICLES_DIR: &str = "./articles"; + +#[derive(Clone)] +pub struct Blogpost { + file_name: String, + title: String, + published_at: DateTime, + updated_at: Option>, + tags: Vec, + content: String, +} + +#[derive(Deserialize)] +pub struct BlogpostMetadata { + title: String, + published_at: String, + updated_at: Option, + tags: Vec, +} + +impl Post for Blogpost { + fn id(&self) -> &str { + &self.file_name + } + + fn subject(&self) -> Option<&str> { + Some(&self.title) + } + + fn published_at(&self) -> &DateTime { + &self.published_at + } + + fn updated_at(&self) -> &DateTime { + if let Some(updated_at) = &self.updated_at { + updated_at + } else { + &self.published_at + } + } + + fn tags(&self) -> &Vec { + &self.tags + } + + fn lang(&self) -> &str { + "en" + } + + fn post_type(&self) -> PostType { + PostType::Article + } + + fn content(&self) -> &str { + &self.content + } +} + +impl Blogpost { + pub fn file_name(&self) -> &str { + &self.file_name + } + + async fn from_path(path: impl AsRef) -> Result { + let mut file = fs::File::open(&path).await?; + let mut buf = String::new(); + file.read_to_string(&mut buf).await?; + let parse_options = ParseOptions { + constructs: Constructs { + frontmatter: true, + ..Constructs::gfm() + }, + ..ParseOptions::default() + }; + let metadata: BlogpostMetadata = markdown::to_mdast(&buf, &parse_options) + .unwrap() + .try_into()?; + let file_name = path + .as_ref() + .file_name() + .ok_or(BlossomError::NotAFile)? + .to_string_lossy(); + let file_name = file_name[..file_name.len() - 3].to_owned(); + let updated_at = if let Some(updated_at) = metadata.updated_at { + Some(updated_at.parse::>()?) + } else { + None + }; + // TODO: cache render only when needed + let options = Options { + parse: parse_options, + compile: CompileOptions { + gfm_task_list_item_checkable: true, + allow_dangerous_html: true, + ..CompileOptions::default() + }, + }; + let content = markdown::to_html_with_options(&buf, &options).unwrap(); + Ok(Self { + file_name, + title: metadata.title, + published_at: metadata.published_at.parse::>()?, + updated_at, + tags: metadata.tags, + content, + }) + } + + // async fn render(&mut self) -> Result { + // let path = Path::new(ARTICLES_DIR) + // .join(&self.file_name) + // .with_extension("md"); + // // TODO: remove unwraps when file read failure + // let mut file = fs::File::open(&path).await.unwrap(); + // let mut buf = String::new(); + // file.read_to_string(&mut buf).await.unwrap(); + // let parse_options = ParseOptions { + // constructs: Constructs { + // frontmatter: true, + // ..Constructs::gfm() + // }, + // ..ParseOptions::default() + // }; + // let options = Options { + // parse: parse_options, + // compile: CompileOptions { + // gfm_task_list_item_checkable: true, + // allow_dangerous_html: true, + // ..CompileOptions::default() + // }, + // }; + // let content = markdown::to_html_with_options(&buf, &options).unwrap(); + // Ok(content) + // } +} + +impl TryFrom for BlogpostMetadata { + type Error = BlossomError; + + fn try_from(tree: Node) -> std::prelude::v1::Result { + let children = tree.children(); + if let Some(children) = children { + if let Some(toml) = children.into_iter().find_map(|el| match el { + Node::Toml(toml) => Some(&toml.value), + _ => None, + }) { + return Ok(toml::from_str(toml)?); + }; + } + Err(BlossomError::NoMetadata) + } +} + +pub async fn get_blogposts() -> Result> { + let mut blogposts: Vec = Vec::new(); + let mut articles_dir = fs::read_dir(ARTICLES_DIR).await?; + while let Some(entry) = articles_dir.next_entry().await? { + let blogpost = Blogpost::from_path(entry.path()).await?; + blogposts.push(blogpost); + } + blogposts.sort_by_key(|post| post.published_at); + blogposts.reverse(); + Ok(blogposts) +} + +pub async fn get_blogpost(file_name: &str) -> Result { + let path = Path::new(ARTICLES_DIR) + .join(Path::new(file_name)) + .with_extension("md"); + println!("{:?}", path); + Blogpost::from_path(path).await +} diff --git a/src/error.rs b/src/error.rs index 42c01b1..611ce79 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,66 +1,112 @@ use std::string::FromUtf8Error; -use rocket::{http::Status, Responder}; +use poem::{error::ResponseError, http::StatusCode, IntoResponse}; +use thiserror::Error; -#[derive(Responder, Debug)] +use crate::templates; + +#[derive(Error, Debug)] pub enum BlossomError { - Reqwest(Status, #[response(ignore)] reqwest::Error), - ListenBrainz(Status, #[response(ignore)] listenbrainz::Error), - Skinnyverse(Status, #[response(ignore)] mastodon_async::Error), - Chrono(Status, #[response(ignore)] chrono::ParseError), - Io(Status, #[response(ignore)] std::io::Error), - Deserialization(Status, #[response(ignore)] toml::de::Error), - Syndicator(Status, #[response(ignore)] atom_syndication::Error), - Utf8Conversion(Status, #[response(ignore)] FromUtf8Error), - NotFound(Status), - NoMetadata(Status), - Unimplemented(Status), + #[error("http client error")] + Reqwest(reqwest::Error), + #[error("listenbrainz error")] + ListenBrainz(listenbrainz::Error), + #[error("mastadon error")] + Skinnyverse(mastodon_async::Error), + #[error("failed to parse timestamp")] + Chrono(chrono::ParseError), + #[error("io error")] + Io(std::io::Error), + #[error("toml deserialization error")] + Deserialization(toml::de::Error), + #[error("atom syndication error")] + Syndicator(atom_syndication::Error), + #[error("utf8 conversion error")] + Utf8Conversion(FromUtf8Error), + #[error("not found")] + NotFound, + #[error("no metadata")] + NoMetadata, + #[error("unimplemented")] + Unimplemented, + #[error("not a file")] + NotAFile, +} + +impl ResponseError for BlossomError { + fn status(&self) -> poem::http::StatusCode { + match self { + BlossomError::Reqwest(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::ListenBrainz(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Skinnyverse(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Chrono(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Deserialization(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Syndicator(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Utf8Conversion(_) => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::NotFound => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::NoMetadata => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::Unimplemented => StatusCode::INTERNAL_SERVER_ERROR, + BlossomError::NotAFile => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn as_response(&self) -> poem::Response + where + Self: std::error::Error + Send + Sync + 'static, + { + templates::Error { + status: self.status(), + message: self.to_string(), + } + .into_response() + } } impl From for BlossomError { fn from(e: reqwest::Error) -> Self { - BlossomError::Reqwest(Status::new(500), e) + BlossomError::Reqwest(e) } } impl From for BlossomError { fn from(e: listenbrainz::Error) -> Self { - BlossomError::ListenBrainz(Status::new(500), e) + BlossomError::ListenBrainz(e) } } impl From for BlossomError { fn from(e: mastodon_async::Error) -> Self { - BlossomError::Skinnyverse(Status::new(500), e) + BlossomError::Skinnyverse(e) } } impl From for BlossomError { fn from(e: chrono::ParseError) -> Self { - BlossomError::Chrono(Status::new(500), e) + BlossomError::Chrono(e) } } impl From for BlossomError { fn from(e: std::io::Error) -> Self { - BlossomError::Io(Status::new(500), e) + BlossomError::Io(e) } } impl From for BlossomError { fn from(e: toml::de::Error) -> Self { - BlossomError::Deserialization(Status::new(500), e) + BlossomError::Deserialization(e) } } impl From for BlossomError { fn from(e: atom_syndication::Error) -> Self { - BlossomError::Syndicator(Status::new(500), e) + BlossomError::Syndicator(e) } } impl From for BlossomError { fn from(e: FromUtf8Error) -> Self { - BlossomError::Utf8Conversion(Status::new(500), e) + BlossomError::Utf8Conversion(e) } } diff --git a/src/main.rs b/src/main.rs index ed768b4..c38b2eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,155 +1,139 @@ +mod atom; +mod blog; mod error; mod live; mod posts; mod scrobbles; mod skweets; - -use std::borrow::Cow; -use std::collections::HashSet; -use std::time::Duration; - -use atom_syndication::Feed; -use rocket::fs::{relative, FileServer}; -use rocket::http::{ContentType, Status}; -use rocket::{Request, State}; -use rocket_dyn_templates::{context, Template}; +mod templates; +mod utils; + +use std::rc::Rc; +use std::{collections::HashSet, time::Duration}; + +use poem::http::StatusCode; +use poem::Response; +use poem::{ + endpoint::EmbeddedFilesEndpoint, + get, handler, + listener::TcpListener, + middleware::AddData, + web::{Data, Path, Query}, + EndpointExt, Route, Server, +}; +use rust_embed::RustEmbed; use error::BlossomError; +use serde::Deserialize; type Result = std::result::Result; -struct Clients { - listenbrainz: listenbrainz::raw::Client, - skinnyverse: mastodon_async::Mastodon, - reqwest: reqwest::Client, -} - -#[macro_use] -extern crate rocket; +#[derive(RustEmbed)] +#[folder = "static/"] +struct Static; -#[get("/")] -async fn home(clients: &State) -> Template { +#[handler] +async fn home(Data(reqwest): Data<&reqwest::Client>) -> templates::Home { + let listenbrainz_client = listenbrainz::raw::Client::new(); let (live, listenbrainz, blogposts) = tokio::join!( - live::get_live_status(&clients.reqwest), - scrobbles::get_now_playing(&clients.listenbrainz), + live::get_live_status(reqwest), + scrobbles::get_now_playing(&listenbrainz_client), // skweets::get_recents(&clients.skinnyverse), - posts::get_blogposts() + blog::get_blogposts() ); let is_live = live.unwrap_or_default().online; let listenbrainz = listenbrainz.unwrap_or_default(); let blogposts = blogposts.unwrap_or_default(); - Template::render( - "home", - context! { - is_live, - listenbrainz, - // skweets, - blogposts, - }, - ) + templates::Home { + is_live, + listenbrainz, + blogposts, + } +} + +// #[get("/blog/")] +#[handler] +async fn blogpost(Path(blogpost): Path) -> Result { + let blogpost = blog::get_blogpost(&blogpost).await?; + Ok(templates::Blogpost { + blogpost, + filter_tags: HashSet::new(), + }) } -#[get("/blog/")] -async fn blogpost(blogpost: &str) -> Result