mirror of
https://git.asonix.dog/asonix/http-signature-normalization.git
synced 2024-11-24 18:31:01 +00:00
Don't use async-trait
This commit is contained in:
parent
20cad6bea8
commit
a38b6aa1ed
7 changed files with 132 additions and 74 deletions
|
@ -27,7 +27,6 @@ required-features = ["sha-2"]
|
||||||
[dependencies]
|
[dependencies]
|
||||||
actix-web = "3.0.0-alpha.1"
|
actix-web = "3.0.0-alpha.1"
|
||||||
actix-http = "2.0.0-alpha.2"
|
actix-http = "2.0.0-alpha.2"
|
||||||
async-trait = "0.1.27"
|
|
||||||
base64 = { version = "0.11", optional = true }
|
base64 = { version = "0.11", optional = true }
|
||||||
bytes = "0.5.4"
|
bytes = "0.5.4"
|
||||||
chrono = "0.4.6"
|
chrono = "0.4.6"
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
use actix_web::client::Client;
|
use actix_web::{client::Client, error::BlockingError};
|
||||||
use http_signature_normalization_actix::prelude::*;
|
use http_signature_normalization_actix::prelude::*;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
async fn request(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
async fn request(config: Config) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut digest = Sha256::new();
|
let digest = Sha256::new();
|
||||||
|
|
||||||
let mut response = Client::default()
|
let mut response = Client::default()
|
||||||
.post("http://127.0.0.1:8010/")
|
.post("http://127.0.0.1:8010/")
|
||||||
.header("User-Agent", "Actix Web")
|
.header("User-Agent", "Actix Web")
|
||||||
.set(actix_web::http::header::Date(SystemTime::now().into()))
|
.set(actix_web::http::header::Date(SystemTime::now().into()))
|
||||||
.signature_with_digest(&config, "my-key-id", &mut digest, "Hewwo-owo", |s| {
|
.signature_with_digest(config, "my-key-id", digest, "Hewwo-owo", |s| {
|
||||||
println!("Signing String\n{}", s);
|
println!("Signing String\n{}", s);
|
||||||
Ok(base64::encode(s)) as Result<_, MyError>
|
Ok(base64::encode(s)) as Result<_, MyError>
|
||||||
})?
|
})
|
||||||
|
.await?
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
|
@ -55,4 +56,16 @@ pub enum MyError {
|
||||||
|
|
||||||
#[error("Failed to retrieve request body")]
|
#[error("Failed to retrieve request body")]
|
||||||
Body,
|
Body,
|
||||||
|
|
||||||
|
#[error("Blocking operation was canceled")]
|
||||||
|
Canceled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BlockingError<MyError>> for MyError {
|
||||||
|
fn from(b: BlockingError<MyError>) -> Self {
|
||||||
|
match b {
|
||||||
|
BlockingError::Error(e) => e,
|
||||||
|
_ => MyError::Canceled,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
use actix_web::{http::StatusCode, web, App, HttpRequest, HttpResponse, HttpServer, ResponseError};
|
use actix_web::{
|
||||||
|
http::StatusCode, middleware::Logger, web, App, HttpRequest, HttpResponse, HttpServer,
|
||||||
|
ResponseError,
|
||||||
|
};
|
||||||
use futures::future::{err, ok, Ready};
|
use futures::future::{err, ok, Ready};
|
||||||
use http_signature_normalization_actix::prelude::*;
|
use http_signature_normalization_actix::prelude::*;
|
||||||
use log::info;
|
use log::info;
|
||||||
|
@ -59,6 +62,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
App::new()
|
App::new()
|
||||||
.wrap(VerifyDigest::new(Sha256::new()).optional())
|
.wrap(VerifyDigest::new(Sha256::new()).optional())
|
||||||
.wrap(VerifySignature::new(MyVerify, config.clone()).optional())
|
.wrap(VerifySignature::new(MyVerify, config.clone()).optional())
|
||||||
|
.wrap(Logger::default())
|
||||||
.route("/", web::post().to(index))
|
.route("/", web::post().to(index))
|
||||||
})
|
})
|
||||||
.bind("127.0.0.1:8010")?
|
.bind("127.0.0.1:8010")?
|
||||||
|
|
|
@ -10,7 +10,7 @@ use actix_web::{
|
||||||
error::BlockingError,
|
error::BlockingError,
|
||||||
http::header::{InvalidHeaderValue, ToStrError},
|
http::header::{InvalidHeaderValue, ToStrError},
|
||||||
};
|
};
|
||||||
use std::{fmt::Display, future::Future};
|
use std::{fmt::Display, future::Future, pin::Pin};
|
||||||
|
|
||||||
use crate::{Config, Sign};
|
use crate::{Config, Sign};
|
||||||
|
|
||||||
|
@ -44,17 +44,16 @@ pub trait DigestVerify {
|
||||||
/// It generates HTTP Signatures after the Digest header has been added, in order to have
|
/// It generates HTTP Signatures after the Digest header has been added, in order to have
|
||||||
/// verification that the body has not been tampered with, or that the request can't be replayed by
|
/// verification that the body has not been tampered with, or that the request can't be replayed by
|
||||||
/// a malicious entity
|
/// a malicious entity
|
||||||
#[async_trait::async_trait(?Send)]
|
|
||||||
pub trait SignExt: Sign {
|
pub trait SignExt: Sign {
|
||||||
/// Set the Digest and Authorization headers on the request
|
/// Set the Digest and Authorization headers on the request
|
||||||
async fn authorization_signature_with_digest<F, E, K, D, V>(
|
fn authorization_signature_with_digest<F, E, K, D, V>(
|
||||||
self,
|
self,
|
||||||
config: &Config,
|
config: Config,
|
||||||
key_id: K,
|
key_id: K,
|
||||||
digest: &mut D,
|
digest: D,
|
||||||
v: V,
|
v: V,
|
||||||
f: F,
|
f: F,
|
||||||
) -> Result<DigestClient<V>, E>
|
) -> Pin<Box<dyn Future<Output = Result<DigestClient<V>, E>>>>
|
||||||
where
|
where
|
||||||
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
||||||
E: From<BlockingError<E>>
|
E: From<BlockingError<E>>
|
||||||
|
@ -63,20 +62,20 @@ pub trait SignExt: Sign {
|
||||||
+ std::fmt::Debug
|
+ std::fmt::Debug
|
||||||
+ Send
|
+ Send
|
||||||
+ 'static,
|
+ 'static,
|
||||||
K: Display,
|
K: Display + 'static,
|
||||||
D: DigestCreate,
|
D: DigestCreate + Send + 'static,
|
||||||
V: AsRef<[u8]>,
|
V: AsRef<[u8]> + Send + 'static,
|
||||||
Self: Sized;
|
Self: Sized;
|
||||||
|
|
||||||
/// Set the Digest and Signature headers on the request
|
/// Set the Digest and Signature headers on the request
|
||||||
async fn signature_with_digest<F, E, K, D, V>(
|
fn signature_with_digest<F, E, K, D, V>(
|
||||||
self,
|
self,
|
||||||
config: &Config,
|
config: Config,
|
||||||
key_id: K,
|
key_id: K,
|
||||||
digest: &mut D,
|
digest: D,
|
||||||
v: V,
|
v: V,
|
||||||
f: F,
|
f: F,
|
||||||
) -> Result<DigestClient<V>, E>
|
) -> Pin<Box<dyn Future<Output = Result<DigestClient<V>, E>>>>
|
||||||
where
|
where
|
||||||
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
||||||
E: From<BlockingError<E>>
|
E: From<BlockingError<E>>
|
||||||
|
@ -85,9 +84,9 @@ pub trait SignExt: Sign {
|
||||||
+ std::fmt::Debug
|
+ std::fmt::Debug
|
||||||
+ Send
|
+ Send
|
||||||
+ 'static,
|
+ 'static,
|
||||||
K: Display,
|
K: Display + 'static,
|
||||||
D: DigestCreate,
|
D: DigestCreate + Send + 'static,
|
||||||
V: AsRef<[u8]>,
|
V: AsRef<[u8]> + Send + 'static,
|
||||||
Self: Sized;
|
Self: Sized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,24 +2,24 @@ use actix_web::{
|
||||||
client::ClientRequest,
|
client::ClientRequest,
|
||||||
error::BlockingError,
|
error::BlockingError,
|
||||||
http::header::{InvalidHeaderValue, ToStrError},
|
http::header::{InvalidHeaderValue, ToStrError},
|
||||||
|
web,
|
||||||
};
|
};
|
||||||
use std::fmt::Display;
|
use std::{fmt::Display, future::Future, pin::Pin};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
digest::{DigestClient, DigestCreate, SignExt},
|
digest::{DigestClient, DigestCreate, SignExt},
|
||||||
Config, Sign,
|
Config, Sign,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[async_trait::async_trait(?Send)]
|
|
||||||
impl SignExt for ClientRequest {
|
impl SignExt for ClientRequest {
|
||||||
async fn authorization_signature_with_digest<F, E, K, D, V>(
|
fn authorization_signature_with_digest<F, E, K, D, V>(
|
||||||
self,
|
self,
|
||||||
config: &Config,
|
config: Config,
|
||||||
key_id: K,
|
key_id: K,
|
||||||
digest: &mut D,
|
mut digest: D,
|
||||||
v: V,
|
v: V,
|
||||||
f: F,
|
f: F,
|
||||||
) -> Result<DigestClient<V>, E>
|
) -> Pin<Box<dyn Future<Output = Result<DigestClient<V>, E>>>>
|
||||||
where
|
where
|
||||||
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
||||||
E: From<BlockingError<E>>
|
E: From<BlockingError<E>>
|
||||||
|
@ -28,27 +28,35 @@ impl SignExt for ClientRequest {
|
||||||
+ std::fmt::Debug
|
+ std::fmt::Debug
|
||||||
+ Send
|
+ Send
|
||||||
+ 'static,
|
+ 'static,
|
||||||
K: Display,
|
K: Display + 'static,
|
||||||
D: DigestCreate,
|
D: DigestCreate + Send + 'static,
|
||||||
V: AsRef<[u8]>,
|
V: AsRef<[u8]> + Send + 'static,
|
||||||
Self: Sized,
|
Self: Sized,
|
||||||
{
|
{
|
||||||
let digest = digest.compute(v.as_ref());
|
Box::pin(async move {
|
||||||
|
let (d, v) = web::block(move || {
|
||||||
|
let d = digest.compute(v.as_ref());
|
||||||
|
Ok((d, v)) as Result<(String, V), E>
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
self.set_header("Digest", format!("{}={}", D::NAME, digest))
|
let c = self
|
||||||
.authorization_signature(config, key_id, f)
|
.set_header("Digest", format!("{}={}", D::NAME, d))
|
||||||
.await
|
.authorization_signature(config, key_id, f)
|
||||||
.map(|c| DigestClient::new(c, v))
|
.await?;
|
||||||
|
|
||||||
|
Ok(DigestClient::new(c, v))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn signature_with_digest<F, E, K, D, V>(
|
fn signature_with_digest<F, E, K, D, V>(
|
||||||
self,
|
self,
|
||||||
config: &Config,
|
config: Config,
|
||||||
key_id: K,
|
key_id: K,
|
||||||
digest: &mut D,
|
mut digest: D,
|
||||||
v: V,
|
v: V,
|
||||||
f: F,
|
f: F,
|
||||||
) -> Result<DigestClient<V>, E>
|
) -> Pin<Box<dyn Future<Output = Result<DigestClient<V>, E>>>>
|
||||||
where
|
where
|
||||||
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
||||||
E: From<BlockingError<E>>
|
E: From<BlockingError<E>>
|
||||||
|
@ -57,16 +65,24 @@ impl SignExt for ClientRequest {
|
||||||
+ std::fmt::Debug
|
+ std::fmt::Debug
|
||||||
+ Send
|
+ Send
|
||||||
+ 'static,
|
+ 'static,
|
||||||
K: Display,
|
K: Display + 'static,
|
||||||
D: DigestCreate,
|
D: DigestCreate + Send + 'static,
|
||||||
V: AsRef<[u8]>,
|
V: AsRef<[u8]> + Send + 'static,
|
||||||
Self: Sized,
|
Self: Sized,
|
||||||
{
|
{
|
||||||
let digest = digest.compute(v.as_ref());
|
Box::pin(async move {
|
||||||
|
let (d, v) = web::block(move || {
|
||||||
|
let d = digest.compute(v.as_ref());
|
||||||
|
Ok((d, v)) as Result<(String, V), E>
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
self.set_header("Digest", format!("{}={}", D::NAME, digest))
|
let c = self
|
||||||
.signature(config, key_id, f)
|
.set_header("Digest", format!("{}={}", D::NAME, d))
|
||||||
.await
|
.signature(config, key_id, f)
|
||||||
.map(|c| DigestClient::new(c, v))
|
.await?;
|
||||||
|
|
||||||
|
Ok(DigestClient::new(c, v))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -99,21 +99,24 @@
|
||||||
//!
|
//!
|
||||||
//! ### Use it in a client
|
//! ### Use it in a client
|
||||||
//! ```rust,ignore
|
//! ```rust,ignore
|
||||||
//! use actix_web::client::Client;
|
//! use actix_web::{client::Client, error::BlockingError};
|
||||||
//! use http_signature_normalization_actix::prelude::*;
|
//! use http_signature_normalization_actix::prelude::*;
|
||||||
//! use sha2::{Digest, Sha256};
|
//! use sha2::{Digest, Sha256};
|
||||||
//!
|
//!
|
||||||
//! #[actix_rt::main]
|
//! #[actix_rt::main]
|
||||||
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
//! let config = Config::default();
|
//! let config = Config::default();
|
||||||
//! let mut digest = Sha256::new();
|
//! let digest = Sha256::new();
|
||||||
//!
|
//!
|
||||||
//! let mut response = Client::default()
|
//! let mut response = Client::default()
|
||||||
//! .post("http://127.0.0.1:8010/")
|
//! .post("http://127.0.0.1:8010/")
|
||||||
//! .header("User-Agent", "Actix Web")
|
//! .header("User-Agent", "Actix Web")
|
||||||
//! .authorization_signature_with_digest(&config, "my-key-id", &mut digest, "Hewwo-owo", |s| {
|
//! .set(actix_web::http::header::Date(SystemTime::now().into()))
|
||||||
|
//! .signature_with_digest(config, "my-key-id", digest, "Hewwo-owo", |s| {
|
||||||
|
//! println!("Signing String\n{}", s);
|
||||||
//! Ok(base64::encode(s)) as Result<_, MyError>
|
//! Ok(base64::encode(s)) as Result<_, MyError>
|
||||||
//! })?
|
//! })
|
||||||
|
//! .await?
|
||||||
//! .send()
|
//! .send()
|
||||||
//! .await
|
//! .await
|
||||||
//! .map_err(|e| {
|
//! .map_err(|e| {
|
||||||
|
@ -143,6 +146,18 @@
|
||||||
//!
|
//!
|
||||||
//! #[error("Failed to retrieve request body")]
|
//! #[error("Failed to retrieve request body")]
|
||||||
//! Body,
|
//! Body,
|
||||||
|
//!
|
||||||
|
//! #[error("Blocking operation was canceled")]
|
||||||
|
//! Canceled,
|
||||||
|
//! }
|
||||||
|
//!
|
||||||
|
//! impl From<BlockingError<MyError>> for MyError {
|
||||||
|
//! fn from(b: BlockingError<MyError>) -> Self {
|
||||||
|
//! match b {
|
||||||
|
//! BlockingError::Error(e) => e,
|
||||||
|
//! _ => MyError::Canceled,
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
@ -155,7 +170,7 @@ use actix_web::{
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use chrono::Duration;
|
use chrono::Duration;
|
||||||
use std::{collections::BTreeMap, fmt::Display, future::Future};
|
use std::{collections::BTreeMap, fmt::Display, future::Future, pin::Pin};
|
||||||
|
|
||||||
mod sign;
|
mod sign;
|
||||||
|
|
||||||
|
@ -216,15 +231,14 @@ pub trait SignatureVerify {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A trait implemented by the Actix Web ClientRequest type to add an HTTP signature to the request
|
/// A trait implemented by the Actix Web ClientRequest type to add an HTTP signature to the request
|
||||||
#[async_trait::async_trait(?Send)]
|
|
||||||
pub trait Sign {
|
pub trait Sign {
|
||||||
/// Add an Authorization Signature to the request
|
/// Add an Authorization Signature to the request
|
||||||
async fn authorization_signature<F, E, K>(
|
fn authorization_signature<F, E, K>(
|
||||||
self,
|
self,
|
||||||
config: &Config,
|
config: Config,
|
||||||
key_id: K,
|
key_id: K,
|
||||||
f: F,
|
f: F,
|
||||||
) -> Result<Self, E>
|
) -> Pin<Box<dyn Future<Output = Result<Self, E>>>>
|
||||||
where
|
where
|
||||||
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
||||||
E: From<BlockingError<E>>
|
E: From<BlockingError<E>>
|
||||||
|
@ -233,11 +247,16 @@ pub trait Sign {
|
||||||
+ std::fmt::Debug
|
+ std::fmt::Debug
|
||||||
+ Send
|
+ Send
|
||||||
+ 'static,
|
+ 'static,
|
||||||
K: Display,
|
K: Display + 'static,
|
||||||
Self: Sized;
|
Self: Sized;
|
||||||
|
|
||||||
/// Add a Signature to the request
|
/// Add a Signature to the request
|
||||||
async fn signature<F, E, K>(self, config: &Config, key_id: K, f: F) -> Result<Self, E>
|
fn signature<F, E, K>(
|
||||||
|
self,
|
||||||
|
config: Config,
|
||||||
|
key_id: K,
|
||||||
|
f: F,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<Self, E>>>>
|
||||||
where
|
where
|
||||||
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
||||||
E: From<BlockingError<E>>
|
E: From<BlockingError<E>>
|
||||||
|
@ -246,7 +265,7 @@ pub trait Sign {
|
||||||
+ std::fmt::Debug
|
+ std::fmt::Debug
|
||||||
+ Send
|
+ Send
|
||||||
+ 'static,
|
+ 'static,
|
||||||
K: Display,
|
K: Display + 'static,
|
||||||
Self: Sized;
|
Self: Sized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,18 +4,17 @@ use actix_web::{
|
||||||
http::header::{InvalidHeaderValue, ToStrError},
|
http::header::{InvalidHeaderValue, ToStrError},
|
||||||
web,
|
web,
|
||||||
};
|
};
|
||||||
use std::fmt::Display;
|
use std::{fmt::Display, future::Future, pin::Pin};
|
||||||
|
|
||||||
use crate::{create::Signed, Config, Sign};
|
use crate::{create::Signed, Config, Sign};
|
||||||
|
|
||||||
#[async_trait::async_trait(?Send)]
|
|
||||||
impl Sign for ClientRequest {
|
impl Sign for ClientRequest {
|
||||||
async fn authorization_signature<F, E, K>(
|
fn authorization_signature<F, E, K>(
|
||||||
mut self,
|
mut self,
|
||||||
config: &Config,
|
config: Config,
|
||||||
key_id: K,
|
key_id: K,
|
||||||
f: F,
|
f: F,
|
||||||
) -> Result<Self, E>
|
) -> Pin<Box<dyn Future<Output = Result<Self, E>>>>
|
||||||
where
|
where
|
||||||
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
||||||
E: From<BlockingError<E>>
|
E: From<BlockingError<E>>
|
||||||
|
@ -24,15 +23,22 @@ impl Sign for ClientRequest {
|
||||||
+ std::fmt::Debug
|
+ std::fmt::Debug
|
||||||
+ Send
|
+ Send
|
||||||
+ 'static,
|
+ 'static,
|
||||||
K: Display,
|
K: Display + 'static,
|
||||||
Self: Sized,
|
Self: Sized,
|
||||||
{
|
{
|
||||||
let signed = prepare(&self, config, key_id, f).await?;
|
Box::pin(async move {
|
||||||
signed.authorization_header(self.headers_mut())?;
|
let signed = prepare(&self, &config, key_id, f).await?;
|
||||||
Ok(self)
|
signed.authorization_header(self.headers_mut())?;
|
||||||
|
Ok(self)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn signature<F, E, K>(mut self, config: &Config, key_id: K, f: F) -> Result<Self, E>
|
fn signature<F, E, K>(
|
||||||
|
mut self,
|
||||||
|
config: Config,
|
||||||
|
key_id: K,
|
||||||
|
f: F,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<Self, E>>>>
|
||||||
where
|
where
|
||||||
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
F: FnOnce(&str) -> Result<String, E> + Send + 'static,
|
||||||
E: From<BlockingError<E>>
|
E: From<BlockingError<E>>
|
||||||
|
@ -41,12 +47,14 @@ impl Sign for ClientRequest {
|
||||||
+ std::fmt::Debug
|
+ std::fmt::Debug
|
||||||
+ Send
|
+ Send
|
||||||
+ 'static,
|
+ 'static,
|
||||||
K: Display,
|
K: Display + 'static,
|
||||||
Self: Sized,
|
Self: Sized,
|
||||||
{
|
{
|
||||||
let signed = prepare(&self, config, key_id, f).await?;
|
Box::pin(async move {
|
||||||
signed.signature_header(self.headers_mut())?;
|
let signed = prepare(&self, &config, key_id, f).await?;
|
||||||
Ok(self)
|
signed.signature_header(self.headers_mut())?;
|
||||||
|
Ok(self)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue