diff --git a/CHANGELOG.md b/CHANGELOG.md index bf5f82e..47c5d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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 +- preliminary timeout API, incl a special case for complex MIMEs by [@Alch-Emi](https://github.com/Alch-Emi) +- `Response::success_with_body` by [@Alch-Emi](https://github.com/Alch-Emi) +- `redirect_temporary_lossy` for `Response` and `ResponseHeader` +- `bad_request_lossy` for `Response` and `ResponseHeader` +- support for a lot more mime-types in `guess_mime_from_path`, backed by the `mime_guess` crate +- customizable TLS cert & key paths by [@Alch-Emi](https://github.com/Alch-Emi) +- `server_dir` default feature for serve_dir utils [@Alch-Emi](https://github.com/Alch-Emi) +### Improved +- build time and size by [@Alch-Emi](https://github.com/Alch-Emi) ## [0.3.0] - 2020-11-14 ### Added @@ -23,4 +34,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.2.0] - 2020-11-14 ### Added -- Access to client certificates by [@Alch-Emi](https://github.com/Alch-Emi) \ No newline at end of file +- Access to client certificates by [@Alch-Emi](https://github.com/Alch-Emi) diff --git a/Cargo.toml b/Cargo.toml index 5a1d4d0..e38e3bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,29 +10,33 @@ documentation = "https://docs.rs/northstar" [features] user_management = ["sled", "bincode", "serde/derive", "bcrypt", "crc32fast"] +default = ["serve_dir"] +serve_dir = ["mime_guess", "tokio/fs"] [dependencies] anyhow = "1.0.33" rustls = { version = "0.18.1", features = ["dangerous_configuration"] } tokio-rustls = "0.20.0" -tokio = { version = "0.3.1", features = ["full"] } +tokio = { version = "0.3.1", features = ["io-util","net","time", "rt"] } mime = "0.3.16" uriparse = "0.6.3" percent-encoding = "2.1.0" -futures = "0.3.7" -itertools = "0.9.0" +futures-core = "0.3.7" log = "0.4.11" webpki = "0.21.0" lazy_static = "1.4.0" +mime_guess = { version = "2.0.3", optional = true } sled = { version = "0.34.6", optional = true } bincode = { version = "1.3.1", optional = true } serde = { version = "1.0", optional = true } bcrypt = { version = "0.9", optional = true } crc32fast = { version = "1.2.1", optional = true } -[dev-dependencies] -env_logger = "0.8.1" - [[example]] name = "user_management" required-features = ["user_management"] + +[dev-dependencies] +env_logger = "0.8.1" +futures-util = "0.3.7" +tokio = { version = "0.3.1", features = ["macros", "rt-multi-thread", "sync"] } diff --git a/examples/certificates.rs b/examples/certificates.rs index 2da1e68..541fbe5 100644 --- a/examples/certificates.rs +++ b/examples/certificates.rs @@ -1,5 +1,6 @@ use anyhow::*; -use futures::{future::BoxFuture, FutureExt}; +use futures_core::future::BoxFuture; +use futures_util::FutureExt; use log::LevelFilter; use tokio::sync::RwLock; use northstar::{Certificate, GEMINI_MIME, GEMINI_PORT, Request, Response, Server}; @@ -37,8 +38,10 @@ fn handle_request(users: Arc>>, request: Reque if let Some(user) = users_read.get(cert_bytes) { // The user has already registered Ok( - Response::success(&GEMINI_MIME) - .with_body(format!("Welcome {}!", user)) + Response::success_with_body( + &GEMINI_MIME, + format!("Welcome {}!", user) + ) ) } else { // The user still needs to register @@ -49,11 +52,13 @@ fn handle_request(users: Arc>>, request: Reque let mut users_write = users.write().await; users_write.insert(cert_bytes.clone(), username.to_owned()); Ok( - Response::success(&GEMINI_MIME) - .with_body(format!( + Response::success_with_body( + &GEMINI_MIME, + format!( "Your account has been created {}! Welcome!", username - )) + ) + ) ) } else { // The user didn't provide input, and should be prompted diff --git a/examples/document.rs b/examples/document.rs new file mode 100644 index 0000000..8ff6bbb --- /dev/null +++ b/examples/document.rs @@ -0,0 +1,51 @@ +use anyhow::*; +use futures_core::future::BoxFuture; +use futures_util::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/examples/serve_dir.rs b/examples/serve_dir.rs index 47145e1..fd26ac4 100644 --- a/examples/serve_dir.rs +++ b/examples/serve_dir.rs @@ -1,5 +1,6 @@ use anyhow::*; -use futures::{future::BoxFuture, FutureExt}; +use futures_core::future::BoxFuture; +use futures_util::FutureExt; use log::LevelFilter; use northstar::{Server, Request, Response, GEMINI_PORT}; diff --git a/src/lib.rs b/src/lib.rs index 41f4f75..84a4cbf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,27 @@ #[macro_use] extern crate log; -use std::{panic::AssertUnwindSafe, convert::TryFrom, io::BufReader, sync::Arc, path::PathBuf}; -use futures::{future::BoxFuture, FutureExt}; +use std::{ + panic::AssertUnwindSafe, + convert::TryFrom, + io::BufReader, + sync::Arc, + path::PathBuf, + time::Duration, +}; +use futures_core::future::BoxFuture; use tokio::{ prelude::*, io::{self, BufStream}, net::{TcpStream, ToSocketAddrs}, + time::timeout, }; use tokio::net::TcpListener; use rustls::ClientCertVerifier; use tokio_rustls::{rustls, TlsAcceptor}; use rustls::*; use anyhow::*; -use uri::URIReference; use lazy_static::lazy_static; +use crate::util::opt_timeout; pub mod types; pub mod util; @@ -31,13 +39,15 @@ pub const REQUEST_URI_MAX_LEN: usize = 1024; pub const GEMINI_PORT: u16 = 1965; type Handler = Arc HandlerResponse + Send + Sync>; -type HandlerResponse = BoxFuture<'static, Result>; +pub (crate) type HandlerResponse = BoxFuture<'static, Result>; #[derive(Clone)] pub struct Server { tls_acceptor: TlsAcceptor, listener: Arc, handler: Handler, + timeout: Duration, + complex_timeout: Option, #[cfg(feature="user_management")] manager: UserManager, } @@ -62,16 +72,25 @@ impl Server { } async fn serve_client(self, stream: TcpStream) -> Result<()> { - let stream = self.tls_acceptor.accept(stream).await - .context("Failed to establish TLS session")?; - let mut stream = BufStream::new(stream); + let fut_accept_request = async { + let stream = self.tls_acceptor.accept(stream).await + .context("Failed to establish TLS session")?; + let mut stream = BufStream::new(stream); - #[cfg(feature="user_management")] - let mut request = self.receive_request(&mut stream).await - .context("Failed to receive request")?; - #[cfg(not(feature="user_management"))] - let mut request = Self::receive_request(&mut stream).await - .context("Failed to receive request")?; + #[cfg(feature="user_management")] + let request = self.receive_request(&mut stream).await + .context("Failed to receive request")?; + #[cfg(not(feature="user_management"))] + let request = Self::receive_request(&mut stream).await + .context("Failed to receive request")?; + + Result::<_, anyhow::Error>::Ok((request, stream)) + }; + + // Use a timeout for interacting with the client + let fut_accept_request = timeout(self.timeout, fut_accept_request); + let (mut request, mut stream) = fut_accept_request.await + .context("Client timed out while waiting for response")??; debug!("Client requested: {}", request.uri()); @@ -88,7 +107,7 @@ impl Server { let handler = (self.handler)(request); let handler = AssertUnwindSafe(handler); - let response = handler.catch_unwind().await + let response = util::HandlerCatchUnwind::new(handler).await .unwrap_or_else(|_| Response::server_error("")) .or_else(|err| { error!("Handler failed: {:?}", err); @@ -96,11 +115,54 @@ impl Server { }) .context("Request handler failed")?; - send_response(response, &mut stream).await - .context("Failed to send response")?; + self.send_response(response, &mut stream).await + .context("Failed to send response")?; - stream.flush().await - .context("Failed to flush response data")?; + Ok(()) + } + + async fn send_response(&self, mut response: Response, stream: &mut (impl AsyncWrite + Unpin)) -> Result<()> { + let maybe_body = response.take_body(); + let header = response.header(); + + let use_complex_timeout = + header.status.is_success() && + maybe_body.is_some() && + header.meta.as_str() != "text/plain" && + header.meta.as_str() != "text/gemini" && + self.complex_timeout.is_some(); + + let send_general_timeout; + let send_header_timeout; + let send_body_timeout; + + if use_complex_timeout { + send_general_timeout = None; + send_header_timeout = Some(self.timeout); + send_body_timeout = self.complex_timeout; + } else { + send_general_timeout = Some(self.timeout); + send_header_timeout = None; + send_body_timeout = None; + } + + opt_timeout(send_general_timeout, async { + // Send the header + opt_timeout(send_header_timeout, send_response_header(response.header(), stream)) + .await + .context("Timed out while sending response header")? + .context("Failed to write response header")?; + + // Send the body + opt_timeout(send_body_timeout, maybe_send_response_body(maybe_body, stream)) + .await + .context("Timed out while sending response body")? + .context("Failed to write response body")?; + + Ok::<_,Error>(()) + }) + .await + .context("Timed out while sending response data")??; Ok(()) } @@ -143,6 +205,10 @@ impl Server { pub struct Builder { addr: A, + cert_path: PathBuf, + key_path: PathBuf, + timeout: Duration, + complex_body_timeout_override: Option, #[cfg(feature="user_management")] data_dir: PathBuf, } @@ -151,6 +217,10 @@ impl Builder { fn bind(addr: A) -> Self { Self { addr, + timeout: Duration::from_secs(1), + complex_body_timeout_override: Some(Duration::from_secs(30)), + cert_path: PathBuf::from("cert/cert.pem"), + key_path: PathBuf::from("cert/key.pem"), #[cfg(feature="user_management")] data_dir: "data".into(), } @@ -165,11 +235,110 @@ impl Builder { self } + /// Sets the directory that northstar should look for TLS certs and keys into + /// + /// Northstar will look for files called `cert.pem` and `key.pem` in the provided + /// directory. + /// + /// This does not need to be set if both [`set_cert()`](Self::set_cert()) and + /// [`set_key()`](Self::set_key()) have been called. + /// + /// If not set, the default is `cert/` + pub fn set_tls_dir(self, dir: impl Into) -> Self { + let dir = dir.into(); + self.set_cert(dir.join("cert.pem")) + .set_key(dir.join("key.pem")) + } + + /// Set the path to the TLS certificate northstar will use + /// + /// This defaults to `cert/cert.pem`. + /// + /// This does not need to be called it [`set_tls_dir()`](Self::set_tls_dir()) has been + /// called. + pub fn set_cert(mut self, cert_path: impl Into) -> Self { + self.cert_path = cert_path.into(); + self + } + + /// Set the path to the ertificate key northstar will use + /// + /// This defaults to `cert/key.pem`. + /// + /// This does not need to be called it [`set_tls_dir()`](Self::set_tls_dir()) has been + /// called. + /// + /// This should of course correspond to the key set in + /// [`set_cert()`](Self::set_cert()) + pub fn set_key(mut self, key_path: impl Into) -> Self { + self.key_path = key_path.into(); + self + } + + /// Set the timeout on incoming requests + /// + /// Note that this timeout is applied twice, once for the delivery of the request, and + /// once for sending the client's response. This means that for a 1 second timeout, + /// the client will have 1 second to complete the TLS handshake and deliver a request + /// header, then your API will have as much time as it needs to handle the request, + /// before the client has another second to receive the response. + /// + /// If you would like a timeout for your code itself, please use + /// [`tokio::time::Timeout`] to implement it internally. + /// + /// **The default timeout is 1 second.** As somewhat of a workaround for + /// shortcomings of the specification, this timeout, and any timeout set using this + /// method, is overridden in special cases, specifically for MIME types outside of + /// `text/plain` and `text/gemini`, to be 30 seconds. If you would like to change or + /// prevent this, please see + /// [`override_complex_body_timeout`](Self::override_complex_body_timeout()). + pub fn set_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Override the timeout for complex body types + /// + /// Many clients choose to handle body types which cannot be displayed by prompting + /// the user if they would like to download or open the request body. However, since + /// this prompt occurs in the middle of receiving a request, often the connection + /// times out before the end user is able to respond to the prompt. + /// + /// As a workaround, it is possible to set an override on the request timeout in + /// specific conditions: + /// + /// 1. **Only override the timeout for receiving the body of the request.** This will + /// not override the timeout on sending the request header, nor on receiving the + /// response header. + /// 2. **Only override the timeout for successful responses.** The only bodies which + /// have bodies are successful ones. In all other cases, there's no body to + /// timeout for + /// 3. **Only override the timeout for complex body types.** Almost all clients are + /// able to display `text/plain` and `text/gemini` responses, and will not prompt + /// the user for these response types. This means that there is no reason to + /// expect a client to have a human-length response time for these MIME types. + /// Because of this, responses of this type will not be overridden. + /// + /// This method is used to override the timeout for responses meeting these specific + /// criteria. All other stages of the connection will use the timeout specified in + /// [`set_timeout()`](Self::set_timeout()). + /// + /// If this is set to [`None`], then the client will have the default amount of time + /// to both receive the header and the body. If this is set to [`Some`], the client + /// will have the default amount of time to recieve the header, and an *additional* + /// alotment of time to recieve the body. + /// + /// The default timeout for this is 30 seconds. + pub fn override_complex_body_timeout(mut self, timeout: Option) -> Self { + self.complex_body_timeout_override = timeout; + self + } + pub async fn serve(self, handler: F) -> Result<()> where F: Fn(Request) -> HandlerResponse + Send + Sync + 'static, { - let config = tls_config() + let config = tls_config(&self.cert_path, &self.key_path) .context("Failed to create TLS config")?; let listener = TcpListener::bind(self.addr).await @@ -179,6 +348,8 @@ impl Builder { tls_acceptor: TlsAcceptor::from(config), listener: Arc::new(listener), handler: Arc::new(handler), + timeout: self.timeout, + complex_timeout: self.complex_body_timeout_override, #[cfg(feature="user_management")] manager: UserManager::new(self.data_dir)?, }; @@ -187,18 +358,6 @@ impl Builder { } } -async fn send_response(mut response: Response, stream: &mut (impl AsyncWrite + Unpin)) -> Result<()> { - send_response_header(response.header(), stream).await - .context("Failed to send response header")?; - - if let Some(body) = response.take_body() { - send_response_body(body, stream).await - .context("Failed to send response body")?; - } - - Ok(()) -} - async fn send_response_header(header: &ResponseHeader, stream: &mut (impl AsyncWrite + Unpin)) -> Result<()> { let header = format!( "{status} {meta}\r\n", @@ -207,6 +366,15 @@ async fn send_response_header(header: &ResponseHeader, stream: &mut (impl AsyncW ); stream.write_all(header.as_bytes()).await?; + stream.flush().await?; + + Ok(()) +} + +async fn maybe_send_response_body(maybe_body: Option, stream: &mut (impl AsyncWrite + Unpin)) -> Result<()> { + if let Some(body) = maybe_body { + send_response_body(body, stream).await?; + } Ok(()) } @@ -217,15 +385,17 @@ async fn send_response_body(body: Body, stream: &mut (impl AsyncWrite + Unpin)) Body::Reader(mut reader) => { io::copy(&mut reader, stream).await?; }, } + stream.flush().await?; + Ok(()) } -fn tls_config() -> Result> { +fn tls_config(cert_path: &PathBuf, key_path: &PathBuf) -> Result> { let mut config = ServerConfig::new(AllowAnonOrSelfsignedClient::new()); - let cert_chain = load_cert_chain() + let cert_chain = load_cert_chain(cert_path) .context("Failed to load TLS certificate")?; - let key = load_key() + let key = load_key(key_path) .context("Failed to load TLS key")?; config.set_single_cert(cert_chain, key) .context("Failed to use loaded TLS certificate")?; @@ -233,24 +403,22 @@ fn tls_config() -> Result> { Ok(config.into()) } -fn load_cert_chain() -> Result> { - let cert_path = "cert/cert.pem"; +fn load_cert_chain(cert_path: &PathBuf) -> Result> { let certs = std::fs::File::open(cert_path) - .with_context(|| format!("Failed to open `{}`", cert_path))?; + .with_context(|| format!("Failed to open `{:?}`", cert_path))?; let mut certs = BufReader::new(certs); let certs = rustls::internal::pemfile::certs(&mut certs) - .map_err(|_| anyhow!("failed to load certs `{}`", cert_path))?; + .map_err(|_| anyhow!("failed to load certs `{:?}`", cert_path))?; Ok(certs) } -fn load_key() -> Result { - let key_path = "cert/key.pem"; +fn load_key(key_path: &PathBuf) -> Result { let keys = std::fs::File::open(key_path) - .with_context(|| format!("Failed to open `{}`", key_path))?; + .with_context(|| format!("Failed to open `{:?}`", key_path))?; let mut keys = BufReader::new(keys); let mut keys = rustls::internal::pemfile::pkcs8_private_keys(&mut keys) - .map_err(|_| anyhow!("failed to load key `{}`", key_path))?; + .map_err(|_| anyhow!("failed to load key `{:?}`", key_path))?; ensure!(!keys.is_empty(), "no key found"); 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..d1356cc 100644 --- a/src/types/body.rs +++ b/src/types/body.rs @@ -1,10 +1,20 @@ -use tokio::{io::AsyncRead, fs::File}; +use tokio::io::AsyncRead; +#[cfg(feature="serve_dir")] +use tokio::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) @@ -29,6 +39,7 @@ impl<'a> From<&'a str> for Body { } } +#[cfg(feature="serve_dir")] impl From for Body { fn from(file: File) -> Self { Self::Reader(Box::new(file)) diff --git a/src/types/document.rs b/src/types/document.rs new file mode 100644 index 0000000..e322357 --- /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 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()) + .collect::>() + .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..3e4a84a 100644 --- a/src/types/response.rs +++ b/src/types/response.rs @@ -1,5 +1,10 @@ +use std::convert::TryInto; + use anyhow::*; -use crate::types::{ResponseHeader, Body, Mime}; +use uriparse::URIReference; +use crate::types::{ResponseHeader, Body, Mime, Document}; +use crate::util::Cowy; +use crate::GEMINI_MIME; pub struct Response { header: ResponseHeader, @@ -14,12 +19,16 @@ impl Response { } } - pub fn input(prompt: impl AsRef + Into) -> Result { + pub fn document(document: Document) -> Self { + Self::success_with_body(&GEMINI_MIME, 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 +38,25 @@ impl Response { Self::new(header) } - pub fn server_error(reason: impl AsRef + Into) -> Result { + pub fn redirect_temporary_lossy<'a>(location: impl TryInto>) -> Self { + let header = ResponseHeader::redirect_temporary_lossy(location); + Self::new(header) + } + + /// Create a successful response with a preconfigured body + /// + /// This is equivilent to: + /// + /// ```ignore + /// Response::success(mime) + /// .with_body(body) + /// ``` + pub fn success_with_body(mime: &Mime, body: impl Into) -> Self { + Self::success(mime) + .with_body(body) + } + + pub fn server_error(reason: impl Cowy) -> Result { let header = ResponseHeader::server_error(reason)?; Ok(Self::new(header)) } @@ -39,6 +66,11 @@ impl Response { Self::new(header) } + pub fn bad_request_lossy(reason: impl Cowy) -> Self { + let header = ResponseHeader::bad_request_lossy(reason); + Self::new(header) + } + pub fn client_certificate_required() -> Self { let header = ResponseHeader::client_certificate_required(); Self::new(header) diff --git a/src/types/response_header.rs b/src/types/response_header.rs index 8307707..56f2af3 100644 --- a/src/types/response_header.rs +++ b/src/types/response_header.rs @@ -1,5 +1,9 @@ +use std::convert::TryInto; + use anyhow::*; -use mime::Mime; +use uriparse::URIReference; +use crate::Mime; +use crate::util::Cowy; use crate::types::{Status, Meta}; #[derive(Debug,Clone)] @@ -9,14 +13,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 +34,26 @@ impl ResponseHeader { } } - pub fn server_error(reason: impl AsRef + Into) -> Result { + pub fn redirect_temporary_lossy<'a>(location: impl TryInto>) -> Self { + let location = match location.try_into() { + Ok(location) => location, + Err(_) => return Self::bad_request_lossy("Invalid redirect location"), + }; + + Self { + status: Status::REDIRECT_TEMPORARY, + meta: Meta::new_lossy(location.to_string()), + } + } + + 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), @@ -51,6 +67,13 @@ impl ResponseHeader { } } + pub fn bad_request_lossy(reason: impl Cowy) -> Self { + Self { + status: Status::BAD_REQUEST, + meta: Meta::new_lossy(reason), + } + } + pub fn client_certificate_required() -> Self { Self { status: Status::CLIENT_CERTIFICATE_REQUIRED, diff --git a/src/util.rs b/src/util.rs index c1265e4..5c623aa 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,14 +1,22 @@ -use std::path::Path; +#[cfg(feature="serve_dir")] +use std::path::{Path, PathBuf}; +#[cfg(feature="serve_dir")] use mime::Mime; -use percent_encoding::utf8_percent_encode; use anyhow::*; +#[cfg(feature="serve_dir")] use tokio::{ fs::{self, File}, io, }; -use crate::{GEMINI_MIME, GEMINI_MIME_STR, Response}; -use itertools::Itertools; +#[cfg(feature="serve_dir")] +use crate::types::{Document, document::HeadingLevel::*}; +use crate::types::Response; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::task::Poll; +use futures_core::future::Future; +use tokio::time; +#[cfg(feature="serve_dir")] pub async fn serve_file>(path: P, mime: &Mime) -> Result { let path = path.as_ref(); @@ -20,9 +28,10 @@ pub async fn serve_file>(path: P, mime: &Mime) -> Result, P: AsRef>(dir: D, virtual_path: &[P]) -> Result { debug!("Dir: {}", dir.as_ref().display()); let dir = dir.as_ref().canonicalize() @@ -48,9 +57,8 @@ pub async fn serve_dir, P: AsRef>(dir: D, virtual_path: &[P serve_dir_listing(path, virtual_path).await } +#[cfg(feature="serve_dir")] 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() { @@ -59,14 +67,14 @@ 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 breadcrumbs: PathBuf = virtual_path.iter().collect(); + let mut document = Document::new(); + + document.add_heading(H1, format!("Index of /{}", breadcrumbs.display())); + 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,33 +83,83 @@ 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)) } +#[cfg(feature="serve_dir")] pub fn guess_mime_from_path>(path: P) -> Mime { let path = path.as_ref(); let extension = path.extension().and_then(|s| s.to_str()); - let mime = match extension { - Some(extension) => match extension { - "gemini" => GEMINI_MIME_STR, - "txt" => "text/plain", - "jpeg" | "jpg" | "jpe" => "image/jpeg", - "png" => "image/png", - _ => "application/octet-stream", - }, - None => "application/octet-stream", + let extension = match extension { + Some(extension) => extension, + None => return mime::APPLICATION_OCTET_STREAM, }; - - mime.parse::().unwrap_or(mime::APPLICATION_OCTET_STREAM) + + if let "gemini" | "gmi" = extension { + return crate::GEMINI_MIME.clone(); + } + + mime_guess::from_ext(extension).first_or_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, +{} + +/// A utility for catching unwinds on Futures. +/// +/// This is adapted from the futures-rs CatchUnwind, in an effort to reduce the large +/// amount of dependencies tied into the feature that provides this simple struct. +#[must_use = "futures do nothing unless polled"] +pub (crate) struct HandlerCatchUnwind { + future: AssertUnwindSafe, +} + +impl HandlerCatchUnwind { + pub(super) fn new(future: AssertUnwindSafe) -> Self { + Self { future } + } +} + +impl Future for HandlerCatchUnwind { + type Output = Result, Box>; + + fn poll( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context + ) -> Poll { + match catch_unwind(AssertUnwindSafe(|| self.future.as_mut().poll(cx))) { + Ok(res) => res.map(Ok), + Err(e) => Poll::Ready(Err(e)) + } + } +} + +pub(crate) async fn opt_timeout(duration: Option, future: impl Future) -> Result { + match duration { + Some(duration) => time::timeout(duration, future).await, + None => Ok(future.await), + } }