feat(lib/user): add verify_add_email handler and share helpers, refresh handlers/services/share/openapi
ZhenYi committed
Modified lib/user/src/handlers/get_profile_detail.rs lib/user/src/handlers/get_profile_detail.rs +3 −4 View lib/user/src/handlers/get_profile_detail.rs
@@ -1,12 +1,11 @@
11use axum::extract::{Path, State};
22use export::rest::AppResponseData;
3use session::Session;
3use session::{Session, SessionUser};
44use uuid::Uuid;
55
66use crate::errors::ApiError;
77use crate::services::profile::ProfileDetailDto;
88use crate::services::service::UserService;
9use crate::share::session::require_uid;
109
1110#[utoipa::path(
1211 get,
@@ -24,7 +23,7 @@
2423 session: Session,
2524 Path(uid): Path<Uuid>,
2625) -> Result<AppResponseData<ProfileDetailDto>, ApiError> {
27 let viewer = require_uid(&session)?;
28 let detail = service.profile_detail(Some(viewer), uid).await?;
26 let viewer = session.uid();
27 let detail = service.profile_detail(viewer, uid).await?;
2928 Ok(AppResponseData::ok(detail))
3029}
Modified lib/user/src/handlers/list_push_subscriptions.rs lib/user/src/handlers/list_push_subscriptions.rs +5 −2 View lib/user/src/handlers/list_push_subscriptions.rs
@@ -1,16 +1,18 @@
1use axum::extract::State;
1use axum::extract::{Query, State};
22use export::rest::AppResponseData;
33use session::Session;
44
55use crate::errors::ApiError;
66use crate::services::push_subscriptions::PushSubscriptionDto;
77use crate::services::service::UserService;
8use crate::share::page::PageQuery;
89use crate::share::session::require_uid;
910
1011#[utoipa::path(
1112 get,
1213 path = "/me/push-subscriptions",
1314 tag = "user",
15 params(PageQuery),
1416 responses(
1517 (status = 200, description = "list push subscriptions", body = AppResponseData<Vec<PushSubscriptionDto>>)
1618 )
@@ -18,8 +20,9 @@
1820pub async fn list_push_subscriptions(
1921 State(service): State<UserService>,
2022 session: Session,
23 Query(query): Query<PageQuery>,
2124) -> Result<AppResponseData<Vec<PushSubscriptionDto>>, ApiError> {
2225 let uid = require_uid(&session)?;
23 let list = service.list_push_subscriptions(uid).await?;
26 let list = service.list_push_subscriptions(uid, query).await?;
2427 Ok(AppResponseData::ok(list))
2528}
Modified lib/user/src/handlers/list_saved_messages.rs lib/user/src/handlers/list_saved_messages.rs +5 −2 View lib/user/src/handlers/list_saved_messages.rs
@@ -1,16 +1,18 @@
1use axum::extract::State;
1use axum::extract::{Query, State};
22use export::rest::AppResponseData;
33use session::Session;
44
55use crate::errors::ApiError;
66use crate::services::notifications::SavedMessageDto;
77use crate::services::service::UserService;
8use crate::share::page::PageQuery;
89use crate::share::session::require_uid;
910
1011#[utoipa::path(
1112 get,
1213 path = "/me/messages/saved",
1314 tag = "user",
15 params(PageQuery),
1416 responses(
1517 (status = 200, description = "list saved messages", body = AppResponseData<Vec<SavedMessageDto>>)
1618 )
@@ -18,8 +20,9 @@
1820pub async fn list_saved_messages(
1921 State(service): State<UserService>,
2022 session: Session,
23 Query(query): Query<PageQuery>,
2124) -> Result<AppResponseData<Vec<SavedMessageDto>>, ApiError> {
2225 let uid = require_uid(&session)?;
23 let list = service.list_saved_messages(uid).await?;
26 let list = service.list_saved_messages(uid, query).await?;
2427 Ok(AppResponseData::ok(list))
2528}
Modified lib/user/src/handlers/list_scheduled_messages.rs lib/user/src/handlers/list_scheduled_messages.rs +5 −2 View lib/user/src/handlers/list_scheduled_messages.rs
@@ -1,16 +1,18 @@
1use axum::extract::State;
1use axum::extract::{Query, State};
22use export::rest::AppResponseData;
33use session::Session;
44
55use crate::errors::ApiError;
66use crate::services::notifications::ScheduledMessageDto;
77use crate::services::service::UserService;
8use crate::share::page::PageQuery;
89use crate::share::session::require_uid;
910
1011#[utoipa::path(
1112 get,
1213 path = "/me/messages/scheduled",
1314 tag = "user",
15 params(PageQuery),
1416 responses(
1517 (status = 200, description = "list scheduled messages", body = AppResponseData<Vec<ScheduledMessageDto>>)
1618 )
@@ -18,8 +20,9 @@
1820pub async fn list_scheduled_messages(
1921 State(service): State<UserService>,
2022 session: Session,
23 Query(query): Query<PageQuery>,
2124) -> Result<AppResponseData<Vec<ScheduledMessageDto>>, ApiError> {
2225 let uid = require_uid(&session)?;
23 let list = service.list_scheduled_messages(uid).await?;
26 let list = service.list_scheduled_messages(uid, query).await?;
2427 Ok(AppResponseData::ok(list))
2528}
Modified lib/user/src/handlers/mod.rs lib/user/src/handlers/mod.rs +1 −0 View lib/user/src/handlers/mod.rs
@@ -25,3 +25,4 @@
2525pub mod unsave_message;
2626pub mod upload_avatar;
2727pub mod upsert_push_subscription;
28pub mod verify_add_email;
Modified lib/user/src/handlers/my_reports.rs lib/user/src/handlers/my_reports.rs +5 −2 View lib/user/src/handlers/my_reports.rs
@@ -1,16 +1,18 @@
1use axum::extract::State;
1use axum::extract::{Query, State};
22use export::rest::AppResponseData;
33use session::Session;
44
55use crate::errors::ApiError;
66use crate::services::notifications::ReportDto;
77use crate::services::service::UserService;
8use crate::share::page::PageQuery;
89use crate::share::session::require_uid;
910
1011#[utoipa::path(
1112 get,
1213 path = "/me/reports",
1314 tag = "user",
15 params(PageQuery),
1416 responses(
1517 (status = 200, description = "my filed reports", body = AppResponseData<Vec<ReportDto>>)
1618 )
@@ -18,8 +20,9 @@
1820pub async fn my_reports(
1921 State(service): State<UserService>,
2022 session: Session,
23 Query(query): Query<PageQuery>,
2124) -> Result<AppResponseData<Vec<ReportDto>>, ApiError> {
2225 let uid = require_uid(&session)?;
23 let reports = service.my_reports(uid).await?;
26 let reports = service.my_reports(uid, query).await?;
2427 Ok(AppResponseData::ok(reports))
2528}
Modified lib/user/src/handlers/my_spaces.rs lib/user/src/handlers/my_spaces.rs +5 −2 View lib/user/src/handlers/my_spaces.rs
@@ -1,16 +1,18 @@
1use axum::extract::State;
1use axum::extract::{Query, State};
22use export::rest::AppResponseData;
33use session::Session;
44
55use crate::errors::ApiError;
66use crate::services::profile::MySpaceDto;
77use crate::services::service::UserService;
8use crate::share::page::PageQuery;
89use crate::share::session::require_uid;
910
1011#[utoipa::path(
1112 get,
1213 path = "/me/spaces",
1314 tag = "user",
15 params(PageQuery),
1416 responses(
1517 (status = 200, description = "my spaces", body = AppResponseData<Vec<MySpaceDto>>)
1618 )
@@ -18,8 +20,9 @@
1820pub async fn my_spaces(
1921 State(service): State<UserService>,
2022 session: Session,
23 Query(query): Query<PageQuery>,
2124) -> Result<AppResponseData<Vec<MySpaceDto>>, ApiError> {
2225 let uid = require_uid(&session)?;
23 let spaces = service.my_spaces(uid).await?;
26 let spaces = service.my_spaces(uid, query).await?;
2427 Ok(AppResponseData::ok(spaces))
2528}
Added lib/user/src/handlers/verify_add_email.rs lib/user/src/handlers/verify_add_email.rs +28 −0 View lib/user/src/handlers/verify_add_email.rs
@@ -0,0 +1,28 @@
1use axum::extract::State;
2use axum::Json;
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::ApiError;
7use crate::services::emails::{EmailDto, VerifyEmailCmd};
8use crate::services::service::UserService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/me/emails/verify",
14 tag = "user",
15 request_body = VerifyEmailCmd,
16 responses(
17 (status = 200, description = "verify added email address", body = AppResponseData<EmailDto>)
18 )
19)]
20pub async fn verify_add_email(
21 State(service): State<UserService>,
22 session: Session,
23 Json(cmd): Json<VerifyEmailCmd>,
24) -> Result<AppResponseData<EmailDto>, ApiError> {
25 let uid = require_uid(&session)?;
26 let email = service.verify_add_email(uid, cmd).await?;
27 Ok(AppResponseData::ok(email))
28}
Modified lib/user/src/services/emails.rs lib/user/src/services/emails.rs +187 −11 View lib/user/src/services/emails.rs
@@ -1,3 +1,4 @@
1use models::users::user_email_verification_codes::{self, EmailVerificationPurpose};
12use models::users::user_emails;
23use sea_orm::sea_query::Expr;
34use sea_orm::{
@@ -10,6 +11,7 @@
1011
1112use crate::errors::UserError;
1213use crate::services::service::UserService;
14use crate::share::crypto::{constant_time_eq, generate_numeric_code, sha256_b64url};
1315
1416pub const EMAIL_MAX_LEN: usize = 254;
1517
@@ -41,9 +43,16 @@
4143 pub email: String,
4244}
4345
46#[derive(Debug, Clone, Deserialize, ToSchema)]
47pub struct VerifyEmailCmd {
48 pub email: String,
49 pub code: String,
50}
51
4452#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
4553pub struct AddEmailResp {
46 pub email_id: Uuid,
54 pub pending: bool,
55 pub expires_in: i64,
4756}
4857
4958impl UserService {
@@ -58,31 +67,198 @@
5867 }
5968
6069 pub async fn add_email(&self, uid: Uuid, cmd: AddEmailCmd) -> Result<AddEmailResp, UserError> {
70 let config = &self.config().user;
6171 let email = cmd.email.trim().to_lowercase();
62 if email.is_empty() || email.len() > EMAIL_MAX_LEN || !email.contains('@') {
72 if email.is_empty()
73 || email.len() > EMAIL_MAX_LEN
74 || !email.contains('@')
75 || email.chars().any(|c| c.is_whitespace())
76 {
6377 return Err(UserError::BadRequest(format!(
6478 "invalid email (length 1..={EMAIL_MAX_LEN})"
6579 )));
6680 }
81
82 let rows = user_emails::Entity::find()
83 .filter(user_emails::Column::LowerEmail.eq(&email))
84 .all(self.db().read())
85 .await?;
86 if rows.iter().any(|row| row.user_id != uid) {
87 return Err(UserError::Conflict("email is already in use".to_string()));
88 }
89 if rows
90 .iter()
91 .any(|row| row.is_activated && row.deleted_at.is_none())
92 {
93 return Err(UserError::Conflict("already added".to_string()));
94 }
95
96 let rate_key = format!("user:email_code_rate:add_email:{email}");
97 let acquired = crate::share::redis::set_nx_ex(
98 &self.state.redis,
99 &rate_key,
100 "1",
101 config.email_code_rate_limit_secs,
102 )
103 .await?;
104 if !acquired {
105 return Err(UserError::BadRequest(
106 "too many requests,try later".to_string(),
107 ));
108 }
109
67110 let now = OffsetDateTime::now_utc();
68 let active = user_emails::ActiveModel {
111 user_email_verification_codes::Entity::update_many()
112 .filter(user_email_verification_codes::Column::UserId.eq(uid))
113 .filter(
114 user_email_verification_codes::Column::Purpose
115 .eq(EmailVerificationPurpose::AddEmail),
116 )
117 .filter(user_email_verification_codes::Column::UsedAt.is_null())
118 .col_expr(
119 user_email_verification_codes::Column::UsedAt,
120 Expr::value(Some(now)),
121 )
122 .exec(self.db().write())
123 .await?;
124
125 let code = generate_numeric_code(config.email_verify_code_len);
126 let expires_at = now + time::Duration::seconds(config.email_verify_code_ttl_secs);
127 let row = user_email_verification_codes::ActiveModel {
69128 id: Set(Uuid::now_v7()),
70129 user_id: Set(uid),
71130 email: Set(email.clone()),
72 lower_email: Set(email),
73 is_activated: Set(false),
74 is_primary: Set(false),
131 purpose: Set(EmailVerificationPurpose::AddEmail),
132 token_hash: Set(sha256_b64url(code.as_bytes())),
75133 created_at: Set(now),
76 updated_at: Set(now),
77 activated_at: Set(None),
78 deleted_at: Set(None),
134 expires_at: Set(expires_at),
135 used_at: Set(None),
79136 };
80 let saved = active.insert(self.db().write()).await?;
137 row.insert(self.db().write()).await?;
138
139 let text = format!("Your email verification code is: {code}");
140 let html = format!("<p>Your email verification code is <strong>{code}</strong>.</p>");
141 if let Err(error) = self
142 .state
143 .email
144 .send_mail(&email, "Verify your email", &text, &html)
145 .await
146 {
147 tracing::warn!(%error, "failed to send add email verification code");
148 }
149
81150 Ok(AddEmailResp {
82 email_id: saved.id,
151 pending: true,
152 expires_in: config.email_verify_code_ttl_secs,
83153 })
84154 }
85155
156 pub async fn verify_add_email(
157 &self,
158 uid: Uuid,
159 cmd: VerifyEmailCmd,
160 ) -> Result<EmailDto, UserError> {
161 let config = &self.config().user;
162 let lower = cmd.email.trim().to_lowercase();
163 let now = OffsetDateTime::now_utc();
164
165 let Some(row) = user_email_verification_codes::Entity::find()
166 .filter(user_email_verification_codes::Column::Email.eq(&lower))
167 .filter(user_email_verification_codes::Column::UserId.eq(uid))
168 .filter(
169 user_email_verification_codes::Column::Purpose
170 .eq(EmailVerificationPurpose::AddEmail),
171 )
172 .filter(user_email_verification_codes::Column::UsedAt.is_null())
173 .order_by_desc(user_email_verification_codes::Column::CreatedAt)
174 .one(self.db().read())
175 .await?
176 else {
177 return Err(UserError::BadRequest("no pending verification".to_string()));
178 };
179 if row.expires_at <= now {
180 return Err(UserError::BadRequest(
181 "verification code expired".to_string(),
182 ));
183 }
184
185 let attempts_key = format!("user:email_verify_attempts:{lower}");
186 let attempts = crate::share::redis::incr_expire(
187 &self.state.redis,
188 &attempts_key,
189 config.email_verify_code_ttl_secs as u64,
190 )
191 .await?;
192 if attempts > config.email_verify_max_attempts as i64 {
193 let mut used: user_email_verification_codes::ActiveModel = row.into();
194 used.used_at = Set(Some(now));
195 used.update(self.db().write()).await?;
196 return Err(UserError::BadRequest("too many attempts".to_string()));
197 }
198
199 let provided = sha256_b64url(cmd.code.trim().as_bytes());
200 if !constant_time_eq(provided.as_bytes(), row.token_hash.as_bytes()) {
201 return Err(UserError::BadRequest(
202 "invalid verification code".to_string(),
203 ));
204 }
205
206 let txn = self.db().primary();
207 let updated = txn
208 .transaction::<_, EmailDto, UserError>(|tx| {
209 Box::pin(async move {
210 let conflicts = user_emails::Entity::find()
211 .filter(user_emails::Column::LowerEmail.eq(&lower))
212 .filter(user_emails::Column::UserId.ne(uid))
213 .one(tx)
214 .await?;
215 if conflicts.is_some() {
216 return Err(UserError::Conflict("email is already in use".to_string()));
217 }
218
219 let mut used: user_email_verification_codes::ActiveModel = row.into();
220 used.used_at = Set(Some(now));
221 used.update(tx).await?;
222
223 let mine = user_emails::Entity::find()
224 .filter(user_emails::Column::UserId.eq(uid))
225 .filter(user_emails::Column::LowerEmail.eq(&lower))
226 .one(tx)
227 .await?;
228 let model = match mine {
229 Some(existing) => {
230 let mut active: user_emails::ActiveModel = existing.into();
231 active.email = Set(lower.clone());
232 active.lower_email = Set(lower.clone());
233 active.is_activated = Set(true);
234 active.activated_at = Set(Some(now));
235 active.deleted_at = Set(None);
236 active.updated_at = Set(now);
237 active.update(tx).await?
238 }
239 None => {
240 let active = user_emails::ActiveModel {
241 id: Set(Uuid::now_v7()),
242 user_id: Set(uid),
243 email: Set(lower.clone()),
244 lower_email: Set(lower.clone()),
245 is_activated: Set(true),
246 is_primary: Set(false),
247 created_at: Set(now),
248 updated_at: Set(now),
249 activated_at: Set(Some(now)),
250 deleted_at: Set(None),
251 };
252 active.insert(tx).await?
253 }
254 };
255 Ok(EmailDto::from(model))
256 })
257 })
258 .await?;
259 Ok(updated)
260 }
261
86262 pub async fn set_primary_email(&self, uid: Uuid, email_id: Uuid) -> Result<EmailDto, UserError> {
87263 let txn = self.db().primary();
88264 let updated = txn
Modified lib/user/src/services/notifications.rs lib/user/src/services/notifications.rs +14 −2 View lib/user/src/services/notifications.rs
@@ -12,6 +12,7 @@
1212
1313use crate::errors::UserError;
1414use crate::services::service::UserService;
15use crate::share::page::PageQuery;
1516
1617#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1718pub struct RecentMentionDto {
@@ -123,19 +124,27 @@
123124 Ok(rows.into_iter().map(RecentMentionDto::from).collect())
124125 }
125126
126 pub async fn my_reports(&self, uid: Uuid) -> Result<Vec<ReportDto>, UserError> {
127 pub async fn my_reports(&self, uid: Uuid, q: PageQuery) -> Result<Vec<ReportDto>, UserError> {
127128 let rows = user_reports::Entity::find()
128129 .filter(user_reports::Column::ReporterUid.eq(Some(uid)))
129130 .order_by_desc(user_reports::Column::CreatedAt)
131 .limit(q.limit())
132 .offset(q.offset())
130133 .all(self.db().read())
131134 .await?;
132135 Ok(rows.into_iter().map(ReportDto::from).collect())
133136 }
134137
135 pub async fn list_saved_messages(&self, uid: Uuid) -> Result<Vec<SavedMessageDto>, UserError> {
138 pub async fn list_saved_messages(
139 &self,
140 uid: Uuid,
141 q: PageQuery,
142 ) -> Result<Vec<SavedMessageDto>, UserError> {
136143 let rows = user_saved_messages::Entity::find()
137144 .filter(user_saved_messages::Column::UserId.eq(uid))
138145 .order_by_desc(user_saved_messages::Column::SavedAt)
146 .limit(q.limit())
147 .offset(q.offset())
139148 .all(self.db().read())
140149 .await?;
141150 Ok(rows.into_iter().map(SavedMessageDto::from).collect())
@@ -180,10 +189,13 @@
180189 pub async fn list_scheduled_messages(
181190 &self,
182191 uid: Uuid,
192 q: PageQuery,
183193 ) -> Result<Vec<ScheduledMessageDto>, UserError> {
184194 let rows = user_scheduled_messages::Entity::find()
185195 .filter(user_scheduled_messages::Column::UserId.eq(uid))
186196 .order_by_asc(user_scheduled_messages::Column::ScheduledAt)
197 .limit(q.limit())
198 .offset(q.offset())
187199 .all(self.db().read())
188200 .await?;
189201 Ok(rows.into_iter().map(ScheduledMessageDto::from).collect())
Modified lib/user/src/services/profile.rs lib/user/src/services/profile.rs +39 −2 View lib/user/src/services/profile.rs
@@ -11,6 +11,7 @@
1111
1212use crate::errors::UserError;
1313use crate::services::service::UserService;
14use crate::share::page::PageQuery;
1415
1516pub const OWNER_PERMISSIONS: [&str; 9] = [
1617 "view_space",
@@ -111,7 +112,9 @@
111112}
112113
113114impl UserService {
114 pub async fn my_spaces(&self, uid: Uuid) -> Result<Vec<MySpaceDto>, UserError> {
115 pub async fn my_spaces(&self, uid: Uuid, q: PageQuery) -> Result<Vec<MySpaceDto>, UserError> {
116 let limit = q.limit();
117 let offset = q.offset();
115118 let members = space_members::Entity::find()
116119 .filter(space_members::Column::UserId.eq(uid))
117120 .filter(space_members::Column::Status.eq(space_members::MemberStatus::Active))
@@ -125,6 +128,8 @@
125128 let space_rows = spaces::Entity::find()
126129 .filter(spaces::Column::Id.is_in(space_ids))
127130 .order_by_asc(spaces::Column::LowerName)
131 .limit(limit)
132 .offset(offset)
128133 .all(self.db().read())
129134 .await?;
130135
@@ -263,7 +268,15 @@
263268 let is_owner = viewer == Some(target_uid);
264269 let can_view = match user.visibility {
265270 ProfileVisibility::Public => true,
266 ProfileVisibility::RegisteredOnly | ProfileVisibility::MembersOnly => viewer.is_some(),
271 ProfileVisibility::RegisteredOnly => viewer.is_some(),
272 ProfileVisibility::MembersOnly => match viewer {
273 Some(viewer_uid)
274 if is_owner || shares_space_with(self, viewer_uid, target_uid).await? =>
275 {
276 true
277 }
278 _ => false,
279 },
267280 ProfileVisibility::Private => is_owner,
268281 };
269282 if !can_view {
@@ -321,3 +334,27 @@
321334 }
322335 }
323336}
337
338async fn shares_space_with(
339 service: &UserService,
340 viewer: Uuid,
341 target: Uuid,
342) -> Result<bool, UserError> {
343 let viewer_rows = space_members::Entity::find()
344 .filter(space_members::Column::UserId.eq(viewer))
345 .filter(space_members::Column::Status.eq(space_members::MemberStatus::Active))
346 .all(service.db().read())
347 .await?;
348 let space_ids: Vec<Uuid> = viewer_rows.into_iter().map(|m| m.space_id).collect();
349 if space_ids.is_empty() {
350 return Ok(false);
351 }
352
353 let shared = space_members::Entity::find()
354 .filter(space_members::Column::SpaceId.is_in(space_ids))
355 .filter(space_members::Column::UserId.eq(target))
356 .filter(space_members::Column::Status.eq(space_members::MemberStatus::Active))
357 .one(service.db().read())
358 .await?;
359 Ok(shared.is_some())
360}
Modified lib/user/src/services/push_subscriptions.rs lib/user/src/services/push_subscriptions.rs +5 −1 View lib/user/src/services/push_subscriptions.rs
@@ -1,6 +1,6 @@
11use models::users::user_push_subscriptions::{self, PushSubscriptionPlatform};
22use sea_orm::{
3 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set,
3 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
44};
55use serde::{Deserialize, Serialize};
66use time::OffsetDateTime;
@@ -9,6 +9,7 @@
99
1010use crate::errors::UserError;
1111use crate::services::service::UserService;
12use crate::share::page::PageQuery;
1213
1314#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1415pub struct PushSubscriptionDto {
@@ -46,10 +47,13 @@
4647 pub async fn list_push_subscriptions(
4748 &self,
4849 uid: Uuid,
50 q: PageQuery,
4951 ) -> Result<Vec<PushSubscriptionDto>, UserError> {
5052 let rows = user_push_subscriptions::Entity::find()
5153 .filter(user_push_subscriptions::Column::UserId.eq(uid))
5254 .order_by_desc(user_push_subscriptions::Column::CreatedAt)
55 .limit(q.limit())
56 .offset(q.offset())
5357 .all(self.db().read())
5458 .await?;
5559 Ok(rows.into_iter().map(PushSubscriptionDto::from).collect())
Modified lib/user/src/services/sessions.rs lib/user/src/services/sessions.rs +9 −2 View lib/user/src/services/sessions.rs
@@ -1,7 +1,7 @@
11use models::users::user_sessions::{self, SessionKind};
22use sea_orm::sea_query::Expr;
33use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set,
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
55};
66use serde::{Deserialize, Serialize};
77use time::OffsetDateTime;
@@ -10,6 +10,7 @@
1010
1111use crate::errors::UserError;
1212use crate::services::service::UserService;
13use crate::share::page::{DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT};
1314
1415#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1516pub struct SessionDto {
@@ -46,6 +47,8 @@
4647pub struct ListSessionsQuery {
4748 pub kind: Option<SessionKind>,
4849 pub include_revoked: Option<bool>,
50 pub limit: Option<u64>,
51 pub offset: Option<u64>,
4952}
5053
5154#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -70,7 +73,11 @@
7073 sel = sel.filter(user_sessions::Column::IsRevoked.eq(false));
7174 }
7275
73 let rows = sel.all(self.db().read()).await?;
76 let rows = sel
77 .limit(q.limit.unwrap_or(DEFAULT_PAGE_LIMIT).clamp(1, MAX_PAGE_LIMIT))
78 .offset(q.offset.unwrap_or(0))
79 .all(self.db().read())
80 .await?;
7481 Ok(rows.into_iter().map(SessionDto::from).collect())
7582 }
7683
Added lib/user/src/share/crypto.rs lib/user/src/share/crypto.rs +27 −0 View lib/user/src/share/crypto.rs
@@ -0,0 +1,27 @@
1use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2use base64::Engine;
3use rand::rngs::OsRng;
4use rand::Rng;
5use sha2::{Digest, Sha256};
6
7pub fn generate_numeric_code(len: usize) -> String {
8 let mut rng = OsRng;
9 (0..len)
10 .map(|_| rng.gen_range(0..10u32).to_string())
11 .collect()
12}
13
14pub fn sha256_b64url(data: &[u8]) -> String {
15 URL_SAFE_NO_PAD.encode(Sha256::digest(data))
16}
17
18pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
19 if a.len() != b.len() {
20 return false;
21 }
22 let mut diff = 0u8;
23 for (x, y) in a.iter().zip(b.iter()) {
24 diff |= x ^ y;
25 }
26 diff == 0
27}
Modified lib/user/src/share/guard.rs lib/user/src/share/guard.rs +4 −0 View lib/user/src/share/guard.rs
@@ -31,7 +31,11 @@
3131 let authorized = match outcome {
3232 Ok(Some((row, Some(current)))) => {
3333 !row.is_revoked
34 && row.user_id == uid
3435 && row.expires_at > OffsetDateTime::now_utc()
36 && current.is_active
37 && !current.prohibit_login
38 && !current.is_restricted
3539 && pwd_version_matches(&session, current.passwd_version)
3640 }
3741 Ok(_) => false,
Modified lib/user/src/share/mod.rs lib/user/src/share/mod.rs +3 −0 View lib/user/src/share/mod.rs
@@ -1,2 +1,5 @@
1pub mod crypto;
12pub mod guard;
3pub mod page;
4pub mod redis;
25pub mod session;
Added lib/user/src/share/page.rs lib/user/src/share/page.rs +21 −0 View lib/user/src/share/page.rs
@@ -0,0 +1,21 @@
1use serde::Deserialize;
2use utoipa::{IntoParams, ToSchema};
3
4pub const DEFAULT_PAGE_LIMIT: u64 = 50;
5pub const MAX_PAGE_LIMIT: u64 = 100;
6
7#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
8pub struct PageQuery {
9 pub limit: Option<u64>,
10 pub offset: Option<u64>,
11}
12
13impl PageQuery {
14 pub fn limit(&self) -> u64 {
15 self.limit.unwrap_or(DEFAULT_PAGE_LIMIT).clamp(1, MAX_PAGE_LIMIT)
16 }
17
18 pub fn offset(&self) -> u64 {
19 self.offset.unwrap_or(0)
20 }
21}
Added lib/user/src/share/redis.rs lib/user/src/share/redis.rs +81 −0 View lib/user/src/share/redis.rs
@@ -0,0 +1,81 @@
1use export::{AppRedis, RedisConnection};
2use redis::RedisError;
3
4macro_rules! dispatch {
5 ($conn:expr, |$c:ident| $body:expr) => {
6 match &mut $conn {
7 RedisConnection::Standalone($c) => $body,
8 RedisConnection::Cluster($c) => $body,
9 }
10 };
11}
12
13pub async fn get_string(redis: &AppRedis, key: &str) -> Result<Option<String>, RedisError> {
14 let mut conn = redis.connection_clone();
15 dispatch!(conn, |c| redis::cmd("GET").arg(key).query_async(c).await)
16}
17
18pub async fn set_string(
19 redis: &AppRedis,
20 key: &str,
21 value: &str,
22 ttl_secs: u64,
23) -> Result<(), RedisError> {
24 let mut conn = redis.connection_clone();
25 dispatch!(conn, |c| {
26 redis::cmd("SET")
27 .arg(key)
28 .arg(value)
29 .arg("EX")
30 .arg(ttl_secs)
31 .exec_async(c)
32 .await
33 })
34}
35
36pub async fn set_nx_ex(
37 redis: &AppRedis,
38 key: &str,
39 value: &str,
40 ttl_secs: u64,
41) -> Result<bool, RedisError> {
42 let mut conn = redis.connection_clone();
43 dispatch!(conn, |c| {
44 redis::cmd("SET")
45 .arg(key)
46 .arg(value)
47 .arg("NX")
48 .arg("EX")
49 .arg(ttl_secs)
50 .query_async(c)
51 .await
52 })
53}
54
55pub async fn getdel_string(redis: &AppRedis, key: &str) -> Result<Option<String>, RedisError> {
56 let mut conn = redis.connection_clone();
57 dispatch!(conn, |c| redis::cmd("GETDEL").arg(key).query_async(c).await)
58}
59
60const INCR_EXPIRE_SCRIPT: &str = r#"local c = redis.call('INCR', KEYS[1])
61if c == 1 then
62 redis.call('EXPIRE', KEYS[1], ARGV[1])
63end
64return c"#;
65
66pub async fn incr_expire(
67 redis: &AppRedis,
68 key: &str,
69 ttl_secs: u64,
70) -> Result<i64, RedisError> {
71 let mut conn = redis.connection_clone();
72 dispatch!(conn, |c| {
73 redis::cmd("EVAL")
74 .arg(INCR_EXPIRE_SCRIPT)
75 .arg(1)
76 .arg(key)
77 .arg(ttl_secs)
78 .query_async(c)
79 .await
80 })
81}
Modified lib/user/src/errors.rs lib/user/src/errors.rs +6 −0 View lib/user/src/errors.rs
@@ -72,6 +72,12 @@
7272 }
7373}
7474
75impl From<redis::RedisError> for UserError {
76 fn from(error: redis::RedisError) -> Self {
77 Self::Internal(format!("redis error: {error}"))
78 }
79}
80
7581impl From<export::ExportError> for UserError {
7682 fn from(error: export::ExportError) -> Self {
7783 Self::Internal(format!("infrastructure error: {error}"))
Modified lib/user/src/openapi.rs lib/user/src/openapi.rs +3 −1 View lib/user/src/openapi.rs
@@ -10,7 +10,7 @@
1010use crate::handlers;
1111use crate::services::avatar::{AvatarDto, AvatarFormat, UploadAvatarCmd};
1212use crate::services::deletion::DeletionDto;
13use crate::services::emails::{AddEmailCmd, AddEmailResp, EmailDto};
13use crate::services::emails::{AddEmailCmd, AddEmailResp, EmailDto, VerifyEmailCmd};
1414use crate::services::notifications::{
1515 RecentMentionDto, ReportDto, SaveMessageCmd, SavedMessageDto, ScheduledMessageDto,
1616};
@@ -35,6 +35,7 @@
3535 handlers::get_profile_detail::get_profile_detail,
3636 handlers::list_emails::list_emails,
3737 handlers::add_email::add_email,
38 handlers::verify_add_email::verify_add_email,
3839 handlers::set_primary_email::set_primary_email,
3940 handlers::delete_email::delete_email,
4041 handlers::list_sessions::list_sessions,
@@ -67,6 +68,7 @@
6768 EmailDto,
6869 AddEmailCmd,
6970 AddEmailResp,
71 VerifyEmailCmd,
7072 SessionDto,
7173 ListSessionsQuery,
7274 CountResp,
Modified lib/user/src/routes.rs lib/user/src/routes.rs +12 −3 View lib/user/src/routes.rs
@@ -28,11 +28,15 @@
2828use crate::handlers::unsave_message::unsave_message;
2929use crate::handlers::upload_avatar::upload_avatar;
3030use crate::handlers::upsert_push_subscription::upsert_push_subscription;
31use crate::handlers::verify_add_email::verify_add_email;
3132use crate::services::service::UserService;
3233use crate::share::guard::user_guard;
3334
3435pub fn router(service: UserService) -> Router<UserService> {
35 Router::new()
36 let public: Router<UserService> = Router::new()
37 .route("/users/{uid}/profile-detail", get(get_profile_detail));
38
39 let protected = Router::new()
3640 .route(
3741 "/me/avatar",
3842 get(get_avatar).put(upload_avatar).delete(delete_avatar),
@@ -40,8 +44,8 @@
4044 .route("/me/spaces", get(my_spaces))
4145 .route("/me/memes", get(list_memes).post(create_meme))
4246 .route("/me/memes/{meme_id}", delete(delete_meme))
43 .route("/users/{uid}/profile-detail", get(get_profile_detail))
4447 .route("/me/emails", get(list_emails).post(add_email))
48 .route("/me/emails/verify", post(verify_add_email))
4549 .route("/me/emails/{email_id}/primary", patch(set_primary_email))
4650 .route("/me/emails/{email_id}", delete(delete_email))
4751 .route(
@@ -71,5 +75,10 @@
7175 )
7276 .route("/me/deletion/confirm", post(confirm_deletion))
7377 .route("/me/suspensions", get(my_suspensions))
74 .route_layer(axum::middleware::from_fn_with_state(service, user_guard))
78 .route_layer(axum::middleware::from_fn_with_state(
79 service.clone(),
80 user_guard,
81 ));
82
83 public.merge(protected)
7584}
Modified lib/user/Cargo.toml lib/user/Cargo.toml +3 −0 View lib/user/Cargo.toml
@@ -11,10 +11,13 @@
1111storage = { path = "../storage" }
1212axum = { workspace = true }
1313sea-orm = { workspace = true }
14redis = { workspace = true }
1415serde = { workspace = true, features = ["derive"] }
1516serde_json = { workspace = true }
1617utoipa = { version = "5.5", features = ["axum_extras", "time", "uuid"] }
1718uuid = { version = "1", features = ["v7", "serde"] }
1819time = { version = "0.3", features = ["serde"] }
1920base64 = { workspace = true }
21rand = { workspace = true }
22sha2 = { workspace = true }
2023tracing = { workspace = true }