main
use models::users::user::{self, ProfileVisibility};
use models::users::{user_favorite_memes, user_profiles};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use crate::errors::UserError;
use crate::services::service::UserService;
use crate::share::page::{Page, PageQuery};
pub const OWNER_PERMISSIONS: [&str; 9] = [
"view_space",
"read",
"message",
"manage_members",
"manage_roles",
"manage_invites",
"manage_bans",
"manage_channels",
"manage_overwrites",
];
pub const MAX_MEME_NAME: usize = 64;
pub const MAX_MEME_URL: usize = 2048;
pub const MAX_MEME_CONTENT_TYPE: usize = 128;
pub const DEFAULT_LIST_LIMIT: u64 = 50;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SpaceDto {
pub id: Uuid,
pub name: String,
pub owner_uid: i64,
pub avatar_url: Option<String>,
pub is_private: bool,
pub members_count: i64,
pub created_at: OffsetDateTime,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MySpaceDto {
pub space: SpaceDto,
pub permissions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct MemeDto {
pub id: Uuid,
pub name: String,
pub content_type: String,
pub url: String,
pub size: i64,
pub width: Option<i32>,
pub height: Option<i32>,
pub created_at: OffsetDateTime,
}
impl From<user_favorite_memes::Model> for MemeDto {
fn from(m: user_favorite_memes::Model) -> Self {
Self {
id: m.id,
name: m.name,
content_type: m.content_type,
url: m.url,
size: m.size,
width: m.width,
height: m.height,
created_at: m.created_at,
}
}
}
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct UpsertMemeCmd {
pub name: String,
pub content_type: String,
pub url: String,
pub size: i64,
pub width: Option<i32>,
pub height: Option<i32>,
}
#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
pub struct ListLimitQuery {
pub limit: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProfileDetailDto {
pub user_id: i64,
pub username: String,
pub display_name: String,
pub avatar_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub visibility: Option<ProfileVisibility>,
pub real_name: Option<String>,
pub bio: Option<String>,
pub pronouns: Option<String>,
pub website: Option<String>,
pub location: Option<String>,
pub company: Option<String>,
pub banner_url: Option<String>,
pub accent_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_email: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_location: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_real_name: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub show_activity: Option<bool>,
pub updated_at: OffsetDateTime,
}
impl UserService {
pub async fn my_spaces(&self, uid: i64, q: PageQuery) -> Result<Page<MySpaceDto>, UserError> {
let (limit, offset) = q.bounds()?;
let mut space_rows = self.access.user_spaces(uid).await?;
space_rows.sort_by(|a, b| a.slug().cmp(b.slug()));
let total = space_rows.len() as u64;
let mut out = Vec::new();
for space in space_rows
.into_iter()
.skip(offset as usize)
.take(limit as usize)
{
let perms = space.permissions(uid).await?;
let avatar_url = space.avatar_url().await?;
let members_count = space.member_count().await?;
let space_dto = SpaceDto {
id: space.id(),
name: space.name().to_owned(),
owner_uid: space.owner(),
avatar_url,
is_private: space.is_private(),
members_count,
created_at: space.created_at(),
};
out.push(MySpaceDto {
space: space_dto,
permissions: perms,
});
}
Ok(Page::new(out, total, limit, offset))
}
pub async fn list_memes(
&self,
uid: i64,
limit: Option<u64>,
) -> Result<Vec<MemeDto>, UserError> {
let lim = limit.unwrap_or(DEFAULT_LIST_LIMIT).clamp(1, 50);
let rows = user_favorite_memes::Entity::find()
.filter(user_favorite_memes::Column::UserId.eq(uid))
.order_by_desc(user_favorite_memes::Column::CreatedAt)
.limit(Some(lim))
.all(self.database.read())
.await?;
Ok(rows.into_iter().map(MemeDto::from).collect())
}
fn validate_meme_cmd(cmd: &UpsertMemeCmd) -> Result<(), UserError> {
if cmd.name.is_empty() || cmd.name.len() > MAX_MEME_NAME {
return Err(UserError::BadRequest(format!(
"meme name length must be 1..={MAX_MEME_NAME}"
)));
}
if cmd.content_type.len() > MAX_MEME_CONTENT_TYPE {
return Err(UserError::BadRequest(format!(
"content_type length must be <={MAX_MEME_CONTENT_TYPE}"
)));
}
if cmd.url.len() > MAX_MEME_URL {
return Err(UserError::BadRequest(format!(
"url length must be <={MAX_MEME_URL}"
)));
}
Ok(())
}
async fn owned_meme(
&self,
uid: i64,
meme_id: Uuid,
) -> Result<user_favorite_memes::Model, UserError> {
let row = user_favorite_memes::Entity::find_by_id(meme_id)
.one(self.database.read())
.await?
.ok_or(UserError::NotFound)?;
if row.user_id != uid {
return Err(UserError::Forbidden);
}
Ok(row)
}
async fn find_meme_by_name(
&self,
uid: i64,
name: &str,
) -> Result<Option<user_favorite_memes::Model>, UserError> {
let existing = user_favorite_memes::Entity::find()
.filter(user_favorite_memes::Column::UserId.eq(uid))
.filter(user_favorite_memes::Column::Name.eq(name))
.one(self.database.read())
.await?;
Ok(existing)
}
#[tracing::instrument(name = "user.create_meme", skip_all, fields(uid = %uid))]
pub async fn create_meme(&self, uid: i64, cmd: UpsertMemeCmd) -> Result<MemeDto, UserError> {
Self::validate_meme_cmd(&cmd)?;
let existing = self.find_meme_by_name(uid, &cmd.name).await?;
if existing.is_some() {
return Err(UserError::Conflict("meme name already exists".to_string()));
}
let model = self.upsert_meme_row(uid, cmd).await?;
Ok(MemeDto::from(model))
}
#[tracing::instrument(
name = "user.update_meme",
skip_all,
fields(uid = %uid, meme_id = %meme_id)
)]
pub async fn update_meme(
&self,
uid: i64,
meme_id: Uuid,
cmd: UpsertMemeCmd,
) -> Result<MemeDto, UserError> {
Self::validate_meme_cmd(&cmd)?;
let row = self.owned_meme(uid, meme_id).await?;
let mut am: user_favorite_memes::ActiveModel = row.into();
am.name = Set(cmd.name);
am.content_type = Set(cmd.content_type);
am.url = Set(cmd.url);
am.size = Set(cmd.size);
am.width = Set(cmd.width);
am.height = Set(cmd.height);
let model = am.update(self.database.write()).await?;
Ok(MemeDto::from(model))
}
async fn upsert_meme_row(
&self,
uid: i64,
cmd: UpsertMemeCmd,
) -> Result<user_favorite_memes::Model, UserError> {
let existing = self.find_meme_by_name(uid, &cmd.name).await?;
let now = OffsetDateTime::now_utc();
let model = match existing {
Some(row) => {
let mut active: user_favorite_memes::ActiveModel = row.into();
active.content_type = Set(cmd.content_type);
active.url = Set(cmd.url);
active.size = Set(cmd.size);
active.width = Set(cmd.width);
active.height = Set(cmd.height);
active.update(self.database.write()).await?
}
None => {
let active = user_favorite_memes::ActiveModel {
id: Set(Uuid::now_v7()),
user_id: Set(uid),
name: Set(cmd.name),
content_type: Set(cmd.content_type),
url: Set(cmd.url),
size: Set(cmd.size),
width: Set(cmd.width),
height: Set(cmd.height),
created_at: Set(now),
};
active.insert(self.database.write()).await?
}
};
Ok(model)
}
#[tracing::instrument(
name = "user.delete_meme",
skip_all,
fields(uid = %uid, meme_id = %meme_id)
)]
pub async fn delete_meme(&self, uid: i64, meme_id: Uuid) -> Result<(), UserError> {
self.owned_meme(uid, meme_id).await?;
user_favorite_memes::Entity::delete_by_id(meme_id)
.exec(self.database.write())
.await?;
Ok(())
}
pub async fn profile_detail(
&self,
viewer: Option<i64>,
target_uid: i64,
) -> Result<ProfileDetailDto, UserError> {
let local = user::Entity::find_by_id(target_uid)
.one(self.database.read())
.await?;
let public = auth::users::get_public_user(&self.accounts, target_uid)
.await?
.ok_or(UserError::NotFound)?;
let status = self
.accounts
.get_user_status(target_uid)
.await
.map_err(|error| UserError::Internal(error.to_string()))?;
let is_owner = viewer == Some(target_uid);
if !status.allow_use && !is_owner {
return Err(UserError::NotFound);
}
let visibility = local
.as_ref()
.map(|row| row.visibility)
.unwrap_or(ProfileVisibility::Public);
let can_view = match visibility {
ProfileVisibility::Public => true,
ProfileVisibility::RegisteredOnly => viewer.is_some(),
ProfileVisibility::MembersOnly => matches!(
viewer,
Some(viewer_uid)
if is_owner || shares_space_with(self, viewer_uid, target_uid).await?
),
ProfileVisibility::Private => is_owner,
};
if !can_view {
return Err(UserError::Forbidden);
}
let profile = user_profiles::Entity::find_by_id(target_uid)
.one(self.database.read())
.await?;
let display_name = if public.displayname.trim().is_empty() {
public.username.clone()
} else {
public.displayname.clone()
};
let local_avatar = local
.as_ref()
.map(|row| row.local_avatar_url.as_str())
.unwrap_or_default();
let avatar_raw = if local_avatar.is_empty() {
public.avatar_url.clone()
} else {
local_avatar.to_string()
};
let mut dto = ProfileDetailDto {
user_id: target_uid,
username: public.username.clone(),
display_name,
avatar_url: space::services::dto::resolve_user_avatar_url(target_uid, &avatar_raw),
visibility: if is_owner { Some(visibility) } else { None },
real_name: None,
bio: None,
pronouns: None,
website: None,
location: None,
company: None,
banner_url: None,
accent_color: None,
show_email: if is_owner { Some(false) } else { None },
show_location: if is_owner { Some(false) } else { None },
show_real_name: if is_owner { Some(false) } else { None },
show_activity: if is_owner { Some(false) } else { None },
updated_at: local
.as_ref()
.map(|row| row.updated_at)
.unwrap_or_else(OffsetDateTime::now_utc),
};
if let Some(p) = profile {
let gate = |flag: bool, v: Option<String>| -> Option<String> {
if is_owner || flag { v } else { None }
};
let emit_activity = is_owner || p.show_activity;
dto.real_name = gate(p.show_real_name, p.real_name);
dto.bio = gate(emit_activity, p.bio);
dto.pronouns = gate(emit_activity, p.pronouns);
dto.website = gate(emit_activity, p.website);
dto.location = gate(p.show_location, p.location);
dto.company = gate(emit_activity, p.company);
dto.banner_url = gate(emit_activity, p.banner_url);
dto.accent_color = gate(emit_activity, p.accent_color);
dto.show_email = if is_owner { Some(p.show_email) } else { None };
dto.show_location = if is_owner {
Some(p.show_location)
} else {
None
};
dto.show_real_name = if is_owner {
Some(p.show_real_name)
} else {
None
};
dto.show_activity = if is_owner {
Some(p.show_activity)
} else {
None
};
dto.updated_at = p.updated_at;
}
Ok(dto)
}
}
async fn shares_space_with(
service: &UserService,
viewer: i64,
target: i64,
) -> Result<bool, UserError> {
let viewer_spaces = service.access.user_spaces(viewer).await?;
if viewer_spaces.is_empty() {
return Ok(false);
}
let target_spaces = service.access.user_spaces(target).await?;
let target_ids: std::collections::HashSet<Uuid> =
target_spaces.into_iter().map(|space| space.id()).collect();
Ok(viewer_spaces
.iter()
.any(|space| target_ids.contains(&space.id())))
}