mirror of
https://github.com/lennart-k/rustical.git
synced 2025-12-14 01:12:24 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d59ae25eba | ||
|
|
d4daa35df6 | ||
|
|
ea43876410 | ||
|
|
18af1b9aa2 | ||
|
|
e69c75102c | ||
|
|
09f1bd20ae | ||
|
|
72f970a857 | ||
|
|
08c250657e | ||
|
|
b8ef2f1ba2 | ||
|
|
c8adf60f48 | ||
|
|
507cb77e85 | ||
|
|
8881ea2a05 | ||
|
|
119e17a8e1 |
22
Cargo.lock
generated
22
Cargo.lock
generated
@@ -2999,7 +2999,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical"
|
name = "rustical"
|
||||||
version = "0.6.0"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -3042,7 +3042,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_caldav"
|
name = "rustical_caldav"
|
||||||
version = "0.6.0"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-std",
|
"async-std",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3080,7 +3080,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_carddav"
|
name = "rustical_carddav"
|
||||||
version = "0.6.0"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3112,7 +3112,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_dav"
|
name = "rustical_dav"
|
||||||
version = "0.6.0"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3137,7 +3137,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_dav_push"
|
name = "rustical_dav_push"
|
||||||
version = "0.6.0"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3163,7 +3163,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_frontend"
|
name = "rustical_frontend"
|
||||||
version = "0.6.0"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"askama",
|
"askama",
|
||||||
"askama_web",
|
"askama_web",
|
||||||
@@ -3196,7 +3196,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_ical"
|
name = "rustical_ical"
|
||||||
version = "0.6.0"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -3214,7 +3214,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_oidc"
|
name = "rustical_oidc"
|
||||||
version = "0.6.0"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3229,7 +3229,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_store"
|
name = "rustical_store"
|
||||||
version = "0.6.0"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3263,7 +3263,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_store_sqlite"
|
name = "rustical_store_sqlite"
|
||||||
version = "0.6.0"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -3284,7 +3284,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustical_xml"
|
name = "rustical_xml"
|
||||||
version = "0.6.0"
|
version = "0.6.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
members = ["crates/*"]
|
members = ["crates/*"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.6.0"
|
version = "0.6.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "A CalDAV server"
|
description = "A CalDAV server"
|
||||||
repository = "https://github.com/lennart-k/rustical"
|
repository = "https://github.com/lennart-k/rustical"
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
|
|||||||
type Principal = Principal;
|
type Principal = Principal;
|
||||||
type PrincipalUri = CalDavPrincipalUri;
|
type PrincipalUri = CalDavPrincipalUri;
|
||||||
|
|
||||||
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy, webdav-push";
|
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, webdav-push";
|
||||||
|
|
||||||
async fn get_resource(
|
async fn get_resource(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -41,11 +41,6 @@ impl Resource for PrincipalResource {
|
|||||||
Resourcetype(&[
|
Resourcetype(&[
|
||||||
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "collection"),
|
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "collection"),
|
||||||
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "principal"),
|
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "principal"),
|
||||||
// https://github.com/apple/ccs-calendarserver/blob/13c706b985fb728b9aab42dc0fef85aae21921c3/doc/Extensions/caldav-proxy.txt
|
|
||||||
// ResourcetypeInner(
|
|
||||||
// Some(rustical_dav::namespace::NS_CALENDARSERVER),
|
|
||||||
// "calendar-proxy-write",
|
|
||||||
// ),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
|
|||||||
type Principal = Principal;
|
type Principal = Principal;
|
||||||
type PrincipalUri = CalDavPrincipalUri;
|
type PrincipalUri = CalDavPrincipalUri;
|
||||||
|
|
||||||
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy";
|
const DAV_HEADER: &str = "1, 3, access-control, calendar-access";
|
||||||
|
|
||||||
async fn get_resource(
|
async fn get_resource(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<!-- Adapted from https://iconoir.com/ -->
|
<!-- Adapted from https://iconoir.com/ -->
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
|
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
|
||||||
<path d="M15 4V2M15 4V6M15 4H10.5M3 10V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V10H3Z" stroke-linecap="round" stroke-linejoin="round"></path>
|
<path d="M15 4V2M15 4V6M15 4H10.5M3 10V19C3 20.1046 3.89543 21 5 21H19C20.1046 21 21 20.1046 21 19V10H3Z" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
<path d="M3 10V6C3 4.89543 3.89543 4 5 4H7" stroke-linecap="round" stroke-linejoin="round"></path>
|
<path d="M3 10V6C3 4.89543 3.89543 4 5 4H7" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 608 B |
@@ -1,5 +1,4 @@
|
|||||||
<!-- Adapted from https://iconoir.com/ -->
|
<!-- Adapted from https://iconoir.com/ -->
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
|
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
|
||||||
<path d="M1 20V19C1 15.134 4.13401 12 8 12V12C11.866 12 15 15.134 15 19V20" stroke-linecap="round"></path>
|
<path d="M1 20V19C1 15.134 4.13401 12 8 12V12C11.866 12 15 15.134 15 19V20" stroke-linecap="round"></path>
|
||||||
<path d="M13 14V14C13 11.2386 15.2386 9 18 9V9C20.7614 9 23 11.2386 23 14V14.5" stroke-linecap="round"></path>
|
<path d="M13 14V14C13 11.2386 15.2386 9 18 9V9C20.7614 9 23 11.2386 23 14V14.5" stroke-linecap="round"></path>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 778 B After Width: | Height: | Size: 739 B |
@@ -1,5 +1,4 @@
|
|||||||
<!-- Adapted from https://iconoir.com/ -->
|
<!-- Adapted from https://iconoir.com/ -->
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
|
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="icon">
|
||||||
<path d="M5 20V19C5 15.134 8.13401 12 12 12V12C15.866 12 19 15.134 19 19V20" stroke-linecap="round" stroke-linejoin="round"></path>
|
<path d="M5 20V19C5 15.134 8.13401 12 12 12V12C15.866 12 19 15.134 19 19V20" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
<path d="M12 12C14.2091 12 16 10.2091 16 8C16 5.79086 14.2091 4 12 4C9.79086 4 8 5.79086 8 8C8 10.2091 9.79086 12 12 12Z" stroke-linecap="round" stroke-linejoin="round"></path>
|
<path d="M12 12C14.2091 12 16 10.2091 16 8C16 5.79086 14.2091 4 12 4C9.79086 4 8 5.79086 8 8C8 10.2091 9.79086 12 12 12Z" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 515 B After Width: | Height: | Size: 476 B |
@@ -22,9 +22,9 @@
|
|||||||
<div id="app">
|
<div id="app">
|
||||||
{% block content %}<p>Placeholder</p>{% endblock %}
|
{% block content %}<p>Placeholder</p>{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
<footer>
|
||||||
|
<a href="{{ env!("CARGO_PKG_REPOSITORY") }}" target="_blank">RustiCal {{ env!("CARGO_PKG_VERSION") }}</a>
|
||||||
|
<a href="/frontend/assets/licenses.html" target="_blank">Open Source Licenses</a>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
<footer>
|
|
||||||
<a href="{{ env!("CARGO_PKG_REPOSITORY") }}" target="_blank">RustiCal {{ env!("CARGO_PKG_VERSION") }}</a>
|
|
||||||
<a href="/frontend/assets/licenses.html" target="_blank">Open Source Licenses</a>
|
|
||||||
</footer>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -141,15 +141,14 @@ async fn unauthorized_handler(mut request: Request, next: Next) -> Response {
|
|||||||
return resp
|
return resp
|
||||||
.body(Body::new(format!(
|
.body(Body::new(format!(
|
||||||
r#"<!Doctype html>
|
r#"<!Doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta http-equiv="refresh" content="1; url={login_url}" />
|
<meta http-equiv="refresh" content="1; url={login_url}" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
Unauthorized, redirecting to <a href="{login_url}">login page</a>
|
Unauthorized, redirecting to <a href="{login_url}">login page</a>
|
||||||
</body>
|
</body>
|
||||||
<html>
|
</html>"#,
|
||||||
"#,
|
|
||||||
)))
|
)))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,9 +56,13 @@ pub async fn route_post_app_token<AP: AuthenticationProvider>(
|
|||||||
assert!(!name.is_empty());
|
assert!(!name.is_empty());
|
||||||
assert_eq!(user_id, user.id);
|
assert_eq!(user_id, user.id);
|
||||||
let token = generate_app_token();
|
let token = generate_app_token();
|
||||||
auth_provider
|
let mut token_id = auth_provider
|
||||||
.add_app_token(&user.id, name.to_owned(), token.clone())
|
.add_app_token(&user.id, name.to_owned(), token.clone())
|
||||||
.await?;
|
.await?;
|
||||||
|
// Get first 4 characters of token identifier
|
||||||
|
token_id.truncate(4);
|
||||||
|
// This will be a hint for the token validator which app token hash to verify against
|
||||||
|
let token = format!("{token_id}_{token}");
|
||||||
if apple {
|
if apple {
|
||||||
let profile = AppleConfig {
|
let profile = AppleConfig {
|
||||||
token_name: name,
|
token_name: name,
|
||||||
|
|||||||
@@ -149,8 +149,23 @@ impl AuthenticationProvider for SqlitePrincipalStore {
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
token: &str,
|
token: &str,
|
||||||
) -> Result<Option<Principal>, Error> {
|
) -> Result<Option<Principal>, Error> {
|
||||||
|
#[instrument(skip(password))]
|
||||||
|
fn verify_password(password: &str, hash: &str) -> Result<(), password_auth::VerifyError> {
|
||||||
|
password_auth::verify_password(password, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow to specify the token id to use to make validation faster
|
||||||
|
// Doesn't match the whole length of the token id to keep the length in bounds
|
||||||
|
// Example: asd_selgkh
|
||||||
|
// where the app token id starts with asd and its value is selgkh
|
||||||
|
let (token_id_prefix, token) = token.split_once('_').unwrap_or(("", token));
|
||||||
|
|
||||||
for app_token in &self.get_app_tokens(user_id).await? {
|
for app_token in &self.get_app_tokens(user_id).await? {
|
||||||
if password_auth::verify_password(token, app_token.token.as_ref()).is_ok() {
|
// Wrong token id
|
||||||
|
if !app_token.id.starts_with(token_id_prefix) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if verify_password(token, app_token.token.as_ref()).is_ok() {
|
||||||
return self.get_principal(user_id).await;
|
return self.get_principal(user_id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,7 +221,10 @@ impl AuthenticationProvider for SqlitePrincipalStore {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Params {
|
Params {
|
||||||
rounds: 10,
|
// The app token has a high entropy so we are quite safe from quessing attacks
|
||||||
|
// Also if an attacker got access to the hashes they'd have already gotten
|
||||||
|
// access to the whole database.
|
||||||
|
rounds: 2,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
&salt,
|
&salt,
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ impl Enum {
|
|||||||
quote! {
|
quote! {
|
||||||
impl #impl_generics ::rustical_xml::XmlDeserialize for #name #type_generics #where_clause {
|
impl #impl_generics ::rustical_xml::XmlDeserialize for #name #type_generics #where_clause {
|
||||||
fn deserialize<R: ::std::io::BufRead>(
|
fn deserialize<R: ::std::io::BufRead>(
|
||||||
reader: &mut quick_xml::NsReader<R>,
|
reader: &mut ::quick_xml::NsReader<R>,
|
||||||
start: &quick_xml::events::BytesStart,
|
start: &::quick_xml::events::BytesStart,
|
||||||
empty: bool
|
empty: bool
|
||||||
) -> Result<Self, rustical_xml::XmlError> {
|
) -> Result<Self, rustical_xml::XmlError> {
|
||||||
#(#variant_branches);*
|
#(#variant_branches);*
|
||||||
@@ -37,8 +37,8 @@ impl Enum {
|
|||||||
quote! {
|
quote! {
|
||||||
impl #impl_generics ::rustical_xml::XmlDeserialize for #name #type_generics #where_clause {
|
impl #impl_generics ::rustical_xml::XmlDeserialize for #name #type_generics #where_clause {
|
||||||
fn deserialize<R: std::io::BufRead>(
|
fn deserialize<R: std::io::BufRead>(
|
||||||
reader: &mut quick_xml::NsReader<R>,
|
reader: &mut ::quick_xml::NsReader<R>,
|
||||||
start: &quick_xml::events::BytesStart,
|
start: &::quick_xml::events::BytesStart,
|
||||||
empty: bool
|
empty: bool
|
||||||
) -> Result<Self, rustical_xml::XmlError> {
|
) -> Result<Self, rustical_xml::XmlError> {
|
||||||
let (_ns, name) = reader.resolve_element(start.name());
|
let (_ns, name) = reader.resolve_element(start.name());
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ impl NamedStruct {
|
|||||||
quote! {
|
quote! {
|
||||||
impl #impl_generics ::rustical_xml::XmlDeserialize for #ident #type_generics #where_clause {
|
impl #impl_generics ::rustical_xml::XmlDeserialize for #ident #type_generics #where_clause {
|
||||||
fn deserialize<R: ::std::io::BufRead>(
|
fn deserialize<R: ::std::io::BufRead>(
|
||||||
reader: &mut quick_xml::NsReader<R>,
|
reader: &mut ::quick_xml::NsReader<R>,
|
||||||
start: &quick_xml::events::BytesStart,
|
start: &::quick_xml::events::BytesStart,
|
||||||
empty: bool
|
empty: bool
|
||||||
) -> Result<Self, rustical_xml::XmlError> {
|
) -> Result<Self, rustical_xml::XmlError> {
|
||||||
use quick_xml::events::Event;
|
use quick_xml::events::Event;
|
||||||
|
|||||||
34
src/app.rs
34
src/app.rs
@@ -1,10 +1,13 @@
|
|||||||
use crate::config::NextcloudLoginConfig;
|
use crate::config::NextcloudLoginConfig;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::body::Body;
|
use axum::body::{Body, HttpBody};
|
||||||
use axum::extract::Request;
|
use axum::extract::Request;
|
||||||
|
use axum::middleware::Next;
|
||||||
use axum::response::{Redirect, Response};
|
use axum::response::{Redirect, Response};
|
||||||
use axum::routing::{any, options};
|
use axum::routing::{any, options};
|
||||||
|
use axum_extra::TypedHeader;
|
||||||
use headers::{HeaderMapExt, UserAgent};
|
use headers::{HeaderMapExt, UserAgent};
|
||||||
|
use http::header::CONNECTION;
|
||||||
use http::{HeaderValue, StatusCode};
|
use http::{HeaderValue, StatusCode};
|
||||||
use rustical_caldav::caldav_router;
|
use rustical_caldav::caldav_router;
|
||||||
use rustical_carddav::carddav_router;
|
use rustical_carddav::carddav_router;
|
||||||
@@ -58,7 +61,17 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
|
|||||||
))
|
))
|
||||||
.route(
|
.route(
|
||||||
"/.well-known/caldav",
|
"/.well-known/caldav",
|
||||||
any(async || Redirect::permanent("/caldav")),
|
any(async |TypedHeader(ua): TypedHeader<UserAgent>| {
|
||||||
|
if ua.as_str().contains("remindd") || ua.as_str().contains("dataaccessd") {
|
||||||
|
// remindd is an Apple Calendar User Agent
|
||||||
|
// Even when explicitly configuring a principal URL in Apple Calendar Apple
|
||||||
|
// will not respect that configuration but call /.well-known/caldav,
|
||||||
|
// so sadly we have to do this user-agent filtering. :(
|
||||||
|
// (I should have never gotten an Apple device)
|
||||||
|
return Redirect::permanent("/caldav-compat");
|
||||||
|
}
|
||||||
|
Redirect::permanent("/caldav")
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.merge(carddav_router(
|
.merge(carddav_router(
|
||||||
"/carddav",
|
"/carddav",
|
||||||
@@ -167,4 +180,21 @@ pub fn make_app<AS: AddressbookStore, CS: CalendarStore, S: SubscriptionStore>(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.layer(axum::middleware::from_fn(
|
||||||
|
async |req: Request, next: Next| {
|
||||||
|
// Closes the connection if the request body might've not been fully consumed
|
||||||
|
// Otherwise subsequent requests reusing the connection might fail.
|
||||||
|
// See https://github.com/lennart-k/rustical/issues/77
|
||||||
|
let body_empty = req.body().is_end_stream();
|
||||||
|
let mut response = next.run(req).await;
|
||||||
|
if !body_empty
|
||||||
|
&& (response.status().is_server_error() || response.status().is_client_error())
|
||||||
|
{
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert(CONNECTION, HeaderValue::from_static("close"));
|
||||||
|
}
|
||||||
|
response
|
||||||
|
},
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user