Skip to content
Clone

feat(lib/user): add user crate with handlers, services, share, and openapi

ZhenYi committed
parent 298b04a commit 1b1fd1c Browse files

45 files changed

+2421 −0

File tree 45
Added lib/user/src/handlers/add_email.rs View lib/user/src/handlers/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::{AddEmailCmd, AddEmailResp};
8use crate::services::service::UserService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/me/emails",
14 tag = "user",
15 request_body = AddEmailCmd,
16 responses(
17 (status = 200, description = "add email address", body = AppResponseData<AddEmailResp>)
18 )
19)]
20pub async fn add_email(
21 State(service): State<UserService>,
22 session: Session,
23 Json(cmd): Json<AddEmailCmd>,
24) -> Result<AppResponseData<AddEmailResp>, ApiError> {
25 let uid = require_uid(&session)?;
26 let resp = service.add_email(uid, cmd).await?;
27 Ok(AppResponseData::ok(resp))
28}
Added lib/user/src/handlers/cancel_scheduled_message.rs View lib/user/src/handlers/cancel_scheduled_message.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/me/messages/scheduled/{id}",
13 tag = "user",
14 params(
15 ("id" = Uuid, Path, description = "scheduled message id")
16 ),
17 responses(
18 (status = 200, description = "cancel scheduled message", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn cancel_scheduled_message(
22 State(service): State<UserService>,
23 session: Session,
24 Path(id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.cancel_scheduled_message(uid, id).await?;
28 Ok(AppResponseData::empty())
29}
Added lib/user/src/handlers/confirm_deletion.rs View lib/user/src/handlers/confirm_deletion.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::deletion::DeletionDto;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 post,
12 path = "/me/deletion/confirm",
13 tag = "user",
14 responses(
15 (status = 200, description = "deletion confirmed", body = AppResponseData<DeletionDto>)
16 )
17)]
18pub async fn confirm_deletion(
19 State(service): State<UserService>,
20 session: Session,
21) -> Result<AppResponseData<DeletionDto>, ApiError> {
22 let uid = require_uid(&session)?;
23 let deletion = service.confirm_deletion(uid).await?;
24 Ok(AppResponseData::ok(deletion))
25}
Added lib/user/src/handlers/create_meme.rs View lib/user/src/handlers/create_meme.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::profile::{MemeDto, UpsertMemeCmd};
8use crate::services::service::UserService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/me/memes",
14 tag = "user",
15 request_body = UpsertMemeCmd,
16 responses(
17 (status = 200, description = "meme created or updated", body = AppResponseData<MemeDto>)
18 )
19)]
20pub async fn create_meme(
21 State(service): State<UserService>,
22 session: Session,
23 Json(cmd): Json<UpsertMemeCmd>,
24) -> Result<AppResponseData<MemeDto>, ApiError> {
25 let uid = require_uid(&session)?;
26 let meme = service.upsert_meme(uid, cmd).await?;
27 Ok(AppResponseData::ok(meme))
28}
Added lib/user/src/handlers/delete_avatar.rs View lib/user/src/handlers/delete_avatar.rs
@@ -0,0 +1,24 @@
1use axum::extract::State;
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::UserService;
7use crate::share::session::require_uid;
8
9#[utoipa::path(
10 delete,
11 path = "/me/avatar",
12 tag = "user",
13 responses(
14 (status = 200, description = "avatar deleted", body = AppResponseData<NoData>)
15 )
16)]
17pub async fn delete_avatar(
18 State(service): State<UserService>,
19 session: Session,
20) -> Result<AppResponseData<NoData>, ApiError> {
21 let uid = require_uid(&session)?;
22 service.delete_avatar(uid).await?;
23 Ok(AppResponseData::empty())
24}
Added lib/user/src/handlers/delete_email.rs View lib/user/src/handlers/delete_email.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/me/emails/{email_id}",
13 tag = "user",
14 params(
15 ("email_id" = Uuid, Path, description = "email id")
16 ),
17 responses(
18 (status = 200, description = "delete email", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_email(
22 State(service): State<UserService>,
23 session: Session,
24 Path(email_id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.delete_email(uid, email_id).await?;
28 Ok(AppResponseData::empty())
29}
Added lib/user/src/handlers/delete_meme.rs View lib/user/src/handlers/delete_meme.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/me/memes/{meme_id}",
13 tag = "user",
14 params(
15 ("meme_id" = Uuid, Path, description = "meme id")
16 ),
17 responses(
18 (status = 200, description = "meme deleted", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_meme(
22 State(service): State<UserService>,
23 session: Session,
24 Path(meme_id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.delete_meme(uid, meme_id).await?;
28 Ok(AppResponseData::empty())
29}
Added lib/user/src/handlers/delete_push_subscription.rs View lib/user/src/handlers/delete_push_subscription.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/me/push-subscriptions/{push_id}",
13 tag = "user",
14 params(
15 ("push_id" = Uuid, Path, description = "push subscription id")
16 ),
17 responses(
18 (status = 200, description = "push subscription deleted", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_push_subscription(
22 State(service): State<UserService>,
23 session: Session,
24 Path(push_id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.delete_push_subscription(uid, push_id).await?;
28 Ok(AppResponseData::empty())
29}
Added lib/user/src/handlers/get_avatar.rs View lib/user/src/handlers/get_avatar.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::avatar::AvatarDto;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/me/avatar",
13 tag = "user",
14 responses(
15 (status = 200, description = "current avatar", body = AppResponseData<Option<AvatarDto>>)
16 )
17)]
18pub async fn get_avatar(
19 State(service): State<UserService>,
20 session: Session,
21) -> Result<AppResponseData<Option<AvatarDto>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let avatar = service.current_avatar(uid).await?;
24 Ok(AppResponseData::ok(avatar))
25}
Added lib/user/src/handlers/get_profile_detail.rs View lib/user/src/handlers/get_profile_detail.rs
@@ -0,0 +1,30 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::services::profile::ProfileDetailDto;
8use crate::services::service::UserService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/users/{uid}/profile-detail",
14 tag = "user",
15 params(
16 ("uid" = Uuid, Path, description = "target user id")
17 ),
18 responses(
19 (status = 200, description = "user profile detail", body = AppResponseData<ProfileDetailDto>)
20 )
21)]
22pub async fn get_profile_detail(
23 State(service): State<UserService>,
24 session: Session,
25 Path(uid): Path<Uuid>,
26) -> Result<AppResponseData<ProfileDetailDto>, ApiError> {
27 let viewer = require_uid(&session)?;
28 let detail = service.profile_detail(Some(viewer), uid).await?;
29 Ok(AppResponseData::ok(detail))
30}
Added lib/user/src/handlers/list_emails.rs View lib/user/src/handlers/list_emails.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::emails::EmailDto;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/me/emails",
13 tag = "user",
14 responses(
15 (status = 200, description = "list user emails", body = AppResponseData<Vec<EmailDto>>)
16 )
17)]
18pub async fn list_emails(
19 State(service): State<UserService>,
20 session: Session,
21) -> Result<AppResponseData<Vec<EmailDto>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let emails = service.list_emails(uid).await?;
24 Ok(AppResponseData::ok(emails))
25}
Added lib/user/src/handlers/list_memes.rs View lib/user/src/handlers/list_memes.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Query, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::profile::{ListLimitQuery, MemeDto};
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/me/memes",
13 tag = "user",
14 params(
15 ListLimitQuery,
16 ),
17 responses(
18 (status = 200, description = "list memes", body = AppResponseData<Vec<MemeDto>>)
19 )
20)]
21pub async fn list_memes(
22 State(service): State<UserService>,
23 session: Session,
24 Query(query): Query<ListLimitQuery>,
25) -> Result<AppResponseData<Vec<MemeDto>>, ApiError> {
26 let uid = require_uid(&session)?;
27 let memes = service.list_memes(uid, query.limit).await?;
28 Ok(AppResponseData::ok(memes))
29}
Added lib/user/src/handlers/list_push_subscriptions.rs View lib/user/src/handlers/list_push_subscriptions.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::push_subscriptions::PushSubscriptionDto;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/me/push-subscriptions",
13 tag = "user",
14 responses(
15 (status = 200, description = "list push subscriptions", body = AppResponseData<Vec<PushSubscriptionDto>>)
16 )
17)]
18pub async fn list_push_subscriptions(
19 State(service): State<UserService>,
20 session: Session,
21) -> Result<AppResponseData<Vec<PushSubscriptionDto>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let list = service.list_push_subscriptions(uid).await?;
24 Ok(AppResponseData::ok(list))
25}
Added lib/user/src/handlers/list_saved_messages.rs View lib/user/src/handlers/list_saved_messages.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::notifications::SavedMessageDto;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/me/messages/saved",
13 tag = "user",
14 responses(
15 (status = 200, description = "list saved messages", body = AppResponseData<Vec<SavedMessageDto>>)
16 )
17)]
18pub async fn list_saved_messages(
19 State(service): State<UserService>,
20 session: Session,
21) -> Result<AppResponseData<Vec<SavedMessageDto>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let list = service.list_saved_messages(uid).await?;
24 Ok(AppResponseData::ok(list))
25}
Added lib/user/src/handlers/list_scheduled_messages.rs View lib/user/src/handlers/list_scheduled_messages.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::notifications::ScheduledMessageDto;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/me/messages/scheduled",
13 tag = "user",
14 responses(
15 (status = 200, description = "list scheduled messages", body = AppResponseData<Vec<ScheduledMessageDto>>)
16 )
17)]
18pub async fn list_scheduled_messages(
19 State(service): State<UserService>,
20 session: Session,
21) -> Result<AppResponseData<Vec<ScheduledMessageDto>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let list = service.list_scheduled_messages(uid).await?;
24 Ok(AppResponseData::ok(list))
25}
Added lib/user/src/handlers/list_sessions.rs View lib/user/src/handlers/list_sessions.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Query, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::UserService;
7use crate::services::sessions::{ListSessionsQuery, SessionDto};
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/me/sessions",
13 tag = "user",
14 params(
15 ListSessionsQuery,
16 ),
17 responses(
18 (status = 200, description = "list user sessions", body = AppResponseData<Vec<SessionDto>>)
19 )
20)]
21pub async fn list_sessions(
22 State(service): State<UserService>,
23 session: Session,
24 Query(query): Query<ListSessionsQuery>,
25) -> Result<AppResponseData<Vec<SessionDto>>, ApiError> {
26 let uid = require_uid(&session)?;
27 let sessions = service.list_sessions(uid, query).await?;
28 Ok(AppResponseData::ok(sessions))
29}
Added lib/user/src/handlers/mod.rs View lib/user/src/handlers/mod.rs
@@ -0,0 +1,27 @@
1pub mod add_email;
2pub mod cancel_scheduled_message;
3pub mod confirm_deletion;
4pub mod create_meme;
5pub mod delete_avatar;
6pub mod delete_email;
7pub mod delete_meme;
8pub mod delete_push_subscription;
9pub mod get_avatar;
10pub mod get_profile_detail;
11pub mod list_emails;
12pub mod list_memes;
13pub mod list_push_subscriptions;
14pub mod list_saved_messages;
15pub mod list_scheduled_messages;
16pub mod list_sessions;
17pub mod my_reports;
18pub mod my_spaces;
19pub mod my_suspensions;
20pub mod recent_mentions;
21pub mod revoke_all_sessions;
22pub mod revoke_session;
23pub mod save_message;
24pub mod set_primary_email;
25pub mod unsave_message;
26pub mod upload_avatar;
27pub mod upsert_push_subscription;
Added lib/user/src/handlers/my_reports.rs View lib/user/src/handlers/my_reports.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::notifications::ReportDto;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/me/reports",
13 tag = "user",
14 responses(
15 (status = 200, description = "my filed reports", body = AppResponseData<Vec<ReportDto>>)
16 )
17)]
18pub async fn my_reports(
19 State(service): State<UserService>,
20 session: Session,
21) -> Result<AppResponseData<Vec<ReportDto>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let reports = service.my_reports(uid).await?;
24 Ok(AppResponseData::ok(reports))
25}
Added lib/user/src/handlers/my_spaces.rs View lib/user/src/handlers/my_spaces.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::profile::MySpaceDto;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/me/spaces",
13 tag = "user",
14 responses(
15 (status = 200, description = "my spaces", body = AppResponseData<Vec<MySpaceDto>>)
16 )
17)]
18pub async fn my_spaces(
19 State(service): State<UserService>,
20 session: Session,
21) -> Result<AppResponseData<Vec<MySpaceDto>>, ApiError> {
22 let uid = require_uid(&session)?;
23 let spaces = service.my_spaces(uid).await?;
24 Ok(AppResponseData::ok(spaces))
25}
Added lib/user/src/handlers/my_suspensions.rs View lib/user/src/handlers/my_suspensions.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Query, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::UserService;
7use crate::services::suspensions::{IncludeRevokedQuery, SuspensionDto};
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/me/suspensions",
13 tag = "user",
14 params(
15 IncludeRevokedQuery,
16 ),
17 responses(
18 (status = 200, description = "my suspensions", body = AppResponseData<Vec<SuspensionDto>>)
19 )
20)]
21pub async fn my_suspensions(
22 State(service): State<UserService>,
23 session: Session,
24 Query(query): Query<IncludeRevokedQuery>,
25) -> Result<AppResponseData<Vec<SuspensionDto>>, ApiError> {
26 let uid = require_uid(&session)?;
27 let list = service.my_suspensions(uid, query).await?;
28 Ok(AppResponseData::ok(list))
29}
Added lib/user/src/handlers/recent_mentions.rs View lib/user/src/handlers/recent_mentions.rs
@@ -0,0 +1,30 @@
1use axum::extract::{Query, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::notifications::RecentMentionDto;
7use crate::services::profile::ListLimitQuery;
8use crate::services::service::UserService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/me/recent-mentions",
14 tag = "user",
15 params(
16 ListLimitQuery,
17 ),
18 responses(
19 (status = 200, description = "recent mentions", body = AppResponseData<Vec<RecentMentionDto>>)
20 )
21)]
22pub async fn recent_mentions(
23 State(service): State<UserService>,
24 session: Session,
25 Query(query): Query<ListLimitQuery>,
26) -> Result<AppResponseData<Vec<RecentMentionDto>>, ApiError> {
27 let uid = require_uid(&session)?;
28 let mentions = service.recent_mentions(uid, query.limit).await?;
29 Ok(AppResponseData::ok(mentions))
30}
Added lib/user/src/handlers/revoke_all_sessions.rs View lib/user/src/handlers/revoke_all_sessions.rs
@@ -0,0 +1,25 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::UserService;
7use crate::services::sessions::CountResp;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/me/sessions",
13 tag = "user",
14 responses(
15 (status = 200, description = "all sessions revoked", body = AppResponseData<CountResp>)
16 )
17)]
18pub async fn revoke_all_sessions(
19 State(service): State<UserService>,
20 session: Session,
21) -> Result<AppResponseData<CountResp>, ApiError> {
22 let uid = require_uid(&session)?;
23 let resp = service.revoke_all_sessions(uid).await?;
24 Ok(AppResponseData::ok(resp))
25}
Added lib/user/src/handlers/revoke_session.rs View lib/user/src/handlers/revoke_session.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/me/sessions/{session_id}",
13 tag = "user",
14 params(
15 ("session_id" = Uuid, Path, description = "session id")
16 ),
17 responses(
18 (status = 200, description = "session revoked", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn revoke_session(
22 State(service): State<UserService>,
23 session: Session,
24 Path(session_id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.revoke_session(uid, session_id).await?;
28 Ok(AppResponseData::empty())
29}
Added lib/user/src/handlers/save_message.rs View lib/user/src/handlers/save_message.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::notifications::{SaveMessageCmd, SavedMessageDto};
8use crate::services::service::UserService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/me/messages/saved",
14 tag = "user",
15 request_body = SaveMessageCmd,
16 responses(
17 (status = 200, description = "save message", body = AppResponseData<SavedMessageDto>)
18 )
19)]
20pub async fn save_message(
21 State(service): State<UserService>,
22 session: Session,
23 Json(cmd): Json<SaveMessageCmd>,
24) -> Result<AppResponseData<SavedMessageDto>, ApiError> {
25 let uid = require_uid(&session)?;
26 let saved = service.save_message(uid, cmd).await?;
27 Ok(AppResponseData::ok(saved))
28}
Added lib/user/src/handlers/set_primary_email.rs View lib/user/src/handlers/set_primary_email.rs
@@ -0,0 +1,30 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::services::emails::EmailDto;
8use crate::services::service::UserService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 patch,
13 path = "/me/emails/{email_id}/primary",
14 tag = "user",
15 params(
16 ("email_id" = Uuid, Path, description = "email id")
17 ),
18 responses(
19 (status = 200, description = "set primary email", body = AppResponseData<EmailDto>)
20 )
21)]
22pub async fn set_primary_email(
23 State(service): State<UserService>,
24 session: Session,
25 Path(email_id): Path<Uuid>,
26) -> Result<AppResponseData<EmailDto>, ApiError> {
27 let uid = require_uid(&session)?;
28 let email = service.set_primary_email(uid, email_id).await?;
29 Ok(AppResponseData::ok(email))
30}
Added lib/user/src/handlers/unsave_message.rs View lib/user/src/handlers/unsave_message.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::services::service::UserService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/me/messages/saved/{message_id}",
13 tag = "user",
14 params(
15 ("message_id" = Uuid, Path, description = "message id")
16 ),
17 responses(
18 (status = 200, description = "unsave message", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn unsave_message(
22 State(service): State<UserService>,
23 session: Session,
24 Path(message_id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.unsave_message(uid, message_id).await?;
28 Ok(AppResponseData::empty())
29}
Added lib/user/src/handlers/upload_avatar.rs View lib/user/src/handlers/upload_avatar.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::avatar::{AvatarDto, UploadAvatarCmd};
8use crate::services::service::UserService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 put,
13 path = "/me/avatar",
14 tag = "user",
15 request_body = UploadAvatarCmd,
16 responses(
17 (status = 200, description = "avatar uploaded", body = AppResponseData<AvatarDto>)
18 )
19)]
20pub async fn upload_avatar(
21 State(service): State<UserService>,
22 session: Session,
23 Json(cmd): Json<UploadAvatarCmd>,
24) -> Result<AppResponseData<AvatarDto>, ApiError> {
25 let uid = require_uid(&session)?;
26 let avatar = service.upload_avatar(uid, cmd).await?;
27 Ok(AppResponseData::ok(avatar))
28}
Added lib/user/src/handlers/upsert_push_subscription.rs View lib/user/src/handlers/upsert_push_subscription.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::push_subscriptions::{PushSubscriptionDto, UpsertPushCmd};
8use crate::services::service::UserService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/me/push-subscriptions",
14 tag = "user",
15 request_body = UpsertPushCmd,
16 responses(
17 (status = 200, description = "upsert push subscription", body = AppResponseData<PushSubscriptionDto>)
18 )
19)]
20pub async fn upsert_push_subscription(
21 State(service): State<UserService>,
22 session: Session,
23 Json(cmd): Json<UpsertPushCmd>,
24) -> Result<AppResponseData<PushSubscriptionDto>, ApiError> {
25 let uid = require_uid(&session)?;
26 let sub = service.upsert_push_subscription(uid, cmd).await?;
27 Ok(AppResponseData::ok(sub))
28}
Added lib/user/src/services/avatar.rs View lib/user/src/services/avatar.rs
@@ -0,0 +1,184 @@
1use base64::Engine;
2use models::users::user;
3use sea_orm::{ActiveModelTrait, EntityTrait, Set};
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6use utoipa::ToSchema;
7use uuid::Uuid;
8
9use crate::errors::UserError;
10use crate::services::service::UserService;
11
12pub const MAX_AVATAR_BYTES: usize = 5 * 1024 * 1024;
13pub const AVATAR_PREFIX: &str = "avatars";
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
16#[serde(rename_all = "lowercase")]
17pub enum AvatarFormat {
18 Jpg,
19 Png,
20 Webp,
21 Gif,
22}
23
24impl AvatarFormat {
25 pub fn extension(self) -> &'static str {
26 match self {
27 Self::Jpg => "jpg",
28 Self::Png => "png",
29 Self::Webp => "webp",
30 Self::Gif => "gif",
31 }
32 }
33
34 pub fn content_type(self) -> &'static str {
35 match self {
36 Self::Jpg => "image/jpeg",
37 Self::Png => "image/png",
38 Self::Webp => "image/webp",
39 Self::Gif => "image/gif",
40 }
41 }
42
43 pub fn from_content_type(ct: &str) -> Option<Self> {
44 match ct.to_ascii_lowercase().as_str() {
45 "image/jpeg" | "image/jpg" => Some(Self::Jpg),
46 "image/png" => Some(Self::Png),
47 "image/webp" => Some(Self::Webp),
48 "image/gif" => Some(Self::Gif),
49 _ => None,
50 }
51 }
52
53 pub fn from_extension(ext: &str) -> Option<Self> {
54 match ext.to_ascii_lowercase().as_str() {
55 "jpg" | "jpeg" => Some(Self::Jpg),
56 "png" => Some(Self::Png),
57 "webp" => Some(Self::Webp),
58 "gif" => Some(Self::Gif),
59 _ => None,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
65pub struct AvatarDto {
66 pub uid: Uuid,
67 pub url: String,
68 pub format: AvatarFormat,
69 pub bytes: u64,
70 pub updated_at: OffsetDateTime,
71}
72
73#[derive(Debug, Clone, Deserialize, ToSchema)]
74pub struct UploadAvatarCmd {
75 pub content_type: String,
76 pub data_base64: String,
77}
78
79impl UserService {
80 pub async fn upload_avatar(&self, uid: Uuid, cmd: UploadAvatarCmd) -> Result<AvatarDto, UserError> {
81 let fmt = AvatarFormat::from_content_type(&cmd.content_type).ok_or_else(|| {
82 UserError::BadRequest(format!(
83 "unsupported content_type '{}' (allowed: image/jpeg, image/png, image/webp, image/gif)",
84 cmd.content_type
85 ))
86 })?;
87 let bytes = base64::engine::general_purpose::STANDARD
88 .decode(cmd.data_base64.as_bytes())
89 .map_err(|e| UserError::BadRequest(format!("invalid base64 payload: {e}")))?;
90 if bytes.is_empty() {
91 return Err(UserError::BadRequest("avatar payload is empty".into()));
92 }
93 if bytes.len() > MAX_AVATAR_BYTES {
94 return Err(UserError::BadRequest(format!(
95 "avatar exceeds max size of {MAX_AVATAR_BYTES} bytes"
96 )));
97 }
98
99 let key = format!("{AVATAR_PREFIX}/{uid}.{}", fmt.extension());
100 self.storage()
101 .client()
102 .put_with_content_type(&key, &bytes, fmt.content_type())
103 .await
104 .map_err(UserError::from)?;
105
106 let now = OffsetDateTime::now_utc();
107 let stored_url = format!("storage://{AVATAR_PREFIX}/{uid}.{}", fmt.extension());
108 let user = user::Entity::find_by_id(uid)
109 .one(self.db().read())
110 .await?
111 .ok_or(UserError::NotFound)?;
112 let mut active: user::ActiveModel = user.into();
113 active.avatar_url = Set(stored_url.clone());
114 active.updated_at = Set(now);
115 active.update(self.db().write()).await?;
116
117 let meta_key = format!("{AVATAR_PREFIX}/{uid}.meta.json");
118 let meta = serde_json::json!({
119 "uid": uid,
120 "format": fmt.extension(),
121 "bytes": bytes.len(),
122 "uploaded_at": now,
123 });
124 if let Ok(meta_bytes) = serde_json::to_vec(&meta) {
125 let _ = self
126 .storage()
127 .client()
128 .put_with_content_type(&meta_key, &meta_bytes, "application/json")
129 .await;
130 }
131
132 Ok(AvatarDto {
133 uid,
134 url: stored_url,
135 format: fmt,
136 bytes: bytes.len() as u64,
137 updated_at: now,
138 })
139 }
140
141 pub async fn delete_avatar(&self, uid: Uuid) -> Result<(), UserError> {
142 let user = user::Entity::find_by_id(uid)
143 .one(self.db().read())
144 .await?
145 .ok_or(UserError::NotFound)?;
146 let old_url = user.avatar_url.clone();
147 let now = OffsetDateTime::now_utc();
148 let mut active: user::ActiveModel = user.into();
149 active.avatar_url = Set(String::new());
150 active.updated_at = Set(now);
151 active.update(self.db().write()).await?;
152
153 if old_url.starts_with(&format!("storage://{AVATAR_PREFIX}/")) {
154 let rel = old_url
155 .trim_start_matches("storage://")
156 .trim_start_matches(AVATAR_PREFIX)
157 .trim_start_matches('/');
158 let key = format!("{AVATAR_PREFIX}/{rel}");
159 let _ = self.storage().client().delete(&key).await;
160 let meta_key = format!("{AVATAR_PREFIX}/{uid}.meta.json");
161 let _ = self.storage().client().delete(&meta_key).await;
162 }
163 Ok(())
164 }
165
166 pub async fn current_avatar(&self, uid: Uuid) -> Result<Option<AvatarDto>, UserError> {
167 let user = user::Entity::find_by_id(uid)
168 .one(self.db().read())
169 .await?
170 .ok_or(UserError::NotFound)?;
171 if user.avatar_url.is_empty() {
172 return Ok(None);
173 }
174 let ext = user.avatar_url.rsplit('.').next().unwrap_or_default();
175 let fmt = AvatarFormat::from_extension(ext).unwrap_or(AvatarFormat::Jpg);
176 Ok(Some(AvatarDto {
177 uid,
178 url: user.avatar_url,
179 format: fmt,
180 bytes: 0,
181 updated_at: user.updated_at,
182 }))
183 }
184}
Added lib/user/src/services/deletion.rs View lib/user/src/services/deletion.rs
@@ -0,0 +1,56 @@
1use models::users::user_deletion_requests::{self, UserDeletionRequestStatus};
2use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use utoipa::ToSchema;
6use uuid::Uuid;
7
8use crate::errors::UserError;
9use crate::services::service::UserService;
10
11#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
12pub struct DeletionDto {
13 pub id: Uuid,
14 pub reason: Option<String>,
15 pub status: String,
16 pub scheduled_for: Option<OffsetDateTime>,
17 pub created_at: OffsetDateTime,
18}
19
20fn status_str(status: UserDeletionRequestStatus) -> &'static str {
21 match status {
22 UserDeletionRequestStatus::Pending => "pending",
23 UserDeletionRequestStatus::Scheduled => "scheduled",
24 UserDeletionRequestStatus::Completed => "completed",
25 UserDeletionRequestStatus::Cancelled => "cancelled",
26 }
27}
28
29impl UserService {
30 pub async fn confirm_deletion(&self, uid: Uuid) -> Result<DeletionDto, UserError> {
31 let row = user_deletion_requests::Entity::find()
32 .filter(user_deletion_requests::Column::UserId.eq(uid))
33 .filter(user_deletion_requests::Column::Status.eq(UserDeletionRequestStatus::Pending))
34 .one(self.db().read())
35 .await?
36 .ok_or(UserError::NotFound)?;
37
38 let now = OffsetDateTime::now_utc();
39 let grace_days = self.config().user.deletion_grace_days;
40 let scheduled_for = now + time::Duration::days(grace_days as i64);
41
42 let mut am: user_deletion_requests::ActiveModel = row.into();
43 am.status = Set(UserDeletionRequestStatus::Scheduled);
44 am.scheduled_for = Set(Some(scheduled_for));
45 am.updated_at = Set(now);
46 let saved = am.update(self.db().write()).await?;
47
48 Ok(DeletionDto {
49 id: saved.id,
50 reason: saved.reason,
51 status: status_str(saved.status).to_string(),
52 scheduled_for: saved.scheduled_for,
53 created_at: saved.created_at,
54 })
55 }
56}
Added lib/user/src/services/emails.rs View lib/user/src/services/emails.rs
@@ -0,0 +1,145 @@
1use models::users::user_emails;
2use sea_orm::sea_query::Expr;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
5};
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8use utoipa::ToSchema;
9use uuid::Uuid;
10
11use crate::errors::UserError;
12use crate::services::service::UserService;
13
14pub const EMAIL_MAX_LEN: usize = 254;
15
16#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
17pub struct EmailDto {
18 pub id: Uuid,
19 pub email: String,
20 pub is_activated: bool,
21 pub is_primary: bool,
22 pub created_at: OffsetDateTime,
23 pub activated_at: Option<OffsetDateTime>,
24}
25
26impl From<user_emails::Model> for EmailDto {
27 fn from(m: user_emails::Model) -> Self {
28 Self {
29 id: m.id,
30 email: m.email,
31 is_activated: m.is_activated,
32 is_primary: m.is_primary,
33 created_at: m.created_at,
34 activated_at: m.activated_at,
35 }
36 }
37}
38
39#[derive(Debug, Clone, Deserialize, ToSchema)]
40pub struct AddEmailCmd {
41 pub email: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
45pub struct AddEmailResp {
46 pub email_id: Uuid,
47}
48
49impl UserService {
50 pub async fn list_emails(&self, uid: Uuid) -> Result<Vec<EmailDto>, UserError> {
51 let rows = user_emails::Entity::find()
52 .filter(user_emails::Column::UserId.eq(uid))
53 .filter(user_emails::Column::DeletedAt.is_null())
54 .order_by_desc(user_emails::Column::CreatedAt)
55 .all(self.db().read())
56 .await?;
57 Ok(rows.into_iter().map(EmailDto::from).collect())
58 }
59
60 pub async fn add_email(&self, uid: Uuid, cmd: AddEmailCmd) -> Result<AddEmailResp, UserError> {
61 let email = cmd.email.trim().to_lowercase();
62 if email.is_empty() || email.len() > EMAIL_MAX_LEN || !email.contains('@') {
63 return Err(UserError::BadRequest(format!(
64 "invalid email (length 1..={EMAIL_MAX_LEN})"
65 )));
66 }
67 let now = OffsetDateTime::now_utc();
68 let active = user_emails::ActiveModel {
69 id: Set(Uuid::now_v7()),
70 user_id: Set(uid),
71 email: Set(email.clone()),
72 lower_email: Set(email),
73 is_activated: Set(false),
74 is_primary: Set(false),
75 created_at: Set(now),
76 updated_at: Set(now),
77 activated_at: Set(None),
78 deleted_at: Set(None),
79 };
80 let saved = active.insert(self.db().write()).await?;
81 Ok(AddEmailResp {
82 email_id: saved.id,
83 })
84 }
85
86 pub async fn set_primary_email(&self, uid: Uuid, email_id: Uuid) -> Result<EmailDto, UserError> {
87 let txn = self.db().primary();
88 let updated = txn
89 .transaction::<_, EmailDto, UserError>(|tx| {
90 Box::pin(async move {
91 let target = user_emails::Entity::find_by_id(email_id)
92 .one(tx)
93 .await?
94 .ok_or(UserError::NotFound)?;
95 if target.user_id != uid {
96 return Err(UserError::Forbidden);
97 }
98 if !target.is_activated {
99 return Err(UserError::BadRequest("email not yet activated".into()));
100 }
101 if target.is_primary {
102 return Err(UserError::BadRequest("email already primary".into()));
103 }
104
105 let now = OffsetDateTime::now_utc();
106 user_emails::Entity::update_many()
107 .filter(user_emails::Column::UserId.eq(uid))
108 .filter(user_emails::Column::IsPrimary.eq(true))
109 .col_expr(user_emails::Column::IsPrimary, Expr::value(false))
110 .col_expr(user_emails::Column::UpdatedAt, Expr::value(now))
111 .exec(tx)
112 .await?;
113
114 let mut am: user_emails::ActiveModel = target.into();
115 am.is_primary = Set(true);
116 am.updated_at = Set(now);
117 let saved = am.update(tx).await?;
118 Ok(EmailDto::from(saved))
119 })
120 })
121 .await?;
122 Ok(updated)
123 }
124
125 pub async fn delete_email(&self, uid: Uuid, email_id: Uuid) -> Result<(), UserError> {
126 let row = user_emails::Entity::find_by_id(email_id)
127 .one(self.db().read())
128 .await?
129 .ok_or(UserError::NotFound)?;
130 if row.user_id != uid {
131 return Err(UserError::Forbidden);
132 }
133 if row.is_primary && row.is_activated {
134 return Err(UserError::BadRequest(
135 "cannot delete primary activated email".into(),
136 ));
137 }
138 let now = OffsetDateTime::now_utc();
139 let mut am: user_emails::ActiveModel = row.into();
140 am.deleted_at = Set(Some(now));
141 am.updated_at = Set(now);
142 am.update(self.db().write()).await?;
143 Ok(())
144 }
145}
Added lib/user/src/services/mod.rs View lib/user/src/services/mod.rs
@@ -0,0 +1,9 @@
1pub mod avatar;
2pub mod deletion;
3pub mod emails;
4pub mod notifications;
5pub mod profile;
6pub mod push_subscriptions;
7pub mod service;
8pub mod sessions;
9pub mod suspensions;
Added lib/user/src/services/notifications.rs View lib/user/src/services/notifications.rs
@@ -0,0 +1,205 @@
1use models::users::user_recent_mentions;
2use models::users::user_reports::{self, ReportStatus};
3use models::users::user_saved_messages;
4use models::users::user_scheduled_messages::{self, ScheduledMessageStatus};
5use sea_orm::{
6 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
7};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10use utoipa::ToSchema;
11use uuid::Uuid;
12
13use crate::errors::UserError;
14use crate::services::service::UserService;
15
16#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
17pub struct RecentMentionDto {
18 pub id: Uuid,
19 pub channel_id: Uuid,
20 pub message_id: Uuid,
21 pub created_at: OffsetDateTime,
22}
23
24impl From<user_recent_mentions::Model> for RecentMentionDto {
25 fn from(m: user_recent_mentions::Model) -> Self {
26 Self {
27 id: m.id,
28 channel_id: m.channel_id,
29 message_id: m.message_id,
30 created_at: m.created_at,
31 }
32 }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
36pub struct ReportDto {
37 pub id: Uuid,
38 pub reported_uid: Uuid,
39 pub reason: String,
40 pub evidence: Option<String>,
41 pub status: ReportStatus,
42 pub resolution_note: Option<String>,
43 pub created_at: OffsetDateTime,
44 pub updated_at: OffsetDateTime,
45}
46
47impl From<user_reports::Model> for ReportDto {
48 fn from(m: user_reports::Model) -> Self {
49 Self {
50 id: m.id,
51 reported_uid: m.reported_uid,
52 reason: m.reason,
53 evidence: m.evidence,
54 status: m.status,
55 resolution_note: m.resolution_note,
56 created_at: m.created_at,
57 updated_at: m.updated_at,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
63pub struct SavedMessageDto {
64 pub id: Uuid,
65 pub channel_id: Uuid,
66 pub message_id: Uuid,
67 pub saved_at: OffsetDateTime,
68}
69
70impl From<user_saved_messages::Model> for SavedMessageDto {
71 fn from(m: user_saved_messages::Model) -> Self {
72 Self {
73 id: m.id,
74 channel_id: m.channel_id,
75 message_id: m.message_id,
76 saved_at: m.saved_at,
77 }
78 }
79}
80
81#[derive(Debug, Clone, Deserialize, ToSchema)]
82pub struct SaveMessageCmd {
83 pub channel_id: Uuid,
84 pub message_id: Uuid,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
88pub struct ScheduledMessageDto {
89 pub id: Uuid,
90 pub target_channel_id: Uuid,
91 pub payload: serde_json::Value,
92 pub scheduled_at: OffsetDateTime,
93 pub status: ScheduledMessageStatus,
94 pub created_at: OffsetDateTime,
95}
96
97impl From<user_scheduled_messages::Model> for ScheduledMessageDto {
98 fn from(m: user_scheduled_messages::Model) -> Self {
99 Self {
100 id: m.id,
101 target_channel_id: m.target_channel_id,
102 payload: m.payload,
103 scheduled_at: m.scheduled_at,
104 status: m.status,
105 created_at: m.created_at,
106 }
107 }
108}
109
110impl UserService {
111 pub async fn recent_mentions(
112 &self,
113 uid: Uuid,
114 limit: Option<u64>,
115 ) -> Result<Vec<RecentMentionDto>, UserError> {
116 let lim = limit.unwrap_or(50).clamp(1, 50);
117 let rows = user_recent_mentions::Entity::find()
118 .filter(user_recent_mentions::Column::UserId.eq(uid))
119 .order_by_desc(user_recent_mentions::Column::CreatedAt)
120 .limit(Some(lim))
121 .all(self.db().read())
122 .await?;
123 Ok(rows.into_iter().map(RecentMentionDto::from).collect())
124 }
125
126 pub async fn my_reports(&self, uid: Uuid) -> Result<Vec<ReportDto>, UserError> {
127 let rows = user_reports::Entity::find()
128 .filter(user_reports::Column::ReporterUid.eq(Some(uid)))
129 .order_by_desc(user_reports::Column::CreatedAt)
130 .all(self.db().read())
131 .await?;
132 Ok(rows.into_iter().map(ReportDto::from).collect())
133 }
134
135 pub async fn list_saved_messages(&self, uid: Uuid) -> Result<Vec<SavedMessageDto>, UserError> {
136 let rows = user_saved_messages::Entity::find()
137 .filter(user_saved_messages::Column::UserId.eq(uid))
138 .order_by_desc(user_saved_messages::Column::SavedAt)
139 .all(self.db().read())
140 .await?;
141 Ok(rows.into_iter().map(SavedMessageDto::from).collect())
142 }
143
144 pub async fn save_message(
145 &self,
146 uid: Uuid,
147 cmd: SaveMessageCmd,
148 ) -> Result<SavedMessageDto, UserError> {
149 let existing = user_saved_messages::Entity::find()
150 .filter(user_saved_messages::Column::UserId.eq(uid))
151 .filter(user_saved_messages::Column::ChannelId.eq(cmd.channel_id))
152 .filter(user_saved_messages::Column::MessageId.eq(cmd.message_id))
153 .one(self.db().read())
154 .await?;
155 if let Some(row) = existing {
156 return Ok(SavedMessageDto::from(row));
157 }
158
159 let now = OffsetDateTime::now_utc();
160 let active = user_saved_messages::ActiveModel {
161 id: Set(Uuid::now_v7()),
162 user_id: Set(uid),
163 channel_id: Set(cmd.channel_id),
164 message_id: Set(cmd.message_id),
165 saved_at: Set(now),
166 };
167 let saved = active.insert(self.db().write()).await?;
168 Ok(SavedMessageDto::from(saved))
169 }
170
171 pub async fn unsave_message(&self, uid: Uuid, message_id: Uuid) -> Result<(), UserError> {
172 user_saved_messages::Entity::delete_many()
173 .filter(user_saved_messages::Column::UserId.eq(uid))
174 .filter(user_saved_messages::Column::MessageId.eq(message_id))
175 .exec(self.db().write())
176 .await?;
177 Ok(())
178 }
179
180 pub async fn list_scheduled_messages(
181 &self,
182 uid: Uuid,
183 ) -> Result<Vec<ScheduledMessageDto>, UserError> {
184 let rows = user_scheduled_messages::Entity::find()
185 .filter(user_scheduled_messages::Column::UserId.eq(uid))
186 .order_by_asc(user_scheduled_messages::Column::ScheduledAt)
187 .all(self.db().read())
188 .await?;
189 Ok(rows.into_iter().map(ScheduledMessageDto::from).collect())
190 }
191
192 pub async fn cancel_scheduled_message(&self, uid: Uuid, id: Uuid) -> Result<(), UserError> {
193 let row = user_scheduled_messages::Entity::find_by_id(id)
194 .one(self.db().read())
195 .await?
196 .ok_or(UserError::NotFound)?;
197 if row.user_id != uid {
198 return Err(UserError::Forbidden);
199 }
200 let mut am: user_scheduled_messages::ActiveModel = row.into();
201 am.status = Set(ScheduledMessageStatus::Cancelled);
202 am.update(self.db().write()).await?;
203 Ok(())
204 }
205}
Added lib/user/src/services/profile.rs View lib/user/src/services/profile.rs
@@ -0,0 +1,323 @@
1use models::space::{space_group_permissions, space_member_groups, space_members, spaces};
2use models::users::user::{self, ProfileVisibility};
3use models::users::{user_favorite_memes, user_profiles};
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
6};
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use utoipa::{IntoParams, ToSchema};
10use uuid::Uuid;
11
12use crate::errors::UserError;
13use crate::services::service::UserService;
14
15pub const OWNER_PERMISSIONS: [&str; 9] = [
16 "view_space",
17 "read",
18 "message",
19 "manage_members",
20 "manage_roles",
21 "manage_invites",
22 "manage_bans",
23 "manage_channels",
24 "manage_overwrites",
25];
26
27pub const MAX_MEME_NAME: usize = 64;
28pub const MAX_MEME_URL: usize = 2048;
29pub const MAX_MEME_CONTENT_TYPE: usize = 128;
30pub const DEFAULT_LIST_LIMIT: u64 = 50;
31
32#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
33pub struct SpaceDto {
34 pub id: Uuid,
35 pub name: String,
36 pub owner_uid: Uuid,
37 pub avatar_url: Option<String>,
38 pub is_private: bool,
39 pub nums_members: i64,
40 pub created_at: OffsetDateTime,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
44pub struct MySpaceDto {
45 pub space: SpaceDto,
46 pub permissions: Vec<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
50pub struct MemeDto {
51 pub id: Uuid,
52 pub name: String,
53 pub content_type: String,
54 pub url: String,
55 pub size: i64,
56 pub width: Option<i32>,
57 pub height: Option<i32>,
58 pub created_at: OffsetDateTime,
59}
60
61impl From<user_favorite_memes::Model> for MemeDto {
62 fn from(m: user_favorite_memes::Model) -> Self {
63 Self {
64 id: m.id,
65 name: m.name,
66 content_type: m.content_type,
67 url: m.url,
68 size: m.size,
69 width: m.width,
70 height: m.height,
71 created_at: m.created_at,
72 }
73 }
74}
75
76#[derive(Debug, Clone, Deserialize, ToSchema)]
77pub struct UpsertMemeCmd {
78 pub name: String,
79 pub content_type: String,
80 pub url: String,
81 pub size: i64,
82 pub width: Option<i32>,
83 pub height: Option<i32>,
84}
85
86#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
87pub struct ListLimitQuery {
88 pub limit: Option<u64>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
92pub struct ProfileDetailDto {
93 pub uid: Uuid,
94 pub username: String,
95 pub display_name: String,
96 pub avatar_url: String,
97 pub visibility: ProfileVisibility,
98 pub real_name: Option<String>,
99 pub bio: Option<String>,
100 pub pronouns: Option<String>,
101 pub website: Option<String>,
102 pub location: Option<String>,
103 pub company: Option<String>,
104 pub banner_url: Option<String>,
105 pub accent_color: Option<String>,
106 pub show_email: bool,
107 pub show_location: bool,
108 pub show_real_name: bool,
109 pub show_activity: bool,
110 pub updated_at: OffsetDateTime,
111}
112
113impl UserService {
114 pub async fn my_spaces(&self, uid: Uuid) -> Result<Vec<MySpaceDto>, UserError> {
115 let members = space_members::Entity::find()
116 .filter(space_members::Column::UserId.eq(uid))
117 .filter(space_members::Column::Status.eq(space_members::MemberStatus::Active))
118 .all(self.db().read())
119 .await?;
120 let space_ids: Vec<Uuid> = members.into_iter().map(|m| m.space_id).collect();
121 if space_ids.is_empty() {
122 return Ok(Vec::new());
123 }
124
125 let space_rows = spaces::Entity::find()
126 .filter(spaces::Column::Id.is_in(space_ids))
127 .order_by_asc(spaces::Column::LowerName)
128 .all(self.db().read())
129 .await?;
130
131 let mut out = Vec::with_capacity(space_rows.len());
132 for space in space_rows {
133 let perms = if space.owner_id == uid {
134 OWNER_PERMISSIONS.iter().map(|s| s.to_string()).collect()
135 } else {
136 let member_groups = space_member_groups::Entity::find()
137 .filter(space_member_groups::Column::SpaceId.eq(space.id))
138 .filter(space_member_groups::Column::UserId.eq(uid))
139 .all(self.db().read())
140 .await?;
141 let group_ids: Vec<Uuid> = member_groups.into_iter().map(|mg| mg.group_id).collect();
142 let mut granted = if group_ids.is_empty() {
143 Vec::new()
144 } else {
145 space_group_permissions::Entity::find()
146 .filter(space_group_permissions::Column::GroupId.is_in(group_ids))
147 .all(self.db().read())
148 .await?
149 .into_iter()
150 .map(|p| p.permission)
151 .collect()
152 };
153 granted.sort();
154 granted.dedup();
155 granted
156 };
157
158 let space_dto = SpaceDto {
159 id: space.id,
160 name: space.name,
161 owner_uid: space.owner_id,
162 avatar_url: space.avatar_url,
163 is_private: space.is_private,
164 nums_members: space.nums_members,
165 created_at: space.created_at,
166 };
167 out.push(MySpaceDto {
168 space: space_dto,
169 permissions: perms,
170 });
171 }
172 Ok(out)
173 }
174
175 pub async fn list_memes(&self, uid: Uuid, limit: Option<u64>) -> Result<Vec<MemeDto>, UserError> {
176 let lim = limit.unwrap_or(DEFAULT_LIST_LIMIT).clamp(1, 50);
177 let rows = user_favorite_memes::Entity::find()
178 .filter(user_favorite_memes::Column::UserId.eq(uid))
179 .order_by_desc(user_favorite_memes::Column::CreatedAt)
180 .limit(Some(lim))
181 .all(self.db().read())
182 .await?;
183 Ok(rows.into_iter().map(MemeDto::from).collect())
184 }
185
186 pub async fn upsert_meme(&self, uid: Uuid, cmd: UpsertMemeCmd) -> Result<MemeDto, UserError> {
187 if cmd.name.is_empty() || cmd.name.len() > MAX_MEME_NAME {
188 return Err(UserError::BadRequest(format!(
189 "meme name length must be 1..={MAX_MEME_NAME}"
190 )));
191 }
192 if cmd.content_type.len() > MAX_MEME_CONTENT_TYPE {
193 return Err(UserError::BadRequest(format!(
194 "content_type length must be <={MAX_MEME_CONTENT_TYPE}"
195 )));
196 }
197 if cmd.url.len() > MAX_MEME_URL {
198 return Err(UserError::BadRequest(format!(
199 "url length must be <={MAX_MEME_URL}"
200 )));
201 }
202
203 let existing = user_favorite_memes::Entity::find()
204 .filter(user_favorite_memes::Column::UserId.eq(uid))
205 .filter(user_favorite_memes::Column::Name.eq(&cmd.name))
206 .one(self.db().read())
207 .await?;
208
209 let now = OffsetDateTime::now_utc();
210 let model = match existing {
211 Some(row) => {
212 let mut active: user_favorite_memes::ActiveModel = row.into();
213 active.content_type = Set(cmd.content_type);
214 active.url = Set(cmd.url);
215 active.size = Set(cmd.size);
216 active.width = Set(cmd.width);
217 active.height = Set(cmd.height);
218 active.update(self.db().write()).await?
219 }
220 None => {
221 let active = user_favorite_memes::ActiveModel {
222 id: Set(Uuid::now_v7()),
223 user_id: Set(uid),
224 name: Set(cmd.name),
225 content_type: Set(cmd.content_type),
226 url: Set(cmd.url),
227 size: Set(cmd.size),
228 width: Set(cmd.width),
229 height: Set(cmd.height),
230 created_at: Set(now),
231 };
232 active.insert(self.db().write()).await?
233 }
234 };
235
236 Ok(MemeDto::from(model))
237 }
238
239 pub async fn delete_meme(&self, uid: Uuid, meme_id: Uuid) -> Result<(), UserError> {
240 let row = user_favorite_memes::Entity::find_by_id(meme_id)
241 .one(self.db().read())
242 .await?
243 .ok_or(UserError::NotFound)?;
244 if row.user_id != uid {
245 return Err(UserError::Forbidden);
246 }
247 user_favorite_memes::Entity::delete_by_id(meme_id)
248 .exec(self.db().write())
249 .await?;
250 Ok(())
251 }
252
253 pub async fn profile_detail(
254 &self,
255 viewer: Option<Uuid>,
256 target_uid: Uuid,
257 ) -> Result<ProfileDetailDto, UserError> {
258 let user = user::Entity::find_by_id(target_uid)
259 .one(self.db().read())
260 .await?
261 .ok_or(UserError::NotFound)?;
262
263 let is_owner = viewer == Some(target_uid);
264 let can_view = match user.visibility {
265 ProfileVisibility::Public => true,
266 ProfileVisibility::RegisteredOnly | ProfileVisibility::MembersOnly => viewer.is_some(),
267 ProfileVisibility::Private => is_owner,
268 };
269 if !can_view {
270 return Err(UserError::Forbidden);
271 }
272
273 let profile = user_profiles::Entity::find_by_id(target_uid)
274 .one(self.db().read())
275 .await?;
276
277 if let Some(p) = profile {
278 let emit_real_name = is_owner || p.show_real_name;
279 let emit_location = is_owner || p.show_location;
280 Ok(ProfileDetailDto {
281 uid: user.uid,
282 username: user.username,
283 display_name: user.display_name,
284 avatar_url: user.avatar_url,
285 visibility: user.visibility,
286 real_name: if emit_real_name { p.real_name } else { None },
287 bio: p.bio,
288 pronouns: p.pronouns,
289 website: p.website,
290 location: if emit_location { p.location } else { None },
291 company: p.company,
292 banner_url: p.banner_url,
293 accent_color: p.accent_color,
294 show_email: p.show_email,
295 show_location: p.show_location,
296 show_real_name: p.show_real_name,
297 show_activity: p.show_activity,
298 updated_at: p.updated_at,
299 })
300 } else {
301 Ok(ProfileDetailDto {
302 uid: user.uid,
303 username: user.username,
304 display_name: user.display_name,
305 avatar_url: user.avatar_url,
306 visibility: user.visibility,
307 real_name: None,
308 bio: None,
309 pronouns: None,
310 website: None,
311 location: None,
312 company: None,
313 banner_url: None,
314 accent_color: None,
315 show_email: false,
316 show_location: false,
317 show_real_name: false,
318 show_activity: false,
319 updated_at: user.updated_at,
320 })
321 }
322 }
323}
Added lib/user/src/services/push_subscriptions.rs View lib/user/src/services/push_subscriptions.rs
@@ -0,0 +1,119 @@
1use models::users::user_push_subscriptions::{self, PushSubscriptionPlatform};
2use sea_orm::{
3 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set,
4};
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use utoipa::ToSchema;
8use uuid::Uuid;
9
10use crate::errors::UserError;
11use crate::services::service::UserService;
12
13#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
14pub struct PushSubscriptionDto {
15 pub id: Uuid,
16 pub endpoint: String,
17 pub platform: PushSubscriptionPlatform,
18 pub user_agent: Option<String>,
19 pub created_at: OffsetDateTime,
20 pub updated_at: Option<OffsetDateTime>,
21}
22
23impl From<user_push_subscriptions::Model> for PushSubscriptionDto {
24 fn from(m: user_push_subscriptions::Model) -> Self {
25 Self {
26 id: m.id,
27 endpoint: m.endpoint,
28 platform: m.platform,
29 user_agent: m.user_agent,
30 created_at: m.created_at,
31 updated_at: m.updated_at,
32 }
33 }
34}
35
36#[derive(Debug, Clone, Deserialize, ToSchema)]
37pub struct UpsertPushCmd {
38 pub endpoint: String,
39 pub p256dh_key: String,
40 pub auth_key: String,
41 pub platform: PushSubscriptionPlatform,
42 pub user_agent: Option<String>,
43}
44
45impl UserService {
46 pub async fn list_push_subscriptions(
47 &self,
48 uid: Uuid,
49 ) -> Result<Vec<PushSubscriptionDto>, UserError> {
50 let rows = user_push_subscriptions::Entity::find()
51 .filter(user_push_subscriptions::Column::UserId.eq(uid))
52 .order_by_desc(user_push_subscriptions::Column::CreatedAt)
53 .all(self.db().read())
54 .await?;
55 Ok(rows.into_iter().map(PushSubscriptionDto::from).collect())
56 }
57
58 pub async fn upsert_push_subscription(
59 &self,
60 uid: Uuid,
61 cmd: UpsertPushCmd,
62 ) -> Result<PushSubscriptionDto, UserError> {
63 if cmd.endpoint.is_empty() || cmd.endpoint.len() > 2048 {
64 return Err(UserError::BadRequest("endpoint invalid".into()));
65 }
66 let existing = user_push_subscriptions::Entity::find()
67 .filter(user_push_subscriptions::Column::UserId.eq(uid))
68 .filter(user_push_subscriptions::Column::Endpoint.eq(&cmd.endpoint))
69 .one(self.db().read())
70 .await?;
71
72 let now = OffsetDateTime::now_utc();
73 let model = match existing {
74 Some(row) => {
75 let mut am: user_push_subscriptions::ActiveModel = row.into();
76 am.p256dh_key = Set(cmd.p256dh_key);
77 am.auth_key = Set(cmd.auth_key);
78 am.platform = Set(cmd.platform);
79 am.user_agent = Set(cmd.user_agent);
80 am.updated_at = Set(Some(now));
81 am.update(self.db().write()).await?
82 }
83 None => {
84 let active = user_push_subscriptions::ActiveModel {
85 id: Set(Uuid::now_v7()),
86 user_id: Set(uid),
87 endpoint: Set(cmd.endpoint),
88 p256dh_key: Set(cmd.p256dh_key),
89 auth_key: Set(cmd.auth_key),
90 platform: Set(cmd.platform),
91 user_agent: Set(cmd.user_agent),
92 created_at: Set(now),
93 updated_at: Set(Some(now)),
94 };
95 active.insert(self.db().write()).await?
96 }
97 };
98
99 Ok(PushSubscriptionDto::from(model))
100 }
101
102 pub async fn delete_push_subscription(
103 &self,
104 uid: Uuid,
105 push_id: Uuid,
106 ) -> Result<(), UserError> {
107 let row = user_push_subscriptions::Entity::find_by_id(push_id)
108 .one(self.db().read())
109 .await?
110 .ok_or(UserError::NotFound)?;
111 if row.user_id != uid {
112 return Err(UserError::Forbidden);
113 }
114 user_push_subscriptions::Entity::delete_by_id(push_id)
115 .exec(self.db().write())
116 .await?;
117 Ok(())
118 }
119}
Added lib/user/src/services/service.rs View lib/user/src/services/service.rs
@@ -0,0 +1,27 @@
1use std::sync::Arc;
2
3use config::AppConfig;
4use export::{AppDatabase, AppState, AppStorage};
5
6#[derive(Clone)]
7pub struct UserService {
8 pub state: AppState,
9}
10
11impl UserService {
12 pub fn new(state: AppState) -> Self {
13 Self { state }
14 }
15
16 pub fn config(&self) -> &AppConfig {
17 &self.state.config
18 }
19
20 pub fn db(&self) -> &Arc<AppDatabase> {
21 &self.state.database
22 }
23
24 pub fn storage(&self) -> &Arc<AppStorage> {
25 &self.state.storage
26 }
27}
Added lib/user/src/services/sessions.rs View lib/user/src/services/sessions.rs
@@ -0,0 +1,108 @@
1use models::users::user_sessions::{self, SessionKind};
2use sea_orm::sea_query::Expr;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set,
5};
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8use utoipa::{IntoParams, ToSchema};
9use uuid::Uuid;
10
11use crate::errors::UserError;
12use crate::services::service::UserService;
13
14#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
15pub struct SessionDto {
16 pub id: Uuid,
17 pub kind: SessionKind,
18 pub device_id: Option<Uuid>,
19 pub user_agent_hash: Option<String>,
20 pub client_ip_hash: Option<String>,
21 pub last_seen_at: OffsetDateTime,
22 pub created_at: OffsetDateTime,
23 pub expires_at: OffsetDateTime,
24 pub is_revoked: bool,
25 pub revoked_at: Option<OffsetDateTime>,
26}
27
28impl From<user_sessions::Model> for SessionDto {
29 fn from(m: user_sessions::Model) -> Self {
30 Self {
31 id: m.id,
32 kind: m.kind,
33 device_id: m.device_id,
34 user_agent_hash: m.user_agent_hash,
35 client_ip_hash: m.client_ip_hash,
36 last_seen_at: m.last_seen_at,
37 created_at: m.created_at,
38 expires_at: m.expires_at,
39 is_revoked: m.is_revoked,
40 revoked_at: m.revoked_at,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
46pub struct ListSessionsQuery {
47 pub kind: Option<SessionKind>,
48 pub include_revoked: Option<bool>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
52pub struct CountResp {
53 pub count: i64,
54}
55
56impl UserService {
57 pub async fn list_sessions(
58 &self,
59 uid: Uuid,
60 q: ListSessionsQuery,
61 ) -> Result<Vec<SessionDto>, UserError> {
62 let mut sel = user_sessions::Entity::find()
63 .filter(user_sessions::Column::UserId.eq(uid))
64 .order_by_desc(user_sessions::Column::CreatedAt);
65
66 if let Some(kind) = q.kind {
67 sel = sel.filter(user_sessions::Column::Kind.eq(kind));
68 }
69 if q.include_revoked != Some(true) {
70 sel = sel.filter(user_sessions::Column::IsRevoked.eq(false));
71 }
72
73 let rows = sel.all(self.db().read()).await?;
74 Ok(rows.into_iter().map(SessionDto::from).collect())
75 }
76
77 pub async fn revoke_session(&self, uid: Uuid, session_id: Uuid) -> Result<(), UserError> {
78 let row = user_sessions::Entity::find_by_id(session_id)
79 .one(self.db().read())
80 .await?
81 .ok_or(UserError::NotFound)?;
82 if row.user_id != uid {
83 return Err(UserError::Forbidden);
84 }
85 let now = OffsetDateTime::now_utc();
86 let mut am: user_sessions::ActiveModel = row.into();
87 am.is_revoked = Set(true);
88 am.revoked_at = Set(Some(now));
89 am.updated_at = Set(now);
90 am.update(self.db().write()).await?;
91 Ok(())
92 }
93
94 pub async fn revoke_all_sessions(&self, uid: Uuid) -> Result<CountResp, UserError> {
95 let now = OffsetDateTime::now_utc();
96 let result = user_sessions::Entity::update_many()
97 .filter(user_sessions::Column::UserId.eq(uid))
98 .filter(user_sessions::Column::IsRevoked.eq(false))
99 .col_expr(user_sessions::Column::IsRevoked, Expr::value(true))
100 .col_expr(user_sessions::Column::RevokedAt, Expr::value(Some(now)))
101 .col_expr(user_sessions::Column::UpdatedAt, Expr::value(now))
102 .exec(self.db().write())
103 .await?;
104 Ok(CountResp {
105 count: result.rows_affected as i64,
106 })
107 }
108}
Added lib/user/src/services/suspensions.rs View lib/user/src/services/suspensions.rs
@@ -0,0 +1,58 @@
1use models::users::user_suspensions::{self, SuspensionType};
2use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use utoipa::{IntoParams, ToSchema};
6use uuid::Uuid;
7
8use crate::errors::UserError;
9use crate::services::service::UserService;
10
11#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
12pub struct SuspensionDto {
13 pub id: Uuid,
14 pub suspension_type: SuspensionType,
15 pub reason: Option<String>,
16 pub starts_at: OffsetDateTime,
17 pub ends_at: Option<OffsetDateTime>,
18 pub revoked_at: Option<OffsetDateTime>,
19 pub created_at: OffsetDateTime,
20}
21
22impl From<user_suspensions::Model> for SuspensionDto {
23 fn from(m: user_suspensions::Model) -> Self {
24 Self {
25 id: m.id,
26 suspension_type: m.suspension_type,
27 reason: m.reason,
28 starts_at: m.starts_at,
29 ends_at: m.ends_at,
30 revoked_at: m.revoked_at,
31 created_at: m.created_at,
32 }
33 }
34}
35
36#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
37pub struct IncludeRevokedQuery {
38 pub include_revoked: Option<bool>,
39}
40
41impl UserService {
42 pub async fn my_suspensions(
43 &self,
44 uid: Uuid,
45 q: IncludeRevokedQuery,
46 ) -> Result<Vec<SuspensionDto>, UserError> {
47 let mut sel = user_suspensions::Entity::find()
48 .filter(user_suspensions::Column::UserId.eq(uid))
49 .order_by_desc(user_suspensions::Column::CreatedAt);
50
51 if q.include_revoked != Some(true) {
52 sel = sel.filter(user_suspensions::Column::RevokedAt.is_null());
53 }
54
55 let rows = sel.all(self.db().read()).await?;
56 Ok(rows.into_iter().map(SuspensionDto::from).collect())
57 }
58}
Added lib/user/src/share/guard.rs View lib/user/src/share/guard.rs
@@ -0,0 +1,45 @@
1use axum::extract::{Request, State};
2use axum::middleware::Next;
3use axum::response::{IntoResponse, Response};
4use models::users::user;
5use models::users::user_sessions;
6use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
7use session::{Session, SessionUser, SessionUserMut};
8use time::OffsetDateTime;
9
10use crate::errors::{ApiError, UserError};
11use crate::services::service::UserService;
12use crate::share::session::{pwd_version_matches, session_token_key};
13
14pub async fn user_guard(
15 State(service): State<UserService>,
16 session: Session,
17 request: Request,
18 next: Next,
19) -> Response {
20 let Some(uid) = session.uid() else {
21 return ApiError::from(UserError::Unauthorized).into_response();
22 };
23 let Some(token_hash) = session.get::<String>(&session_token_key(uid)) else {
24 return ApiError::from(UserError::Unauthorized).into_response();
25 };
26 let outcome = user_sessions::Entity::find()
27 .filter(user_sessions::Column::TokenHash.eq(&token_hash))
28 .find_also_related(user::Entity)
29 .one(service.db().read())
30 .await;
31 let authorized = match outcome {
32 Ok(Some((row, Some(current)))) => {
33 !row.is_revoked
34 && row.expires_at > OffsetDateTime::now_utc()
35 && pwd_version_matches(&session, current.passwd_version)
36 }
37 Ok(_) => false,
38 Err(error) => return ApiError::from(UserError::from(error)).into_response(),
39 };
40 if !authorized {
41 session.logout_user(uid);
42 return ApiError::from(UserError::Unauthorized).into_response();
43 }
44 next.run(request).await
45}
Added lib/user/src/share/mod.rs View lib/user/src/share/mod.rs
@@ -0,0 +1,2 @@
1pub mod guard;
2pub mod session;
Added lib/user/src/share/session.rs View lib/user/src/share/session.rs
@@ -0,0 +1,26 @@
1use session::{Session, SessionUser};
2use uuid::Uuid;
3
4use crate::errors::{ApiError, UserError};
5
6pub const PWD_VERSION_KEY: &str = "auth:pwd_version";
7
8pub fn session_token_key(uid: Uuid) -> String {
9 format!("auth:token_hash:{uid}")
10}
11
12pub fn require_uid(session: &Session) -> Result<Uuid, Box<ApiError>> {
13 match session.uid() {
14 Some(uid) => Ok(uid),
15 None => Err(Box::new(ApiError::from(UserError::Unauthorized))),
16 }
17}
18
19pub fn pwd_version_matches(session: &Session, current: Option<i64>) -> bool {
20 let stored = session.get::<i64>(PWD_VERSION_KEY);
21 match (stored, current) {
22 (Some(stored_ver), Some(curr_ver)) => stored_ver == curr_ver,
23 (None, None) => true,
24 _ => false,
25 }
26}
Added lib/user/src/errors.rs View lib/user/src/errors.rs
@@ -0,0 +1,175 @@
1use std::fmt;
2
3use axum::extract::rejection::{PathRejection, QueryRejection};
4use axum::http::StatusCode;
5use axum::response::{IntoResponse, Response};
6use axum::Json;
7use export::rest::{AppResponseData, NoData};
8use utoipa::IntoResponses;
9
10#[derive(Debug)]
11pub enum UserError {
12 BadRequest(String),
13 Unauthorized,
14 Forbidden,
15 NotFound,
16 Conflict(String),
17 Internal(String),
18}
19
20impl fmt::Display for UserError {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 match self {
23 Self::BadRequest(message) => write!(f, "{message}"),
24 Self::Unauthorized => write!(f, "unauthorized"),
25 Self::Forbidden => write!(f, "forbidden"),
26 Self::NotFound => write!(f, "not found"),
27 Self::Conflict(message) => write!(f, "{message}"),
28 Self::Internal(message) => write!(f, "{message}"),
29 }
30 }
31}
32
33impl std::error::Error for UserError {}
34
35impl From<sea_orm::DbErr> for UserError {
36 fn from(error: sea_orm::DbErr) -> Self {
37 if is_unique_violation(&error) {
38 return Self::Conflict("resource already exists".to_string());
39 }
40 Self::Internal(format!("database error: {error}"))
41 }
42}
43
44impl From<sea_orm::TransactionError<sea_orm::DbErr>> for UserError {
45 fn from(error: sea_orm::TransactionError<sea_orm::DbErr>) -> Self {
46 match &error {
47 sea_orm::TransactionError::Transaction(db_err) if is_unique_violation(db_err) => {
48 Self::Conflict("resource already exists".to_string())
49 }
50 _ => Self::Internal(format!("database transaction error: {error}")),
51 }
52 }
53}
54
55impl From<sea_orm::TransactionError<UserError>> for UserError {
56 fn from(error: sea_orm::TransactionError<UserError>) -> Self {
57 match error {
58 sea_orm::TransactionError::Connection(db_err) => Self::from(db_err),
59 sea_orm::TransactionError::Transaction(user_err) => user_err,
60 }
61 }
62}
63
64fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
65 let msg = err.to_string().to_lowercase();
66 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
67}
68
69impl From<storage::StorageError> for UserError {
70 fn from(error: storage::StorageError) -> Self {
71 Self::Internal(format!("storage error: {error}"))
72 }
73}
74
75impl From<export::ExportError> for UserError {
76 fn from(error: export::ExportError) -> Self {
77 Self::Internal(format!("infrastructure error: {error}"))
78 }
79}
80
81impl From<serde_json::Error> for UserError {
82 fn from(error: serde_json::Error) -> Self {
83 Self::BadRequest(format!("invalid payload: {error}"))
84 }
85}
86
87#[derive(Debug, IntoResponses)]
88pub enum ApiError {
89 #[response(status = 400, description = "request failed validation")]
90 BadRequest(#[to_schema] AppResponseData<NoData>),
91 #[response(status = 401, description = "authentication required")]
92 Unauthorized(#[to_schema] AppResponseData<NoData>),
93 #[response(status = 403, description = "operation not allowed")]
94 Forbidden(#[to_schema] AppResponseData<NoData>),
95 #[response(status = 404, description = "resource not found")]
96 NotFound(#[to_schema] AppResponseData<NoData>),
97 #[response(status = 409, description = "resource conflict")]
98 Conflict(#[to_schema] AppResponseData<NoData>),
99 #[response(status = 500, description = "internal server error")]
100 Internal(#[to_schema] AppResponseData<NoData>),
101}
102
103impl ApiError {
104 fn payload(code: i32, message: &'static str) -> AppResponseData<NoData> {
105 AppResponseData::err(code, message)
106 }
107
108 fn decompose(self) -> (u16, AppResponseData<NoData>) {
109 match self {
110 Self::BadRequest(envelope) => (400, envelope),
111 Self::Unauthorized(envelope) => (401, envelope),
112 Self::Forbidden(envelope) => (403, envelope),
113 Self::NotFound(envelope) => (404, envelope),
114 Self::Conflict(envelope) => (409, envelope),
115 Self::Internal(envelope) => (500, envelope),
116 }
117 }
118}
119
120impl IntoResponse for ApiError {
121 fn into_response(self) -> Response {
122 let (code, envelope) = self.decompose();
123 let status = match StatusCode::from_u16(code) {
124 Ok(status) => status,
125 Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
126 };
127 (status, Json(envelope)).into_response()
128 }
129}
130
131impl From<UserError> for ApiError {
132 fn from(error: UserError) -> Self {
133 match error {
134 UserError::BadRequest(msg) => Self::BadRequest(AppResponseData::err(400, msg)),
135 UserError::Unauthorized => Self::Unauthorized(Self::payload(401, "unauthorized")),
136 UserError::Forbidden => Self::Forbidden(Self::payload(403, "forbidden")),
137 UserError::NotFound => Self::NotFound(Self::payload(404, "not found")),
138 UserError::Conflict(msg) => Self::Conflict(AppResponseData::err(409, msg)),
139 UserError::Internal(message) => {
140 tracing::warn!(error = %message, "user handler failed");
141 Self::Internal(Self::payload(500, "internal server error"))
142 }
143 }
144 }
145}
146
147impl From<Box<ApiError>> for ApiError {
148 fn from(error: Box<ApiError>) -> Self {
149 *error
150 }
151}
152
153impl From<AppResponseData<()>> for ApiError {
154 fn from(_error: AppResponseData<()>) -> Self {
155 Self::BadRequest(Self::payload(400, "bad request"))
156 }
157}
158
159impl From<PathRejection> for ApiError {
160 fn from(_error: PathRejection) -> Self {
161 Self::BadRequest(Self::payload(400, "bad request"))
162 }
163}
164
165impl From<QueryRejection> for ApiError {
166 fn from(_error: QueryRejection) -> Self {
167 Self::BadRequest(Self::payload(400, "bad request"))
168 }
169}
170
171impl From<(StatusCode, &'static str)> for ApiError {
172 fn from(_error: (StatusCode, &'static str)) -> Self {
173 Self::Internal(Self::payload(500, "internal server error"))
174 }
175}
Added lib/user/src/lib.rs View lib/user/src/lib.rs
@@ -0,0 +1,6 @@
1pub mod errors;
2pub mod handlers;
3pub mod openapi;
4pub mod routes;
5pub mod services;
6pub mod share;
Added lib/user/src/openapi.rs View lib/user/src/openapi.rs
@@ -0,0 +1,91 @@
1use export::rest::NoData;
2use models::users::user::ProfileVisibility;
3use models::users::user_push_subscriptions::PushSubscriptionPlatform;
4use models::users::user_reports::ReportStatus;
5use models::users::user_scheduled_messages::ScheduledMessageStatus;
6use models::users::user_sessions::SessionKind;
7use models::users::user_suspensions::SuspensionType;
8use utoipa::OpenApi;
9
10use crate::handlers;
11use crate::services::avatar::{AvatarDto, AvatarFormat, UploadAvatarCmd};
12use crate::services::deletion::DeletionDto;
13use crate::services::emails::{AddEmailCmd, AddEmailResp, EmailDto};
14use crate::services::notifications::{
15 RecentMentionDto, ReportDto, SaveMessageCmd, SavedMessageDto, ScheduledMessageDto,
16};
17use crate::services::profile::{
18 ListLimitQuery, MemeDto, MySpaceDto, ProfileDetailDto, SpaceDto, UpsertMemeCmd,
19};
20use crate::services::push_subscriptions::{PushSubscriptionDto, UpsertPushCmd};
21use crate::services::sessions::{CountResp, ListSessionsQuery, SessionDto};
22use crate::services::suspensions::{IncludeRevokedQuery, SuspensionDto};
23
24#[derive(OpenApi)]
25#[openapi(
26 info(title = "gitdata user api", version = "1.0"),
27 paths(
28 handlers::get_avatar::get_avatar,
29 handlers::upload_avatar::upload_avatar,
30 handlers::delete_avatar::delete_avatar,
31 handlers::my_spaces::my_spaces,
32 handlers::list_memes::list_memes,
33 handlers::create_meme::create_meme,
34 handlers::delete_meme::delete_meme,
35 handlers::get_profile_detail::get_profile_detail,
36 handlers::list_emails::list_emails,
37 handlers::add_email::add_email,
38 handlers::set_primary_email::set_primary_email,
39 handlers::delete_email::delete_email,
40 handlers::list_sessions::list_sessions,
41 handlers::revoke_session::revoke_session,
42 handlers::revoke_all_sessions::revoke_all_sessions,
43 handlers::list_push_subscriptions::list_push_subscriptions,
44 handlers::upsert_push_subscription::upsert_push_subscription,
45 handlers::delete_push_subscription::delete_push_subscription,
46 handlers::recent_mentions::recent_mentions,
47 handlers::my_reports::my_reports,
48 handlers::list_saved_messages::list_saved_messages,
49 handlers::save_message::save_message,
50 handlers::unsave_message::unsave_message,
51 handlers::list_scheduled_messages::list_scheduled_messages,
52 handlers::cancel_scheduled_message::cancel_scheduled_message,
53 handlers::confirm_deletion::confirm_deletion,
54 handlers::my_suspensions::my_suspensions,
55 ),
56 components(schemas(
57 NoData,
58 AvatarFormat,
59 AvatarDto,
60 UploadAvatarCmd,
61 SpaceDto,
62 MySpaceDto,
63 MemeDto,
64 UpsertMemeCmd,
65 ListLimitQuery,
66 ProfileDetailDto,
67 EmailDto,
68 AddEmailCmd,
69 AddEmailResp,
70 SessionDto,
71 ListSessionsQuery,
72 CountResp,
73 PushSubscriptionDto,
74 UpsertPushCmd,
75 RecentMentionDto,
76 ReportDto,
77 SavedMessageDto,
78 SaveMessageCmd,
79 ScheduledMessageDto,
80 DeletionDto,
81 SuspensionDto,
82 IncludeRevokedQuery,
83 ProfileVisibility,
84 SessionKind,
85 PushSubscriptionPlatform,
86 ReportStatus,
87 ScheduledMessageStatus,
88 SuspensionType,
89 ))
90)]
91pub struct UserApiDoc;
Added lib/user/src/routes.rs View lib/user/src/routes.rs
@@ -0,0 +1,75 @@
1use axum::routing::{delete, get, patch, post};
2use axum::Router;
3
4use crate::handlers::add_email::add_email;
5use crate::handlers::cancel_scheduled_message::cancel_scheduled_message;
6use crate::handlers::confirm_deletion::confirm_deletion;
7use crate::handlers::create_meme::create_meme;
8use crate::handlers::delete_avatar::delete_avatar;
9use crate::handlers::delete_email::delete_email;
10use crate::handlers::delete_meme::delete_meme;
11use crate::handlers::delete_push_subscription::delete_push_subscription;
12use crate::handlers::get_avatar::get_avatar;
13use crate::handlers::get_profile_detail::get_profile_detail;
14use crate::handlers::list_emails::list_emails;
15use crate::handlers::list_memes::list_memes;
16use crate::handlers::list_push_subscriptions::list_push_subscriptions;
17use crate::handlers::list_saved_messages::list_saved_messages;
18use crate::handlers::list_scheduled_messages::list_scheduled_messages;
19use crate::handlers::list_sessions::list_sessions;
20use crate::handlers::my_reports::my_reports;
21use crate::handlers::my_spaces::my_spaces;
22use crate::handlers::my_suspensions::my_suspensions;
23use crate::handlers::recent_mentions::recent_mentions;
24use crate::handlers::revoke_all_sessions::revoke_all_sessions;
25use crate::handlers::revoke_session::revoke_session;
26use crate::handlers::save_message::save_message;
27use crate::handlers::set_primary_email::set_primary_email;
28use crate::handlers::unsave_message::unsave_message;
29use crate::handlers::upload_avatar::upload_avatar;
30use crate::handlers::upsert_push_subscription::upsert_push_subscription;
31use crate::services::service::UserService;
32use crate::share::guard::user_guard;
33
34pub fn router(service: UserService) -> Router<UserService> {
35 Router::new()
36 .route(
37 "/me/avatar",
38 get(get_avatar).put(upload_avatar).delete(delete_avatar),
39 )
40 .route("/me/spaces", get(my_spaces))
41 .route("/me/memes", get(list_memes).post(create_meme))
42 .route("/me/memes/{meme_id}", delete(delete_meme))
43 .route("/users/{uid}/profile-detail", get(get_profile_detail))
44 .route("/me/emails", get(list_emails).post(add_email))
45 .route("/me/emails/{email_id}/primary", patch(set_primary_email))
46 .route("/me/emails/{email_id}", delete(delete_email))
47 .route(
48 "/me/sessions",
49 get(list_sessions).delete(revoke_all_sessions),
50 )
51 .route("/me/sessions/{session_id}", delete(revoke_session))
52 .route(
53 "/me/push-subscriptions",
54 get(list_push_subscriptions).post(upsert_push_subscription),
55 )
56 .route(
57 "/me/push-subscriptions/{push_id}",
58 delete(delete_push_subscription),
59 )
60 .route("/me/recent-mentions", get(recent_mentions))
61 .route("/me/reports", get(my_reports))
62 .route(
63 "/me/messages/saved",
64 get(list_saved_messages).post(save_message),
65 )
66 .route("/me/messages/saved/{message_id}", delete(unsave_message))
67 .route("/me/messages/scheduled", get(list_scheduled_messages))
68 .route(
69 "/me/messages/scheduled/{id}",
70 delete(cancel_scheduled_message),
71 )
72 .route("/me/deletion/confirm", post(confirm_deletion))
73 .route("/me/suspensions", get(my_suspensions))
74 .route_layer(axum::middleware::from_fn_with_state(service, user_guard))
75}