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 { 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, ) -> UserResult { 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 { 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 { 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> { 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 { 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) -> 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 { 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( 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(()) }