Use enum_delegate crate (#5)
This commit is contained in:
parent
34a41d327f
commit
da32da5a67
11 changed files with 65 additions and 259 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1 +1,2 @@
|
||||||
/target
|
/target
|
||||||
|
/.idea
|
||||||
|
|
3
.idea/.gitignore
vendored
3
.idea/.gitignore
vendored
|
@ -1,3 +0,0 @@
|
||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
|
@ -1,14 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="JAVA_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
||||||
<exclude-output />
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/derive/src" isTestSource="false" />
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/examples" isTestSource="false" />
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
|
@ -1,8 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/activitypub-federation-rust.iml" filepath="$PROJECT_DIR$/.idea/activitypub-federation-rust.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
|
@ -1,6 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
91
Cargo.lock
generated
91
Cargo.lock
generated
|
@ -6,7 +6,6 @@ version = 3
|
||||||
name = "activitypub_federation"
|
name = "activitypub_federation"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub_federation_derive",
|
|
||||||
"activitystreams-kinds",
|
"activitystreams-kinds",
|
||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
|
@ -17,6 +16,7 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"derive_builder",
|
"derive_builder",
|
||||||
"dyn-clone",
|
"dyn-clone",
|
||||||
|
"enum_delegate",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"http",
|
"http",
|
||||||
"http-signature-normalization-actix",
|
"http-signature-normalization-actix",
|
||||||
|
@ -36,16 +36,6 @@ dependencies = [
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "activitypub_federation_derive"
|
|
||||||
version = "0.2.0"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
"trybuild",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "activitystreams-kinds"
|
name = "activitystreams-kinds"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
@ -525,12 +515,6 @@ dependencies = [
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dissimilar"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8c97b9233581d84b8e1e689cdd3a47b6f69770084fc246e86a7f78b0d9c1d4a5"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dyn-clone"
|
name = "dyn-clone"
|
||||||
version = "1.0.9"
|
version = "1.0.9"
|
||||||
|
@ -552,6 +536,30 @@ dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enum_delegate"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8ea75f31022cba043afe037940d73684327e915f88f62478e778c3de914cd0a"
|
||||||
|
dependencies = [
|
||||||
|
"enum_delegate_lib",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enum_delegate_lib"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2e1f6c3800b304a6be0012039e2a45a322a093539c45ab818d9e6895a39c90fe"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rand",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "env_logger"
|
name = "env_logger"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
|
@ -716,12 +724,6 @@ dependencies = [
|
||||||
"wasi",
|
"wasi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "glob"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
|
@ -1572,15 +1574,6 @@ dependencies = [
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "termcolor"
|
|
||||||
version = "1.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755"
|
|
||||||
dependencies = [
|
|
||||||
"winapi-util",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.37"
|
version = "1.0.37"
|
||||||
|
@ -1691,15 +1684,6 @@ dependencies = [
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml"
|
|
||||||
version = "0.5.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-service"
|
name = "tower-service"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
|
@ -1776,22 +1760,6 @@ version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642"
|
checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "trybuild"
|
|
||||||
version = "1.0.65"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9e13556ba7dba80b3c76d1331989a341290c77efcf688eca6c307ee3066383dd"
|
|
||||||
dependencies = [
|
|
||||||
"dissimilar",
|
|
||||||
"glob",
|
|
||||||
"once_cell",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"termcolor",
|
|
||||||
"toml",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
|
@ -1970,15 +1938,6 @@ version = "0.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winapi-util"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178"
|
|
||||||
dependencies = [
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-x86_64-pc-windows-gnu"
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
|
|
|
@ -7,11 +7,7 @@ license = "AGPL-3.0"
|
||||||
repository = "https://github.com/LemmyNet/activitypub-federation-rust"
|
repository = "https://github.com/LemmyNet/activitypub-federation-rust"
|
||||||
documentation = "https://docs.rs/activitypub_federation/"
|
documentation = "https://docs.rs/activitypub_federation/"
|
||||||
|
|
||||||
[workspace]
|
|
||||||
members = ["derive"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
activitypub_federation_derive = { version = "0.2.0", path = "derive" }
|
|
||||||
chrono = { version = "0.4.22", features = ["clock"], default-features = false }
|
chrono = { version = "0.4.22", features = ["clock"], default-features = false }
|
||||||
serde = { version = "1.0.145", features = ["derive"] }
|
serde = { version = "1.0.145", features = ["derive"] }
|
||||||
async-trait = "0.1.57"
|
async-trait = "0.1.57"
|
||||||
|
@ -34,6 +30,7 @@ thiserror = "1.0.37"
|
||||||
derive_builder = "0.11.2"
|
derive_builder = "0.11.2"
|
||||||
itertools = "0.10.5"
|
itertools = "0.10.5"
|
||||||
dyn-clone = "1.0.9"
|
dyn-clone = "1.0.9"
|
||||||
|
enum_delegate = "0.2.0"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
activitystreams-kinds = "0.2.1"
|
activitystreams-kinds = "0.2.1"
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "activitypub_federation_derive"
|
|
||||||
version = "0.2.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "High-level Activitypub framework"
|
|
||||||
license = "AGPL-3.0"
|
|
||||||
repository = "https://github.com/LemmyNet/activitypub-federation-rust"
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
proc-macro = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
trybuild = { version = "1.0.63", features = ["diff"] }
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
proc-macro2 = "1.0.39"
|
|
||||||
syn = "1.0.96"
|
|
||||||
quote = "1.0.18"
|
|
|
@ -1,137 +0,0 @@
|
||||||
use proc_macro2::TokenStream;
|
|
||||||
use quote::quote;
|
|
||||||
use syn::{parse_macro_input, Data, DeriveInput, Fields::Unnamed, Ident, Variant};
|
|
||||||
|
|
||||||
/// Generates implementation ActivityHandler for an enum, which looks like the following (handling
|
|
||||||
/// all enum variants).
|
|
||||||
///
|
|
||||||
/// Based on this code:
|
|
||||||
/// ```ignore
|
|
||||||
/// #[derive(serde::Deserialize, serde::Serialize)]
|
|
||||||
/// #[serde(untagged)]
|
|
||||||
/// #[activity_handler(LemmyContext, LemmyError)]
|
|
||||||
/// pub enum PersonInboxActivities {
|
|
||||||
/// CreateNote(CreateNote),
|
|
||||||
/// UpdateNote(UpdateNote),
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
/// It will generate this:
|
|
||||||
/// ```ignore
|
|
||||||
/// impl ActivityHandler for PersonInboxActivities {
|
|
||||||
/// type DataType = LemmyContext;
|
|
||||||
/// type Error = LemmyError;
|
|
||||||
///
|
|
||||||
/// async fn verify(
|
|
||||||
/// &self,
|
|
||||||
/// data: &Self::DataType,
|
|
||||||
/// request_counter: &mut i32,
|
|
||||||
/// ) -> Result<(), Self::Error> {
|
|
||||||
/// match self {
|
|
||||||
/// PersonInboxActivities::CreateNote(a) => a.verify(data, request_counter).await,
|
|
||||||
/// PersonInboxActivities::UpdateNote(a) => a.verify(context, request_counter).await,
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// async fn receive(
|
|
||||||
/// &self,
|
|
||||||
/// data: &Self::DataType,
|
|
||||||
/// request_counter: &mut i32,
|
|
||||||
/// ) -> Result<(), Self::Error> {
|
|
||||||
/// match self {
|
|
||||||
/// PersonInboxActivities::CreateNote(a) => a.receive(data, request_counter).await,
|
|
||||||
/// PersonInboxActivities::UpdateNote(a) => a.receive(data, request_counter).await,
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
#[proc_macro_attribute]
|
|
||||||
pub fn activity_handler(
|
|
||||||
attr: proc_macro::TokenStream,
|
|
||||||
input: proc_macro::TokenStream,
|
|
||||||
) -> proc_macro::TokenStream {
|
|
||||||
let derive_input = parse_macro_input!(input as DeriveInput);
|
|
||||||
let derive_input2 = derive_input.clone();
|
|
||||||
let attr = proc_macro2::TokenStream::from(attr);
|
|
||||||
let mut attr = attr.into_iter();
|
|
||||||
let data_type = attr.next().expect("data type input");
|
|
||||||
let _delimiter = attr.next();
|
|
||||||
let error = attr.next().expect("error type input");
|
|
||||||
|
|
||||||
let enum_name = derive_input2.ident;
|
|
||||||
|
|
||||||
let (impl_generics, ty_generics, where_clause) = derive_input2.generics.split_for_impl();
|
|
||||||
|
|
||||||
let enum_variants = if let Data::Enum(d) = derive_input2.data {
|
|
||||||
d.variants
|
|
||||||
} else {
|
|
||||||
unimplemented!()
|
|
||||||
};
|
|
||||||
|
|
||||||
let impl_id = enum_variants
|
|
||||||
.iter()
|
|
||||||
.map(|v| generate_match_arm(&enum_name, v, "e! {a.id()}));
|
|
||||||
let impl_actor = enum_variants
|
|
||||||
.iter()
|
|
||||||
.map(|v| generate_match_arm(&enum_name, v, "e! {a.actor()}));
|
|
||||||
let body_verify = quote! {a.verify(context, request_counter).await};
|
|
||||||
let impl_verify = enum_variants
|
|
||||||
.iter()
|
|
||||||
.map(|v| generate_match_arm(&enum_name, v, &body_verify));
|
|
||||||
let body_receive = quote! {a.receive(context, request_counter).await};
|
|
||||||
let impl_receive = enum_variants
|
|
||||||
.iter()
|
|
||||||
.map(|v| generate_match_arm(&enum_name, v, &body_receive));
|
|
||||||
|
|
||||||
let expanded = quote! {
|
|
||||||
#derive_input
|
|
||||||
#[async_trait::async_trait(?Send)]
|
|
||||||
impl #impl_generics activitypub_federation::traits::ActivityHandler for #enum_name #ty_generics #where_clause {
|
|
||||||
type DataType = #data_type;
|
|
||||||
type Error = #error;
|
|
||||||
fn id(
|
|
||||||
&self,
|
|
||||||
) -> &Url {
|
|
||||||
match self {
|
|
||||||
#(#impl_id)*
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn actor(
|
|
||||||
&self,
|
|
||||||
) -> &Url {
|
|
||||||
match self {
|
|
||||||
#(#impl_actor)*
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async fn verify(
|
|
||||||
&self,
|
|
||||||
context: &activitypub_federation::data::Data<Self::DataType>,
|
|
||||||
request_counter: &mut i32,
|
|
||||||
) -> Result<(), Self::Error> {
|
|
||||||
match self {
|
|
||||||
#(#impl_verify)*
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async fn receive(
|
|
||||||
self,
|
|
||||||
context: &activitypub_federation::data::Data<Self::DataType>,
|
|
||||||
request_counter: &mut i32,
|
|
||||||
) -> Result<(), Self::Error> {
|
|
||||||
match self {
|
|
||||||
#(#impl_receive)*
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
expanded.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_match_arm(enum_name: &Ident, variant: &Variant, body: &TokenStream) -> TokenStream {
|
|
||||||
let id = &variant.ident;
|
|
||||||
match &variant.fields {
|
|
||||||
Unnamed(_) => {
|
|
||||||
quote! {
|
|
||||||
#enum_name::#id(a) => #body,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => unimplemented!(),
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -11,11 +11,11 @@ use activitypub_federation::{
|
||||||
object_id::ObjectId,
|
object_id::ObjectId,
|
||||||
signatures::{Keypair, PublicKey},
|
signatures::{Keypair, PublicKey},
|
||||||
},
|
},
|
||||||
|
data::Data,
|
||||||
deser::context::WithContext,
|
deser::context::WithContext,
|
||||||
traits::{ActivityHandler, Actor, ApubObject},
|
traits::{ActivityHandler, Actor, ApubObject},
|
||||||
LocalInstance,
|
LocalInstance,
|
||||||
};
|
};
|
||||||
use activitypub_federation_derive::activity_handler;
|
|
||||||
use activitystreams_kinds::actor::PersonType;
|
use activitystreams_kinds::actor::PersonType;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
@ -33,9 +33,9 @@ pub struct MyUser {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List of all activities which this actor can receive.
|
/// List of all activities which this actor can receive.
|
||||||
#[activity_handler(InstanceHandle, Error)]
|
|
||||||
#[derive(Deserialize, Serialize, Debug)]
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
|
#[enum_delegate::implement(ActivityHandler)]
|
||||||
pub enum PersonAcceptedActivities {
|
pub enum PersonAcceptedActivities {
|
||||||
Follow(Follow),
|
Follow(Follow),
|
||||||
Accept(Accept),
|
Accept(Accept),
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
use crate::data::Data;
|
use crate::data::Data;
|
||||||
pub use activitypub_federation_derive::*;
|
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
|
use std::ops::Deref;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
/// Trait which allows verification and reception of incoming activities.
|
/// Trait which allows verification and reception of incoming activities.
|
||||||
#[async_trait::async_trait(?Send)]
|
#[async_trait::async_trait(?Send)]
|
||||||
|
#[enum_delegate::register]
|
||||||
pub trait ActivityHandler {
|
pub trait ActivityHandler {
|
||||||
type DataType;
|
type DataType;
|
||||||
type Error;
|
type Error;
|
||||||
|
@ -32,6 +33,40 @@ pub trait ActivityHandler {
|
||||||
) -> Result<(), Self::Error>;
|
) -> Result<(), Self::Error>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Allow for boxing of enum variants
|
||||||
|
#[async_trait::async_trait(?Send)]
|
||||||
|
impl<T> ActivityHandler for Box<T>
|
||||||
|
where
|
||||||
|
T: ActivityHandler,
|
||||||
|
{
|
||||||
|
type DataType = T::DataType;
|
||||||
|
type Error = T::Error;
|
||||||
|
|
||||||
|
fn id(&self) -> &Url {
|
||||||
|
self.deref().id()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn actor(&self) -> &Url {
|
||||||
|
self.deref().actor()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify(
|
||||||
|
&self,
|
||||||
|
data: &Data<Self::DataType>,
|
||||||
|
request_counter: &mut i32,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
self.verify(data, request_counter).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn receive(
|
||||||
|
self,
|
||||||
|
data: &Data<Self::DataType>,
|
||||||
|
request_counter: &mut i32,
|
||||||
|
) -> Result<(), Self::Error> {
|
||||||
|
self.receive(data, request_counter).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait(?Send)]
|
#[async_trait::async_trait(?Send)]
|
||||||
pub trait ApubObject {
|
pub trait ApubObject {
|
||||||
type DataType;
|
type DataType;
|
||||||
|
|
Loading…
Reference in a new issue