mirror of
https://github.com/lennart-k/rustical.git
synced 2026-01-30 03:28:23 +00:00
Make AddressObject object_id an extrinsic property
This commit is contained in:
@@ -103,10 +103,10 @@ pub async fn put_object<AS: AddressbookStore>(
|
||||
true
|
||||
};
|
||||
|
||||
let object = AddressObject::from_vcf(object_id, body)?;
|
||||
let object = AddressObject::from_vcf(body)?;
|
||||
let etag = object.get_etag();
|
||||
addr_store
|
||||
.put_object(principal, addressbook_id, object, overwrite)
|
||||
.put_object(&principal, &addressbook_id, &object_id, object, overwrite)
|
||||
.await?;
|
||||
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
@@ -21,11 +21,12 @@ use rustical_store::auth::Principal;
|
||||
pub struct AddressObjectResource {
|
||||
pub object: AddressObject,
|
||||
pub principal: String,
|
||||
pub object_id: String,
|
||||
}
|
||||
|
||||
impl ResourceName for AddressObjectResource {
|
||||
fn get_name(&self) -> Cow<'_, str> {
|
||||
Cow::from(format!("{}.vcf", self.object.get_id()))
|
||||
Cow::from(format!("{}.vcf", self.object_id))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ impl<AS: AddressbookStore> ResourceService for AddressObjectResourceService<AS>
|
||||
.await?;
|
||||
Ok(AddressObjectResource {
|
||||
object,
|
||||
object_id: object_id.to_owned(),
|
||||
principal: principal.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use http::{HeaderValue, Method, StatusCode, header};
|
||||
use percent_encoding::{CONTROLS, utf8_percent_encode};
|
||||
use rustical_dav::privileges::UserPrivilege;
|
||||
use rustical_dav::resource::Resource;
|
||||
use rustical_ical::AddressObject;
|
||||
use rustical_store::auth::Principal;
|
||||
use rustical_store::{AddressbookStore, SubscriptionStore};
|
||||
use std::str::FromStr;
|
||||
@@ -40,7 +39,7 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
|
||||
let objects = addr_store.get_objects(&principal, &addressbook_id).await?;
|
||||
let vcf = objects
|
||||
.iter()
|
||||
.map(AddressObject::get_vcf)
|
||||
.map(|(_id, obj)| obj.get_vcf())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n");
|
||||
|
||||
|
||||
@@ -40,8 +40,9 @@ pub async fn route_import<AS: AddressbookStore, S: SubscriptionStore>(
|
||||
});
|
||||
card = card_mut.build(&HashMap::new()).unwrap();
|
||||
}
|
||||
|
||||
objects.push(card.try_into().unwrap());
|
||||
// TODO: Make nicer
|
||||
let uid = card.get_uid().unwrap();
|
||||
objects.push((uid.to_owned(), card.into()));
|
||||
}
|
||||
|
||||
if objects.is_empty() {
|
||||
|
||||
@@ -29,7 +29,7 @@ pub async fn get_objects_addressbook_multiget<AS: AddressbookStore>(
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
store: &AS,
|
||||
) -> Result<(Vec<AddressObject>, Vec<String>), Error> {
|
||||
) -> Result<(Vec<(String, AddressObject)>, Vec<String>), Error> {
|
||||
let mut result = vec![];
|
||||
let mut not_found = vec![];
|
||||
|
||||
@@ -43,7 +43,7 @@ pub async fn get_objects_addressbook_multiget<AS: AddressbookStore>(
|
||||
.get_object(principal, addressbook_id, object_id, false)
|
||||
.await
|
||||
{
|
||||
Ok(object) => result.push(object),
|
||||
Ok(object) => result.push((object_id.to_owned(), object)),
|
||||
Err(rustical_store::Error::NotFound) => not_found.push(href.to_string()),
|
||||
Err(err) => return Err(err.into()),
|
||||
}
|
||||
@@ -74,11 +74,12 @@ pub async fn handle_addressbook_multiget<AS: AddressbookStore>(
|
||||
.await?;
|
||||
|
||||
let mut responses = Vec::new();
|
||||
for object in objects {
|
||||
let path = format!("{}/{}.vcf", path, object.get_id());
|
||||
for (object_id, object) in objects {
|
||||
let path = format!("{path}/{object_id}.vcf");
|
||||
responses.push(
|
||||
AddressObjectResource {
|
||||
object,
|
||||
object_id,
|
||||
principal: principal.to_owned(),
|
||||
}
|
||||
.propfind(&path, prop, None, puri, user)?,
|
||||
|
||||
@@ -15,8 +15,8 @@ pub async fn get_objects_addressbook_query<AS: AddressbookStore>(
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
store: &AS,
|
||||
) -> Result<Vec<AddressObject>, Error> {
|
||||
) -> Result<Vec<(String, AddressObject)>, Error> {
|
||||
let mut objects = store.get_objects(principal, addressbook_id).await?;
|
||||
objects.retain(|object| addr_query.filter.matches(object));
|
||||
objects.retain(|(_id, object)| addr_query.filter.matches(object));
|
||||
Ok(objects)
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ const FILTER_2: &str = r#"
|
||||
#[case(VCF_2, FILTER_2, true)]
|
||||
fn test_filter(#[case] vcf: &str, #[case] filter: &str, #[case] matches: bool) {
|
||||
dbg!(vcf);
|
||||
let obj = AddressObject::from_vcf(String::new(), vcf.to_owned()).unwrap();
|
||||
let obj = AddressObject::from_vcf(vcf.to_owned()).unwrap();
|
||||
let filter = FilterElement::parse_str(filter).unwrap();
|
||||
assert_eq!(matches, filter.matches(&obj));
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ impl ReportRequest {
|
||||
}
|
||||
|
||||
fn objects_response(
|
||||
objects: Vec<AddressObject>,
|
||||
objects: Vec<(String, AddressObject)>,
|
||||
not_found: Vec<String>,
|
||||
path: &str,
|
||||
principal: &str,
|
||||
@@ -64,11 +64,12 @@ fn objects_response(
|
||||
prop: &PropfindType<AddressObjectPropWrapperName>,
|
||||
) -> Result<MultistatusElement<AddressObjectPropWrapper, String>, Error> {
|
||||
let mut responses = Vec::new();
|
||||
for object in objects {
|
||||
let path = format!("{}/{}.vcf", path, object.get_id());
|
||||
for (object_id, object) in objects {
|
||||
let path = format!("{}/{}.vcf", path, &object_id);
|
||||
responses.push(
|
||||
AddressObjectResource {
|
||||
object,
|
||||
object_id,
|
||||
principal: principal.to_owned(),
|
||||
}
|
||||
.propfind(&path, prop, None, puri, user)?,
|
||||
|
||||
@@ -32,11 +32,12 @@ pub async fn handle_sync_collection<AS: AddressbookStore>(
|
||||
.await?;
|
||||
|
||||
let mut responses = Vec::new();
|
||||
for object in new_objects {
|
||||
let path = format!("{}/{}.vcf", path.trim_end_matches('/'), object.get_id());
|
||||
for (object_id, object) in new_objects {
|
||||
let path = format!("{}/{}.vcf", path.trim_end_matches('/'), object_id);
|
||||
responses.push(
|
||||
AddressObjectResource {
|
||||
object,
|
||||
object_id,
|
||||
principal: principal.to_owned(),
|
||||
}
|
||||
.propfind(&path, &sync_collection.prop, None, puri, user)?,
|
||||
|
||||
@@ -78,7 +78,8 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
|
||||
.get_objects(principal, addressbook_id)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|object| AddressObjectResource {
|
||||
.map(|(object_id, object)| AddressObjectResource {
|
||||
object_id,
|
||||
object,
|
||||
principal: principal.to_owned(),
|
||||
})
|
||||
@@ -91,7 +92,7 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
|
||||
file: Self::Resource,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.addr_store
|
||||
.update_addressbook(principal.to_owned(), addressbook_id.to_owned(), file.into())
|
||||
.update_addressbook(principal, addressbook_id, file.into())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9,30 +9,19 @@ use std::{collections::HashMap, io::BufReader};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AddressObject {
|
||||
id: String,
|
||||
vcf: String,
|
||||
vcard: VcardContact,
|
||||
}
|
||||
|
||||
impl TryFrom<VcardContact> for AddressObject {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(vcard: VcardContact) -> Result<Self, Self::Error> {
|
||||
let uid = vcard
|
||||
.get_uid()
|
||||
.ok_or_else(|| Error::InvalidData("missing UID".to_owned()))?
|
||||
.to_owned();
|
||||
impl From<VcardContact> for AddressObject {
|
||||
fn from(vcard: VcardContact) -> Self {
|
||||
let vcf = vcard.generate();
|
||||
Ok(Self {
|
||||
vcf,
|
||||
vcard,
|
||||
id: uid,
|
||||
})
|
||||
Self { vcf, vcard }
|
||||
}
|
||||
}
|
||||
|
||||
impl AddressObject {
|
||||
pub fn from_vcf(id: String, vcf: String) -> Result<Self, Error> {
|
||||
pub fn from_vcf(vcf: String) -> Result<Self, Error> {
|
||||
let mut parser = vcard::VcardParser::new(BufReader::new(vcf.as_bytes()));
|
||||
let vcard = parser.next().ok_or(Error::MissingContact)??;
|
||||
if parser.next().is_some() {
|
||||
@@ -40,18 +29,12 @@ impl AddressObject {
|
||||
"multiple vcards, only one allowed".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(Self { id, vcf, vcard })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_id(&self) -> &str {
|
||||
&self.id
|
||||
Ok(Self { vcf, vcard })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_etag(&self) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.get_id());
|
||||
hasher.update(self.get_vcf());
|
||||
format!("\"{:x}\"", hasher.finalize())
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ pub trait AddressbookStore: Send + Sync + 'static {
|
||||
|
||||
async fn update_addressbook(
|
||||
&self,
|
||||
principal: String,
|
||||
id: String,
|
||||
principal: &str,
|
||||
id: &str,
|
||||
addressbook: Addressbook,
|
||||
) -> Result<(), Error>;
|
||||
async fn insert_addressbook(&self, addressbook: Addressbook) -> Result<(), Error>;
|
||||
@@ -33,7 +33,7 @@ pub trait AddressbookStore: Send + Sync + 'static {
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
synctoken: i64,
|
||||
) -> Result<(Vec<AddressObject>, Vec<String>, i64), Error>;
|
||||
) -> Result<(Vec<(String, AddressObject)>, Vec<String>, i64), Error>;
|
||||
|
||||
async fn addressbook_metadata(
|
||||
&self,
|
||||
@@ -45,7 +45,7 @@ pub trait AddressbookStore: Send + Sync + 'static {
|
||||
&self,
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
) -> Result<Vec<AddressObject>, Error>;
|
||||
) -> Result<Vec<(String, AddressObject)>, Error>;
|
||||
async fn get_object(
|
||||
&self,
|
||||
principal: &str,
|
||||
@@ -55,8 +55,9 @@ pub trait AddressbookStore: Send + Sync + 'static {
|
||||
) -> Result<AddressObject, Error>;
|
||||
async fn put_object(
|
||||
&self,
|
||||
principal: String,
|
||||
addressbook_id: String,
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
object_id: &str,
|
||||
object: AddressObject,
|
||||
overwrite: bool,
|
||||
) -> Result<(), Error>;
|
||||
@@ -77,7 +78,7 @@ pub trait AddressbookStore: Send + Sync + 'static {
|
||||
async fn import_addressbook(
|
||||
&self,
|
||||
addressbook: Addressbook,
|
||||
objects: Vec<AddressObject>,
|
||||
objects: Vec<(String, AddressObject)>,
|
||||
merge_existing: bool,
|
||||
) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use rustical_store::{
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{Executor, Sqlite};
|
||||
use std::collections::HashMap;
|
||||
use tracing::instrument;
|
||||
|
||||
pub const BIRTHDAYS_PREFIX: &str = "_birthdays_";
|
||||
@@ -330,13 +329,14 @@ impl CalendarStore for SqliteAddressbookStore {
|
||||
.ok_or(Error::NotFound)?;
|
||||
let (objects, deleted_objects, new_synctoken) =
|
||||
AddressbookStore::sync_changes(self, principal, cal_id, synctoken).await?;
|
||||
let objects: Result<Vec<Option<CalendarObject>>, rustical_ical::Error> = objects
|
||||
.iter()
|
||||
.map(AddressObject::get_birthday_object)
|
||||
.collect();
|
||||
let objects = objects?.into_iter().flatten().collect();
|
||||
|
||||
Ok((objects, deleted_objects, new_synctoken))
|
||||
todo!();
|
||||
// let objects: Result<Vec<Option<CalendarObject>>, rustical_ical::Error> = objects
|
||||
// .iter()
|
||||
// .map(AddressObject::get_birthday_object)
|
||||
// .collect();
|
||||
// let objects = objects?.into_iter().flatten().collect();
|
||||
//
|
||||
// Ok((objects, deleted_objects, new_synctoken))
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
@@ -357,21 +357,22 @@ impl CalendarStore for SqliteAddressbookStore {
|
||||
principal: &str,
|
||||
cal_id: &str,
|
||||
) -> Result<Vec<CalendarObject>, Error> {
|
||||
let cal_id = cal_id
|
||||
.strip_prefix(BIRTHDAYS_PREFIX)
|
||||
.ok_or(Error::NotFound)?;
|
||||
let objects: Result<Vec<HashMap<&'static str, CalendarObject>>, rustical_ical::Error> =
|
||||
AddressbookStore::get_objects(self, principal, cal_id)
|
||||
.await?
|
||||
.iter()
|
||||
.map(AddressObject::get_significant_dates)
|
||||
.collect();
|
||||
let objects = objects?
|
||||
.into_iter()
|
||||
.flat_map(HashMap::into_values)
|
||||
.collect();
|
||||
|
||||
Ok(objects)
|
||||
todo!()
|
||||
// let cal_id = cal_id
|
||||
// .strip_prefix(BIRTHDAYS_PREFIX)
|
||||
// .ok_or(Error::NotFound)?;
|
||||
// let objects: Result<Vec<HashMap<&'static str, CalendarObject>>, rustical_ical::Error> =
|
||||
// AddressbookStore::get_objects(self, principal, cal_id)
|
||||
// .await?
|
||||
// .iter()
|
||||
// .map(AddressObject::get_significant_dates)
|
||||
// .collect();
|
||||
// let objects = objects?
|
||||
// .into_iter()
|
||||
// .flat_map(HashMap::into_values)
|
||||
// .collect();
|
||||
//
|
||||
// Ok(objects)
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
|
||||
@@ -19,11 +19,11 @@ struct AddressObjectRow {
|
||||
vcf: String,
|
||||
}
|
||||
|
||||
impl TryFrom<AddressObjectRow> for AddressObject {
|
||||
impl TryFrom<AddressObjectRow> for (String, AddressObject) {
|
||||
type Error = rustical_store::Error;
|
||||
|
||||
fn try_from(value: AddressObjectRow) -> Result<Self, Self::Error> {
|
||||
Ok(Self::from_vcf(value.id, value.vcf)?)
|
||||
Ok((value.id, AddressObject::from_vcf(value.vcf)?))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ impl SqliteAddressbookStore {
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
synctoken: i64,
|
||||
) -> Result<(Vec<AddressObject>, Vec<String>, i64), rustical_store::Error> {
|
||||
) -> Result<(Vec<(String, AddressObject)>, Vec<String>, i64), rustical_store::Error> {
|
||||
struct Row {
|
||||
object_id: String,
|
||||
synctoken: i64,
|
||||
@@ -318,7 +318,7 @@ impl SqliteAddressbookStore {
|
||||
for Row { object_id, .. } in changes {
|
||||
match Self::_get_object(&mut *conn, principal, addressbook_id, &object_id, false).await
|
||||
{
|
||||
Ok(object) => objects.push(object),
|
||||
Ok(object) => objects.push((object_id, object)),
|
||||
Err(rustical_store::Error::NotFound) => deleted_objects.push(object_id),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
@@ -353,7 +353,7 @@ impl SqliteAddressbookStore {
|
||||
executor: E,
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
) -> Result<Vec<AddressObject>, rustical_store::Error> {
|
||||
) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> {
|
||||
sqlx::query_as!(
|
||||
AddressObjectRow,
|
||||
"SELECT id, vcf FROM addressobjects WHERE principal = ? AND addressbook_id = ? AND deleted_at IS NULL",
|
||||
@@ -374,7 +374,7 @@ impl SqliteAddressbookStore {
|
||||
object_id: &str,
|
||||
show_deleted: bool,
|
||||
) -> Result<AddressObject, rustical_store::Error> {
|
||||
sqlx::query_as!(
|
||||
let (id, object) = sqlx::query_as!(
|
||||
AddressObjectRow,
|
||||
"SELECT id, vcf FROM addressobjects WHERE (principal, addressbook_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
|
||||
principal,
|
||||
@@ -385,17 +385,20 @@ impl SqliteAddressbookStore {
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
.map_err(crate::Error::from)?
|
||||
.try_into()
|
||||
.try_into()?;
|
||||
assert_eq!(id, object_id);
|
||||
Ok(object)
|
||||
}
|
||||
|
||||
async fn _put_object<'e, E: Executor<'e, Database = Sqlite>>(
|
||||
executor: E,
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
object_id: &str,
|
||||
object: &AddressObject,
|
||||
overwrite: bool,
|
||||
) -> Result<(), rustical_store::Error> {
|
||||
let (object_id, vcf) = (object.get_id(), object.get_vcf());
|
||||
let vcf = object.get_vcf();
|
||||
|
||||
(if overwrite {
|
||||
sqlx::query!(
|
||||
@@ -500,10 +503,12 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
#[instrument]
|
||||
async fn update_addressbook(
|
||||
&self,
|
||||
principal: String,
|
||||
id: String,
|
||||
principal: &str,
|
||||
id: &str,
|
||||
addressbook: Addressbook,
|
||||
) -> Result<(), rustical_store::Error> {
|
||||
assert_eq!(principal, &addressbook.principal);
|
||||
assert_eq!(id, &addressbook.id);
|
||||
Self::_update_addressbook(&self.db, &principal, &id, &addressbook).await
|
||||
}
|
||||
|
||||
@@ -569,7 +574,7 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
synctoken: i64,
|
||||
) -> Result<(Vec<AddressObject>, Vec<String>, i64), rustical_store::Error> {
|
||||
) -> Result<(Vec<(String, AddressObject)>, Vec<String>, i64), rustical_store::Error> {
|
||||
Self::_sync_changes(&self.db, principal, addressbook_id, synctoken).await
|
||||
}
|
||||
|
||||
@@ -601,7 +606,7 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
&self,
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
) -> Result<Vec<AddressObject>, rustical_store::Error> {
|
||||
) -> Result<Vec<(String, AddressObject)>, rustical_store::Error> {
|
||||
Self::_get_objects(&self.db, principal, addressbook_id).await
|
||||
}
|
||||
|
||||
@@ -619,8 +624,9 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
#[instrument]
|
||||
async fn put_object(
|
||||
&self,
|
||||
principal: String,
|
||||
addressbook_id: String,
|
||||
principal: &str,
|
||||
addressbook_id: &str,
|
||||
object_id: &str,
|
||||
object: AddressObject,
|
||||
overwrite: bool,
|
||||
) -> Result<(), rustical_store::Error> {
|
||||
@@ -630,9 +636,15 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
.await
|
||||
.map_err(crate::Error::from)?;
|
||||
|
||||
let object_id = object.get_id().to_owned();
|
||||
|
||||
Self::_put_object(&mut *tx, &principal, &addressbook_id, &object, overwrite).await?;
|
||||
Self::_put_object(
|
||||
&mut *tx,
|
||||
principal,
|
||||
addressbook_id,
|
||||
object_id,
|
||||
&object,
|
||||
overwrite,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let sync_token = Self::log_object_operation(
|
||||
&mut tx,
|
||||
@@ -648,7 +660,7 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
|
||||
self.send_push_notification(
|
||||
CollectionOperationInfo::Content { sync_token },
|
||||
self.get_addressbook(&principal, &addressbook_id, false)
|
||||
self.get_addressbook(principal, addressbook_id, false)
|
||||
.await?
|
||||
.push_topic,
|
||||
);
|
||||
@@ -733,7 +745,7 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
async fn import_addressbook(
|
||||
&self,
|
||||
addressbook: Addressbook,
|
||||
objects: Vec<AddressObject>,
|
||||
objects: Vec<(String, AddressObject)>,
|
||||
merge_existing: bool,
|
||||
) -> Result<(), Error> {
|
||||
let mut tx = self
|
||||
@@ -758,11 +770,12 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
}
|
||||
|
||||
let mut sync_token = None;
|
||||
for object in objects {
|
||||
for (object_id, object) in objects {
|
||||
Self::_put_object(
|
||||
&mut *tx,
|
||||
&addressbook.principal,
|
||||
&addressbook.id,
|
||||
&object_id,
|
||||
&object,
|
||||
false,
|
||||
)
|
||||
@@ -773,7 +786,7 @@ impl AddressbookStore for SqliteAddressbookStore {
|
||||
&mut tx,
|
||||
&addressbook.principal,
|
||||
&addressbook.id,
|
||||
object.get_id(),
|
||||
&object_id,
|
||||
ChangeOperation::Add,
|
||||
)
|
||||
.await?,
|
||||
|
||||
Reference in New Issue
Block a user