diff --git a/examples/document.rs b/examples/document.rs new file mode 100644 index 0000000..8b096f3 --- /dev/null +++ b/examples/document.rs @@ -0,0 +1,50 @@ +use anyhow::*; +use futures::{future::BoxFuture, FutureExt}; +use log::LevelFilter; +use northstar::{Server, Request, Response, GEMINI_PORT, Document}; +use northstar::document::HeadingLevel::*; + +#[tokio::main] +async fn main() -> Result<()> { + env_logger::builder() + .filter_module("northstar", LevelFilter::Debug) + .init(); + + Server::bind(("localhost", GEMINI_PORT)) + .serve(handle_request) + .await +} + +fn handle_request(_request: Request) -> BoxFuture<'static, Result> { + async move { + let mut document = Document::new(); + + document + .add_preformatted(include_str!("northstar_logo.txt")) + .add_blank_line() + .add_link("https://docs.rs/northstar", "Documentation") + .add_link("https://github.com/panicbit/northstar", "GitHub") + .add_blank_line() + .add_heading(H1, "Usage") + .add_blank_line() + .add_text("Add the latest version of northstar to your `Cargo.toml`.") + .add_blank_line() + .add_heading(H2, "Manually") + .add_blank_line() + .add_preformatted_with_alt("alt", r#"northstar = "0.3.0" # check crates.io for the latest version"#) + .add_blank_line() + .add_heading(H2, "Automatically") + .add_blank_line() + .add_preformatted_with_alt("sh", "cargo add northstar") + .add_blank_line() + .add_heading(H1, "Generating a key & certificate") + .add_blank_line() + .add_preformatted_with_alt("sh", concat!( + "mkdir cert && cd cert\n", + "openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365", + )); + + Ok(Response::document(document)) + } + .boxed() +} diff --git a/examples/northstar_logo.txt b/examples/northstar_logo.txt new file mode 100644 index 0000000..9fe390c --- /dev/null +++ b/examples/northstar_logo.txt @@ -0,0 +1,5 @@ + __ __ __ + ____ ____ _____/ /_/ /_ _____/ /_____ ______ + / __ \/ __ \/ ___/ __/ __ \/ ___/ __/ __ `/ ___/ + / / / / /_/ / / / /_/ / / (__ ) /_/ /_/ / / +/_/ /_/\____/_/ \__/_/ /_/____/\__/\__,_/_/ \ No newline at end of file diff --git a/src/types.rs b/src/types.rs index b52d0da..b376427 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,5 +1,6 @@ pub use ::mime::Mime; pub use rustls::Certificate; +pub use uriparse::URIReference; mod meta; pub use self::meta::Meta; @@ -18,3 +19,6 @@ pub use response::Response; mod body; pub use body::Body; + +pub mod document; +pub use document::Document; diff --git a/src/types/body.rs b/src/types/body.rs index 7e697b5..dfeb8ca 100644 --- a/src/types/body.rs +++ b/src/types/body.rs @@ -1,10 +1,18 @@ use tokio::{io::AsyncRead, fs::File}; +use crate::types::Document; + pub enum Body { Bytes(Vec), Reader(Box), } +impl From for Body { + fn from(document: Document) -> Self { + Body::from(document.to_string()) + } +} + impl From> for Body { fn from(bytes: Vec) -> Self { Self::Bytes(bytes) diff --git a/src/types/document.rs b/src/types/document.rs new file mode 100644 index 0000000..e62eecb --- /dev/null +++ b/src/types/document.rs @@ -0,0 +1,332 @@ +use std::convert::TryInto; +use std::fmt; + +use itertools::Itertools; +use crate::types::URIReference; + +#[derive(Default)] +pub struct Document { + items: Vec, +} + +impl Document { + pub fn new() -> Self { + Self::default() + } + + pub fn add_item(&mut self, item: Item) -> &mut Self { + self.items.push(item); + self + } + + pub fn add_items(&mut self, items: I) -> &mut Self + where + I: IntoIterator, + { + self.items.extend(items); + self + } + + pub fn add_blank_line(&mut self) -> &mut Self { + self.add_item(Item::Text(Text::blank())) + } + + pub fn add_text(&mut self, text: &str) -> &mut Self { + let text = text + .lines() + .map(Text::new_lossy) + .map(Item::Text); + + self.add_items(text); + + self + } + + pub fn add_link<'a, U>(&mut self, uri: U, label: impl AsRef + Into) -> &mut Self + where + U: TryInto>, + { + let uri = uri + .try_into() + .map(URIReference::into_owned) + .or_else(|_| ".".try_into()).expect("Northstar BUG"); + let label = LinkLabel::from_lossy(label); + let link = Link { uri, label: Some(label) }; + let link = Item::Link(link); + + self.add_item(link); + + self + } + + pub fn add_link_without_label(&mut self, uri: URIReference<'static>) -> &mut Self { + let link = Link { + uri, + label: None, + }; + let link = Item::Link(link); + + self.add_item(link); + + self + } + + pub fn add_preformatted(&mut self, preformatted_text: &str) -> &mut Self { + self.add_preformatted_with_alt("", preformatted_text) + } + + pub fn add_preformatted_with_alt(&mut self, alt: &str, preformatted_text: &str) -> &mut Self { + let alt = AltText::new_lossy(alt); + let lines = preformatted_text + .lines() + .map(PreformattedText::new_lossy) + .collect(); + let preformatted = Preformatted { + alt, + lines, + }; + let preformatted = Item::Preformatted(preformatted); + + self.add_item(preformatted); + + self + } + + pub fn add_heading(&mut self, level: HeadingLevel, text: impl AsRef + Into) -> &mut Self { + let text = HeadingText::new_lossy(text); + let heading = Heading { + level, + text, + }; + let heading = Item::Heading(heading); + + self.add_item(heading); + + self + } + + pub fn add_unordered_list_item(&mut self, text: &str) -> &mut Self { + let item = UnorderedListItem::new_lossy(text); + let item = Item::UnorderedListItem(item); + + self.add_item(item); + + self + } + + pub fn add_quote(&mut self, text: &str) -> &mut Self { + let quote = text + .lines() + .map(Quote::new_lossy) + .map(Item::Quote); + + self.add_items(quote); + + self + } +} + +impl fmt::Display for Document { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for item in &self.items { + match item { + Item::Text(text) => writeln!(f, "{}", text.0)?, + Item::Link(link) => { + let separator = if link.label.is_some() {" "} else {""}; + let label = link.label.as_ref().map(|label| label.0.as_str()) + .unwrap_or(""); + + writeln!(f, "=>{}{}{}", link.uri, separator, label)?; + } + Item::Preformatted(preformatted) => { + writeln!(f, "```{}", preformatted.alt.0)?; + + for line in &preformatted.lines { + writeln!(f, "{}", line.0)?; + } + + writeln!(f, "```")? + } + Item::Heading(heading) => { + let level = match heading.level { + HeadingLevel::H1 => "#", + HeadingLevel::H2 => "##", + HeadingLevel::H3 => "###", + }; + + writeln!(f, "{} {}", level, heading.text.0)?; + } + Item::UnorderedListItem(item) => writeln!(f, "* {}", item.0)?, + Item::Quote(quote) => writeln!(f, "> {}", quote.0)?, + } + } + + Ok(()) + } +} + +pub enum Item { + Text(Text), + Link(Link), + Preformatted(Preformatted), + Heading(Heading), + UnorderedListItem(UnorderedListItem), + Quote(Quote), +} + +#[derive(Default)] +pub struct Text(String); + +impl Text { + pub fn blank() -> Self { + Self::default() + } + + pub fn new_lossy(line: impl AsRef + Into) -> Self { + Self(lossy_escaped_line(line, SPECIAL_STARTS)) + } +} + +pub struct Link { + pub uri: URIReference<'static>, + pub label: Option, +} + +pub struct LinkLabel(String); + +impl LinkLabel { + pub fn from_lossy(line: impl AsRef + Into) -> Self { + let line = strip_newlines(line); + + LinkLabel(line) + } +} + +pub struct Preformatted { + pub alt: AltText, + pub lines: Vec, +} + +pub struct PreformattedText(String); + +impl PreformattedText { + pub fn new_lossy(line: impl AsRef + Into) -> Self { + Self(lossy_escaped_line(line, &[PREFORMATTED_TOGGLE_START])) + } +} + +pub struct AltText(String); + +impl AltText { + pub fn new_lossy(alt: &str) -> Self { + let alt = strip_newlines(alt); + + Self(alt) + } +} + +pub struct Heading { + pub level: HeadingLevel, + pub text: HeadingText, +} + +pub enum HeadingLevel { + H1, + H2, + H3, +} + +impl Heading { + pub fn new_lossy(level: HeadingLevel, line: &str) -> Self { + Self { + level, + text: HeadingText::new_lossy(line), + } + } +} + +pub struct HeadingText(String); + +impl HeadingText { + pub fn new_lossy(line: impl AsRef + Into) -> Self { + let line = strip_newlines(line); + + Self(lossy_escaped_line(line, &[HEADING_START])) + } +} + +pub struct UnorderedListItem(String); + +impl UnorderedListItem { + pub fn new_lossy(text: &str) -> Self { + let text = strip_newlines(text); + + Self(text) + } +} + +pub struct Quote(String); + +impl Quote { + pub fn new_lossy(text: &str) -> Self { + Self(lossy_escaped_line(text, &[QUOTE_START])) + } +} + + +const LINK_START: &str = "=>"; +const PREFORMATTED_TOGGLE_START: &str = "```"; +const HEADING_START: &str = "#"; +const UNORDERED_LIST_ITEM_START: &str = "*"; +const QUOTE_START: &str = ">"; + +const SPECIAL_STARTS: &[&str] = &[ + LINK_START, + PREFORMATTED_TOGGLE_START, + HEADING_START, + UNORDERED_LIST_ITEM_START, + QUOTE_START, +]; + +fn starts_with_any(s: &str, starts: &[&str]) -> bool { + for start in starts { + if s.starts_with(start) { + return true; + } + } + + false +} + +fn lossy_escaped_line(line: impl AsRef + Into, escape_starts: &[&str]) -> String { + let line_ref = line.as_ref(); + let contains_newline = line_ref.contains('\n'); + let has_special_start = starts_with_any(line_ref, escape_starts); + + if !contains_newline && !has_special_start { + return line.into(); + } + + let mut line = String::new(); + + if has_special_start { + line.push(' '); + } + + if let Some(line_ref) = line_ref.split('\n').next() { + line.push_str(line_ref); + } + + line +} + +fn strip_newlines(text: impl AsRef + Into) -> String { + if !text.as_ref().contains(&['\r', '\n'][..]) { + return text.into(); + } + + text.as_ref() + .lines() + .filter(|part| !part.is_empty()) + .join(" ") +} diff --git a/src/types/response.rs b/src/types/response.rs index f2389a0..6a24de7 100644 --- a/src/types/response.rs +++ b/src/types/response.rs @@ -1,5 +1,6 @@ use anyhow::*; -use crate::types::{ResponseHeader, Body, Mime}; +use crate::types::{ResponseHeader, Body, Mime, Document}; +use crate::GEMINI_MIME; pub struct Response { header: ResponseHeader, @@ -14,6 +15,10 @@ impl Response { } } + pub fn document(document: Document) -> Self { + Self::success(&GEMINI_MIME).with_body(document) + } + pub fn input(prompt: impl AsRef + Into) -> Result { let header = ResponseHeader::input(prompt)?; Ok(Self::new(header)) diff --git a/src/util.rs b/src/util.rs index c1265e4..bd7a28c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,12 +1,12 @@ use std::path::Path; use mime::Mime; -use percent_encoding::utf8_percent_encode; use anyhow::*; use tokio::{ fs::{self, File}, io, }; -use crate::{GEMINI_MIME, GEMINI_MIME_STR, Response}; +use crate::GEMINI_MIME_STR; +use crate::types::{Response, Document, document::HeadingLevel::*}; use itertools::Itertools; pub async fn serve_file>(path: P, mime: &Mime) -> Result { @@ -49,8 +49,6 @@ pub async fn serve_dir, P: AsRef>(dir: D, virtual_path: &[P } async fn serve_dir_listing, B: AsRef>(path: P, virtual_path: &[B]) -> Result { - use std::fmt::Write; - let mut dir = match fs::read_dir(path).await { Ok(dir) => dir, Err(err) => match err.kind() { @@ -60,13 +58,13 @@ async fn serve_dir_listing, B: AsRef>(path: P, virtual_path }; let breadcrumbs = virtual_path.iter().map(|segment| segment.as_ref().display()).join("/"); - let mut listing = String::new(); - - writeln!(listing, "# Index of /{}", breadcrumbs)?; - writeln!(listing)?; + let mut document = Document::new(); + + document.add_heading(H1, format!("Index of /{}", breadcrumbs)); + document.add_blank_line(); if virtual_path.get(0).map(<_>::as_ref) != Some(Path::new("")) { - writeln!(listing, "=> .. 📁 ../")?; + document.add_link("..", "📁 ../"); } while let Some(entry) = dir.next_entry().await.context("Failed to list directory")? { @@ -75,18 +73,17 @@ async fn serve_dir_listing, B: AsRef>(path: P, virtual_path let is_dir = entry.file_type().await .with_context(|| format!("Failed to get file type of `{}`", entry.path().display()))? .is_dir(); + let trailing_slash = if is_dir { "/" } else { "" }; + let uri = format!("./{}{}", file_name, trailing_slash); - writeln!( - listing, - "=> {link}{trailing_slash} {icon} {name}{trailing_slash}", + document.add_link(uri.as_str(), format!("{icon} {name}{trailing_slash}", icon = if is_dir { '📁' } else { '📄' }, - link = utf8_percent_encode(&file_name, percent_encoding::NON_ALPHANUMERIC), - trailing_slash = if is_dir { "/" } else { "" }, name = file_name, - )?; + trailing_slash = trailing_slash + )); } - Ok(Response::success(&GEMINI_MIME).with_body(listing)) + Ok(Response::document(document)) } pub fn guess_mime_from_path>(path: P) -> Mime {