feat: add sharing endpoints — share, link, revoke, public access
This commit is contained in:
@@ -6,8 +6,10 @@ pub mod asset_repository;
|
||||
pub mod ingest_session_repository;
|
||||
pub mod library_path_repository;
|
||||
pub mod quota_repository;
|
||||
pub mod share_repository;
|
||||
pub mod storage_volume_repository;
|
||||
pub mod user_repository;
|
||||
pub mod visibility_filter_repository;
|
||||
|
||||
pub use db::{PgPool, connect, run_migrations};
|
||||
|
||||
@@ -17,5 +19,7 @@ pub use asset_repository::PostgresAssetRepository;
|
||||
pub use ingest_session_repository::PostgresIngestSessionRepository;
|
||||
pub use library_path_repository::PostgresLibraryPathRepository;
|
||||
pub use quota_repository::{PostgresQuotaRepository, PostgresUsageLedgerRepository};
|
||||
pub use share_repository::PostgresShareRepository;
|
||||
pub use storage_volume_repository::PostgresStorageVolumeRepository;
|
||||
pub use user_repository::PostgresUserRepository;
|
||||
pub use visibility_filter_repository::PostgresVisibilityFilterRepository;
|
||||
|
||||
417
crates/adapters/postgres/src/share_repository.rs
Normal file
417
crates/adapters/postgres/src/share_repository.rs
Normal file
@@ -0,0 +1,417 @@
|
||||
use crate::db::PgPool;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::{
|
||||
entities::{
|
||||
InviteCode, LinkAccessLevel, ScopeType, ShareLink, ShareScope, ShareTarget, ShareableType,
|
||||
TargetType,
|
||||
},
|
||||
errors::DomainError,
|
||||
ports::ShareRepository,
|
||||
value_objects::{DateTimeStamp, SystemId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- String constants for DB enum mapping ---
|
||||
|
||||
const SCOPE_PRIVATE: &str = "private";
|
||||
const SCOPE_USER: &str = "user";
|
||||
const SCOPE_GROUP: &str = "group";
|
||||
const SCOPE_LINK: &str = "link";
|
||||
const SCOPE_PUBLIC: &str = "public";
|
||||
|
||||
const SHAREABLE_ASSET: &str = "asset";
|
||||
const SHAREABLE_ALBUM: &str = "album";
|
||||
const SHAREABLE_COLLECTION: &str = "collection";
|
||||
const SHAREABLE_DIRECTORY: &str = "directory";
|
||||
|
||||
const TARGET_USER: &str = "user";
|
||||
const TARGET_GROUP: &str = "group";
|
||||
|
||||
const ACCESS_VIEW_ONLY: &str = "view_only";
|
||||
const ACCESS_LIMITED_SEARCH: &str = "limited_search";
|
||||
|
||||
// --- Row structs ---
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ShareScopeRow {
|
||||
scope_id: Uuid,
|
||||
scope_type: String,
|
||||
shareable_type: String,
|
||||
shareable_id: Uuid,
|
||||
created_by_user_id: Uuid,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ShareTargetRow {
|
||||
scope_id: Uuid,
|
||||
target_type: String,
|
||||
target_id: Uuid,
|
||||
role_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ShareLinkRow {
|
||||
scope_id: Uuid,
|
||||
token: String,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
access_level: String,
|
||||
is_active: bool,
|
||||
max_uses: Option<i32>,
|
||||
use_count: i32,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct InviteCodeRow {
|
||||
code_id: Uuid,
|
||||
scope_id: Uuid,
|
||||
created_by_user_id: Uuid,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
max_uses: Option<i32>,
|
||||
use_count: i32,
|
||||
assigned_role_id: Uuid,
|
||||
}
|
||||
|
||||
// --- Enum conversions ---
|
||||
|
||||
fn scope_type_to_str(t: ScopeType) -> &'static str {
|
||||
match t {
|
||||
ScopeType::Private => SCOPE_PRIVATE,
|
||||
ScopeType::User => SCOPE_USER,
|
||||
ScopeType::Group => SCOPE_GROUP,
|
||||
ScopeType::Link => SCOPE_LINK,
|
||||
ScopeType::Public => SCOPE_PUBLIC,
|
||||
}
|
||||
}
|
||||
|
||||
fn scope_type_from_str(s: &str) -> Result<ScopeType, DomainError> {
|
||||
match s {
|
||||
SCOPE_PRIVATE => Ok(ScopeType::Private),
|
||||
SCOPE_USER => Ok(ScopeType::User),
|
||||
SCOPE_GROUP => Ok(ScopeType::Group),
|
||||
SCOPE_LINK => Ok(ScopeType::Link),
|
||||
SCOPE_PUBLIC => Ok(ScopeType::Public),
|
||||
_ => Err(DomainError::Internal(format!("Unknown scope_type: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn shareable_type_to_str(t: ShareableType) -> &'static str {
|
||||
match t {
|
||||
ShareableType::Asset => SHAREABLE_ASSET,
|
||||
ShareableType::Album => SHAREABLE_ALBUM,
|
||||
ShareableType::Collection => SHAREABLE_COLLECTION,
|
||||
ShareableType::Directory => SHAREABLE_DIRECTORY,
|
||||
}
|
||||
}
|
||||
|
||||
fn shareable_type_from_str(s: &str) -> Result<ShareableType, DomainError> {
|
||||
match s {
|
||||
SHAREABLE_ASSET => Ok(ShareableType::Asset),
|
||||
SHAREABLE_ALBUM => Ok(ShareableType::Album),
|
||||
SHAREABLE_COLLECTION => Ok(ShareableType::Collection),
|
||||
SHAREABLE_DIRECTORY => Ok(ShareableType::Directory),
|
||||
_ => Err(DomainError::Internal(format!(
|
||||
"Unknown shareable_type: {s}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn target_type_to_str(t: TargetType) -> &'static str {
|
||||
match t {
|
||||
TargetType::User => TARGET_USER,
|
||||
TargetType::Group => TARGET_GROUP,
|
||||
}
|
||||
}
|
||||
|
||||
fn target_type_from_str(s: &str) -> Result<TargetType, DomainError> {
|
||||
match s {
|
||||
TARGET_USER => Ok(TargetType::User),
|
||||
TARGET_GROUP => Ok(TargetType::Group),
|
||||
_ => Err(DomainError::Internal(format!("Unknown target_type: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn access_level_to_str(t: LinkAccessLevel) -> &'static str {
|
||||
match t {
|
||||
LinkAccessLevel::ViewOnly => ACCESS_VIEW_ONLY,
|
||||
LinkAccessLevel::LimitedSearch => ACCESS_LIMITED_SEARCH,
|
||||
}
|
||||
}
|
||||
|
||||
fn access_level_from_str(s: &str) -> Result<LinkAccessLevel, DomainError> {
|
||||
match s {
|
||||
ACCESS_VIEW_ONLY => Ok(LinkAccessLevel::ViewOnly),
|
||||
ACCESS_LIMITED_SEARCH => Ok(LinkAccessLevel::LimitedSearch),
|
||||
_ => Err(DomainError::Internal(format!("Unknown access_level: {s}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Row → Domain conversions ---
|
||||
|
||||
impl TryFrom<ShareScopeRow> for ShareScope {
|
||||
type Error = DomainError;
|
||||
|
||||
fn try_from(r: ShareScopeRow) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
scope_id: SystemId::from_uuid(r.scope_id),
|
||||
scope_type: scope_type_from_str(&r.scope_type)?,
|
||||
shareable_type: shareable_type_from_str(&r.shareable_type)?,
|
||||
shareable_id: SystemId::from_uuid(r.shareable_id),
|
||||
created_by_user_id: SystemId::from_uuid(r.created_by_user_id),
|
||||
expires_at: r.expires_at.map(DateTimeStamp::from_datetime),
|
||||
created_at: DateTimeStamp::from_datetime(r.created_at),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ShareTargetRow> for ShareTarget {
|
||||
type Error = DomainError;
|
||||
|
||||
fn try_from(r: ShareTargetRow) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
scope_id: SystemId::from_uuid(r.scope_id),
|
||||
target_type: target_type_from_str(&r.target_type)?,
|
||||
target_id: SystemId::from_uuid(r.target_id),
|
||||
role_id: SystemId::from_uuid(r.role_id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ShareLinkRow> for ShareLink {
|
||||
type Error = DomainError;
|
||||
|
||||
fn try_from(r: ShareLinkRow) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
scope_id: SystemId::from_uuid(r.scope_id),
|
||||
token: r.token,
|
||||
expires_at: r.expires_at.map(DateTimeStamp::from_datetime),
|
||||
access_level: access_level_from_str(&r.access_level)?,
|
||||
is_active: r.is_active,
|
||||
max_uses: r.max_uses.map(|v| v as u32),
|
||||
use_count: r.use_count as u32,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<InviteCodeRow> for InviteCode {
|
||||
type Error = DomainError;
|
||||
|
||||
fn try_from(r: InviteCodeRow) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
code_id: SystemId::from_uuid(r.code_id),
|
||||
scope_id: SystemId::from_uuid(r.scope_id),
|
||||
created_by_user_id: SystemId::from_uuid(r.created_by_user_id),
|
||||
expires_at: r.expires_at.map(DateTimeStamp::from_datetime),
|
||||
max_uses: r.max_uses.map(|v| v as u32),
|
||||
use_count: r.use_count as u32,
|
||||
assigned_role_id: SystemId::from_uuid(r.assigned_role_id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Repository ---
|
||||
|
||||
pub struct PostgresShareRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresShareRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ShareRepository for PostgresShareRepository {
|
||||
// --- Scopes ---
|
||||
|
||||
async fn save_scope(&self, scope: &ShareScope) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO share_scopes (scope_id, scope_type, shareable_type, shareable_id, created_by_user_id, expires_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (scope_id) DO UPDATE SET
|
||||
scope_type = EXCLUDED.scope_type,
|
||||
expires_at = EXCLUDED.expires_at",
|
||||
)
|
||||
.bind(*scope.scope_id.as_uuid())
|
||||
.bind(scope_type_to_str(scope.scope_type))
|
||||
.bind(shareable_type_to_str(scope.shareable_type))
|
||||
.bind(*scope.shareable_id.as_uuid())
|
||||
.bind(*scope.created_by_user_id.as_uuid())
|
||||
.bind(scope.expires_at.as_ref().map(|d| d.as_datetime()).copied())
|
||||
.bind(scope.created_at.as_datetime())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_scope_by_id(&self, id: &SystemId) -> Result<Option<ShareScope>, DomainError> {
|
||||
let row = sqlx::query_as::<_, ShareScopeRow>(
|
||||
"SELECT scope_id, scope_type, shareable_type, shareable_id, created_by_user_id, expires_at, created_at
|
||||
FROM share_scopes WHERE scope_id = $1",
|
||||
)
|
||||
.bind(*id.as_uuid())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn find_scopes_for_resource(
|
||||
&self,
|
||||
resource_id: &SystemId,
|
||||
) -> Result<Vec<ShareScope>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, ShareScopeRow>(
|
||||
"SELECT scope_id, scope_type, shareable_type, shareable_id, created_by_user_id, expires_at, created_at
|
||||
FROM share_scopes WHERE shareable_id = $1",
|
||||
)
|
||||
.bind(*resource_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn delete_scope(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM share_scopes WHERE scope_id = $1")
|
||||
.bind(*id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Targets ---
|
||||
|
||||
async fn save_target(&self, target: &ShareTarget) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO share_targets (scope_id, target_type, target_id, role_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (scope_id, target_id) DO UPDATE SET
|
||||
target_type = EXCLUDED.target_type,
|
||||
role_id = EXCLUDED.role_id",
|
||||
)
|
||||
.bind(*target.scope_id.as_uuid())
|
||||
.bind(target_type_to_str(target.target_type))
|
||||
.bind(*target.target_id.as_uuid())
|
||||
.bind(*target.role_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_targets_for_scope(
|
||||
&self,
|
||||
scope_id: &SystemId,
|
||||
) -> Result<Vec<ShareTarget>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, ShareTargetRow>(
|
||||
"SELECT scope_id, target_type, target_id, role_id
|
||||
FROM share_targets WHERE scope_id = $1",
|
||||
)
|
||||
.bind(*scope_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn find_targets_for_user(
|
||||
&self,
|
||||
user_id: &SystemId,
|
||||
) -> Result<Vec<ShareTarget>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, ShareTargetRow>(
|
||||
"SELECT scope_id, target_type, target_id, role_id
|
||||
FROM share_targets WHERE target_id = $1",
|
||||
)
|
||||
.bind(*user_id.as_uuid())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
// --- Links ---
|
||||
|
||||
async fn save_link(&self, link: &ShareLink) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO share_links (scope_id, token, expires_at, access_level, is_active, max_uses, use_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (scope_id) DO UPDATE SET
|
||||
token = EXCLUDED.token,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
access_level = EXCLUDED.access_level,
|
||||
is_active = EXCLUDED.is_active,
|
||||
max_uses = EXCLUDED.max_uses,
|
||||
use_count = EXCLUDED.use_count",
|
||||
)
|
||||
.bind(*link.scope_id.as_uuid())
|
||||
.bind(&link.token)
|
||||
.bind(link.expires_at.as_ref().map(|d| d.as_datetime()).copied())
|
||||
.bind(access_level_to_str(link.access_level))
|
||||
.bind(link.is_active)
|
||||
.bind(link.max_uses.map(|v| v as i32))
|
||||
.bind(link.use_count as i32)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_link_by_token(&self, token: &str) -> Result<Option<ShareLink>, DomainError> {
|
||||
let row = sqlx::query_as::<_, ShareLinkRow>(
|
||||
"SELECT scope_id, token, expires_at, access_level, is_active, max_uses, use_count
|
||||
FROM share_links WHERE token = $1",
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
// --- Invites ---
|
||||
|
||||
async fn save_invite(&self, invite: &InviteCode) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO invite_codes (code_id, scope_id, created_by_user_id, expires_at, max_uses, use_count, assigned_role_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (code_id) DO UPDATE SET
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
max_uses = EXCLUDED.max_uses,
|
||||
use_count = EXCLUDED.use_count",
|
||||
)
|
||||
.bind(*invite.code_id.as_uuid())
|
||||
.bind(*invite.scope_id.as_uuid())
|
||||
.bind(*invite.created_by_user_id.as_uuid())
|
||||
.bind(invite.expires_at.as_ref().map(|d| d.as_datetime()).copied())
|
||||
.bind(invite.max_uses.map(|v| v as i32))
|
||||
.bind(invite.use_count as i32)
|
||||
.bind(*invite.assigned_role_id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_invite_by_id(&self, id: &SystemId) -> Result<Option<InviteCode>, DomainError> {
|
||||
let row = sqlx::query_as::<_, InviteCodeRow>(
|
||||
"SELECT code_id, scope_id, created_by_user_id, expires_at, max_uses, use_count, assigned_role_id
|
||||
FROM invite_codes WHERE code_id = $1",
|
||||
)
|
||||
.bind(*id.as_uuid())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
}
|
||||
83
crates/adapters/postgres/src/visibility_filter_repository.rs
Normal file
83
crates/adapters/postgres/src/visibility_filter_repository.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use crate::db::PgPool;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
entities::VisibilityFilter, errors::DomainError, ports::VisibilityFilterRepository,
|
||||
value_objects::SystemId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct VisibilityFilterRow {
|
||||
filter_id: Uuid,
|
||||
scope_id: Uuid,
|
||||
role_id: Uuid,
|
||||
hidden_fields: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<VisibilityFilterRow> for VisibilityFilter {
|
||||
fn from(r: VisibilityFilterRow) -> Self {
|
||||
Self {
|
||||
filter_id: SystemId::from_uuid(r.filter_id),
|
||||
scope_id: SystemId::from_uuid(r.scope_id),
|
||||
role_id: SystemId::from_uuid(r.role_id),
|
||||
hidden_fields: r.hidden_fields,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PostgresVisibilityFilterRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresVisibilityFilterRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VisibilityFilterRepository for PostgresVisibilityFilterRepository {
|
||||
async fn find_by_scope_and_role(
|
||||
&self,
|
||||
scope_id: &SystemId,
|
||||
role_id: &SystemId,
|
||||
) -> Result<Option<VisibilityFilter>, DomainError> {
|
||||
let row = sqlx::query_as::<_, VisibilityFilterRow>(
|
||||
"SELECT filter_id, scope_id, role_id, hidden_fields
|
||||
FROM visibility_filters WHERE scope_id = $1 AND role_id = $2",
|
||||
)
|
||||
.bind(*scope_id.as_uuid())
|
||||
.bind(*role_id.as_uuid())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn save(&self, filter: &VisibilityFilter) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO visibility_filters (filter_id, scope_id, role_id, hidden_fields)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (filter_id) DO UPDATE SET
|
||||
hidden_fields = EXCLUDED.hidden_fields",
|
||||
)
|
||||
.bind(*filter.filter_id.as_uuid())
|
||||
.bind(*filter.scope_id.as_uuid())
|
||||
.bind(*filter.role_id.as_uuid())
|
||||
.bind(&filter.hidden_fields)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM visibility_filters WHERE filter_id = $1")
|
||||
.bind(*id.as_uuid())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user