Merged Meta, Status, and ResponseHeader into Response
This commit is contained in:
parent
a92b3788e2
commit
f922f8c70d
|
@ -59,41 +59,33 @@ impl Handler {
|
||||||
HandlerCatchUnwind::new(fut_handle).await
|
HandlerCatchUnwind::new(fut_handle).await
|
||||||
.unwrap_or_else(|err| {
|
.unwrap_or_else(|err| {
|
||||||
error!("Handler failed: {:?}", err);
|
error!("Handler failed: {:?}", err);
|
||||||
Response::server_error("").unwrap()
|
Response::temporary_failure("")
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
Self::StaticHandler(response) => {
|
Self::StaticHandler(response) => {
|
||||||
let body = response.as_ref();
|
match &response.body {
|
||||||
match body {
|
None => Response::new(response.status, &response.meta),
|
||||||
None => Response::new(response.header().clone()),
|
Some(Body::Bytes(bytes)) => Response::success(&response.meta, bytes.clone()),
|
||||||
Some(Body::Bytes(bytes)) => {
|
|
||||||
Response::new(response.header().clone())
|
|
||||||
.with_body(bytes.clone())
|
|
||||||
},
|
|
||||||
_ => {
|
_ => {
|
||||||
error!(concat!(
|
error!(concat!(
|
||||||
"Cannot construct a static handler with a reader-based body! ",
|
"Cannot construct a static handler with a reader-based body! ",
|
||||||
" We're sending a response so that the client doesn't crash, but",
|
" We're sending a response so that the client doesn't crash, but",
|
||||||
" given that this is a release build you should really fix this."
|
" given that this is a release build you should really fix this."
|
||||||
));
|
));
|
||||||
Response::server_error(
|
Response::permanent_failure(
|
||||||
"Very bad server error, go tell the sysadmin to look at the logs."
|
"Very bad server error, go tell the sysadmin to look at the logs."
|
||||||
).unwrap()
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
#[cfg(feature = "serve_dir")]
|
#[cfg(feature = "serve_dir")]
|
||||||
Self::FilesHandler(path) => {
|
Self::FilesHandler(path) => {
|
||||||
let resp = if path.is_dir() {
|
if path.is_dir() {
|
||||||
crate::util::serve_dir(path, request.trailing_segments()).await
|
crate::util::serve_dir(path, request.trailing_segments()).await
|
||||||
} else {
|
} else {
|
||||||
let mime = crate::util::guess_mime_from_path(&path);
|
let mime = crate::util::guess_mime_from_path(&path);
|
||||||
crate::util::serve_file(path, &mime).await
|
crate::util::serve_file(path, &mime).await
|
||||||
};
|
}
|
||||||
resp.unwrap_or_else(|e| {
|
|
||||||
warn!("Unexpected error serving from {}: {:?}", path.display(), e);
|
|
||||||
Response::server_error("").unwrap()
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -204,7 +196,7 @@ impl Future for HandlerCatchUnwind {
|
||||||
Ok(res) => res,
|
Ok(res) => res,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Handler panic! {:?}", e);
|
error!("Handler panic! {:?}", e);
|
||||||
Poll::Ready(Response::server_error(""))
|
Poll::Ready(Ok(Response::temporary_failure("")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
57
src/lib.rs
57
src/lib.rs
|
@ -14,6 +14,8 @@ use std::{
|
||||||
};
|
};
|
||||||
#[cfg(any(feature = "gemini_srv", feature = "user_management"))]
|
#[cfg(any(feature = "gemini_srv", feature = "user_management"))]
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
#[cfg(feature="ratelimiting")]
|
||||||
|
use std::net::IpAddr;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
io,
|
io,
|
||||||
io::BufReader,
|
io::BufReader,
|
||||||
|
@ -217,15 +219,12 @@ impl ServerInner {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_response(&self, mut response: Response, stream: &mut (impl AsyncWrite + Unpin + Send)) -> Result<()> {
|
async fn send_response(&self, response: Response, stream: &mut (impl AsyncWrite + Unpin + Send)) -> Result<()> {
|
||||||
let maybe_body = response.take_body();
|
|
||||||
let header = response.header();
|
|
||||||
|
|
||||||
let use_complex_timeout =
|
let use_complex_timeout =
|
||||||
header.status.is_success() &&
|
response.is_success() &&
|
||||||
maybe_body.is_some() &&
|
response.body.is_some() &&
|
||||||
header.meta.as_str() != "text/plain" &&
|
response.meta != "text/plain" &&
|
||||||
header.meta.as_str() != "text/gemini" &&
|
response.meta != "text/gemini" &&
|
||||||
self.complex_timeout.is_some();
|
self.complex_timeout.is_some();
|
||||||
|
|
||||||
let send_general_timeout;
|
let send_general_timeout;
|
||||||
|
@ -244,13 +243,13 @@ impl ServerInner {
|
||||||
|
|
||||||
opt_timeout(send_general_timeout, async {
|
opt_timeout(send_general_timeout, async {
|
||||||
// Send the header
|
// Send the header
|
||||||
opt_timeout(send_header_timeout, send_response_header(response.header(), stream))
|
opt_timeout(send_header_timeout, send_response_header(&response, stream))
|
||||||
.await
|
.await
|
||||||
.context("Timed out while sending response header")?
|
.context("Timed out while sending response header")?
|
||||||
.context("Failed to write response header")?;
|
.context("Failed to write response header")?;
|
||||||
|
|
||||||
// Send the body
|
// Send the body
|
||||||
opt_timeout(send_body_timeout, maybe_send_response_body(maybe_body, stream))
|
opt_timeout(send_body_timeout, send_response_body(response.body, stream))
|
||||||
.await
|
.await
|
||||||
.context("Timed out while sending response body")?
|
.context("Timed out while sending response body")?
|
||||||
.context("Failed to write response body")?;
|
.context("Failed to write response body")?;
|
||||||
|
@ -267,10 +266,7 @@ impl ServerInner {
|
||||||
fn check_rate_limits(&self, addr: IpAddr, req: &Request) -> Option<Response> {
|
fn check_rate_limits(&self, addr: IpAddr, req: &Request) -> Option<Response> {
|
||||||
if let Some((_, limiter)) = self.rate_limits.match_request(req) {
|
if let Some((_, limiter)) = self.rate_limits.match_request(req) {
|
||||||
if let Err(when) = limiter.check_key(addr) {
|
if let Err(when) = limiter.check_key(addr) {
|
||||||
return Some(Response::new(ResponseHeader {
|
return Some(Response::slow_down(when.as_secs()))
|
||||||
status: Status::SLOW_DOWN,
|
|
||||||
meta: Meta::new(when.as_secs().to_string()).unwrap()
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
@ -682,11 +678,19 @@ impl Default for Server {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send_response_header(header: &ResponseHeader, stream: &mut (impl AsyncWrite + Unpin + Send)) -> Result<()> {
|
async fn send_response_header(response: &Response, stream: &mut (impl AsyncWrite + Unpin + Send)) -> Result<()> {
|
||||||
|
|
||||||
|
let meta = if response.meta.len() > 1024 {
|
||||||
|
warn!("Attempted to send response with META exceeding maximum length, truncating");
|
||||||
|
&response.meta[..1024]
|
||||||
|
} else {
|
||||||
|
&response.meta[..]
|
||||||
|
};
|
||||||
|
|
||||||
let header = format!(
|
let header = format!(
|
||||||
"{status} {meta}\r\n",
|
"{status} {meta}\r\n",
|
||||||
status = header.status.code(),
|
status = response.status,
|
||||||
meta = header.meta.as_str(),
|
meta = meta,
|
||||||
);
|
);
|
||||||
|
|
||||||
stream.write_all(header.as_bytes()).await?;
|
stream.write_all(header.as_bytes()).await?;
|
||||||
|
@ -695,22 +699,17 @@ async fn send_response_header(header: &ResponseHeader, stream: &mut (impl AsyncW
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn maybe_send_response_body(maybe_body: Option<Body>, stream: &mut (impl AsyncWrite + Unpin + Send)) -> Result<()> {
|
async fn send_response_body(mut body: Option<Body>, stream: &mut (impl AsyncWrite + Unpin + Send)) -> Result<()> {
|
||||||
if let Some(body) = maybe_body {
|
match &mut body {
|
||||||
send_response_body(body, stream).await?;
|
Some(Body::Bytes(ref bytes)) => stream.write_all(&bytes).await?,
|
||||||
|
Some(Body::Reader(ref mut reader)) => { io::copy(reader, stream).await?; },
|
||||||
|
None => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
if body.is_some() {
|
||||||
}
|
stream.flush().await?;
|
||||||
|
|
||||||
async fn send_response_body(body: Body, stream: &mut (impl AsyncWrite + Unpin + Send)) -> Result<()> {
|
|
||||||
match body {
|
|
||||||
Body::Bytes(bytes) => stream.write_all(&bytes).await?,
|
|
||||||
Body::Reader(mut reader) => { io::copy(&mut reader, stream).await?; },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stream.flush().await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,17 +1,8 @@
|
||||||
pub use uriparse::URIReference;
|
pub use uriparse::URIReference;
|
||||||
|
|
||||||
mod meta;
|
|
||||||
pub use self::meta::Meta;
|
|
||||||
|
|
||||||
mod request;
|
mod request;
|
||||||
pub use request::Request;
|
pub use request::Request;
|
||||||
|
|
||||||
mod response_header;
|
|
||||||
pub use response_header::ResponseHeader;
|
|
||||||
|
|
||||||
mod status;
|
|
||||||
pub use status::{Status, StatusCategory};
|
|
||||||
|
|
||||||
mod response;
|
mod response;
|
||||||
pub use response::Response;
|
pub use response::Response;
|
||||||
|
|
||||||
|
|
|
@ -1,130 +0,0 @@
|
||||||
use anyhow::*;
|
|
||||||
use crate::util::Cowy;
|
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug,Clone,PartialEq,Eq,Default)]
|
|
||||||
pub struct Meta(String);
|
|
||||||
|
|
||||||
impl Meta {
|
|
||||||
pub const MAX_LEN: usize = 1024;
|
|
||||||
|
|
||||||
/// Creates a new "Meta" string.
|
|
||||||
/// Fails if `meta` contains `\n`.
|
|
||||||
pub fn new(meta: impl Cowy<str>) -> Result<Self> {
|
|
||||||
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);
|
|
||||||
|
|
||||||
Ok(Self(meta.into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a new "Meta" string.
|
|
||||||
/// Truncates `meta` to before:
|
|
||||||
/// - the first occurrence of `\n`
|
|
||||||
/// - the character that makes `meta` exceed `Meta::MAX_LEN`
|
|
||||||
pub fn new_lossy(meta: impl Cowy<str>) -> Self {
|
|
||||||
let meta = meta.as_ref();
|
|
||||||
let truncate_pos = meta.char_indices().position(|(i, ch)| {
|
|
||||||
let is_newline = ch == '\n';
|
|
||||||
let exceeds_limit = (i + ch.len_utf8()) > Self::MAX_LEN;
|
|
||||||
|
|
||||||
is_newline || exceeds_limit
|
|
||||||
});
|
|
||||||
|
|
||||||
let meta: String = match truncate_pos {
|
|
||||||
None => meta.into(),
|
|
||||||
Some(truncate_pos) => meta.get(..truncate_pos).unwrap().into(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Self(meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn empty() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::iter::repeat;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_rejects_newlines() {
|
|
||||||
let meta = "foo\nbar";
|
|
||||||
let meta = Meta::new(meta);
|
|
||||||
|
|
||||||
assert!(meta.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_accepts_max_len() {
|
|
||||||
let meta: String = repeat('x').take(Meta::MAX_LEN).collect();
|
|
||||||
let meta = Meta::new(meta);
|
|
||||||
|
|
||||||
assert!(meta.is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_rejects_exceeding_max_len() {
|
|
||||||
let meta: String = repeat('x').take(Meta::MAX_LEN + 1).collect();
|
|
||||||
let meta = Meta::new(meta);
|
|
||||||
|
|
||||||
assert!(meta.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_lossy_truncates() {
|
|
||||||
let meta = "foo\r\nbar\nquux";
|
|
||||||
let meta = Meta::new_lossy(meta);
|
|
||||||
|
|
||||||
assert_eq!(meta.as_str(), "foo\r");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_lossy_no_truncate() {
|
|
||||||
let meta = "foo bar\r";
|
|
||||||
let meta = Meta::new_lossy(meta);
|
|
||||||
|
|
||||||
assert_eq!(meta.as_str(), "foo bar\r");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_lossy_empty() {
|
|
||||||
let meta = "";
|
|
||||||
let meta = Meta::new_lossy(meta);
|
|
||||||
|
|
||||||
assert_eq!(meta.as_str(), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_lossy_truncates_to_empty() {
|
|
||||||
let meta = "\n\n\n";
|
|
||||||
let meta = Meta::new_lossy(meta);
|
|
||||||
|
|
||||||
assert_eq!(meta.as_str(), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_lossy_truncates_to_max_len() {
|
|
||||||
let meta: String = repeat('x').take(Meta::MAX_LEN + 1).collect();
|
|
||||||
let meta = Meta::new_lossy(meta);
|
|
||||||
|
|
||||||
assert_eq!(meta.as_str().len(), Meta::MAX_LEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_lossy_truncates_multi_byte_sequences() {
|
|
||||||
let mut meta: String = repeat('x').take(Meta::MAX_LEN - 1).collect();
|
|
||||||
meta.push('🦀');
|
|
||||||
|
|
||||||
assert_eq!(meta.len(), Meta::MAX_LEN + 3);
|
|
||||||
|
|
||||||
let meta = Meta::new_lossy(meta);
|
|
||||||
|
|
||||||
assert_eq!(meta.as_str().len(), Meta::MAX_LEN - 1);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,101 +1,148 @@
|
||||||
use std::convert::TryInto;
|
|
||||||
use std::borrow::Borrow;
|
use std::borrow::Borrow;
|
||||||
|
|
||||||
use anyhow::*;
|
use crate::types::{Body, Document};
|
||||||
use uriparse::URIReference;
|
|
||||||
use crate::types::{ResponseHeader, Body, Document};
|
|
||||||
use crate::util::Cowy;
|
|
||||||
|
|
||||||
pub struct Response {
|
pub struct Response {
|
||||||
header: ResponseHeader,
|
pub status: u8,
|
||||||
body: Option<Body>,
|
pub meta: String,
|
||||||
|
pub body: Option<Body>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Response {
|
impl Response {
|
||||||
pub const fn new(header: ResponseHeader) -> Self {
|
|
||||||
|
/// Create a response with a given status and meta
|
||||||
|
pub fn new(status: u8, meta: impl ToString) -> Self {
|
||||||
Self {
|
Self {
|
||||||
header,
|
status,
|
||||||
|
meta: meta.to_string(),
|
||||||
body: None,
|
body: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[deprecated(
|
/// Create a INPUT (10) response with a given prompt
|
||||||
since = "0.4.0",
|
///
|
||||||
note = "Deprecated in favor of Response::success_gemini() or Document::into()"
|
/// Use [`Response::sensitive_input()`] for collecting any sensitive input, as input
|
||||||
)]
|
/// collected by this request may be logged.
|
||||||
pub fn document(document: impl Borrow<Document>) -> Self {
|
pub fn input(prompt: impl ToString) -> Self {
|
||||||
Self::success_gemini(document)
|
Self::new(10, prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn input(prompt: impl Cowy<str>) -> Result<Self> {
|
/// Create a SENSITIVE INPUT (11) response with a given prompt
|
||||||
let header = ResponseHeader::input(prompt)?;
|
///
|
||||||
Ok(Self::new(header))
|
/// See also [`Response::input()`] for unsensitive inputs
|
||||||
|
pub fn sensitive_input(prompt: impl ToString) -> Self {
|
||||||
|
Self::new(11, prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn input_lossy(prompt: impl Cowy<str>) -> Self {
|
/// Create a SUCCESS (20) response with a given body and MIME
|
||||||
let header = ResponseHeader::input_lossy(prompt);
|
|
||||||
Self::new(header)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn redirect_temporary_lossy<'a>(location: impl TryInto<URIReference<'a>>) -> Self {
|
|
||||||
let header = ResponseHeader::redirect_temporary_lossy(location);
|
|
||||||
Self::new(header)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a successful response with a given body and MIME
|
|
||||||
pub fn success(mime: impl ToString, body: impl Into<Body>) -> Self {
|
pub fn success(mime: impl ToString, body: impl Into<Body>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
header: ResponseHeader::success(mime),
|
status: 20,
|
||||||
|
meta: mime.to_string(),
|
||||||
body: Some(body.into()),
|
body: Some(body.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a successful response with a `text/gemini` MIME
|
/// Create a SUCCESS (20) response with a `text/gemini` MIME
|
||||||
pub fn success_gemini(body: impl Into<Body>) -> Self {
|
pub fn success_gemini(body: impl Into<Body>) -> Self {
|
||||||
Self::success("text/gemini", body)
|
Self::success("text/gemini", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a successful response with a `text/plain` MIME
|
/// Create a SUCCESS (20) response with a `text/plain` MIME
|
||||||
pub fn success_plain(body: impl Into<Body>) -> Self {
|
pub fn success_plain(body: impl Into<Body>) -> Self {
|
||||||
Self::success("text/plain", body)
|
Self::success("text/plain", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn server_error(reason: impl Cowy<str>) -> Result<Self> {
|
/// Create a REDIRECT - TEMPORARY (30) response with a destination
|
||||||
let header = ResponseHeader::server_error(reason)?;
|
pub fn redirect_temporary(dest: impl ToString) -> Self {
|
||||||
Ok(Self::new(header))
|
Self::new(30, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a REDIRECT - PERMANENT (31) response with a destination
|
||||||
|
pub fn redirect_permanent(dest: impl ToString) -> Self {
|
||||||
|
Self::new(31, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a TEMPORARY FAILURE (40) response with a human readable error
|
||||||
|
pub fn temporary_failure(reason: impl ToString) -> Self {
|
||||||
|
Self::new(40, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a SERVER UNAVAILABLE (41) response with a human readable error
|
||||||
|
///
|
||||||
|
/// Used to denote that the server is temporarily unavailable, for example due to
|
||||||
|
/// heavy load, or maintenance
|
||||||
|
pub fn server_unavailable(reason: impl ToString) -> Self {
|
||||||
|
Self::new(41, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a CGI ERROR (42) response with a human readable error
|
||||||
|
pub fn cgi_error(reason: impl ToString) -> Self {
|
||||||
|
Self::new(42, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a PROXY ERROR (43) response with a human readable error
|
||||||
|
pub fn proxy_error(reason: impl ToString) -> Self {
|
||||||
|
Self::new(43, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a SLOW DOWN (44) response with a wait time in seconds
|
||||||
|
///
|
||||||
|
/// Used to denote that the user should wait a certain number of seconds before
|
||||||
|
/// sending another request, often for ratelimiting purposes
|
||||||
|
pub fn slow_down(wait: u64) -> Self {
|
||||||
|
Self::new(44, wait)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a PERMANENT FAILURE (50) response with a human readable error
|
||||||
|
pub fn permanent_failure(reason: impl ToString) -> Self {
|
||||||
|
Self::new(50, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a NOT FOUND (51) response with no further information
|
||||||
|
///
|
||||||
|
/// Essentially a 404
|
||||||
pub fn not_found() -> Self {
|
pub fn not_found() -> Self {
|
||||||
let header = ResponseHeader::not_found();
|
Self::new(51, String::new())
|
||||||
Self::new(header)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bad_request_lossy(reason: impl Cowy<str>) -> Self {
|
/// Create a GONE (52) response with a human readable error
|
||||||
let header = ResponseHeader::bad_request_lossy(reason);
|
///
|
||||||
Self::new(header)
|
/// For when a resource used to be here, but never will be again
|
||||||
|
pub fn gone(reason: impl ToString) -> Self {
|
||||||
|
Self::new(52, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn client_certificate_required() -> Self {
|
/// Create a PROXY REQUEST REFUSED (53) response with a human readable error
|
||||||
let header = ResponseHeader::client_certificate_required();
|
///
|
||||||
Self::new(header)
|
/// The server does not serve content on this domain
|
||||||
|
pub fn proxy_request_refused(reason: impl ToString) -> Self {
|
||||||
|
Self::new(53, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn certificate_not_authorized() -> Self {
|
/// Create a BAD REQUEST (59) response with a human readable error
|
||||||
let header = ResponseHeader::certificate_not_authorized();
|
pub fn bad_request(reason: impl ToString) -> Self {
|
||||||
Self::new(header)
|
Self::new(59, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_body(mut self, body: impl Into<Body>) -> Self {
|
/// Create a CLIENT CERTIFICATE REQUIRED (60) response with a human readable error
|
||||||
self.body = Some(body.into());
|
pub fn client_certificate_required(reason: impl ToString) -> Self {
|
||||||
self
|
Self::new(60, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn header(&self) -> &ResponseHeader {
|
/// Create a CERTIFICATE NOT AUTHORIZED (61) response with a human readable error
|
||||||
&self.header
|
pub fn certificate_not_authorized(reason: impl ToString) -> Self {
|
||||||
|
Self::new(61, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn take_body(&mut self) -> Option<Body> {
|
/// Create a CERTIFICATE NOT VALID (62) response with a human readable error
|
||||||
self.body.take()
|
pub fn certificate_not_valid(reason: impl ToString) -> Self {
|
||||||
|
Self::new(62, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if the response is a SUCCESS (10) response
|
||||||
|
pub const fn is_success(&self) -> bool {
|
||||||
|
self.status == 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,97 +0,0 @@
|
||||||
use std::convert::TryInto;
|
|
||||||
|
|
||||||
use anyhow::*;
|
|
||||||
use uriparse::URIReference;
|
|
||||||
use crate::util::Cowy;
|
|
||||||
use crate::types::{Status, Meta};
|
|
||||||
|
|
||||||
#[derive(Debug,Clone)]
|
|
||||||
pub struct ResponseHeader {
|
|
||||||
pub status: Status,
|
|
||||||
pub meta: Meta,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResponseHeader {
|
|
||||||
pub fn input(prompt: impl Cowy<str>) -> Result<Self> {
|
|
||||||
Ok(Self {
|
|
||||||
status: Status::INPUT,
|
|
||||||
meta: Meta::new(prompt).context("Invalid input prompt")?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn input_lossy(prompt: impl Cowy<str>) -> Self {
|
|
||||||
Self {
|
|
||||||
status: Status::INPUT,
|
|
||||||
meta: Meta::new_lossy(prompt),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn success(mime: impl ToString) -> Self {
|
|
||||||
Self {
|
|
||||||
status: Status::SUCCESS,
|
|
||||||
meta: Meta::new_lossy(mime.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn redirect_temporary_lossy<'a>(location: impl TryInto<URIReference<'a>>) -> 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<str>) -> Result<Self> {
|
|
||||||
Ok(Self {
|
|
||||||
status: Status::PERMANENT_FAILURE,
|
|
||||||
meta: Meta::new(reason).context("Invalid server error reason")?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn server_error_lossy(reason: impl Cowy<str>) -> Self {
|
|
||||||
Self {
|
|
||||||
status: Status::PERMANENT_FAILURE,
|
|
||||||
meta: Meta::new_lossy(reason),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn not_found() -> Self {
|
|
||||||
Self {
|
|
||||||
status: Status::NOT_FOUND,
|
|
||||||
meta: Meta::new_lossy("Not found"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bad_request_lossy(reason: impl Cowy<str>) -> Self {
|
|
||||||
Self {
|
|
||||||
status: Status::BAD_REQUEST,
|
|
||||||
meta: Meta::new_lossy(reason),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn client_certificate_required() -> Self {
|
|
||||||
Self {
|
|
||||||
status: Status::CLIENT_CERTIFICATE_REQUIRED,
|
|
||||||
meta: Meta::new_lossy("No certificate provided"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn certificate_not_authorized() -> Self {
|
|
||||||
Self {
|
|
||||||
status: Status::CERTIFICATE_NOT_AUTHORIZED,
|
|
||||||
meta: Meta::new_lossy("Your certificate is not authorized to view this content"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn status(&self) -> &Status {
|
|
||||||
&self.status
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn meta(&self) -> &Meta {
|
|
||||||
&self.meta
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,82 +0,0 @@
|
||||||
#[derive(Debug,Copy,Clone,PartialEq,Eq)]
|
|
||||||
pub struct Status(u8);
|
|
||||||
|
|
||||||
impl Status {
|
|
||||||
pub const INPUT: Self = Self(10);
|
|
||||||
pub const SENSITIVE_INPUT: Self = Self(11);
|
|
||||||
pub const SUCCESS: Self = Self(20);
|
|
||||||
pub const REDIRECT_TEMPORARY: Self = Self(30);
|
|
||||||
pub const REDIRECT_PERMANENT: Self = Self(31);
|
|
||||||
pub const TEMPORARY_FAILURE: Self = Self(40);
|
|
||||||
pub const SERVER_UNAVAILABLE: Self = Self(41);
|
|
||||||
pub const CGI_ERROR: Self = Self(42);
|
|
||||||
pub const PROXY_ERROR: Self = Self(43);
|
|
||||||
pub const SLOW_DOWN: Self = Self(44);
|
|
||||||
pub const PERMANENT_FAILURE: Self = Self(50);
|
|
||||||
pub const NOT_FOUND: Self = Self(51);
|
|
||||||
pub const GONE: Self = Self(52);
|
|
||||||
pub const PROXY_REQUEST_REFUSED: Self = Self(53);
|
|
||||||
pub const BAD_REQUEST: Self = Self(59);
|
|
||||||
pub const CLIENT_CERTIFICATE_REQUIRED: Self = Self(60);
|
|
||||||
pub const CERTIFICATE_NOT_AUTHORIZED: Self = Self(61);
|
|
||||||
pub const CERTIFICATE_NOT_VALID: Self = Self(62);
|
|
||||||
|
|
||||||
pub const fn code(&self) -> u8 {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_success(&self) -> bool {
|
|
||||||
self.category().is_success()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::missing_const_for_fn)]
|
|
||||||
pub fn category(&self) -> StatusCategory {
|
|
||||||
let class = self.0 / 10;
|
|
||||||
|
|
||||||
match class {
|
|
||||||
1 => StatusCategory::Input,
|
|
||||||
2 => StatusCategory::Success,
|
|
||||||
3 => StatusCategory::Redirect,
|
|
||||||
4 => StatusCategory::TemporaryFailure,
|
|
||||||
5 => StatusCategory::PermanentFailure,
|
|
||||||
6 => StatusCategory::ClientCertificateRequired,
|
|
||||||
_ => StatusCategory::PermanentFailure,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy,Clone,PartialEq,Eq)]
|
|
||||||
pub enum StatusCategory {
|
|
||||||
Input,
|
|
||||||
Success,
|
|
||||||
Redirect,
|
|
||||||
TemporaryFailure,
|
|
||||||
PermanentFailure,
|
|
||||||
ClientCertificateRequired,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StatusCategory {
|
|
||||||
pub fn is_input(&self) -> bool {
|
|
||||||
*self == Self::Input
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_success(&self) -> bool {
|
|
||||||
*self == Self::Success
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn redirect(&self) -> bool {
|
|
||||||
*self == Self::Redirect
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_temporary_failure(&self) -> bool {
|
|
||||||
*self == Self::TemporaryFailure
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_permanent_failure(&self) -> bool {
|
|
||||||
*self == Self::PermanentFailure
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_client_certificate_required(&self) -> bool {
|
|
||||||
*self == Self::ClientCertificateRequired
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -165,7 +165,7 @@ impl UserManagementRoutes for crate::Server {
|
||||||
if let Some(input) = request.input().map(str::to_owned) {
|
if let Some(input) = request.input().map(str::to_owned) {
|
||||||
(handler.clone())(request, user, input).await
|
(handler.clone())(request, user, input).await
|
||||||
} else {
|
} else {
|
||||||
Response::input(prompt)
|
Ok(Response::input(prompt))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -207,7 +207,7 @@ async fn handle_base<UserData: Serialize + DeserializeOwned>(request: Request) -
|
||||||
async fn handle_ask_cert<UserData: Serialize + DeserializeOwned>(request: Request) -> Result<Response> {
|
async fn handle_ask_cert<UserData: Serialize + DeserializeOwned>(request: Request) -> Result<Response> {
|
||||||
Ok(match request.user::<UserData>()? {
|
Ok(match request.user::<UserData>()? {
|
||||||
User::Unauthenticated => {
|
User::Unauthenticated => {
|
||||||
Response::client_certificate_required()
|
Response::client_certificate_required("Please select a client certificate to proceed.")
|
||||||
},
|
},
|
||||||
User::NotSignedIn(nsi) => {
|
User::NotSignedIn(nsi) => {
|
||||||
let segments = request.trailing_segments().iter().map(String::as_str).collect::<Vec<&str>>();
|
let segments = request.trailing_segments().iter().map(String::as_str).collect::<Vec<&str>>();
|
||||||
|
@ -270,7 +270,7 @@ async fn handle_register<UserData: Serialize + DeserializeOwned + Default>(reque
|
||||||
Err(e) => return Err(e.into())
|
Err(e) => return Err(e.into())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Response::input_lossy("Please pick a username")
|
Response::input("Please pick a username")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
User::SignedIn(user) => {
|
User::SignedIn(user) => {
|
||||||
|
@ -305,14 +305,14 @@ async fn handle_login<UserData: Serialize + DeserializeOwned + Default>(request:
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Response::input_lossy("Please enter your password")
|
Response::sensitive_input("Please enter your password")
|
||||||
}
|
}
|
||||||
} else if let Some(username) = request.input() {
|
} else if let Some(username) = request.input() {
|
||||||
Response::redirect_temporary_lossy(
|
Response::redirect_temporary(
|
||||||
format!("/account/login/{}", username).as_str()
|
format!("/account/login/{}", username).as_str()
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Response::input_lossy("Please enter your username")
|
Response::input("Please enter your username")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
User::SignedIn(user) => {
|
User::SignedIn(user) => {
|
||||||
|
@ -336,7 +336,7 @@ async fn handle_password<UserData: Serialize + DeserializeOwned + Default>(reque
|
||||||
user.set_password(password)?;
|
user.set_password(password)?;
|
||||||
Response::success_gemini(include_str!("pages/password/success.gmi"))
|
Response::success_gemini(include_str!("pages/password/success.gmi"))
|
||||||
} else {
|
} else {
|
||||||
Response::input(
|
Response::sensitive_input(
|
||||||
format!("Please enter a {}password",
|
format!("Please enter a {}password",
|
||||||
if user.has_password() {
|
if user.has_password() {
|
||||||
"new "
|
"new "
|
||||||
|
@ -344,7 +344,7 @@ async fn handle_password<UserData: Serialize + DeserializeOwned + Default>(reque
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)?
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
36
src/util.rs
36
src/util.rs
|
@ -14,7 +14,7 @@ use std::future::Future;
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
|
|
||||||
#[cfg(feature="serve_dir")]
|
#[cfg(feature="serve_dir")]
|
||||||
pub async fn serve_file<P: AsRef<Path>>(path: P, mime: &str) -> Result<Response> {
|
pub async fn serve_file<P: AsRef<Path>>(path: P, mime: &str) -> Response {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
|
|
||||||
let file = match File::open(path).await {
|
let file = match File::open(path).await {
|
||||||
|
@ -22,17 +22,17 @@ pub async fn serve_file<P: AsRef<Path>>(path: P, mime: &str) -> Result<Response>
|
||||||
Err(err) => match err.kind() {
|
Err(err) => match err.kind() {
|
||||||
std::io::ErrorKind::PermissionDenied => {
|
std::io::ErrorKind::PermissionDenied => {
|
||||||
warn!("Asked to serve {}, but permission denied by OS", path.display());
|
warn!("Asked to serve {}, but permission denied by OS", path.display());
|
||||||
return Ok(Response::not_found());
|
return Response::not_found();
|
||||||
},
|
},
|
||||||
_ => return warn_unexpected(err, path, line!()),
|
_ => return warn_unexpected(err, path, line!()),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Response::success(mime, file))
|
Response::success(mime, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature="serve_dir")]
|
#[cfg(feature="serve_dir")]
|
||||||
pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P]) -> Result<Response> {
|
pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P]) -> Response {
|
||||||
debug!("Dir: {}", dir.as_ref().display());
|
debug!("Dir: {}", dir.as_ref().display());
|
||||||
let dir = dir.as_ref();
|
let dir = dir.as_ref();
|
||||||
let dir = match dir.canonicalize() {
|
let dir = match dir.canonicalize() {
|
||||||
|
@ -41,11 +41,11 @@ pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P
|
||||||
match e.kind() {
|
match e.kind() {
|
||||||
std::io::ErrorKind::NotFound => {
|
std::io::ErrorKind::NotFound => {
|
||||||
warn!("Path {} not found. Check your configuration.", dir.display());
|
warn!("Path {} not found. Check your configuration.", dir.display());
|
||||||
return Response::server_error("Server incorrectly configured")
|
return Response::temporary_failure("Server incorrectly configured")
|
||||||
},
|
},
|
||||||
std::io::ErrorKind::PermissionDenied => {
|
std::io::ErrorKind::PermissionDenied => {
|
||||||
warn!("Permission denied for {}. Check that the server has access.", dir.display());
|
warn!("Permission denied for {}. Check that the server has access.", dir.display());
|
||||||
return Response::server_error("Server incorrectly configured")
|
return Response::temporary_failure("Server incorrectly configured")
|
||||||
},
|
},
|
||||||
_ => return warn_unexpected(e, dir, line!()),
|
_ => return warn_unexpected(e, dir, line!()),
|
||||||
}
|
}
|
||||||
|
@ -61,12 +61,12 @@ pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P
|
||||||
Ok(dir) => dir,
|
Ok(dir) => dir,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
match e.kind() {
|
match e.kind() {
|
||||||
std::io::ErrorKind::NotFound => return Ok(Response::not_found()),
|
std::io::ErrorKind::NotFound => return Response::not_found(),
|
||||||
std::io::ErrorKind::PermissionDenied => {
|
std::io::ErrorKind::PermissionDenied => {
|
||||||
// Runs when asked to serve a file in a restricted dir
|
// Runs when asked to serve a file in a restricted dir
|
||||||
// i.e. not /noaccess, but /noaccess/file
|
// i.e. not /noaccess, but /noaccess/file
|
||||||
warn!("Asked to serve {}, but permission denied by OS", path.display());
|
warn!("Asked to serve {}, but permission denied by OS", path.display());
|
||||||
return Ok(Response::not_found());
|
return Response::not_found();
|
||||||
},
|
},
|
||||||
_ => return warn_unexpected(e, path.as_ref(), line!()),
|
_ => return warn_unexpected(e, path.as_ref(), line!()),
|
||||||
}
|
}
|
||||||
|
@ -74,7 +74,7 @@ pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P
|
||||||
};
|
};
|
||||||
|
|
||||||
if !path.starts_with(&dir) {
|
if !path.starts_with(&dir) {
|
||||||
return Ok(Response::not_found());
|
return Response::not_found();
|
||||||
}
|
}
|
||||||
|
|
||||||
if !path.is_dir() {
|
if !path.is_dir() {
|
||||||
|
@ -86,14 +86,14 @@ pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature="serve_dir")]
|
#[cfg(feature="serve_dir")]
|
||||||
async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path: &[B]) -> Result<Response> {
|
async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path: &[B]) -> Response {
|
||||||
let mut dir = match fs::read_dir(path.as_ref()).await {
|
let mut dir = match fs::read_dir(path.as_ref()).await {
|
||||||
Ok(dir) => dir,
|
Ok(dir) => dir,
|
||||||
Err(err) => match err.kind() {
|
Err(err) => match err.kind() {
|
||||||
io::ErrorKind::NotFound => return Ok(Response::not_found()),
|
io::ErrorKind::NotFound => return Response::not_found(),
|
||||||
std::io::ErrorKind::PermissionDenied => {
|
std::io::ErrorKind::PermissionDenied => {
|
||||||
warn!("Asked to serve {}, but permission denied by OS", path.as_ref().display());
|
warn!("Asked to serve {}, but permission denied by OS", path.as_ref().display());
|
||||||
return Ok(Response::not_found());
|
return Response::not_found();
|
||||||
},
|
},
|
||||||
_ => return warn_unexpected(err, path.as_ref(), line!()),
|
_ => return warn_unexpected(err, path.as_ref(), line!()),
|
||||||
}
|
}
|
||||||
|
@ -109,12 +109,10 @@ async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path
|
||||||
document.add_link("..", "📁 ../");
|
document.add_link("..", "📁 ../");
|
||||||
}
|
}
|
||||||
|
|
||||||
while let Some(entry) = dir.next_entry().await.context("Failed to list directory")? {
|
while let Some(entry) = dir.next_entry().await.expect("Failed to list directory") {
|
||||||
let file_name = entry.file_name();
|
let file_name = entry.file_name();
|
||||||
let file_name = file_name.to_string_lossy();
|
let file_name = file_name.to_string_lossy();
|
||||||
let is_dir = entry.file_type().await
|
let is_dir = entry.file_type().await.unwrap().is_dir();
|
||||||
.with_context(|| format!("Failed to get file type of `{}`", entry.path().display()))?
|
|
||||||
.is_dir();
|
|
||||||
let trailing_slash = if is_dir { "/" } else { "" };
|
let trailing_slash = if is_dir { "/" } else { "" };
|
||||||
let uri = format!("./{}{}", file_name, trailing_slash);
|
let uri = format!("./{}{}", file_name, trailing_slash);
|
||||||
|
|
||||||
|
@ -125,7 +123,7 @@ async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(document.into())
|
document.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature="serve_dir")]
|
#[cfg(feature="serve_dir")]
|
||||||
|
@ -146,7 +144,7 @@ pub fn guess_mime_from_path<P: AsRef<Path>>(path: P) -> &'static str {
|
||||||
|
|
||||||
#[cfg(feature="serve_dir")]
|
#[cfg(feature="serve_dir")]
|
||||||
/// Print a warning to the log asking to file an issue and respond with "Unexpected Error"
|
/// Print a warning to the log asking to file an issue and respond with "Unexpected Error"
|
||||||
pub (crate) fn warn_unexpected(err: impl std::fmt::Debug, path: &Path, line: u32) -> Result<Response> {
|
pub (crate) fn warn_unexpected(err: impl std::fmt::Debug, path: &Path, line: u32) -> Response {
|
||||||
warn!(
|
warn!(
|
||||||
concat!(
|
concat!(
|
||||||
"Unexpected error serving path {} at util.rs:{}, please report to ",
|
"Unexpected error serving path {} at util.rs:{}, please report to ",
|
||||||
|
@ -157,7 +155,7 @@ pub (crate) fn warn_unexpected(err: impl std::fmt::Debug, path: &Path, line: u32
|
||||||
line,
|
line,
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
Response::server_error("Unexpected error")
|
Response::temporary_failure("Unexpected error")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A convenience trait alias for `AsRef<T> + Into<T::Owned>`,
|
/// A convenience trait alias for `AsRef<T> + Into<T::Owned>`,
|
||||||
|
|
Loading…
Reference in a new issue