diff --git a/CHANGELOG.md b/CHANGELOG.md index bf5f82e..daade96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `document` API for creating Gemini documents ## [0.3.0] - 2020-11-14 ### Added diff --git a/examples/document.rs b/examples/document.rs new file mode 100644 index 0000000..5986896 --- /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("toml", 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..f6fc5aa --- /dev/null +++ b/src/types/document.rs @@ -0,0 +1,554 @@ +//! Provides types for creating Gemini Documents. +//! +//! The module is centered around the `Document` type, +//! which provides all the necessary methods for programatically +//! creation of Gemini documents. +//! +//! # Examples +//! +//! ``` +//! use northstar::document::HeadingLevel::*; +//! +//! let mut document = northstar::Document::new(); +//! +//! document.add_heading(H1, "Heading 1"); +//! document.add_heading(H2, "Heading 2"); +//! document.add_heading(H3, "Heading 3"); +//! document.add_blank_line(); +//! document.add_text("text"); +//! document.add_link("gemini://gemini.circumlunar.space", "Project Gemini"); +//! document.add_unordered_list_item("list item"); +//! document.add_quote("quote"); +//! document.add_preformatted("preformatted"); +//! +//! assert_eq!(document.to_string(), "\ +//! ## Heading 1\n\ +//! ### Heading 2\n\ +//! #### Heading 3\n\ +//! \n\ +//! text\n\ +//! => gemini://gemini.circumlunar.space/ Project Gemini\n\ +//! * list item\n\ +//! > quote\n\ +//! ```\n\ +//! preformatted\n\ +//! ```\n\ +//! "); +//! ``` +#![warn(missing_docs)] +use std::convert::TryInto; +use std::fmt; + +use itertools::Itertools; +use crate::types::URIReference; +use crate::util::Cowy; + +#[derive(Default)] +/// Represents a Gemini document. +/// +/// Provides convenient methods for programatically +/// creation of Gemini documents. +pub struct Document { + items: Vec, +} + +impl Document { + /// Creates an empty Gemini `Document`. + /// + /// # Examples + /// + /// ``` + /// let document = northstar::Document::new(); + /// + /// assert_eq!(document.to_string(), ""); + /// ``` + pub fn new() -> Self { + Self::default() + } + + /// Adds an `item` to the document. + /// + /// An `item` usually corresponds to a single line, + /// except in the case of preformatted text. + /// + /// # Examples + /// + /// ```compile_fail + /// use northstar::document::{Document, Item, Text}; + /// + /// let mut document = Document::new(); + /// let text = Text::new_lossy("foo"); + /// let item = Item::Text(text); + /// + /// document.add_item(item); + /// + /// assert_eq!(document.to_string(), "foo\n"); + /// ``` + fn add_item(&mut self, item: Item) -> &mut Self { + self.items.push(item); + self + } + + /// Adds multiple `items` to the document. + /// + /// This is a convenience wrapper around `add_item`. + /// + /// # Examples + /// + /// ```compile_fail + /// use northstar::document::{Document, Item, Text}; + /// + /// let mut document = Document::new(); + /// let items = vec!["foo", "bar", "baz"] + /// .into_iter() + /// .map(Text::new_lossy) + /// .map(Item::Text); + /// + /// document.add_items(items); + /// + /// assert_eq!(document.to_string(), "foo\nbar\nbaz\n"); + /// ``` + fn add_items(&mut self, items: I) -> &mut Self + where + I: IntoIterator, + { + self.items.extend(items); + self + } + + /// Adds a blank line to the document. + /// + /// # Examples + /// + /// ``` + /// let mut document = northstar::Document::new(); + /// + /// document.add_blank_line(); + /// + /// assert_eq!(document.to_string(), "\n"); + /// ``` + pub fn add_blank_line(&mut self) -> &mut Self { + self.add_item(Item::Text(Text::blank())) + } + + /// Adds plain text to the document. + /// + /// This function allows adding multiple lines at once. + /// + /// It inserts a whitespace at the beginning of a line + /// if it starts with a character sequence that + /// would make it a non-plain text line (e.g. link, heading etc). + /// + /// # Examples + /// + /// ``` + /// let mut document = northstar::Document::new(); + /// + /// document.add_text("hello\n* world!"); + /// + /// assert_eq!(document.to_string(), "hello\n * world!\n"); + /// ``` + 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 + } + + /// Adds a link to the document. + /// + /// `uri`s that fail to parse are substituted with `.`. + /// + /// Consecutive newlines in `label` will be replaced + /// with a single whitespace. + /// + /// # Examples + /// + /// ``` + /// let mut document = northstar::Document::new(); + /// + /// document.add_link("https://wikipedia.org", "Wiki\n\nWiki"); + /// + /// assert_eq!(document.to_string(), "=> https://wikipedia.org/ Wiki Wiki\n"); + /// ``` + pub fn add_link<'a, U>(&mut self, uri: U, label: impl Cowy) -> &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 + } + + /// Adds a link to the document, but without a label. + /// + /// See `add_link` for details. + /// + /// # Examples + /// + /// ``` + /// let mut document = northstar::Document::new(); + /// + /// document.add_link_without_label("https://wikipedia.org"); + /// + /// assert_eq!(document.to_string(), "=> https://wikipedia.org/\n"); + /// ``` + pub fn add_link_without_label<'a, U>(&mut self, uri: U) -> &mut Self + where + U: TryInto>, + { + let uri = uri + .try_into() + .map(URIReference::into_owned) + .or_else(|_| ".".try_into()).expect("Northstar BUG"); + let link = Link { + uri, + label: None, + }; + let link = Item::Link(link); + + self.add_item(link); + + self + } + + /// Adds a block of preformatted text. + /// + /// Lines that start with ` ``` ` will be prependend with a whitespace. + /// + /// # Examples + /// + /// ``` + /// let mut document = northstar::Document::new(); + /// + /// document.add_preformatted("a\n b\n c"); + /// + /// assert_eq!(document.to_string(), "```\na\n b\n c\n```\n"); + /// ``` + pub fn add_preformatted(&mut self, preformatted_text: &str) -> &mut Self { + self.add_preformatted_with_alt("", preformatted_text) + } + + /// Adds a block of preformatted text with an alt text. + /// + /// Consecutive newlines in `alt` will be replaced + /// with a single whitespace. + /// + /// `preformatted_text` lines that start with ` ``` ` + /// will be prependend with a whitespace. + /// + /// # Examples + /// + /// ``` + /// let mut document = northstar::Document::new(); + /// + /// document.add_preformatted_with_alt("rust", "fn main() {\n}\n"); + /// + /// assert_eq!(document.to_string(), "```rust\nfn main() {\n}\n```\n"); + /// ``` + 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 + } + + /// Adds a heading. + /// + /// Consecutive newlines in `text` will be replaced + /// with a single whitespace. + /// + /// # Examples + /// + /// ``` + /// use northstar::document::HeadingLevel::H1; + /// + /// let mut document = northstar::Document::new(); + /// + /// document.add_heading(H1, "Welcome!"); + /// + /// assert_eq!(document.to_string(), "# Welcome!\n"); + /// ``` + pub fn add_heading(&mut self, level: HeadingLevel, text: impl Cowy) -> &mut Self { + let text = HeadingText::new_lossy(text); + let heading = Heading { + level, + text, + }; + let heading = Item::Heading(heading); + + self.add_item(heading); + + self + } + + /// Adds an unordered list item. + /// + /// Consecutive newlines in `text` will be replaced + /// with a single whitespace. + /// + /// # Examples + /// + /// ``` + /// let mut document = northstar::Document::new(); + /// + /// document.add_unordered_list_item("milk"); + /// document.add_unordered_list_item("eggs"); + /// + /// assert_eq!(document.to_string(), "* milk\n* eggs\n"); + /// ``` + 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 + } + + /// Adds a quote. + /// + /// This function allows adding multiple quote lines at once. + /// + /// # Examples + /// + /// ``` + /// let mut document = northstar::Document::new(); + /// + /// document.add_quote("I think,\ntherefore I am"); + /// + /// assert_eq!(document.to_string(), "> I think,\n> therefore I am\n"); + /// ``` + 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(()) + } +} + +enum Item { + Text(Text), + Link(Link), + Preformatted(Preformatted), + Heading(Heading), + UnorderedListItem(UnorderedListItem), + Quote(Quote), +} + +#[derive(Default)] +struct Text(String); + +impl Text { + fn blank() -> Self { + Self::default() + } + + fn new_lossy(line: impl Cowy) -> Self { + Self(lossy_escaped_line(line, SPECIAL_STARTS)) + } +} + +struct Link { + uri: URIReference<'static>, + label: Option, +} + +struct LinkLabel(String); + +impl LinkLabel { + fn from_lossy(line: impl Cowy) -> Self { + let line = strip_newlines(line); + + LinkLabel(line) + } +} + +struct Preformatted { + alt: AltText, + lines: Vec, +} + +struct PreformattedText(String); + +impl PreformattedText { + fn new_lossy(line: impl Cowy) -> Self { + Self(lossy_escaped_line(line, &[PREFORMATTED_TOGGLE_START])) + } +} + +struct AltText(String); + +impl AltText { + fn new_lossy(alt: &str) -> Self { + let alt = strip_newlines(alt); + + Self(alt) + } +} + +struct Heading { + level: HeadingLevel, + text: HeadingText, +} + +/// The level of a heading. +pub enum HeadingLevel { + /// Heading level 1 (`#`) + H1, + /// Heading level 2 (`##`) + H2, + /// Heading level 3 (`###`) + H3, +} + +struct HeadingText(String); + +impl HeadingText { + fn new_lossy(line: impl Cowy) -> Self { + let line = strip_newlines(line); + + Self(line) + } +} + +struct UnorderedListItem(String); + +impl UnorderedListItem { + fn new_lossy(text: &str) -> Self { + let text = strip_newlines(text); + + Self(text) + } +} + +struct Quote(String); + +impl Quote { + 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 Cowy, 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 Cowy) -> 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/meta.rs b/src/types/meta.rs index 144fc13..ccc17ba 100644 --- a/src/types/meta.rs +++ b/src/types/meta.rs @@ -1,5 +1,7 @@ use anyhow::*; -use mime::Mime; +use crate::Mime; +use crate::util::Cowy; + #[derive(Debug,Clone,PartialEq,Eq,Default)] pub struct Meta(String); @@ -9,7 +11,7 @@ impl Meta { /// Creates a new "Meta" string. /// Fails if `meta` contains `\n`. - pub fn new(meta: impl AsRef + Into) -> Result { + pub fn new(meta: impl Cowy) -> Result { ensure!(!meta.as_ref().contains("\n"), "Meta must not contain newlines"); ensure!(meta.as_ref().len() <= Self::MAX_LEN, "Meta must not exceed {} bytes", Self::MAX_LEN); @@ -20,7 +22,7 @@ impl Meta { /// Truncates `meta` to before: /// - the first occurrence of `\n` /// - the character that makes `meta` exceed `Meta::MAX_LEN` - pub fn new_lossy(meta: impl AsRef + Into) -> Self { + pub fn new_lossy(meta: impl Cowy) -> Self { let meta = meta.as_ref(); let truncate_pos = meta.char_indices().position(|(i, ch)| { let is_newline = ch == '\n'; diff --git a/src/types/response.rs b/src/types/response.rs index f2389a0..a76d6a1 100644 --- a/src/types/response.rs +++ b/src/types/response.rs @@ -1,5 +1,7 @@ use anyhow::*; -use crate::types::{ResponseHeader, Body, Mime}; +use crate::types::{ResponseHeader, Body, Mime, Document}; +use crate::util::Cowy; +use crate::GEMINI_MIME; pub struct Response { header: ResponseHeader, @@ -14,12 +16,16 @@ impl Response { } } - pub fn input(prompt: impl AsRef + Into) -> Result { + pub fn document(document: Document) -> Self { + Self::success(&GEMINI_MIME).with_body(document) + } + + pub fn input(prompt: impl Cowy) -> Result { let header = ResponseHeader::input(prompt)?; Ok(Self::new(header)) } - pub fn input_lossy(prompt: impl AsRef + Into) -> Self { + pub fn input_lossy(prompt: impl Cowy) -> Self { let header = ResponseHeader::input_lossy(prompt); Self::new(header) } @@ -29,7 +35,7 @@ impl Response { Self::new(header) } - pub fn server_error(reason: impl AsRef + Into) -> Result { + pub fn server_error(reason: impl Cowy) -> Result { let header = ResponseHeader::server_error(reason)?; Ok(Self::new(header)) } diff --git a/src/types/response_header.rs b/src/types/response_header.rs index 8307707..824401e 100644 --- a/src/types/response_header.rs +++ b/src/types/response_header.rs @@ -1,5 +1,6 @@ use anyhow::*; -use mime::Mime; +use crate::Mime; +use crate::util::Cowy; use crate::types::{Status, Meta}; #[derive(Debug,Clone)] @@ -9,14 +10,14 @@ pub struct ResponseHeader { } impl ResponseHeader { - pub fn input(prompt: impl AsRef + Into) -> Result { + pub fn input(prompt: impl Cowy) -> Result { Ok(Self { status: Status::INPUT, meta: Meta::new(prompt).context("Invalid input prompt")?, }) } - pub fn input_lossy(prompt: impl AsRef + Into) -> Self { + pub fn input_lossy(prompt: impl Cowy) -> Self { Self { status: Status::INPUT, meta: Meta::new_lossy(prompt), @@ -30,14 +31,14 @@ impl ResponseHeader { } } - pub fn server_error(reason: impl AsRef + Into) -> Result { + pub fn server_error(reason: impl Cowy) -> Result { Ok(Self { status: Status::PERMANENT_FAILURE, meta: Meta::new(reason).context("Invalid server error reason")?, }) } - pub fn server_error_lossy(reason: impl AsRef + Into) -> Self { + pub fn server_error_lossy(reason: impl Cowy) -> Self { Self { status: Status::PERMANENT_FAILURE, meta: Meta::new_lossy(reason), diff --git a/src/util.rs b/src/util.rs index c1265e4..8af22d9 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 { @@ -102,6 +99,22 @@ pub fn guess_mime_from_path>(path: P) -> Mime { }, None => "application/octet-stream", }; - + mime.parse::().unwrap_or(mime::APPLICATION_OCTET_STREAM) } + +/// A convenience trait alias for `AsRef + Into`, +/// most commonly used to accept `&str` or `String`: +/// +/// `Cowy` ⇔ `AsRef + Into` +pub trait Cowy +where + Self: AsRef + Into, + T: ToOwned + ?Sized, +{} + +impl Cowy for C +where + C: AsRef + Into, + T: ToOwned + ?Sized, +{}