Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ members = ["rossweisse"]
[package]
autotests = false
name = "windmark"
version = "0.8.0"
version = "0.9.0"
authors = ["Fuwn <contact@fuwn.me>"]
edition = "2021"
description = "An elegant and highly performant async Gemini server framework"
Expand Down Expand Up @@ -56,6 +56,7 @@ language-tags = "0.3.2"
tree_magic_mini = { version = "3.2.2", optional = true } # MIME

[dev-dependencies]
percent-encoding = "2.3.2"
pretty_env_logger = "0.5.0"
rossweisse = { version = "0.0.4", path = "./rossweisse" }

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ Now supporting both [Tokio](https://tokio.rs/) and [`async-std`](https://async.r
# Cargo.toml

[dependencies]
windmark = "0.8.0"
windmark = "0.9.0"
tokio = { version = "1.26.0", features = ["full"] }

# If you would like to use the built-in MIME deduction when `Success`-ing a file
# (recommended)
# windmark = { version = "0.8.0", features = ["auto-deduce-mime"] }
# windmark = { version = "0.9.0", features = ["auto-deduce-mime"] }
```

### Implementing a Windmark Server
Expand Down
2 changes: 1 addition & 1 deletion examples/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!(
"accepted connection from {} to {}",
context.peer_address.unwrap().ip(),
context.url
context.url.path()
)
})
.set_post_route_callback(|context: &HookContext, content: &mut Response| {
Expand Down
9 changes: 6 additions & 3 deletions examples/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.set_certificate_file("windmark_public.pem")
.mount("/input", |context: RouteContext| {
if let Some(name) = context.url.query() {
Response::success(format!("Your name is {}!", name))
match percent_encoding::percent_decode_str(name).decode_utf8() {
Ok(name) => Response::success(format!("Your name is {name}!")),
Err(_) => Response::bad_request("Input must be valid UTF-8."),
}
} else {
Response::input("What is your name?")
}
})
.mount("/sensitive", |context: RouteContext| {
if let Some(password) = context.url.query() {
Response::success(format!("Your password is {}!", password))
if context.url.query().is_some() {
Response::success("Your input was received.")
} else {
Response::sensitive_input("What is your password?")
}
Expand Down
10 changes: 8 additions & 2 deletions src/context/parameters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,21 @@ impl Parameters {
parameters: &matchit::Params<'_, '_>,
lookup: &str,
original: &str,
offsets: &[usize],
) -> Self {
Self(
parameters
.iter()
.map(|(name, value)| {
// Matchit borrows each value from the lookup path. ASCII case folding
// preserves byte offsets, so the original path has the same spans.
// Matchit borrows from the lookup path. Percent decoding needs a
// boundary map; ASCII case folding alone preserves byte offsets.
let start = value.as_ptr().addr() - lookup.as_ptr().addr();
let end = start + value.len();
let (start, end) = if offsets.is_empty() {
(start, end)
} else {
(offsets[start], offsets[end])
};

(name.to_owned(), original[start..end].to_owned())
})
Expand Down
17 changes: 12 additions & 5 deletions src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ impl Response {
}
}

/// Return the response's media type override.
/// Return the response's media type override. Explicit character-set and
/// language setters override parameters included in this value.
#[must_use]
pub fn mime(&self) -> Option<&str> { self.mime.as_deref() }

Expand All @@ -228,8 +229,8 @@ impl Response {
#[must_use]
pub fn character_set(&self) -> Option<&str> { self.character_set.as_deref() }

/// Borrow the character set override; set it to `None` to use the router's
/// default.
/// Borrow the character set override; `None` preserves a MIME parameter or,
/// for string responses without one, uses the router's default.
pub const fn character_set_mut(&mut self) -> &mut Option<String> {
&mut self.character_set
}
Expand All @@ -239,12 +240,16 @@ impl Response {
#[must_use]
pub fn languages(&self) -> Option<&[String]> { self.languages.as_deref() }

/// Borrow the language override; set it to `None` to use the router's
/// default.
/// Borrow the language override; `None` preserves a MIME parameter or,
/// for string responses without one, uses the router's default.
pub const fn languages_mut(&mut self) -> &mut Option<Vec<String>> {
&mut self.languages
}

/// Set the media type and its parameters. Explicit character-set and
/// language overrides take precedence; router defaults fill omitted
/// parameters on string responses. Invalid metadata produces a temporary
/// failure when the server sends the response.
pub fn with_mime(
&mut self,
mime: impl Into<String> + AsRef<str>,
Expand All @@ -254,6 +259,8 @@ impl Response {
self
}

/// Declare the payload's character set without transcoding it. String
/// responses require UTF-8; use a binary response for pre-encoded content.
pub fn with_character_set(
&mut self,
character_set: impl Into<String> + AsRef<str>,
Expand Down
169 changes: 126 additions & 43 deletions src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,43 +397,8 @@ fn status_line(
let status = content.status();

if matches!(status, 20..=22) {
let mime = content.mime().unwrap_or(if status == 20 {
"text/gemini"
} else {
"application/octet-stream"
});
let metadata = if status == 20 {
let character_set =
content.character_set().unwrap_or(default_character_set);
let languages = content.languages().map_or_else(
|| default_languages.to_owned(),
|languages| languages.join(","),
);
let mut metadata =
format!("{mime}; charset={}", mime_parameter(character_set));

if !languages.is_empty() {
if languages
.split(',')
.any(|language| language_tags::LanguageTag::parse(language).is_err())
{
return Err("invalid response language tag");
}

write!(&mut metadata, "; lang={}", mime_parameter(&languages))
.expect("writing to a string cannot fail");
}

metadata
} else {
mime.to_owned()
};

if metadata.chars().any(char::is_control)
|| metadata.parse::<mime::Mime>().is_err()
{
return Err("invalid response media type or parameters");
}
let metadata =
response_metadata(content, default_character_set, default_languages)?;

return Ok(format!("20 {metadata}"));
}
Expand Down Expand Up @@ -468,6 +433,112 @@ fn status_line(
}
}

fn response_metadata(
content: &Response,
default_character_set: &str,
default_languages: &str,
) -> Result<String, &'static str> {
let text_payload = content.status() == 20;
let media_type = content.mime().unwrap_or(if text_payload {
"text/gemini"
} else {
"application/octet-stream"
});

if !media_type.is_ascii() || media_type.chars().any(char::is_control) {
return Err("invalid response media type or parameters");
}

let media_type = media_type
.parse::<mime::Mime>()
.map_err(|_| "invalid response media type or parameters")?;

if media_type.type_() == mime::STAR || media_type.subtype() == mime::STAR {
return Err("response media type must not contain a wildcard");
}

let mut parameters = Vec::<(String, String)>::new();

for (name, value) in media_type.params() {
if parameters
.iter()
.any(|(existing, _)| name == existing.as_str())
{
return Err("duplicate response media type parameter");
}

let mut decoded = String::new();
let mut characters = value.as_str().chars();

while let Some(character) = characters.next() {
decoded.push(if character == '\\' {
characters
.next()
.ok_or("invalid quoted media type parameter")?
} else {
character
});
}

parameters.push((name.as_str().to_ascii_lowercase(), decoded));
}

let language_override =
content.languages().map(|languages| languages.join(","));

for (name, explicit, fallback) in [
("charset", content.character_set(), default_character_set),
("lang", language_override.as_deref(), default_languages),
] {
let existing = parameters
.iter()
.position(|(parameter, _)| parameter == name);

if let Some(value) = explicit
.or_else(|| (text_payload && existing.is_none()).then_some(fallback))
{
if let Some(position) = existing {
parameters.remove(position);
}

if name != "lang" || !value.is_empty() {
parameters.push((name.to_owned(), value.to_owned()));
}
}
}

let mut metadata = media_type.essence_str().to_owned();

for (name, value) in parameters {
if !value.is_ascii() || value.chars().any(char::is_control) {
return Err("invalid response media type parameter");
}

if text_payload && name == "charset" && !value.eq_ignore_ascii_case("utf-8")
{
return Err("string responses require the UTF-8 character set");
}

if media_type.essence_str() == "text/gemini"
&& name == "lang"
&& value
.split(',')
.any(|language| language_tags::LanguageTag::parse(language).is_err())
{
return Err("invalid response language tag");
}

write!(&mut metadata, "; {name}={}", mime_parameter(&value))
.expect("writing to a string cannot fail");
}

metadata
.parse::<mime::Mime>()
.map_err(|_| "invalid response media type or parameters")?;

Ok(metadata)
}

fn mime_parameter(value: &str) -> String {
if !value.is_empty()
&& value.bytes().all(|byte| {
Expand Down Expand Up @@ -600,6 +671,9 @@ impl Router {
/// Map routes to URL paths.
///
/// This method supports both synchronous and asynchronous handlers.
/// Percent-escape case and escaped unreserved characters are normalised
/// for matching. Captures retain their original spelling; escaped slashes
/// remain distinct from path separators.
///
/// # Examples
///
Expand All @@ -616,7 +690,8 @@ impl Router {
/// # Panics
///
/// This method panics if the route is invalid or conflicts with a mounted
/// route, including conflicts under case-insensitive matching when enabled.
/// route after URI normalisation, including conflicts under case-insensitive
/// matching when enabled.
pub fn mount<R>(
&mut self,
route: impl Into<String> + AsRef<str>,
Expand All @@ -626,10 +701,12 @@ impl Router {
R: IntoFuture<Output = Response> + Send + 'static,
<R as IntoFuture>::IntoFuture: Send,
{
let route = route.into();

self
.routes
.insert(
route.into(),
&route,
Arc::new(move |context: RouteContext| handler(context).into_future()),
)
.expect("failed to mount route");
Expand Down Expand Up @@ -842,7 +919,9 @@ impl Router {
}

fn create_acceptor(&self) -> Result<SslAcceptor, Box<dyn Error>> {
let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls())?;
let mut builder = SslAcceptor::mozilla_intermediate_v5(SslMethod::tls())?;

builder.set_min_proto_version(Some(ssl::SslVersion::TLS1_2))?;

if let Some(ref content) = self.certificate_content {
let mut certificates =
Expand Down Expand Up @@ -897,7 +976,7 @@ impl Router {
///
/// windmark::router::Router::new().set_ssl_acceptor({
/// let mut builder =
/// ssl::SslAcceptor::mozilla_intermediate(ssl::SslMethod::tls()).unwrap();
/// ssl::SslAcceptor::mozilla_intermediate_v5(ssl::SslMethod::tls()).unwrap();
///
/// builder
/// .set_private_key_file("windmark_private.pem", ssl::SslFiletype::PEM)
Expand Down Expand Up @@ -1150,9 +1229,12 @@ impl Router {

/// Specify a custom character set.
///
/// A character set specified in a [`Response`] overrides this setting.
/// A character set specified in a [`Response`] or its MIME parameters
/// overrides this setting.
///
/// The default character set is `"utf-8"`.
/// String responses require UTF-8. To serve another encoding, use a binary
/// response with an explicit character set; its bytes are sent unchanged.
///
/// # Examples
///
Expand All @@ -1170,7 +1252,8 @@ impl Router {

/// Specify a custom language.
///
/// Languages specified in a [`Response`] override this setting.
/// Languages specified in a [`Response`] or its MIME parameters override
/// this setting. Router defaults apply only to string responses.
///
/// The default language is `"en"`.
///
Expand Down
Loading
Loading