Skip to content
Clone

feat(api): phase3b user resolution endpoints (batch, by-username, avatar stream) + username/visibility

ZhenYi committed
parent 339b7ad commit 5cb60f0 Browse files

13 files changed

+265 −6

File tree 13
Modified lib/auth/src/handlers/mod.rs View lib/auth/src/handlers/mod.rs
@@ -43,4 +43,5 @@
4343pub mod update_preferences;
4444pub mod update_profile;
4545pub mod verify_email;
46pub mod users;
4647pub mod verify_email_page;
Added lib/auth/src/handlers/users.rs View lib/auth/src/handlers/users.rs
@@ -0,0 +1,88 @@
1use axum::Json;
2use axum::extract::{Path, Query, State};
3use export::rest::AppResponseData;
4use serde::Deserialize;
5use session::Session;
6use session::share::session::require_uid;
7use utoipa::IntoParams;
8use uuid::Uuid;
9
10use crate::errors::ApiError;
11use crate::services::AuthService;
12use crate::share::types::UserPublic;
13
14#[derive(Debug, Deserialize, IntoParams)]
15pub struct UsersBatchQuery {
16 pub ids: Vec<Uuid>,
17}
18
19#[utoipa::path(
20 get,
21 path = "/users",
22 tag = "users",
23 params(UsersBatchQuery),
24 responses(
25 (status = 200, description = "resolved public identities", body = AppResponseData<Vec<UserPublic>>)
26 )
27)]
28pub async fn list_users_batch(
29 State(service): State<AuthService>,
30 session: Session,
31 Query(query): Query<UsersBatchQuery>,
32) -> Result<AppResponseData<Vec<UserPublic>>, ApiError> {
33 let caller = require_uid(&session)?;
34 let users = service
35 .public_users(caller, query.ids)
36 .await
37 .map_err(ApiError::from)?;
38 Ok(AppResponseData::ok(users))
39}
40
41#[utoipa::path(
42 get,
43 path = "/users/by-username/{username}",
44 tag = "users",
45 params(("username" = String, Path, description = "username")),
46 responses(
47 (status = 200, description = "public user identity", body = AppResponseData<UserPublic>)
48 )
49)]
50pub async fn get_user_by_username(
51 State(service): State<AuthService>,
52 session: Session,
53 Path(username): Path<String>,
54) -> Result<AppResponseData<UserPublic>, ApiError> {
55 let caller = require_uid(&session)?;
56 let user = service
57 .public_user_by_name(caller, &username)
58 .await
59 .map_err(ApiError::from)?;
60 Ok(AppResponseData::ok(user))
61}
62
63#[derive(Debug, Deserialize, utoipa::ToSchema)]
64pub struct UpdateUsernameCmd {
65 pub username: String,
66}
67
68#[utoipa::path(
69 put,
70 path = "/me/username",
71 tag = "account",
72 request_body = UpdateUsernameCmd,
73 responses(
74 (status = 200, description = "username updated", body = AppResponseData<UserPublic>)
75 )
76)]
77pub async fn update_username(
78 State(service): State<AuthService>,
79 session: Session,
80 Json(cmd): Json<UpdateUsernameCmd>,
81) -> Result<AppResponseData<UserPublic>, ApiError> {
82 let uid = require_uid(&session)?;
83 let user = service
84 .update_username(uid, &cmd.username)
85 .await
86 .map_err(ApiError::from)?;
87 Ok(AppResponseData::ok(user))
88}
Modified lib/auth/src/services/profile.rs View lib/auth/src/services/profile.rs
@@ -1,6 +1,6 @@
11use models::users::user::ProfileVisibility;
22use models::users::{user, user_profiles};
3use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, Set, TransactionTrait};
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set, TransactionTrait};
44use serde::{Deserialize, Serialize};
55use time::{Date, OffsetDateTime};
66use uuid::Uuid;
@@ -60,6 +60,8 @@
6060 pub show_location: Option<bool>,
6161 pub show_real_name: Option<bool>,
6262 pub show_activity: Option<bool>,
63 #[serde(default)]
64 pub visibility: Option<ProfileVisibility>,
6365}
6466
6567impl AuthService {
@@ -132,6 +134,9 @@
132134 if let Some(dob) = cmd.date_of_birth {
133135 validate_age(Some(dob), self.config().auth.min_age)?;
134136 }
137 if let Some(visibility) = cmd.visibility {
138 user_active.visibility = Set(visibility);
139 }
135140 user_active.updated_at = Set(now);
136141
137142 TransactionTrait::transaction(db.primary(), |txn| {
@@ -211,4 +216,40 @@
211216 .await
212217 .map_err(AuthError::from)
213218 }
219
220 pub async fn update_username(
221 &self,
222 uid: Uuid,
223 new_username: &str,
224 ) -> Result<crate::share::types::UserPublic, AuthError> {
225 let username = crate::share::validate::validate_username(new_username.trim())?;
226 let lower = username.to_lowercase();
227 let db = self.db();
228 let user = user::Entity::find_by_id(uid)
229 .one(db.read())
230 .await?
231 .ok_or(AuthError::NotFound)?;
232 if user.username != username {
233 let exists = user::Entity::find()
234 .filter(user::Column::LowerName.eq(&lower))
235 .one(db.read())
236 .await?;
237 if exists.is_some() {
238 return Err(AuthError::Conflict(
239 "username already taken".to_string(),
240 ));
241 }
242 let mut active = user.clone().into_active_model();
243 active.username = Set(username.clone());
244 active.lower_name = Set(lower);
245 active.updated_at = Set(OffsetDateTime::now_utc());
246 active.update(db.write()).await?;
247 }
248 Ok(crate::share::types::UserPublic {
249 user_id: uid,
250 username,
251 display_name: user.display_name,
252 avatar_url: user.avatar_url,
253 })
254 }
214255}
Modified lib/auth/src/services/users.rs View lib/auth/src/services/users.rs
@@ -1,17 +1,16 @@
11use models::users::user::ProfileVisibility;
22use models::users::user;
3use sea_orm::EntityTrait;
3use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
44use uuid::Uuid;
55
66use super::AuthService;
77use crate::errors::AuthError;
88use crate::share::types::UserPublic;
99
10const MAX_BATCH_USERS: usize = 100;
11
1012impl AuthService {
11 pub async fn public_user(&self, caller: Uuid, uid: Uuid) -> Result<UserPublic, AuthError> {
12 let Some(user) = user::Entity::find_by_id(uid).one(self.db().read()).await? else {
13 return Err(AuthError::NotFound);
14 };
13 fn filter_public_user(&self, caller: Uuid, user: user::Model) -> Result<UserPublic, AuthError> {
1514 if !user.is_active || user.system {
1615 return Err(AuthError::NotFound);
1716 }
@@ -25,4 +24,53 @@
2524 avatar_url: user.avatar_url,
2625 })
2726 }
27
28 pub async fn public_user(&self, caller: Uuid, uid: Uuid) -> Result<UserPublic, AuthError> {
29 let Some(user) = user::Entity::find_by_id(uid).one(self.db().read()).await? else {
30 return Err(AuthError::NotFound);
31 };
32 self.filter_public_user(caller, user)
33 }
34
35 pub async fn public_users(
36 &self,
37 caller: Uuid,
38 ids: Vec<Uuid>,
39 ) -> Result<Vec<UserPublic>, AuthError> {
40 if ids.len() > MAX_BATCH_USERS {
41 return Err(AuthError::BadRequest(format!(
42 "ids must contain at most {MAX_BATCH_USERS} entries"
43 )));
44 }
45 if ids.is_empty() {
46 return Ok(Vec::new());
47 }
48 let rows = user::Entity::find()
49 .filter(user::Column::Uid.is_in(ids))
50 .all(self.db().read())
51 .await?;
52 Ok(rows
53 .into_iter()
54 .filter_map(|u| self.filter_public_user(caller, u).ok())
55 .collect())
56 }
57
58 pub async fn public_user_by_name(
59 &self,
60 caller: Uuid,
61 username: &str,
62 ) -> Result<UserPublic, AuthError> {
63 let lower = username.trim().to_lowercase();
64 if lower.is_empty() {
65 return Err(AuthError::NotFound);
66 }
67 let Some(user) = user::Entity::find()
68 .filter(user::Column::LowerName.eq(&lower))
69 .one(self.db().read())
70 .await?
71 else {
72 return Err(AuthError::NotFound);
73 };
74 self.filter_public_user(caller, user)
75 }
2876}
Modified lib/auth/src/openapi.rs View lib/auth/src/openapi.rs
@@ -59,6 +59,9 @@
5959 handlers::forgot_password::forgot_password,
6060 handlers::get_preferences::get_preferences,
6161 handlers::get_user::get_user,
62 handlers::users::list_users_batch,
63 handlers::users::get_user_by_username,
64 handlers::users::update_username,
6265 handlers::list_access_keys::list_access_keys,
6366 handlers::list_agreements::list_agreements,
6467 handlers::list_notifications::list_notifications,
Modified lib/auth/src/routes.rs View lib/auth/src/routes.rs
@@ -48,6 +48,7 @@
4848use crate::handlers::update_profile::update_profile;
4949use crate::handlers::verify_email::verify_email;
5050use crate::handlers::verify_email_page::verify_email_page;
51use crate::handlers::users;
5152use crate::services::AuthService;
5253use crate::share::guard::auth_guard;
5354
@@ -66,6 +67,8 @@
6667 .route("/auth/agreements", get(list_agreements));
6768
6869 let authed = Router::new()
70 .route("/users", get(users::list_users_batch))
71 .route("/users/by-username/{username}", get(users::get_user_by_username))
6972 .route("/users/{user_id}", get(get_user))
7073 .route("/auth/email/change", post(request_email_change))
7174 .route("/auth/email/change/confirm", post(confirm_email_change))
@@ -90,6 +93,7 @@
9093 .route("/auth/ssh-keys", get(list_ssh_keys).post(add_ssh_key))
9194 .route("/auth/ssh-keys/{key_id}", delete(delete_ssh_key))
9295 .route("/me", get(me).patch(update_profile))
96 .route("/me/username", put(users::update_username))
9397 .route(
9498 "/me/preferences",
9599 get(get_preferences).patch(update_preferences),
Added lib/user/src/handlers/get_user_avatar.rs View lib/user/src/handlers/get_user_avatar.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Path, State};
2use axum::http::header;
3use axum::response::Response;
4use session::Session;
5use session::share::session::require_uid;
6use uuid::Uuid;
7
8use crate::errors::ApiError;
9use crate::services::service::UserService;
10
11#[utoipa::path(
12 get,
13 path = "/users/{user_id}/avatar",
14 tag = "users",
15 params(("user_id" = Uuid, Path, description = "user id")),
16 responses(
17 (status = 200, description = "avatar image bytes", content_type = "image/*"),
18 (status = 404, description = "no avatar or user not visible")
19 )
20)]
21pub async fn get_user_avatar(
22 State(service): State<UserService>,
23 session: Session,
24 Path(user_id): Path<Uuid>,
25) -> Result<Response, ApiError> {
26 let caller = require_uid(&session).map_err(|_| ApiError::Unauthorized(export::rest::AppResponseData::err(401, "unauthorized")))?;
27 let (content_type, body) = service.avatar_stream(caller, user_id).await?;
28 Ok(Response::builder()
29 .status(200)
30 .header(header::CONTENT_TYPE, content_type)
31 .header(header::CACHE_CONTROL, "public, max-age=300")
32 .body(body)
33 .unwrap())
34}
Modified lib/user/src/handlers/mod.rs View lib/user/src/handlers/mod.rs
@@ -5,6 +5,7 @@
55pub mod delete_meme;
66pub mod delete_push_subscription;
77pub mod get_avatar;
8pub mod get_user_avatar;
89pub mod get_profile_detail;
910pub mod list_emails;
1011pub mod list_memes;
Modified lib/user/src/services/avatar.rs View lib/user/src/services/avatar.rs
@@ -1,4 +1,5 @@
11use base64::Engine;
2use models::users::user::ProfileVisibility;
23use models::users::user;
34use sea_orm::{ActiveModelTrait, EntityTrait, Set};
45use serde::{Deserialize, Serialize};
@@ -179,6 +180,39 @@
179180 Ok(())
180181 }
181182
183 pub async fn avatar_stream(
184 &self,
185 caller: Uuid,
186 target_uid: Uuid,
187 ) -> Result<(String, axum::body::Body), UserError> {
188 let target = user::Entity::find_by_id(target_uid)
189 .one(self.db().read())
190 .await?
191 .ok_or(UserError::NotFound)?;
192 if !target.is_active && caller != target_uid {
193 return Err(UserError::NotFound);
194 }
195 if target.visibility == ProfileVisibility::Private && caller != target_uid {
196 return Err(UserError::NotFound);
197 }
198 if target.avatar_url.is_empty() {
199 return Err(UserError::NotFound);
200 }
201 let ext = target.avatar_url.rsplit('.').next().unwrap_or_default();
202 let fmt = AvatarFormat::from_extension(ext).unwrap_or(AvatarFormat::Jpg);
203 let rel = target
204 .avatar_url
205 .trim_start_matches("storage://")
206 .trim_start_matches(AVATAR_PREFIX)
207 .trim_start_matches('/');
208 let key = format!("{AVATAR_PREFIX}/{rel}");
209 let stream = self.storage().client().get_stream(&key).await.map_err(UserError::from)?;
210 let stream = futures::StreamExt::map(stream, |item| {
211 item.map_err(|e| std::io::Error::other(e.to_string()))
212 });
213 Ok((fmt.content_type().to_string(), axum::body::Body::from_stream(stream)))
214 }
215
182216 pub async fn current_avatar(&self, uid: Uuid) -> Result<Option<AvatarDto>, UserError> {
183217 let user = user::Entity::find_by_id(uid)
184218 .one(self.db().read())
Modified lib/user/src/openapi.rs View lib/user/src/openapi.rs
@@ -24,6 +24,7 @@
2424 info(title = "gitdata user api", version = "1.0"),
2525 paths(
2626 handlers::get_avatar::get_avatar,
27 handlers::get_user_avatar::get_user_avatar,
2728 handlers::upload_avatar::upload_avatar,
2829 handlers::delete_avatar::delete_avatar,
2930 handlers::my_spaces::my_spaces,
Modified lib/user/src/routes.rs View lib/user/src/routes.rs
@@ -8,6 +8,7 @@
88use crate::handlers::delete_meme::delete_meme;
99use crate::handlers::delete_push_subscription::delete_push_subscription;
1010use crate::handlers::get_avatar::get_avatar;
11use crate::handlers::get_user_avatar::get_user_avatar;
1112use crate::handlers::get_profile_detail::get_profile_detail;
1213use crate::handlers::list_emails::list_emails;
1314use crate::handlers::list_memes::list_memes;
@@ -34,6 +35,7 @@
3435 Router::new().route("/users/{user_id}/profile-detail", get(get_profile_detail));
3536
3637 let protected = Router::new()
38 .route("/users/{user_id}/avatar", get(get_user_avatar))
3739 .route(
3840 "/me/avatar",
3941 get(get_avatar).put(upload_avatar).delete(delete_avatar),
Modified lib/user/Cargo.toml View lib/user/Cargo.toml
@@ -17,6 +17,7 @@
1717serde_json = { workspace = true }
1818utoipa = { workspace = true }
1919uuid = { workspace = true }
20futures = { workspace = true }
2021time = { workspace = true }
2122base64 = { workspace = true }
2223rand = { workspace = true }
Modified Cargo.lock View Cargo.lock
@@ -8596,6 +8596,7 @@
85968596 "base64 0.23.1",
85978597 "config",
85988598 "export",
8599 "futures",
85998600 "models",
86008601 "rand 0.8.7",
86018602 "redis",