chore: refactor
moved into models converters to ap objects, single conditions are now direct column checks
This commit is contained in:
parent
2073015b7f
commit
dfe116506a
14 changed files with 97 additions and 127 deletions
|
@ -1,5 +1,8 @@
|
|||
use apb::{ActivityMut, BaseMut, ObjectMut};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::routes::activitypub::jsonld::LD;
|
||||
|
||||
use super::Audience;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
|
@ -37,6 +40,20 @@ impl Model {
|
|||
bcc: activity.bcc().into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ap(self) -> serde_json::Value {
|
||||
serde_json::Value::new_object()
|
||||
.set_id(Some(&self.id))
|
||||
.set_activity_type(Some(self.activity_type))
|
||||
.set_actor(apb::Node::link(self.actor))
|
||||
.set_object(apb::Node::maybe_link(self.object))
|
||||
.set_target(apb::Node::maybe_link(self.target))
|
||||
.set_published(Some(self.published))
|
||||
.set_to(apb::Node::links(self.to.0.clone()))
|
||||
.set_bto(apb::Node::Empty)
|
||||
.set_cc(apb::Node::links(self.cc.0.clone()))
|
||||
.set_bcc(apb::Node::Empty)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
use apb::{ActivityMut, Node};
|
||||
use sea_orm::{entity::prelude::*, FromQueryResult, Iterable, QuerySelect, SelectColumns};
|
||||
|
||||
use crate::routes::activitypub::{activity::ap_activity, object::ap_object};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "addressing")]
|
||||
pub struct Model {
|
||||
|
@ -67,9 +65,10 @@ pub struct EmbeddedActivity {
|
|||
|
||||
impl From<EmbeddedActivity> for serde_json::Value {
|
||||
fn from(value: EmbeddedActivity) -> Self {
|
||||
let a = value.activity.ap();
|
||||
match value.object {
|
||||
Some(o) => ap_activity(value.activity).set_object(Node::object(ap_object(o))),
|
||||
None => ap_activity(value.activity)
|
||||
None => a,
|
||||
Some(o) => a.set_object(Node::object(o.ap())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
use apb::{BaseMut, ObjectMut};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use crate::routes::activitypub::jsonld::LD;
|
||||
|
||||
use super::Audience;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
|
@ -45,6 +48,23 @@ impl Model {
|
|||
bcc: object.bcc().into(),
|
||||
})
|
||||
}
|
||||
// TODO this is used outside /routes, maybe move in model?
|
||||
pub fn ap(self) -> serde_json::Value {
|
||||
serde_json::Value::new_object()
|
||||
.set_id(Some(&self.id))
|
||||
.set_object_type(Some(self.object_type))
|
||||
.set_attributed_to(apb::Node::maybe_link(self.attributed_to))
|
||||
.set_name(self.name.as_deref())
|
||||
.set_summary(self.summary.as_deref())
|
||||
.set_content(self.content.as_deref())
|
||||
.set_context(apb::Node::maybe_link(self.context.clone()))
|
||||
.set_in_reply_to(apb::Node::maybe_link(self.in_reply_to.clone()))
|
||||
.set_published(Some(self.published))
|
||||
.set_to(apb::Node::links(self.to.0.clone()))
|
||||
.set_bto(apb::Node::Empty)
|
||||
.set_cc(apb::Node::links(self.cc.0.clone()))
|
||||
.set_bcc(apb::Node::Empty)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
use sea_orm::entity::prelude::*;
|
||||
|
||||
use apb::{Collection, Object, Actor, PublicKey, ActorType};
|
||||
use apb::{Actor, ActorMut, ActorType, BaseMut, Collection, DocumentMut, Object, ObjectMut, PublicKey, PublicKeyMut};
|
||||
|
||||
use crate::routes::activitypub::jsonld::LD;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
|
@ -62,6 +64,38 @@ impl Model {
|
|||
private_key: None, // there's no way to transport privkey over AP json, must come from DB
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ap(self) -> serde_json::Value {
|
||||
serde_json::Value::new_object()
|
||||
.set_id(Some(&self.id))
|
||||
.set_actor_type(Some(self.actor_type))
|
||||
.set_name(self.name.as_deref())
|
||||
.set_summary(self.summary.as_deref())
|
||||
.set_icon(apb::Node::maybe_object(self.icon.map(|i|
|
||||
serde_json::Value::new_object()
|
||||
.set_document_type(Some(apb::DocumentType::Image))
|
||||
.set_url(apb::Node::link(i.clone()))
|
||||
)))
|
||||
.set_image(apb::Node::maybe_object(self.image.map(|i|
|
||||
serde_json::Value::new_object()
|
||||
.set_document_type(Some(apb::DocumentType::Image))
|
||||
.set_url(apb::Node::link(i.clone()))
|
||||
)))
|
||||
.set_published(Some(self.created))
|
||||
.set_preferred_username(Some(&self.preferred_username))
|
||||
.set_inbox(apb::Node::maybe_link(self.inbox))
|
||||
.set_outbox(apb::Node::maybe_link(self.outbox))
|
||||
.set_following(apb::Node::maybe_link(self.following))
|
||||
.set_followers(apb::Node::maybe_link(self.followers))
|
||||
.set_public_key(apb::Node::object(
|
||||
serde_json::Value::new_object()
|
||||
.set_id(Some(&format!("{}#main-key", self.id)))
|
||||
.set_owner(Some(&self.id))
|
||||
.set_public_key_pem(&self.public_key)
|
||||
))
|
||||
.set_discoverable(Some(true))
|
||||
.set_endpoints(apb::Node::Empty)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
|
|
@ -1,25 +1,9 @@
|
|||
use axum::extract::{Path, Query, State};
|
||||
use sea_orm::{ColumnTrait, QueryFilter};
|
||||
use crate::{errors::UpubError, model::{self, addressing::EmbeddedActivity}, server::{auth::AuthIdentity, fetcher::Fetcher, Context}};
|
||||
use apb::{ActivityMut, ObjectMut, BaseMut, Node};
|
||||
|
||||
use super::{jsonld::LD, JsonLD, TryFetch};
|
||||
|
||||
// TODO this is used outside /routes, maybe move in model?
|
||||
pub fn ap_activity(activity: model::activity::Model) -> serde_json::Value {
|
||||
serde_json::Value::new_object()
|
||||
.set_id(Some(&activity.id))
|
||||
.set_activity_type(Some(activity.activity_type))
|
||||
.set_actor(Node::link(activity.actor))
|
||||
.set_object(Node::maybe_link(activity.object))
|
||||
.set_target(Node::maybe_link(activity.target))
|
||||
.set_published(Some(activity.published))
|
||||
.set_to(Node::links(activity.to.0.clone()))
|
||||
.set_bto(Node::Empty)
|
||||
.set_cc(Node::links(activity.cc.0.clone()))
|
||||
.set_bcc(Node::Empty)
|
||||
}
|
||||
|
||||
pub async fn view(
|
||||
State(ctx): State<Context>,
|
||||
Path(id): Path<String>,
|
||||
|
@ -40,7 +24,7 @@ pub async fn view(
|
|||
{
|
||||
Some(activity) => Ok(JsonLD(serde_json::Value::from(activity).ld_context())),
|
||||
None => if auth.is_local() && query.fetch && !ctx.is_local(&aid) {
|
||||
Ok(JsonLD(ap_activity(ctx.fetch_activity(&aid).await?).ld_context()))
|
||||
Ok(JsonLD(ctx.fetch_activity(&aid).await?.ap().ld_context()))
|
||||
} else {
|
||||
Err(UpubError::not_found())
|
||||
},
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
use axum::extract::{Path, Query, State};
|
||||
use sea_orm::{ColumnTrait, QueryFilter};
|
||||
|
||||
use apb::{ObjectMut, BaseMut, Node};
|
||||
use crate::{errors::UpubError, model::{self, addressing::EmbeddedActivity}, server::{auth::AuthIdentity, fetcher::Fetcher, Context}};
|
||||
|
||||
use super::{jsonld::LD, JsonLD, TryFetch};
|
||||
|
||||
// TODO this is used outside /routes, maybe move in model?
|
||||
pub fn ap_object(object: model::object::Model) -> serde_json::Value {
|
||||
serde_json::Value::new_object()
|
||||
.set_id(Some(&object.id))
|
||||
.set_object_type(Some(object.object_type))
|
||||
.set_attributed_to(Node::maybe_link(object.attributed_to))
|
||||
.set_name(object.name.as_deref())
|
||||
.set_summary(object.summary.as_deref())
|
||||
.set_content(object.content.as_deref())
|
||||
.set_context(Node::maybe_link(object.context.clone()))
|
||||
.set_in_reply_to(Node::maybe_link(object.in_reply_to.clone()))
|
||||
.set_published(Some(object.published))
|
||||
.set_to(Node::links(object.to.0.clone()))
|
||||
.set_bto(Node::Empty)
|
||||
.set_cc(Node::links(object.cc.0.clone()))
|
||||
.set_bcc(Node::Empty)
|
||||
}
|
||||
|
||||
pub async fn view(
|
||||
State(ctx): State<Context>,
|
||||
Path(id): Path<String>,
|
||||
AuthIdentity(auth): AuthIdentity,
|
||||
Query(query): Query<TryFetch>,
|
||||
) -> crate::Result<JsonLD<serde_json::Value>> {
|
||||
let oid = if id.starts_with('+') {
|
||||
format!("https://{}", id.replacen('+', "", 1).replace('@', "/"))
|
||||
} else {
|
||||
ctx.oid(id.clone())
|
||||
};
|
||||
match model::addressing::Entity::find_activities()
|
||||
.filter(model::object::Column::Id.eq(&oid))
|
||||
.filter(auth.filter_condition())
|
||||
.into_model::<EmbeddedActivity>()
|
||||
.one(ctx.db())
|
||||
.await?
|
||||
{
|
||||
Some(EmbeddedActivity { activity: _, object: Some(object) }) => Ok(JsonLD(ap_object(object).ld_context())),
|
||||
Some(EmbeddedActivity { activity: _, object: None }) => Err(UpubError::not_found()),
|
||||
None => if auth.is_local() && query.fetch && !ctx.is_local(&oid) {
|
||||
Ok(JsonLD(ap_object(ctx.fetch_object(&oid).await?).ld_context()))
|
||||
} else {
|
||||
Err(UpubError::not_found())
|
||||
},
|
||||
}
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
use axum::{extract::{Path, Query, State}, http::StatusCode};
|
||||
use sea_orm::{ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, SelectColumns};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, SelectColumns};
|
||||
|
||||
use crate::{routes::activitypub::{jsonld::LD, JsonLD, Pagination}, model, server::Context, url};
|
||||
|
||||
|
@ -11,7 +11,7 @@ pub async fn get<const OUTGOING: bool>(
|
|||
) -> Result<JsonLD<serde_json::Value>, StatusCode> {
|
||||
let follow___ = if OUTGOING { "following" } else { "followers" };
|
||||
let count = model::relation::Entity::find()
|
||||
.filter(Condition::all().add(if OUTGOING { Follower } else { Following }.eq(ctx.uid(id.clone()))))
|
||||
.filter(if OUTGOING { Follower } else { Following }.eq(ctx.uid(id.clone())))
|
||||
.count(ctx.db()).await.unwrap_or_else(|e| {
|
||||
tracing::error!("failed counting {follow___} for {id}: {e}");
|
||||
0
|
||||
|
@ -33,7 +33,7 @@ pub async fn page<const OUTGOING: bool>(
|
|||
let limit = page.batch.unwrap_or(20).min(50);
|
||||
let offset = page.offset.unwrap_or(0);
|
||||
match model::relation::Entity::find()
|
||||
.filter(Condition::all().add(if OUTGOING { Follower } else { Following }.eq(ctx.uid(id.clone()))))
|
||||
.filter(if OUTGOING { Follower } else { Following }.eq(ctx.uid(id.clone())))
|
||||
.select_only()
|
||||
.select_column(if OUTGOING { Following } else { Follower })
|
||||
.limit(limit)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use axum::{extract::{Path, Query, State}, http::StatusCode, Json};
|
||||
|
||||
use sea_orm::{ColumnTrait, Condition, Order, QueryFilter, QueryOrder, QuerySelect};
|
||||
use sea_orm::{ColumnTrait, Order, QueryFilter, QueryOrder, QuerySelect};
|
||||
use crate::{errors::UpubError, model::{self, addressing::EmbeddedActivity}, routes::activitypub::{jsonld::LD, JsonLD, Pagination}, server::{auth::{AuthIdentity, Identity}, Context}, url};
|
||||
|
||||
pub async fn get(
|
||||
|
@ -35,7 +35,7 @@ pub async fn page(
|
|||
let limit = page.batch.unwrap_or(20).min(50);
|
||||
let offset = page.offset.unwrap_or(0);
|
||||
let activities = model::addressing::Entity::find_activities()
|
||||
.filter(Condition::all().add(model::addressing::Column::Actor.eq(&uid)))
|
||||
.filter(model::addressing::Column::Actor.eq(&uid))
|
||||
.order_by(model::addressing::Column::Published, Order::Desc)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
|
|
|
@ -7,42 +7,11 @@ pub mod following;
|
|||
use axum::extract::{Path, Query, State};
|
||||
use sea_orm::EntityTrait;
|
||||
|
||||
use apb::{ActorMut, BaseMut, CollectionMut, DocumentMut, DocumentType, Node, ObjectMut, PublicKeyMut};
|
||||
use apb::{ActorMut, BaseMut, CollectionMut, Node};
|
||||
use crate::{errors::UpubError, model::{self, user}, server::{auth::AuthIdentity, fetcher::Fetcher, Context}, url};
|
||||
|
||||
use super::{jsonld::LD, JsonLD, TryFetch};
|
||||
|
||||
pub fn ap_user(user: model::user::Model) -> serde_json::Value {
|
||||
serde_json::Value::new_object()
|
||||
.set_id(Some(&user.id))
|
||||
.set_actor_type(Some(user.actor_type))
|
||||
.set_name(user.name.as_deref())
|
||||
.set_summary(user.summary.as_deref())
|
||||
.set_icon(Node::maybe_object(user.icon.map(|i|
|
||||
serde_json::Value::new_object()
|
||||
.set_document_type(Some(DocumentType::Image))
|
||||
.set_url(Node::link(i.clone()))
|
||||
)))
|
||||
.set_image(Node::maybe_object(user.image.map(|i|
|
||||
serde_json::Value::new_object()
|
||||
.set_document_type(Some(DocumentType::Image))
|
||||
.set_url(Node::link(i.clone()))
|
||||
)))
|
||||
.set_published(Some(user.created))
|
||||
.set_preferred_username(Some(&user.preferred_username))
|
||||
.set_inbox(Node::maybe_link(user.inbox))
|
||||
.set_outbox(Node::maybe_link(user.outbox))
|
||||
.set_following(Node::maybe_link(user.following))
|
||||
.set_followers(Node::maybe_link(user.followers))
|
||||
.set_public_key(Node::object(
|
||||
serde_json::Value::new_object()
|
||||
.set_id(Some(&format!("{}#main-key", user.id)))
|
||||
.set_owner(Some(&user.id))
|
||||
.set_public_key_pem(&user.public_key)
|
||||
))
|
||||
.set_discoverable(Some(true))
|
||||
.set_endpoints(Node::Empty)
|
||||
}
|
||||
|
||||
pub async fn view(
|
||||
State(ctx) : State<Context>,
|
||||
|
@ -61,7 +30,7 @@ pub async fn view(
|
|||
{
|
||||
// local user
|
||||
Some((user, Some(cfg))) => {
|
||||
Ok(JsonLD(ap_user(user.clone()) // ew ugly clone TODO
|
||||
Ok(JsonLD(user.clone().ap() // ew ugly clone TODO
|
||||
.set_inbox(Node::link(url!(ctx, "/users/{id}/inbox"))) // TODO unread activities as count
|
||||
.set_outbox(Node::object(
|
||||
serde_json::Value::new_object()
|
||||
|
@ -101,9 +70,9 @@ pub async fn view(
|
|||
))
|
||||
},
|
||||
// remote user TODDO doesn't work?
|
||||
Some((user, None)) => Ok(JsonLD(ap_user(user).ld_context())),
|
||||
Some((user, None)) => Ok(JsonLD(user.ap().ld_context())),
|
||||
None => if auth.is_local() && query.fetch && !ctx.is_local(&uid) {
|
||||
Ok(JsonLD(ap_user(ctx.fetch_user(&uid).await?).ld_context()))
|
||||
Ok(JsonLD(ctx.fetch_user(&uid).await?.ap().ld_context()))
|
||||
} else {
|
||||
Err(UpubError::not_found())
|
||||
},
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
use axum::{extract::{Path, Query, State}, http::StatusCode, Json};
|
||||
use sea_orm::{ColumnTrait, Condition, Order, QueryFilter, QueryOrder, QuerySelect};
|
||||
use sea_orm::{ColumnTrait, Order, QueryFilter, QueryOrder, QuerySelect};
|
||||
|
||||
use apb::{server::Outbox, AcceptType, ActivityType, Base, BaseType, ObjectType, RejectType};
|
||||
use crate::{errors::UpubError, model::{self, addressing::EmbeddedActivity}, routes::activitypub::{jsonld::LD, CreationResult, JsonLD, Pagination}, server::{auth::{AuthIdentity, Identity}, Context}, url};
|
||||
|
@ -28,7 +28,7 @@ pub async fn page(
|
|||
let offset = page.offset.unwrap_or(0);
|
||||
|
||||
match model::addressing::Entity::find_activities()
|
||||
.filter(Condition::all().add(model::activity::Column::Actor.eq(&uid)))
|
||||
.filter(model::activity::Column::Actor.eq(&uid))
|
||||
.filter(auth.filter_condition())
|
||||
.order_by(model::addressing::Column::Published, Order::Desc)
|
||||
.limit(limit)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
use axum::{extract::{Path, Query, State}, http::StatusCode, Json};
|
||||
use mastodon_async_entities::{account::{Account, AccountId}, status::Status};
|
||||
use sea_orm::{ColumnTrait, Condition, EntityTrait, Order, QueryFilter, QueryOrder};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, Order, QueryFilter, QueryOrder};
|
||||
|
||||
use crate::{model, server::{auth::AuthIdentity, Context}};
|
||||
|
||||
|
@ -71,7 +71,7 @@ pub async fn statuses(
|
|||
) -> Result<Json<Vec<Status>>, StatusCode> {
|
||||
let uid = ctx.uid(id);
|
||||
model::addressing::Entity::find_activities()
|
||||
.filter(Condition::all().add(model::activity::Column::Actor.eq(uid)))
|
||||
.filter(model::activity::Column::Actor.eq(uid))
|
||||
.filter(auth.filter_condition())
|
||||
.order_by(model::addressing::Column::Published, Order::Desc);
|
||||
|
||||
|
|
|
@ -77,7 +77,7 @@ where
|
|||
|
||||
if auth_header.starts_with("Bearer ") {
|
||||
match model::session::Entity::find_by_id(auth_header.replace("Bearer ", ""))
|
||||
.filter(Condition::all().add(model::session::Column::Expires.gt(chrono::Utc::now())))
|
||||
.filter(model::session::Column::Expires.gt(chrono::Utc::now()))
|
||||
.one(ctx.db())
|
||||
.await
|
||||
{
|
||||
|
|
|
@ -2,7 +2,7 @@ use std::sync::Arc;
|
|||
|
||||
use apb::{BaseMut, CollectionMut, CollectionPageMut};
|
||||
use openssl::rsa::Rsa;
|
||||
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect, SelectColumns, Set};
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect, SelectColumns, Set};
|
||||
|
||||
use crate::{model, routes::activitypub::jsonld::LD};
|
||||
|
||||
|
@ -134,7 +134,7 @@ impl Context {
|
|||
if target.ends_with("/followers") {
|
||||
let target_id = target.replace("/followers", "");
|
||||
model::relation::Entity::find()
|
||||
.filter(Condition::all().add(model::relation::Column::Following.eq(target_id)))
|
||||
.filter(model::relation::Column::Following.eq(target_id))
|
||||
.select_only()
|
||||
.select_column(model::relation::Column::Follower)
|
||||
.into_tuple::<String>()
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
use reqwest::Method;
|
||||
use sea_orm::{ColumnTrait, Condition, DatabaseConnection, EntityTrait, Order, QueryFilter, QueryOrder};
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, Order, QueryFilter, QueryOrder};
|
||||
use tokio::{sync::broadcast, task::JoinHandle};
|
||||
|
||||
use apb::{ActivityMut, Node};
|
||||
use crate::{errors::UpubError, model, routes::activitypub::{activity::ap_activity, object::ap_object}, server::{fetcher::Fetcher, Context}};
|
||||
use crate::{errors::UpubError, model, server::{fetcher::Fetcher, Context}};
|
||||
|
||||
pub struct Dispatcher {
|
||||
waker: broadcast::Sender<()>,
|
||||
|
@ -39,7 +39,7 @@ impl Dispatcher {
|
|||
async fn worker(db: DatabaseConnection, domain: String, poll_interval: u64, mut waker: broadcast::Receiver<()>) -> Result<(), UpubError> {
|
||||
loop {
|
||||
let Some(delivery) = model::delivery::Entity::find()
|
||||
.filter(Condition::all().add(model::delivery::Column::NotBefore.lte(chrono::Utc::now())))
|
||||
.filter(model::delivery::Column::NotBefore.lte(chrono::Utc::now()))
|
||||
.order_by(model::delivery::Column::NotBefore, Order::Asc)
|
||||
.one(&db)
|
||||
.await?
|
||||
|
@ -76,8 +76,8 @@ async fn worker(db: DatabaseConnection, domain: String, poll_interval: u64, mut
|
|||
.one(&db)
|
||||
.await? // TODO probably should not fail here and at least re-insert the delivery
|
||||
{
|
||||
Some((activity, Some(object))) => ap_activity(activity).set_object(Node::object(ap_object(object))),
|
||||
Some((activity, None)) => ap_activity(activity),
|
||||
Some((activity, Some(object))) => activity.ap().set_object(Node::object(object.ap())),
|
||||
Some((activity, None)) => activity.ap(),
|
||||
None => {
|
||||
tracing::warn!("skipping dispatch for deleted object {}", delivery.activity);
|
||||
continue;
|
||||
|
|
Loading…
Reference in a new issue