Skip to content
Clone

feat(api): phase5 pagination unification (Page envelope everywhere, PageQuery sort), uid embeds, temp_id simplification, pow RFC3339, avatar streaming upload

ZhenYi committed
parent a66f22a commit e9a74d0 Browse files

44 files changed

+312 −263

File tree 44
Modified lib/auth/src/handlers/list_notifications.rs View lib/auth/src/handlers/list_notifications.rs
@@ -24,14 +24,14 @@
2424 tag = "notifications",
2525 params(ListNotificationsQuery),
2626 responses(
27 (status = 200, description = "notifications for the current user", body = AppResponseData<Vec<NotificationDto>>)
27 (status = 200, description = "notifications for the current user", body = AppResponseData<export::pagination::Page<NotificationDto>>)
2828 )
2929)]
3030pub async fn list_notifications(
3131 State(service): State<AuthService>,
3232 session: Session,
3333 Query(query): Query<ListNotificationsQuery>,
34) -> Result<AppResponseData<Vec<NotificationDto>>, ApiError> {
34) -> Result<AppResponseData<export::pagination::Page<NotificationDto>>, ApiError> {
3535 let uid = require_uid(&session)?;
3636 let notifications = service
3737 .list_notifications(
@@ -43,5 +43,18 @@
4343 )
4444 .await
4545 .map_err(ApiError::from)?;
46 Ok(AppResponseData::ok(notifications))
46 let limit = query.limit.unwrap_or(50).min(100);
47 let offset = query.offset.unwrap_or(0);
48 let total = notifications.len() as i64;
49 let items: Vec<NotificationDto> = notifications
50 .into_iter()
51 .skip(offset as usize)
52 .take(limit as usize)
53 .collect();
54 Ok(AppResponseData::ok(export::pagination::Page {
55 items,
56 total,
57 limit,
58 offset,
59 }))
4760}
Modified lib/auth/src/handlers/pow_challenge.rs View lib/auth/src/handlers/pow_challenge.rs
@@ -11,7 +11,7 @@
1111pub struct PowChallengeResp {
1212 pub nonce: String,
1313 pub difficulty: u32,
14 pub expires_at: i64,
14 pub expires_at: time::OffsetDateTime,
1515 pub mac: Option<String>,
1616}
1717
@@ -33,7 +33,8 @@
3333 Ok(AppResponseData::ok(PowChallengeResp {
3434 nonce: challenge.nonce,
3535 difficulty: challenge.difficulty,
36 expires_at: challenge.expires_at,
36 expires_at: time::OffsetDateTime::from_unix_timestamp(challenge.expires_at)
37 .unwrap_or_else(|_| time::OffsetDateTime::now_utc()),
3738 mac: challenge.mac,
3839 }))
3940}
Modified lib/auth/src/services/email.rs View lib/auth/src/services/email.rs
@@ -13,7 +13,6 @@
1313use crate::errors::AuthError;
1414use crate::share::crypto::{constant_time_eq, generate_numeric_code, sha256_b64url};
1515use crate::share::rate::{attempts_reset, attempts_track, rate_limit_once};
16use crate::share::types::TempKey;
1716use crate::share::validate::validate_email;
1817
1918fn purpose_str(purpose: EmailVerificationPurpose) -> &'static str {
@@ -25,28 +24,11 @@
2524 }
2625}
2726
28fn temp_key(
29 prefix: &str,
30 purpose: Option<EmailVerificationPurpose>,
31 email: &str,
32 value: Uuid,
33 ttl_seconds: u64,
34) -> TempKey {
35 let part = purpose.map(purpose_str).unwrap_or_default();
36 TempKey {
37 name: format!("auth_{prefix}{part}_{}", email.replace('@', "_at_")),
38 value: value.to_string(),
39 ttl_seconds,
40 secure: true,
41 http_only: true,
42 same_site: "Strict".to_string(),
43 domain: None,
44 path: "/".to_string(),
45 }
46}
4727
28
4829pub(crate) struct IssueCodeArtifacts {
49 pub temp_key: TempKey,
30 pub temp_id: Uuid,
31 pub expires_in_secs: u64,
5032 pub code: String,
5133}
5234
@@ -73,7 +55,8 @@
7355#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
7456pub struct ResendCodeResp {
7557 pub sent: bool,
76 pub temp_key: TempKey,
58 pub temp_id: Uuid,
59 pub expires_in_secs: u64,
7760}
7861
7962#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -86,7 +69,8 @@
8669#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
8770pub struct ReclaimResp {
8871 pub sent: bool,
89 pub temp_key: TempKey,
72 pub temp_id: Uuid,
73 pub expires_in_secs: u64,
9074}
9175
9276#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -155,14 +139,11 @@
155139 };
156140 row.insert(db.write()).await?;
157141
158 let temp_key = temp_key(
159 "",
160 Some(purpose),
161 &lower_email,
162 temp_id,
163 config.verify_code_ttl_secs,
164 );
165 Ok(IssueCodeArtifacts { temp_key, code })
142 Ok(IssueCodeArtifacts {
143 temp_id,
144 expires_in_secs: config.verify_code_ttl_secs,
145 code,
146 })
166147 }
167148
168149 pub async fn verify_verification_code(
@@ -292,13 +273,8 @@
292273 else {
293274 return Ok(ResendCodeResp {
294275 sent: true,
295 temp_key: temp_key(
296 "resend_",
297 Some(cmd.purpose),
298 &lower_email,
299 Uuid::now_v7(),
300 self.config().auth.verify_code_ttl_secs,
301 ),
276 temp_id: Uuid::now_v7(),
277 expires_in_secs: self.config().auth.verify_code_ttl_secs,
302278 });
303279 };
304280 let artifacts = self
@@ -314,7 +290,7 @@
314290 let link = build_verify_link(
315291 &self.config().web.web_base_url,
316292 &lower_email,
317 &artifacts.temp_key.value,
293 &artifacts.temp_id.to_string(),
318294 );
319295 let html =
320296 crate::templates::mail_verify_code(&artifacts.code, minutes, subject, link.as_deref());
@@ -329,7 +305,8 @@
329305 });
330306 Ok(ResendCodeResp {
331307 sent,
332 temp_key: artifacts.temp_key,
308 temp_id: artifacts.temp_id,
309 expires_in_secs: artifacts.expires_in_secs,
333310 })
334311 }
335312
@@ -342,13 +319,8 @@
342319 let lower_email = cmd.email.trim().to_lowercase();
343320 let placeholder = || ReclaimResp {
344321 sent: true,
345 temp_key: temp_key(
346 "reclaim_",
347 None,
348 &lower_email,
349 Uuid::now_v7(),
350 self.config().auth.verify_code_ttl_secs,
351 ),
322 temp_id: Uuid::now_v7(),
323 expires_in_secs: self.config().auth.verify_code_ttl_secs,
352324 };
353325 let Some((email_row, Some(user))) = user_emails::Entity::find()
354326 .filter(user_emails::Column::LowerEmail.eq(&lower_email))
@@ -376,7 +348,7 @@
376348 let link = build_verify_link(
377349 &self.config().web.web_base_url,
378350 &lower_email,
379 &artifacts.temp_key.value,
351 &artifacts.temp_id.to_string(),
380352 );
381353 let html = crate::templates::mail_verify_code(
382354 &artifacts.code,
@@ -400,7 +372,8 @@
400372 });
401373 Ok(ReclaimResp {
402374 sent,
403 temp_key: artifacts.temp_key,
375 temp_id: artifacts.temp_id,
376 expires_in_secs: artifacts.expires_in_secs,
404377 })
405378 }
406379
Modified lib/auth/src/services/password.rs View lib/auth/src/services/password.rs
@@ -11,7 +11,6 @@
1111 constant_time_eq, hash_password, random_token, sha256_b64url, validate_password,
1212};
1313use crate::share::rate::rate_limit_once;
14use crate::share::types::TempKey;
1514
1615#[derive(Serialize, Deserialize)]
1716struct StoredResetToken {
@@ -28,7 +27,8 @@
2827#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
2928pub struct ForgotPasswordResp {
3029 pub sent: bool,
31 pub temp_key: TempKey,
30 pub temp_id: Uuid,
31 pub expires_in_secs: u64,
3232}
3333
3434#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -57,16 +57,6 @@
5757 let rate_key = format!("auth:code_rate:forgot_pwd:{lower_email}");
5858 rate_limit_once(self.redis(), &rate_key, config.email_code_rate_limit_secs).await?;
5959 let temp_id = Uuid::now_v7();
60 let temp_key = TempKey {
61 name: format!("auth_reset_{}", lower_email.replace('@', "_at_")),
62 value: temp_id.to_string(),
63 ttl_seconds: config.reset_token_ttl_secs,
64 secure: true,
65 http_only: true,
66 same_site: "Strict".to_string(),
67 domain: None,
68 path: "/".to_string(),
69 };
7060
7161 let Some(email_row) = user_emails::Entity::find()
7262 .filter(user_emails::Column::LowerEmail.eq(&lower_email))
@@ -76,7 +66,8 @@
7666 else {
7767 return Ok(ForgotPasswordResp {
7868 sent: true,
79 temp_key,
69 temp_id,
70 expires_in_secs: config.reset_token_ttl_secs,
8071 });
8172 };
8273
@@ -97,7 +88,7 @@
9788
9889 let base_url = &self.config().web.web_base_url;
9990 let html =
100 crate::templates::mail_reset_link(&lower_email, &token, &temp_key.value, base_url);
91 crate::templates::mail_reset_link(&lower_email, &token, &temp_id.to_string(), base_url);
10192 let body = format!("Use this reset token within the next minutes: {token}");
10293 let sent = self
10394 .send_email(
@@ -112,7 +103,11 @@
112103 tracing::warn!(%error, "failed to send reset email");
113104 false
114105 });
115 Ok(ForgotPasswordResp { sent, temp_key })
106 Ok(ForgotPasswordResp {
107 sent,
108 temp_id,
109 expires_in_secs: config.reset_token_ttl_secs,
110 })
116111 }
117112
118113 pub async fn reset_password(&self, cmd: ResetPasswordCmd) -> Result<(), AuthError> {
Modified lib/auth/src/services/register.rs View lib/auth/src/services/register.rs
@@ -1,4 +1,5 @@
11use models::users::user::ProfileVisibility;
2use models::users::user_email_verification_codes::EmailVerificationPurpose;
23use models::users::{user, user_emails, user_profiles};
34use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set};
45use serde::{Deserialize, Serialize};
@@ -10,7 +11,6 @@
1011use super::email::build_verify_link;
1112use crate::errors::AuthError;
1213use crate::share::crypto::{hash_password, validate_password};
13use crate::share::types::TempKey;
1414use crate::share::validate::{derive_username, validate_age, validate_email};
1515
1616#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
@@ -27,8 +27,9 @@
2727pub struct RegisterResp {
2828 pub user_id: Uuid,
2929 pub pending: bool,
30 pub purpose: String,
31 pub temp_key: TempKey,
30 pub purpose: EmailVerificationPurpose,
31 pub temp_id: Uuid,
32 pub expires_in_secs: u64,
3233}
3334
3435impl AuthService {
@@ -155,7 +156,7 @@
155156 let link = build_verify_link(
156157 &self.config().web.web_base_url,
157158 &lower_email,
158 &artifacts.temp_key.value,
159 &artifacts.temp_id.to_string(),
159160 );
160161 let html = crate::templates::mail_verify_code(
161162 &artifacts.code,
@@ -185,8 +186,9 @@
185186 Ok(RegisterResp {
186187 user_id: uid,
187188 pending: true,
188 purpose: "signup".to_string(),
189 temp_key: artifacts.temp_key,
189 purpose: EmailVerificationPurpose::Signup,
190 temp_id: artifacts.temp_id,
191 expires_in_secs: artifacts.expires_in_secs,
190192 })
191193 }
192194}
Modified lib/auth/src/share/mod.rs View lib/auth/src/share/mod.rs
@@ -6,4 +6,4 @@
66pub mod types;
77pub mod validate;
88
9pub use types::{AgreementDoc, TempKey, UserPublic};
9pub use types::{AgreementDoc, UserPublic};
Modified lib/auth/src/openapi.rs View lib/auth/src/openapi.rs
@@ -38,7 +38,7 @@
3838 RecoveryCodesResp, RegenerateRecoveryCodesCmd, TotpCodeCmd, TotpEnableCmd, TotpSetupResp,
3939 TotpStatusResp,
4040};
41use crate::share::types::{AgreementDoc, TempKey, UserPublic};
41use crate::share::types::{AgreementDoc, UserPublic};
4242
4343#[derive(OpenApi)]
4444#[openapi(
@@ -95,10 +95,12 @@
9595 handlers::verify_email::verify_email,
9696 ),
9797 components(schemas(
98 export::pagination::PageQuery,
99 export::pagination::Page<crate::services::notifications::NotificationDto>,
100
98101 handlers::mark_notifications_read::MarkNotificationsReadCmd,
99102 handlers::mark_notifications_read::MarkedCountResp,
100103 NoData,
101 TempKey,
102104 UserPublic,
103105 AgreementDoc,
104106 RegisterCmd,
Modified lib/chat/src/handlers/bookmarks.rs View lib/chat/src/handlers/bookmarks.rs
@@ -1,4 +1,4 @@
1use axum::extract::{Json, Path, State};
1use axum::extract::{Json, Path, Query, State};
22use export::rest::{AppResponseData, NoData};
33use session::Session;
44use session::share::session::require_uid;
@@ -12,9 +12,9 @@
1212 get,
1313 path = "/chat/rooms/{room_id}/bookmarks",
1414 tag = "chat",
15 params(("room_id" = Uuid, Path, description = "room id")),
15 params(("room_id" = Uuid, Path, description = "room id"), export::pagination::PageQuery),
1616 responses(
17 (status = 200, description = "bookmark list", body = AppResponseData<Vec<BookmarkDto>>),
17 (status = 200, description = "bookmark list", body = AppResponseData<export::pagination::Page<BookmarkDto>>),
1818 (status = 404, description = "not found", body = AppResponseData<NoData>)
1919 )
2020)]
@@ -22,10 +22,11 @@
2222 State(service): State<ChatService>,
2323 session: Session,
2424 Path(room_id): Path<Uuid>,
25) -> Result<AppResponseData<Vec<BookmarkDto>>, ApiError> {
25 Query(page): Query<export::pagination::PageQuery>,
26) -> Result<AppResponseData<export::pagination::Page<BookmarkDto>>, ApiError> {
2627 let uid = require_uid(&session)?;
2728 let bookmarks = service.list_bookmarks(uid, room_id).await?;
28 Ok(AppResponseData::ok(bookmarks))
29 Ok(AppResponseData::ok(export::pagination::paginate(bookmarks, &page)))
2930}
3031
3132#[utoipa::path(
Modified lib/chat/src/handlers/dms.rs View lib/chat/src/handlers/dms.rs
@@ -1,4 +1,4 @@
1use axum::extract::{Json, Path, State};
1use axum::extract::{Json, Path, Query, State};
22use export::rest::{AppResponseData, NoData};
33use session::Session;
44use session::share::session::require_uid;
@@ -12,6 +12,7 @@
1212 post,
1313 path = "/chat/dms",
1414 tag = "chat",
15 params(export::pagination::PageQuery),
1516 request_body = OpenDmCmd,
1617 responses(
1718 (status = 200, description = "dm opened", body = AppResponseData<DmDto>),
@@ -33,18 +34,20 @@
3334 get,
3435 path = "/chat/dms",
3536 tag = "chat",
37 params(export::pagination::PageQuery),
3638 responses(
37 (status = 200, description = "my dms", body = AppResponseData<Vec<DmDto>>),
39 (status = 200, description = "my dms", body = AppResponseData<export::pagination::Page<DmDto>>),
3840 (status = 401, description = "unauthorized", body = AppResponseData<NoData>)
3941 )
4042)]
4143pub async fn list_dms(
4244 State(service): State<ChatService>,
4345 session: Session,
44) -> Result<AppResponseData<Vec<DmDto>>, ApiError> {
46 Query(page): Query<export::pagination::PageQuery>,
47) -> Result<AppResponseData<export::pagination::Page<DmDto>>, ApiError> {
4548 let uid = require_uid(&session)?;
4649 let dms = service.list_dms(uid).await?;
47 Ok(AppResponseData::ok(dms))
50 Ok(AppResponseData::ok(export::pagination::paginate(dms, &page)))
4851}
4952
5053#[utoipa::path(
Modified lib/chat/src/handlers/drafts.rs View lib/chat/src/handlers/drafts.rs
@@ -61,17 +61,18 @@
6161 path = "/chat/drafts",
6262 tag = "chat",
6363 responses(
64 (status = 200, description = "my drafts", body = AppResponseData<Vec<DraftDto>>),
64 (status = 200, description = "my drafts", body = AppResponseData<export::pagination::Page<DraftDto>>),
6565 (status = 401, description = "unauthorized", body = AppResponseData<NoData>)
6666 )
6767)]
6868pub async fn list_my_drafts(
6969 State(service): State<ChatService>,
7070 session: Session,
71) -> Result<AppResponseData<Vec<DraftDto>>, ApiError> {
71 Query(page): Query<export::pagination::PageQuery>,
72) -> Result<AppResponseData<export::pagination::Page<DraftDto>>, ApiError> {
7273 let uid = require_uid(&session)?;
7374 let drafts = service.list_my_drafts(uid).await?;
74 Ok(AppResponseData::ok(drafts))
75 Ok(AppResponseData::ok(export::pagination::paginate(drafts, &page)))
7576}
7677
7778#[utoipa::path(
Modified lib/chat/src/handlers/emojis.rs View lib/chat/src/handlers/emojis.rs
@@ -1,4 +1,4 @@
1use axum::extract::{Json, Path, State};
1use axum::extract::{Json, Path, Query, State};
22use axum::http::StatusCode;
33use axum::response::{IntoResponse, Response};
44use export::rest::{AppResponseData, NoData};
@@ -17,7 +17,7 @@
1717 tag = "chat",
1818 params(("space_name" = String, Path, description = "space name")),
1919 responses(
20 (status = 200, description = "emoji list", body = AppResponseData<Vec<EmojiDto>>),
20 (status = 200, description = "emoji list", body = AppResponseData<export::pagination::Page<EmojiDto>>),
2121 (status = 404, description = "not found", body = AppResponseData<NoData>)
2222 )
2323)]
Modified lib/chat/src/handlers/messages.rs View lib/chat/src/handlers/messages.rs
@@ -184,7 +184,7 @@
184184 tag = "chat",
185185 params(("room_id" = Uuid, Path, description = "room id")),
186186 responses(
187 (status = 200, description = "pinned messages", body = AppResponseData<Vec<MessageDto>>),
187 (status = 200, description = "pinned messages", body = AppResponseData<export::pagination::Page<MessageDto>>),
188188 (status = 404, description = "not found", body = AppResponseData<NoData>)
189189 )
190190)]
Modified lib/chat/src/handlers/room_admin.rs View lib/chat/src/handlers/room_admin.rs
@@ -1,4 +1,4 @@
1use axum::extract::{Json, Path, State};
1use axum::extract::{Json, Path, Query, State};
22use export::rest::{AppResponseData, NoData};
33use session::Session;
44use session::share::session::require_uid;
@@ -77,19 +77,20 @@
7777 get,
7878 path = "/chat/rooms/{room_id}/bans",
7979 tag = "chat",
80 params(("room_id" = Uuid, Path)),
80 params(("room_id" = Uuid, Path), export::pagination::PageQuery),
8181 responses(
82 (status = 200, description = "room bans", body = AppResponseData<Vec<RoomBanDto>>)
82 (status = 200, description = "room bans", body = AppResponseData<export::pagination::Page<RoomBanDto>>)
8383 )
8484)]
8585pub async fn list_room_bans(
8686 State(service): State<ChatService>,
8787 session: Session,
8888 Path(room_id): Path<Uuid>,
89) -> Result<AppResponseData<Vec<RoomBanDto>>, ApiError> {
89 Query(page): Query<export::pagination::PageQuery>,
90) -> Result<AppResponseData<export::pagination::Page<RoomBanDto>>, ApiError> {
9091 let uid = require_uid(&session)?;
9192 let bans = service.list_room_bans(uid, room_id).await?;
92 Ok(AppResponseData::ok(bans))
93 Ok(AppResponseData::ok(export::pagination::paginate(bans, &page)))
9394}
9495
9596#[utoipa::path(
Modified lib/chat/src/handlers/threads.rs View lib/chat/src/handlers/threads.rs
@@ -60,18 +60,20 @@
6060 get,
6161 path = "/chat/threads",
6262 tag = "chat",
63 params(export::pagination::PageQuery),
6364 responses(
64 (status = 200, description = "my threads", body = AppResponseData<Vec<ThreadDto>>),
65 (status = 200, description = "my threads", body = AppResponseData<export::pagination::Page<ThreadDto>>),
6566 (status = 401, description = "unauthorized", body = AppResponseData<NoData>)
6667 )
6768)]
6869pub async fn list_my_threads(
6970 State(service): State<ChatService>,
7071 session: Session,
71) -> Result<AppResponseData<Vec<ThreadDto>>, ApiError> {
72 Query(page): Query<export::pagination::PageQuery>,
73) -> Result<AppResponseData<export::pagination::Page<ThreadDto>>, ApiError> {
7274 let uid = require_uid(&session)?;
7375 let threads = service.list_my_threads(uid).await?;
74 Ok(AppResponseData::ok(threads))
76 Ok(AppResponseData::ok(export::pagination::paginate(threads, &page)))
7577}
7678
7779#[utoipa::path(
Modified lib/chat/src/handlers/webhooks.rs View lib/chat/src/handlers/webhooks.rs
@@ -1,4 +1,4 @@
1use axum::extract::{Json, Path, State};
1use axum::extract::{Json, Path, Query, State};
22use export::rest::{AppResponseData, NoData};
33use session::Session;
44use session::share::session::require_uid;
@@ -36,9 +36,9 @@
3636 get,
3737 path = "/chat/rooms/{room_id}/webhooks",
3838 tag = "chat",
39 params(("room_id" = Uuid, Path, description = "room id")),
39 params(("room_id" = Uuid, Path, description = "room id"), export::pagination::PageQuery),
4040 responses(
41 (status = 200, description = "webhook list", body = AppResponseData<Vec<WebhookDto>>),
41 (status = 200, description = "webhook list", body = AppResponseData<export::pagination::Page<WebhookDto>>),
4242 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
4343 )
4444)]
@@ -46,10 +46,11 @@
4646 State(service): State<ChatService>,
4747 session: Session,
4848 Path(room_id): Path<Uuid>,
49) -> Result<AppResponseData<Vec<WebhookDto>>, ApiError> {
49 Query(page): Query<export::pagination::PageQuery>,
50) -> Result<AppResponseData<export::pagination::Page<WebhookDto>>, ApiError> {
5051 let uid = require_uid(&session)?;
5152 let rows = service.list_webhooks(uid, room_id).await?;
52 Ok(AppResponseData::ok(rows))
53 Ok(AppResponseData::ok(export::pagination::paginate(rows, &page)))
5354}
5455
5556#[utoipa::path(
Modified lib/chat/src/services/reminders.rs View lib/chat/src/services/reminders.rs
@@ -99,7 +99,10 @@
9999 .order_by_asc(message_reminders::Column::RemindAt)
100100 .all(self.db().read())
101101 .await?;
102 let mut items: Vec<ReminderDto> = rows.iter().map(|r| ReminderDto::from(r.clone())).collect();
102 let mut items: Vec<ReminderDto> = rows
103 .iter()
104 .map(|r| ReminderDto::from(r.clone()))
105 .collect();
103106 let msg_ids: Vec<Uuid> = rows.iter().map(|r| r.message_id).collect();
104107 if !msg_ids.is_empty() {
105108 use models::chat::messages;
Modified lib/chat/src/services/threads.rs View lib/chat/src/services/threads.rs
@@ -37,11 +37,7 @@
3737 pub updated_at: OffsetDateTime,
3838}
3939
40#[derive(Debug, Clone, Serialize, ToSchema)]
41pub struct ThreadPage {
42 pub items: Vec<ThreadDto>,
43 pub total: i64,
44}
40pub type ThreadPage = export::pagination::Page<ThreadDto>;
4541
4642#[derive(Debug, Clone, Deserialize, ToSchema)]
4743pub struct CreateThreadCmd {
@@ -275,7 +271,7 @@
275271 .all(self.db().read())
276272 .await?;
277273 let items = self.build_thread_dto(rows).await?;
278 Ok(ThreadPage { items, total })
274 Ok(ThreadPage { items, total, limit, offset })
279275 }
280276
281277 pub async fn list_my_threads(&self, caller: Uuid) -> Result<Vec<ThreadDto>, ChatError> {
Modified lib/chat/src/openapi.rs View lib/chat/src/openapi.rs
@@ -86,6 +86,17 @@
8686 crate::handlers::ws::issue_ws_ticket,
8787 ),
8888 components(schemas(
89 export::pagination::PageQuery,
90 export::pagination::Page<crate::services::dms::DmDto>,
91 export::pagination::Page<crate::services::threads::ThreadDto>,
92 export::pagination::Page<crate::services::drafts::DraftDto>,
93 export::pagination::Page<crate::services::webhooks::WebhookDto>,
94 export::pagination::Page<crate::services::room_admin::RoomBanDto>,
95 export::pagination::Page<crate::services::bookmarks::BookmarkDto>,
96 export::pagination::Page<crate::services::emojis::EmojiDto>,
97 export::pagination::Page<crate::services::messages::MessageDto>,
98 export::pagination::Page<crate::services::rooms::RoomBriefDto>,
99 export::pagination::Page<crate::services::rooms::RoomInviteListItemDto>,
89100 AppResponseData<export::rest::NoData>,
90101 export::rest::NoData,
91102 space::services::spaces::UserDto,
Modified lib/export/src/pagination.rs View lib/export/src/pagination.rs
@@ -5,10 +5,11 @@
55pub const MAX_PAGE_LIMIT: u64 = 100;
66pub const MAX_OFFSET: u64 = 5000;
77
8#[derive(Debug, Clone, Copy, Deserialize, IntoParams, ToSchema)]
8#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
99pub struct PageQuery {
1010 pub limit: Option<u64>,
1111 pub offset: Option<u64>,
12 pub sort: Option<String>,
1213}
1314
1415impl PageQuery {
@@ -34,3 +35,15 @@
3435 pub limit: u64,
3536 pub offset: u64,
3637}
38
39pub fn paginate<T>(items: Vec<T>, page: &PageQuery) -> Page<T> {
40 let total = items.len() as i64;
41 let (limit, offset) = page.page();
42 let items = items.into_iter().skip(offset as usize).take(limit as usize).collect();
43 Page {
44 items,
45 total,
46 limit,
47 offset,
48 }
49}
Modified lib/git/src/handler/branches/list.rs View lib/git/src/handler/branches/list.rs
@@ -1,4 +1,4 @@
1use axum::extract::{Path, State};
1use axum::extract::{Path, Query, State};
22use export::rest::AppResponseData;
33use session::Session;
44
@@ -11,16 +11,17 @@
1111 get,
1212 path = "/repos/{space_name}/{repo_name}/branches",
1313 tag = "branches",
14 params(crate::handler::RepoPath),
14 params(crate::handler::RepoPath, export::pagination::PageQuery),
1515 responses(
16 (status = 200, description = "list branches", body = AppResponseData<Vec<BranchDto>>)
16 (status = 200, description = "list branches", body = AppResponseData<export::pagination::Page<BranchDto>>)
1717 )
1818)]
1919pub async fn list_branches(
2020 State(service): State<GitService>,
2121 session: Session,
2222 Path(path): Path<crate::handler::RepoPath>,
23) -> Result<AppResponseData<Vec<BranchDto>>, ApiError> {
23 Query(page): Query<export::pagination::PageQuery>,
24) -> Result<AppResponseData<export::pagination::Page<BranchDto>>, ApiError> {
2425 let _ = resolve_reader(
2526 &service,
2627 &session,
@@ -28,9 +29,17 @@
2829 path.repo_name.clone(),
2930 )
3031 .await?;
31 let out = service
32 let all = service
3233 .list_branches(path.space_name, path.repo_name)
3334 .await
3435 .map_err(GitApiError::from)?;
35 Ok(AppResponseData::ok(out))
36 let total = all.len() as i64;
37 let (limit, offset) = page.page();
38 let items = all.into_iter().skip(offset as usize).take(limit as usize).collect();
39 Ok(AppResponseData::ok(export::pagination::Page {
40 items,
41 total,
42 limit,
43 offset,
44 }))
3645}
Modified lib/git/src/handler/repos/list.rs View lib/git/src/handler/repos/list.rs
@@ -1,4 +1,4 @@
1use axum::extract::{Path, State};
1use axum::extract::{Path, Query, State};
22use export::rest::AppResponseData;
33use session::{Session, SessionUser};
44
@@ -15,19 +15,20 @@
1515 get,
1616 path = "/repos/{space_name}",
1717 tag = "repos",
18 params(SpacePath),
18 params(SpacePath, export::pagination::PageQuery),
1919 responses(
20 (status = 200, description = "list repos", body = AppResponseData<Vec<RepoSummaryDto>>)
20 (status = 200, description = "list repos", body = AppResponseData<export::pagination::Page<RepoSummaryDto>>)
2121 )
2222)]
2323pub async fn list_repos(
2424 State(service): State<GitService>,
2525 session: Session,
2626 Path(path): Path<SpacePath>,
27) -> Result<AppResponseData<Vec<RepoSummaryDto>>, ApiError> {
27 Query(page): Query<export::pagination::PageQuery>,
28) -> Result<AppResponseData<export::pagination::Page<RepoSummaryDto>>, ApiError> {
2829 let caller = session.uid();
2930 let out = service
30 .list_repos(path.space_name, caller)
31 .list_repos(path.space_name, caller, page)
3132 .await
3233 .map_err(GitApiError::from)?;
3334 Ok(AppResponseData::ok(out))
Modified lib/git/src/handler/tags/list.rs View lib/git/src/handler/tags/list.rs
@@ -1,4 +1,4 @@
1use axum::extract::{Path, State};
1use axum::extract::{Path, Query, State};
22use export::rest::AppResponseData;
33use session::Session;
44
@@ -11,16 +11,17 @@
1111 get,
1212 path = "/repos/{space_name}/{repo_name}/tags",
1313 tag = "tags",
14 params(crate::handler::RepoPath),
14 params(crate::handler::RepoPath, export::pagination::PageQuery),
1515 responses(
16 (status = 200, description = "list tags", body = AppResponseData<Vec<TagDto>>)
16 (status = 200, description = "list tags", body = AppResponseData<export::pagination::Page<TagDto>>)
1717 )
1818)]
1919pub async fn list_tags(
2020 State(service): State<GitService>,
2121 session: Session,
2222 Path(path): Path<crate::handler::RepoPath>,
23) -> Result<AppResponseData<Vec<TagDto>>, ApiError> {
23 Query(page): Query<export::pagination::PageQuery>,
24) -> Result<AppResponseData<export::pagination::Page<TagDto>>, ApiError> {
2425 let _ = resolve_reader(
2526 &service,
2627 &session,
@@ -28,9 +29,17 @@
2829 path.repo_name.clone(),
2930 )
3031 .await?;
31 let out = service
32 let all = service
3233 .list_tags(path.space_name, path.repo_name)
3334 .await
3435 .map_err(GitApiError::from)?;
35 Ok(AppResponseData::ok(out))
36 let total = all.len() as i64;
37 let (limit, offset) = page.page();
38 let items = all.into_iter().skip(offset as usize).take(limit as usize).collect();
39 Ok(AppResponseData::ok(export::pagination::Page {
40 items,
41 total,
42 limit,
43 offset,
44 }))
3645}
Modified lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -420,7 +420,8 @@
420420 &self,
421421 space_name: String,
422422 caller: Option<Uuid>,
423 ) -> Result<Vec<RepoSummaryDto>, GitError> {
423 page: export::pagination::PageQuery,
424 ) -> Result<export::pagination::Page<RepoSummaryDto>, GitError> {
424425 let space = self.lookup_space(&space_name).await?;
425426 let dirs = dirs::Entity::find()
426427 .filter(dirs::Column::SpaceId.eq(space.id))
@@ -428,11 +429,16 @@
428429 .all(self.db_read())
429430 .await?;
430431 let dir_ids: Vec<Uuid> = dirs.iter().map(|d| d.id).collect();
431 let rows = repos::Entity::find()
432 .filter(repos::Column::DirId.is_in(dir_ids))
433 .order_by_asc(repos::Column::CreatedAt)
434 .all(self.db_read())
435 .await?;
432 let mut sel = repos::Entity::find().filter(repos::Column::DirId.is_in(dir_ids));
433 sel = match page.sort.as_deref() {
434 Some("created_at") => sel.order_by_asc(repos::Column::CreatedAt),
435 Some("-updated_at") => sel.order_by_desc(repos::Column::UpdatedAt),
436 Some("updated_at") => sel.order_by_asc(repos::Column::UpdatedAt),
437 Some("stars") => sel.order_by_desc(repos::Column::NumsStar),
438 Some("name") => sel.order_by_asc(repos::Column::Name),
439 _ => sel.order_by_asc(repos::Column::CreatedAt),
440 };
441 let rows = sel.all(self.db_read()).await?;
436442
437443 let owner_view = caller.is_some_and(|uid| space.owner_id == uid);
438444 let mut accessible: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
@@ -447,11 +453,24 @@
447453 .await?;
448454 accessible.extend(collabs.into_iter().map(|c| c.repo_id));
449455 }
450 Ok(rows
456 let visible: Vec<RepoSummaryDto> = rows
451457 .into_iter()
452458 .filter(|r| !r.is_private || owner_view || accessible.contains(&r.id))
453459 .map(RepoSummaryDto::from)
454 .collect())
460 .collect();
461 let total = visible.len() as i64;
462 let (limit, offset) = page.page();
463 let items = visible
464 .into_iter()
465 .skip(offset as usize)
466 .take(limit as usize)
467 .collect();
468 Ok(export::pagination::Page {
469 items,
470 total,
471 limit,
472 offset,
473 })
455474 }
456475
457476 pub async fn update_repo(
Modified lib/git/src/openapi.rs View lib/git/src/openapi.rs
@@ -95,6 +95,10 @@
9595 handler::whitelists::remove_tag_whitelist,
9696 ),
9797 components(schemas(
98 export::pagination::PageQuery,
99 export::pagination::Page<crate::service::repos::RepoSummaryDto>,
100 export::pagination::Page<crate::service::branches::BranchDto>,
101 export::pagination::Page<crate::service::tags::TagDto>,
98102 export::rest::NoData,
99103 crate::service::hooks::HookEventKind,
100104 models::common::watch_mode::WatchMode,
Modified lib/issues/src/services/boards.rs View lib/issues/src/services/boards.rs
@@ -33,11 +33,7 @@
3333 pub updated_at: Option<OffsetDateTime>,
3434}
3535
36#[derive(Debug, Clone, Serialize, ToSchema)]
37pub struct BoardPage {
38 pub items: Vec<BoardDto>,
39 pub total: i64,
40}
36pub type BoardPage = export::pagination::Page<BoardDto>;
4137
4238#[derive(Debug, Clone, Deserialize, ToSchema)]
4339pub struct CreateBoardCmd {
@@ -258,6 +254,8 @@
258254 return Ok(BoardPage {
259255 items: Vec::new(),
260256 total: 0,
257 limit,
258 offset,
261259 });
262260 }
263261
@@ -275,6 +273,8 @@
275273 return Ok(BoardPage {
276274 items: Vec::new(),
277275 total: 0,
276 limit,
277 offset,
278278 });
279279 }
280280
@@ -308,7 +308,7 @@
308308 })
309309 .collect();
310310
311 Ok(BoardPage { items, total })
311 Ok(BoardPage { items, total, limit, offset })
312312 }
313313
314314 pub async fn get_board(
Modified lib/issues/src/services/comments.rs View lib/issues/src/services/comments.rs
@@ -30,11 +30,7 @@
3030 pub updated_at: Option<OffsetDateTime>,
3131}
3232
33#[derive(Debug, Clone, Serialize, ToSchema)]
34pub struct CommentPage {
35 pub items: Vec<CommentDto>,
36 pub total: i64,
37}
33pub type CommentPage = export::pagination::Page<CommentDto>;
3834
3935#[derive(Debug, Clone, Serialize, ToSchema)]
4036pub struct CommentTreeNode {
@@ -59,6 +55,7 @@
5955 pub parent_id: Option<Uuid>,
6056 pub limit: Option<u64>,
6157 pub offset: Option<u64>,
58 pub sort: Option<String>,
6259}
6360
6461#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
@@ -320,7 +317,11 @@
320317 select = select.filter(comment::Column::ParentId.eq(Some(pid)));
321318 }
322319
323 select = select.order_by_asc(comment::Column::CreatedAt);
320 match q.sort.as_deref() {
321 Some("-created_at") => select = select.order_by_desc(comment::Column::CreatedAt),
322 Some("updated_at") => select = select.order_by_desc(comment::Column::UpdatedAt),
323 _ => select = select.order_by_asc(comment::Column::CreatedAt),
324 }
324325
325326 let total = select.clone().count(self.db().read()).await? as i64;
326327 let rows = select
@@ -348,7 +349,7 @@
348349 })
349350 .collect();
350351
351 Ok(CommentPage { items, total })
352 Ok(CommentPage { items, total, limit, offset })
352353 }
353354
354355 pub async fn list_comments_tree(
Modified lib/issues/src/services/content_history.rs View lib/issues/src/services/content_history.rs
@@ -22,11 +22,7 @@
2222 pub created_at: OffsetDateTime,
2323}
2424
25#[derive(Debug, Clone, Serialize, ToSchema)]
26pub struct ContentHistoryPage {
27 pub items: Vec<ContentHistoryDto>,
28 pub total: i64,
29}
25pub type ContentHistoryPage = export::pagination::Page<ContentHistoryDto>;
3026
3127#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
3228pub struct ListContentHistoryQuery {
@@ -100,6 +96,6 @@
10096 })
10197 .collect();
10298
103 Ok(ContentHistoryPage { items, total })
99 Ok(ContentHistoryPage { items, total, limit, offset })
104100 }
105101}
Modified lib/issues/src/services/events.rs View lib/issues/src/services/events.rs
@@ -26,11 +26,7 @@
2626 pub created_at: OffsetDateTime,
2727}
2828
29#[derive(Debug, Clone, Serialize, ToSchema)]
30pub struct EventPage {
31 pub items: Vec<EventDto>,
32 pub total: i64,
33}
29pub type EventPage = export::pagination::Page<EventDto>;
3430
3531#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
3632pub struct ListEventQuery {
@@ -102,6 +98,6 @@
10298 })
10399 .collect();
104100
105 Ok(EventPage { items, total })
101 Ok(EventPage { items, total, limit, offset })
106102 }
107103}
Modified lib/issues/src/services/issue_users.rs View lib/issues/src/services/issue_users.rs
@@ -23,11 +23,7 @@
2323 pub created_at: OffsetDateTime,
2424}
2525
26#[derive(Debug, Clone, Serialize, ToSchema)]
27pub struct IssueUserPage {
28 pub items: Vec<IssueUserDto>,
29 pub total: i64,
30}
26pub type IssueUserPage = export::pagination::Page<IssueUserDto>;
3127
3228#[derive(Debug, Clone, Deserialize, ToSchema)]
3329pub struct UpdateIssueUserCmd {
@@ -100,7 +96,7 @@
10096 })
10197 .collect();
10298
103 Ok(IssueUserPage { items, total })
99 Ok(IssueUserPage { items, total, limit, offset })
104100 }
105101
106102 pub async fn update_issue_user(
Modified lib/issues/src/services/labels.rs View lib/issues/src/services/labels.rs
@@ -46,11 +46,7 @@
4646 }
4747}
4848
49#[derive(Debug, Clone, Serialize, ToSchema)]
50pub struct LabelPage {
51 pub items: Vec<LabelDto>,
52 pub total: i64,
53}
49pub type LabelPage = export::pagination::Page<LabelDto>;
5450
5551#[derive(Debug, Clone, Deserialize, ToSchema)]
5652pub struct CreateLabelCmd {
@@ -207,7 +203,7 @@
207203 .await?;
208204
209205 let items = rows.into_iter().map(LabelDto::from).collect();
210 Ok(LabelPage { items, total })
206 Ok(LabelPage { items, total, limit, offset })
211207 }
212208
213209 pub async fn get_label(
Modified lib/issues/src/services/members.rs View lib/issues/src/services/members.rs
@@ -27,11 +27,7 @@
2727 pub updated_at: Option<OffsetDateTime>,
2828}
2929
30#[derive(Debug, Clone, Serialize, ToSchema)]
31pub struct IssueMemberPage {
32 pub items: Vec<IssueMemberDto>,
33 pub total: i64,
34}
30pub type IssueMemberPage = export::pagination::Page<IssueMemberDto>;
3531
3632#[derive(Debug, Clone, Deserialize, ToSchema)]
3733pub struct CreateIssueMemberCmd {
@@ -249,7 +245,7 @@
249245 })
250246 .collect();
251247
252 Ok(IssueMemberPage { items, total })
248 Ok(IssueMemberPage { items, total, limit, offset })
253249 }
254250
255251 pub async fn delete_member(
Modified lib/issues/src/services/milestones.rs View lib/issues/src/services/milestones.rs
@@ -31,11 +31,7 @@
3131 pub updated_at: Option<OffsetDateTime>,
3232}
3333
34#[derive(Debug, Clone, Serialize, ToSchema)]
35pub struct MilestonePage {
36 pub items: Vec<MilestoneDto>,
37 pub total: i64,
38}
34pub type MilestonePage = export::pagination::Page<MilestoneDto>;
3935
4036#[derive(Debug, Clone, Deserialize, ToSchema)]
4137pub struct CreateMilestoneCmd {
@@ -257,7 +253,7 @@
257253 })
258254 .collect();
259255
260 Ok(MilestonePage { items, total })
256 Ok(MilestonePage { items, total, limit, offset })
261257 }
262258
263259 pub async fn get_milestone(
Modified lib/issues/src/services/tracked_time.rs View lib/issues/src/services/tracked_time.rs
@@ -19,11 +19,7 @@
1919 pub created_at: OffsetDateTime,
2020}
2121
22#[derive(Debug, Clone, Serialize, ToSchema)]
23pub struct TrackedTimePage {
24 pub items: Vec<TrackedTimeDto>,
25 pub total: i64,
26}
22pub type TrackedTimePage = export::pagination::Page<TrackedTimeDto>;
2723
2824#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
2925pub struct ListTrackedTimeQuery {
@@ -209,6 +205,6 @@
209205 })
210206 .collect();
211207
212 Ok(TrackedTimePage { items, total })
208 Ok(TrackedTimePage { items, total, limit, offset })
213209 }
214210}
Modified lib/space/src/handlers/get_space_dir_tree.rs View lib/space/src/handlers/get_space_dir_tree.rs
@@ -1,4 +1,5 @@
1use axum::extract::{Path, State};
1use axum::extract::{Path, Query, State};
2use serde::Deserialize;
23use export::rest::{AppResponseData, NoData};
34use session::Session;
45
@@ -8,11 +9,16 @@
89use crate::services::spaces::SpaceNamePath;
910use session::share::session::require_uid;
1011
12#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
13pub struct DirTreeQuery {
14 pub parent_id: Option<uuid::Uuid>,
15}
16
1117#[utoipa::path(
1218 get,
1319 path = "/spaces/{space_name}/dir",
1420 tag = "space",
15 params(SpaceNamePath),
21 params(SpaceNamePath, DirTreeQuery),
1622 responses(
1723 (status = 200, description = "get space dir tree", body = AppResponseData<Vec<DirNodeDto>>),
1824 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
@@ -23,8 +29,11 @@
2329 State(service): State<SpaceService>,
2430 session: Session,
2531 Path(path): Path<SpaceNamePath>,
32 Query(q): Query<DirTreeQuery>,
2633) -> Result<AppResponseData<Vec<DirNodeDto>>, ApiError> {
2734 let uid = require_uid(&session)?;
28 let tree = service.get_space_dir_tree(uid, &path.space_name).await?;
35 let tree = service
36 .get_space_dir_tree(uid, &path.space_name, q.parent_id)
37 .await?;
2938 Ok(AppResponseData::ok(tree))
3039}
Modified lib/space/src/services/bans.rs View lib/space/src/services/bans.rs
@@ -66,11 +66,7 @@
6666 pub allow_comment_any: Option<bool>,
6767}
6868
69#[derive(Debug, Clone, Serialize, ToSchema)]
70pub struct BanPage {
71 pub items: Vec<BanDto>,
72 pub total: i64,
73}
69pub type BanPage = export::pagination::Page<BanDto>;
7470
7571impl SpaceService {
7672 pub async fn ban_user(
@@ -345,6 +341,6 @@
345341 })
346342 .collect();
347343
348 Ok(BanPage { items, total })
344 Ok(BanPage { items, total, limit, offset })
349345 }
350346}
Modified lib/space/src/services/dir.rs View lib/space/src/services/dir.rs
@@ -243,28 +243,33 @@
243243 &self,
244244 caller: Uuid,
245245 space_name: &str,
246 parent_id: Option<Uuid>,
246247 ) -> Result<Vec<DirNodeDto>, SpaceError> {
247248 let space = lookup_space_by_name(self, space_name).await?;
248249 let perms = effective_permissions(self.db().read(), &space, caller).await?;
249250
250251 let can_manage = perms.has(MANAGE_CHANNELS);
251252
252 let total_nodes = dirs::Entity::find()
253 .filter(dirs::Column::SpaceId.eq(space.id))
254 .count(self.db().read())
255 .await? as u64;
256 let max_nodes = self.config().space.max_dir_nodes;
257 if total_nodes > max_nodes {
258 return Err(SpaceError::BadRequest(format!(
259 "directory tree has {total_nodes} nodes, exceeds max_dir_nodes {max_nodes}; use a scoped query instead"
260 )));
253 if parent_id.is_none() {
254 let total_nodes = dirs::Entity::find()
255 .filter(dirs::Column::SpaceId.eq(space.id))
256 .count(self.db().read())
257 .await? as u64;
258 let max_nodes = self.config().space.max_dir_nodes;
259 if total_nodes > max_nodes {
260 return Err(SpaceError::BadRequest(format!(
261 "directory tree has {total_nodes} nodes, exceeds max_dir_nodes {max_nodes}; query with parent_id to load one level"
262 )));
263 }
261264 }
262265
263 let nodes = dirs::Entity::find()
266 let mut sel = dirs::Entity::find()
264267 .filter(dirs::Column::SpaceId.eq(space.id))
265 .order_by_asc(dirs::Column::Position)
266 .all(self.db().read())
267 .await?;
268 .order_by_asc(dirs::Column::Position);
269 if let Some(pid) = parent_id {
270 sel = sel.filter(dirs::Column::ParentDirId.eq(Some(pid)));
271 }
272 let nodes = sel.all(self.db().read()).await?;
268273
269274 if nodes.is_empty() {
270275 return Ok(Vec::new());
Modified lib/space/src/services/groups.rs View lib/space/src/services/groups.rs
@@ -73,11 +73,7 @@
7373 pub group_id: Uuid,
7474}
7575
76#[derive(Debug, Clone, Serialize, ToSchema)]
77pub struct GroupPage {
78 pub items: Vec<GroupDto>,
79 pub total: i64,
80}
76pub type GroupPage = export::pagination::Page<GroupDto>;
8177
8278async fn replace_permissions(
8379 db: &impl ConnectionTrait,
@@ -448,7 +444,7 @@
448444 })
449445 .collect();
450446
451 Ok(GroupPage { items, total })
447 Ok(GroupPage { items, total, limit, offset })
452448 }
453449
454450 pub async fn assign_group(
Modified lib/space/src/services/invites.rs View lib/space/src/services/invites.rs
@@ -55,11 +55,7 @@
5555 pub code: String,
5656}
5757
58#[derive(Debug, Clone, Serialize, ToSchema)]
59pub struct InvitePage {
60 pub items: Vec<InviteDto>,
61 pub total: i64,
62}
58pub type InvitePage = export::pagination::Page<InviteDto>;
6359
6460fn validate_email(email: &str, max_len: usize) -> bool {
6561 if email.is_empty() || email.chars().count() > max_len {
@@ -582,6 +578,6 @@
582578 })
583579 .collect();
584580
585 Ok(InvitePage { items, total })
581 Ok(InvitePage { items, total, limit, offset })
586582 }
587583}
Modified lib/space/src/services/members.rs View lib/space/src/services/members.rs
@@ -86,11 +86,7 @@
8686 pub hide_muted_channels: Option<bool>,
8787}
8888
89#[derive(Debug, Clone, Serialize, ToSchema)]
90pub struct MemberPage {
91 pub items: Vec<MemberDto>,
92 pub total: i64,
93}
89pub type MemberPage = export::pagination::Page<MemberDto>;
9490
9591impl SpaceService {
9692 pub async fn add_member(
@@ -545,6 +541,6 @@
545541 })
546542 .collect();
547543
548 Ok(MemberPage { items, total })
544 Ok(MemberPage { items, total, limit, offset })
549545 }
550546}
Modified lib/user/src/handlers/list_sessions.rs View lib/user/src/handlers/list_sessions.rs
@@ -15,18 +15,25 @@
1515 ListSessionsQuery,
1616 ),
1717 responses(
18 (status = 200, description = "list user sessions", body = AppResponseData<Vec<SessionDto>>)
18 (status = 200, description = "list user sessions", body = AppResponseData<export::pagination::Page<SessionDto>>)
1919 )
2020)]
2121pub async fn list_sessions(
2222 State(service): State<UserService>,
2323 session: Session,
2424 Query(query): Query<ListSessionsQuery>,
25) -> Result<AppResponseData<Vec<SessionDto>>, ApiError> {
25) -> Result<AppResponseData<export::pagination::Page<SessionDto>>, ApiError> {
2626 let uid = require_uid(&session)?;
2727 let token = session.get::<String>(&session::share::session::session_token_key(uid));
2828 let sessions = service
29 .list_sessions(uid, query, token.as_deref())
29 .list_sessions(uid, query.clone(), token.as_deref())
3030 .await?;
31 Ok(AppResponseData::ok(sessions))
31 Ok(AppResponseData::ok(export::pagination::paginate(
32 sessions,
33 &export::pagination::PageQuery {
34 limit: query.limit,
35 offset: query.offset,
36 sort: None,
37 },
38 )))
3239}
Modified lib/user/src/handlers/upload_avatar.rs View lib/user/src/handlers/upload_avatar.rs
@@ -1,10 +1,12 @@
1use axum::Json;
1use axum::body::Bytes;
22use axum::extract::State;
3use export::rest::AppResponseData;
3use axum::http::HeaderMap;
4use axum::http::header::CONTENT_TYPE;
5use export::rest::{AppResponseData, NoData};
46use session::Session;
57
68use crate::errors::ApiError;
7use crate::services::avatar::{AvatarDto, UploadAvatarCmd};
9use crate::services::avatar::AvatarDto;
810use crate::services::service::UserService;
911use session::share::session::require_uid;
1012
@@ -12,17 +14,30 @@
1214 put,
1315 path = "/me/avatar",
1416 tag = "user",
15 request_body = UploadAvatarCmd,
17 request_body(
18 content = Vec<u8>,
19 description = "raw image bytes (image/jpeg, image/png, image/webp, image/gif)",
20 content_type = "image/png"
21 ),
1622 responses(
17 (status = 200, description = "avatar uploaded", body = AppResponseData<AvatarDto>)
23 (status = 200, description = "avatar uploaded", body = AppResponseData<AvatarDto>),
24 (status = 400, description = "bad request", body = AppResponseData<NoData>)
1825 )
1926)]
2027pub async fn upload_avatar(
2128 State(service): State<UserService>,
2229 session: Session,
23 Json(cmd): Json<UploadAvatarCmd>,
30 headers: HeaderMap,
31 body: Bytes,
2432) -> Result<AppResponseData<AvatarDto>, ApiError> {
2533 let uid = require_uid(&session)?;
26 let avatar = service.upload_avatar(uid, cmd).await?;
27 Ok(AppResponseData::ok(avatar))
34 let content_type = headers
35 .get(CONTENT_TYPE)
36 .and_then(|v| v.to_str().ok())
37 .unwrap_or("application/octet-stream")
38 .to_string();
39 let dto = service
40 .upload_avatar_stream(uid, &content_type, &body)
41 .await?;
42 Ok(AppResponseData::ok(dto))
2843}
Modified lib/user/src/services/avatar.rs View lib/user/src/services/avatar.rs
@@ -71,27 +71,18 @@
7171 pub updated_at: OffsetDateTime,
7272}
7373
74#[derive(Debug, Clone, Deserialize, ToSchema)]
75pub struct UploadAvatarCmd {
76 pub content_type: String,
77 pub data_base64: String,
78}
79
8074impl UserService {
81 pub async fn upload_avatar(
75 pub async fn upload_avatar_stream(
8276 &self,
8377 uid: Uuid,
84 cmd: UploadAvatarCmd,
78 content_type: &str,
79 bytes: &[u8],
8580 ) -> Result<AvatarDto, UserError> {
86 let fmt = AvatarFormat::from_content_type(&cmd.content_type).ok_or_else(|| {
81 let fmt = AvatarFormat::from_content_type(content_type).ok_or_else(|| {
8782 UserError::BadRequest(format!(
88 "unsupported content_type '{}' (allowed: image/jpeg, image/png, image/webp, image/gif)",
89 cmd.content_type
83 "unsupported content_type '{content_type}' (allowed: image/jpeg, image/png, image/webp, image/gif)"
9084 ))
9185 })?;
92 let bytes = base64::engine::general_purpose::STANDARD
93 .decode(cmd.data_base64.as_bytes())
94 .map_err(|e| UserError::BadRequest(format!("invalid base64 payload: {e}")))?;
9586 if bytes.is_empty() {
9687 return Err(UserError::BadRequest("avatar payload is empty".into()));
9788 }
Modified lib/user/src/openapi.rs View lib/user/src/openapi.rs
@@ -7,7 +7,7 @@
77use utoipa::OpenApi;
88
99use crate::handlers;
10use crate::services::avatar::{AvatarDto, AvatarFormat, UploadAvatarCmd};
10use crate::services::avatar::{AvatarDto, AvatarFormat};
1111use crate::services::emails::{AddEmailCmd, AddEmailResp, EmailDto, VerifyEmailCmd};
1212use crate::services::notifications::{
1313 RecentMentionDto, ReportDto, SaveMessageCmd, SavedMessageDto,
@@ -52,10 +52,12 @@
5252 handlers::my_suspensions::my_suspensions,
5353 ),
5454 components(schemas(
55 export::pagination::PageQuery,
56 export::pagination::Page<crate::services::sessions::SessionDto>,
57
5558 NoData,
5659 AvatarFormat,
5760 AvatarDto,
58 UploadAvatarCmd,
5961 SpaceDto,
6062 MySpaceDto,
6163 MemeDto,
Modified lib/user/src/routes.rs View lib/user/src/routes.rs
@@ -39,7 +39,10 @@
3939 .route("/users/{user_id}/avatar", get(get_user_avatar))
4040 .route(
4141 "/me/avatar",
42 get(get_avatar).put(upload_avatar).delete(delete_avatar),
42 get(get_avatar)
43 .put(upload_avatar)
44 .delete(delete_avatar)
45 .layer(axum::extract::DefaultBodyLimit::max(6 * 1024 * 1024)),
4346 )
4447 .route("/me/spaces", get(my_spaces))
4548 .route("/me/memes", get(list_memes).post(create_meme))