Skip to content
Clone
a06673b
321 lines 9.7 KB Rust
use db::user::users as user_entity;
use db::conn::sea_orm::{
ActiveModelTrait, ConnectionTrait, EntityTrait, QueryFilter, QuerySelect, Set,
TransactionTrait,
};
use time::OffsetDateTime;
use uuid::Uuid;
use crate::error::{UserError, UserResult};
use crate::features::UserService;
use super::types::{
MemeView, ProfileView, UpdateProfileCmd, UpdateProfileFields, UserMe, UserPublic, UpsertMemeCmd,
};
const MAX_DISPLAY_NAME: usize = 64;
const MAX_LOCALE: usize = 16;
const MAX_LANGUAGE: usize = 16;
const MAX_TIMEZONE: usize = 64;
const MAX_AVATAR: usize = 1024;
const MAX_MEME_NAME: usize = 64;
const MAX_MEME_URL: usize = 2048;
const MAX_MEME_CONTENT_TYPE: usize = 128;
pub async fn fetch_user_me(svc: &UserService, uid: Uuid) -> UserResult<UserMe> {
let db = svc.db.reader();
let model = user_entity::Entity::find_by_id(uid)
.one(&db)
.await?
.ok_or_else(|| UserError::NotFound(format!("user {uid}")))?;
Ok(UserMe::from(model))
}
pub async fn fetch_user_public(
svc: &UserService,
uid: Uuid,
viewer: Option<Uuid>,
) -> UserResult<UserPublic> {
let db = svc.db.reader();
let model = user_entity::Entity::find_by_id(uid)
.one(&db)
.await?
.ok_or_else(|| UserError::NotFound(format!("user {uid}")))?;
if !can_view(&model, viewer) {
return Err(UserError::Forbidden(format!("user {uid} is private")));
}
Ok(UserPublic::from(model))
}
pub async fn update_profile(
svc: &UserService,
caller: Uuid,
target: Uuid,
cmd: UpdateProfileCmd,
) -> UserResult<UserMe> {
let cfg = &svc.config;
let cmd = validate_update_cmd(cmd, cfg.max_display_name_len, MAX_AVATAR)?;
let db = svc.db.writer();
let txn = db.begin().await?;
let mut active: user_entity::ActiveModel = user_entity::Entity::find_by_id(target)
.one(&txn)
.await?
.ok_or_else(|| UserError::NotFound(format!("user {target}")))?
.into();
if let Some(v) = cmd.display_name {
active.display_name = Set(v);
}
if let Some(v) = cmd.avatar {
active.avatar = Set(v);
}
if let Some(v) = cmd.locale {
active.locale = Set(Some(v));
}
if let Some(v) = cmd.language {
active.language = Set(Some(v));
}
if let Some(v) = cmd.timezone {
active.timezone = Set(Some(v));
}
if let Some(v) = cmd.visibility {
active.visibility = Set(v.into());
}
active.updated_at = Set(OffsetDateTime::now_utc());
let updated = active.update(&txn).await?;
if let Some(fields) = cmd.profile {
apply_profile_fields(&txn, target, fields).await?;
}
txn.commit().await?;
let _ = caller;
Ok(UserMe::from(updated))
}
pub async fn fetch_profile(svc: &UserService, target: Uuid) -> UserResult<ProfileView> {
let db = svc.db.reader();
let model = db::user::user_profiles::Entity::find_by_id(target)
.one(&db)
.await?
.ok_or_else(|| UserError::NotFound(format!("profile {target}")))?;
Ok(ProfileView::from(model))
}
pub async fn list_memes(
svc: &UserService,
caller: Uuid,
limit: usize,
) -> UserResult<Vec<MemeView>> {
let limit = limit.min(svc.config.max_profile_pagination).max(1) as u64;
let db = svc.db.reader();
let rows = db::user::user_favorite_memes::Entity::find()
.filter(db::user::user_favorite_memes::COLUMN.user_id.eq(caller))
.limit(Some(limit))
.all(&db)
.await?;
Ok(rows.into_iter().map(MemeView::from).collect())
}
pub async fn upsert_meme(
svc: &UserService,
caller: Uuid,
cmd: UpsertMemeCmd,
) -> UserResult<MemeView> {
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}"
)));
}
let db = svc.db.writer();
let existing = db::user::user_favorite_memes::Entity::find()
.filter(db::user::user_favorite_memes::COLUMN.user_id.eq(caller))
.filter(db::user::user_favorite_memes::COLUMN.name.eq(&cmd.name))
.one(&db)
.await?;
let now = OffsetDateTime::now_utc();
let saved = match existing {
Some(row) => {
let mut active: db::user::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(&db).await?
}
None => {
let active: db::user::user_favorite_memes::ActiveModel =
db::user::user_favorite_memes::ActiveModel::builder()
.set_id(Uuid::new_v4())
.set_user_id(caller)
.set_name(cmd.name)
.set_content_type(cmd.content_type)
.set_url(cmd.url)
.set_size(cmd.size)
.set_width(cmd.width)
.set_height(cmd.height)
.set_created_at(now)
.into();
active.insert(&db).await?
}
};
Ok(MemeView::from(saved))
}
pub async fn delete_meme(svc: &UserService, caller: Uuid, meme_id: Uuid) -> UserResult<()> {
let db = svc.db.writer();
let row = db::user::user_favorite_memes::Entity::find_by_id(meme_id)
.one(&db)
.await?
.ok_or_else(|| UserError::NotFound(format!("meme {meme_id}")))?;
if row.user_id != caller {
return Err(UserError::Forbidden("cannot delete other user's meme".into()));
}
db::user::user_favorite_memes::Entity::delete_by_id(meme_id)
.exec(&db)
.await?;
Ok(())
}
fn can_view(model: &user_entity::Model, viewer: Option<Uuid>) -> bool {
match model.visibility {
user_entity::ProfileVisibility::Public => true,
user_entity::ProfileVisibility::RegisteredOnly => viewer.is_some(),
user_entity::ProfileVisibility::MembersOnly => viewer.is_some(),
user_entity::ProfileVisibility::Private => viewer == Some(model.uid),
}
}
fn validate_update_cmd(
cmd: UpdateProfileCmd,
max_display_name: usize,
max_avatar: usize,
) -> UserResult<UpdateProfileCmd> {
if let Some(ref v) = cmd.display_name
&& (v.is_empty() || v.chars().count() > max_display_name.min(MAX_DISPLAY_NAME)) {
return Err(UserError::BadRequest(format!(
"display_name length must be 1..={}",
max_display_name.min(MAX_DISPLAY_NAME)
)));
}
if let Some(ref v) = cmd.avatar
&& v.len() > max_avatar.min(MAX_AVATAR) {
return Err(UserError::BadRequest(format!(
"avatar length must be <={}",
max_avatar.min(MAX_AVATAR)
)));
}
if let Some(ref v) = cmd.locale
&& v.len() > MAX_LOCALE {
return Err(UserError::BadRequest(format!(
"locale length must be <={MAX_LOCALE}"
)));
}
if let Some(ref v) = cmd.language
&& v.len() > MAX_LANGUAGE {
return Err(UserError::BadRequest(format!(
"language length must be <={MAX_LANGUAGE}"
)));
}
if let Some(ref v) = cmd.timezone
&& v.len() > MAX_TIMEZONE {
return Err(UserError::BadRequest(format!(
"timezone length must be <={MAX_TIMEZONE}"
)));
}
Ok(cmd)
}
async fn apply_profile_fields<C>(
conn: &C,
user_id: Uuid,
f: UpdateProfileFields,
) -> UserResult<()>
where
C: ConnectionTrait,
{
use db::user::user_profiles::Entity as ProfileEntity;
let now = OffsetDateTime::now_utc();
let existing_row = ProfileEntity::find_by_id(user_id).one(conn).await?;
let existing_present = existing_row.is_some();
let mut active: db::user::user_profiles::ActiveModel = match existing_row {
Some(m) => m.into(),
None => db::user::user_profiles::ActiveModel::builder()
.set_user_id(user_id)
.set_created_at(now)
.into(),
};
if let Some(v) = f.real_name {
active.real_name = Set(v);
}
if let Some(v) = f.bio {
active.bio = Set(v);
}
if let Some(v) = f.pronouns {
active.pronouns = Set(v);
}
if let Some(v) = f.pronouns_privacy {
active.pronouns_privacy = Set(v.map(Into::into));
}
if let Some(v) = f.website {
active.website = Set(v);
}
if let Some(v) = f.location {
active.location = Set(v);
}
if let Some(v) = f.company {
active.company = Set(v);
}
if let Some(v) = f.date_of_birth {
active.date_of_birth = Set(v);
}
if let Some(v) = f.banner_url {
active.banner_url = Set(v);
}
if let Some(v) = f.banner_color {
active.banner_color = Set(v);
}
if let Some(v) = f.accent_color {
active.accent_color = Set(v);
}
if let Some(v) = f.show_email {
active.show_email = Set(v);
}
if let Some(v) = f.show_location {
active.show_location = Set(v);
}
if let Some(v) = f.show_real_name {
active.show_real_name = Set(v);
}
if let Some(v) = f.show_activity {
active.show_activity = Set(v);
}
active.updated_at = Set(now);
if existing_present {
active.update(conn).await?;
} else {
active.insert(conn).await?;
}
Ok(())
}