feat(lib/space): add space crate with handlers, services, share, and openapi
ZhenYi committed
Added lib/space/src/handlers/accept_invite.rs lib/space/src/handlers/accept_invite.rs +30 −0 View lib/space/src/handlers/accept_invite.rs
@@ -0,0 +1,30 @@
1use axum::extract::{Json, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::invites::AcceptInviteCmd;
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 post,
12 path = "/spaces/invites/accept",
13 tag = "space",
14 request_body = AcceptInviteCmd,
15 responses(
16 (status = 200, description = "invite accepted", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "invite not found", body = AppResponseData<NoData>),
19 (status = 409, description = "conflict", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn accept_invite(
23 State(service): State<SpaceService>,
24 session: Session,
25 Json(cmd): Json<AcceptInviteCmd>,
26) -> Result<AppResponseData<NoData>, ApiError> {
27 let uid = require_uid(&session)?;
28 service.accept_invite(uid, cmd).await?;
29 Ok(AppResponseData::empty())
30}
Added lib/space/src/handlers/add_member.rs lib/space/src/handlers/add_member.rs +34 −0 View lib/space/src/handlers/add_member.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::members::{AddMemberCmd, MemberDto};
7use crate::services::service::SpaceService;
8use crate::services::spaces::SpaceNamePath;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/spaces/{space_name}/members",
14 tag = "space",
15 params(SpaceNamePath),
16 request_body = AddMemberCmd,
17 responses(
18 (status = 200, description = "member added", body = AppResponseData<MemberDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>),
22 (status = 409, description = "already a member", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn add_member(
26 State(service): State<SpaceService>,
27 session: Session,
28 Path(path): Path<SpaceNamePath>,
29 Json(cmd): Json<AddMemberCmd>,
30) -> Result<AppResponseData<MemberDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let member = service.add_member(uid, &path.space_name, cmd).await?;
33 Ok(AppResponseData::ok(member))
34}
Added lib/space/src/handlers/assign_group.rs lib/space/src/handlers/assign_group.rs +35 −0 View lib/space/src/handlers/assign_group.rs
@@ -0,0 +1,35 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::get_member::MemberPath;
7use crate::services::groups::AssignGroupCmd;
8use crate::services::service::SpaceService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/spaces/{space_name}/members/{uid}/groups",
14 tag = "space",
15 params(MemberPath),
16 request_body = AssignGroupCmd,
17 responses(
18 (status = 200, description = "group assigned", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>),
21 (status = 409, description = "already assigned", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn assign_group(
25 State(service): State<SpaceService>,
26 session: Session,
27 Path(path): Path<MemberPath>,
28 Json(cmd): Json<AssignGroupCmd>,
29) -> Result<AppResponseData<NoData>, ApiError> {
30 let caller = require_uid(&session)?;
31 service
32 .assign_group(caller, &path.space_name, path.uid, cmd)
33 .await?;
34 Ok(AppResponseData::empty())
35}
Added lib/space/src/handlers/ban_user.rs lib/space/src/handlers/ban_user.rs +33 −0 View lib/space/src/handlers/ban_user.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::bans::{BanDto, BanUserCmd};
7use crate::services::service::SpaceService;
8use crate::services::spaces::SpaceNamePath;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/spaces/{space_name}/bans",
14 tag = "space",
15 params(SpaceNamePath),
16 request_body = BanUserCmd,
17 responses(
18 (status = 200, description = "user banned", body = AppResponseData<BanDto>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "user or space not found", body = AppResponseData<NoData>),
21 (status = 409, description = "already banned", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn ban_user(
25 State(service): State<SpaceService>,
26 session: Session,
27 Path(path): Path<SpaceNamePath>,
28 Json(cmd): Json<BanUserCmd>,
29) -> Result<AppResponseData<BanDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let ban = service.ban_user(uid, &path.space_name, cmd).await?;
32 Ok(AppResponseData::ok(ban))
33}
Added lib/space/src/handlers/create_dir_node.rs lib/space/src/handlers/create_dir_node.rs +33 −0 View lib/space/src/handlers/create_dir_node.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::dir::{CreateDirCmd, DirNodeDto};
7use crate::services::service::SpaceService;
8use crate::services::spaces::SpaceNamePath;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/spaces/{space_name}/dir",
14 tag = "space",
15 params(SpaceNamePath),
16 request_body = CreateDirCmd,
17 responses(
18 (status = 200, description = "dir node created", body = AppResponseData<DirNodeDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "space or parent not found", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn create_dir_node(
25 State(service): State<SpaceService>,
26 session: Session,
27 Path(path): Path<SpaceNamePath>,
28 Json(cmd): Json<CreateDirCmd>,
29) -> Result<AppResponseData<DirNodeDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let node = service.create_dir_node(uid, &path.space_name, cmd).await?;
32 Ok(AppResponseData::ok(node))
33}
Added lib/space/src/handlers/create_group.rs lib/space/src/handlers/create_group.rs +34 −0 View lib/space/src/handlers/create_group.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::groups::{CreateGroupCmd, GroupDto};
7use crate::services::service::SpaceService;
8use crate::services::spaces::SpaceNamePath;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/spaces/{space_name}/groups",
14 tag = "space",
15 params(SpaceNamePath),
16 request_body = CreateGroupCmd,
17 responses(
18 (status = 200, description = "group created", body = AppResponseData<GroupDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "space not found", body = AppResponseData<NoData>),
22 (status = 409, description = "group name conflict", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn create_group(
26 State(service): State<SpaceService>,
27 session: Session,
28 Path(path): Path<SpaceNamePath>,
29 Json(cmd): Json<CreateGroupCmd>,
30) -> Result<AppResponseData<GroupDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let group = service.create_group(uid, &path.space_name, cmd).await?;
33 Ok(AppResponseData::ok(group))
34}
Added lib/space/src/handlers/create_invite.rs lib/space/src/handlers/create_invite.rs +34 −0 View lib/space/src/handlers/create_invite.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::invites::{CreateInviteCmd, InviteDto};
7use crate::services::service::SpaceService;
8use crate::services::spaces::SpaceNamePath;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/spaces/{space_name}/invites",
14 tag = "space",
15 params(SpaceNamePath),
16 request_body = CreateInviteCmd,
17 responses(
18 (status = 200, description = "invite created", body = AppResponseData<InviteDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "user or space not found", body = AppResponseData<NoData>),
22 (status = 409, description = "active invite conflict", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn create_invite(
26 State(service): State<SpaceService>,
27 session: Session,
28 Path(path): Path<SpaceNamePath>,
29 Json(cmd): Json<CreateInviteCmd>,
30) -> Result<AppResponseData<InviteDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let invite = service.create_invite(uid, &path.space_name, cmd).await?;
33 Ok(AppResponseData::ok(invite))
34}
Added lib/space/src/handlers/create_space.rs lib/space/src/handlers/create_space.rs +29 −0 View lib/space/src/handlers/create_space.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Json, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::SpaceService;
7use crate::services::spaces::{CreateSpaceCmd, SpaceDto};
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 post,
12 path = "/spaces",
13 tag = "space",
14 request_body = CreateSpaceCmd,
15 responses(
16 (status = 200, description = "space created", body = AppResponseData<SpaceDto>),
17 (status = 400, description = "bad request", body = AppResponseData<NoData>),
18 (status = 409, description = "space name conflict", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn create_space(
22 State(service): State<SpaceService>,
23 session: Session,
24 Json(cmd): Json<CreateSpaceCmd>,
25) -> Result<AppResponseData<SpaceDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let space = service.create_space(uid, cmd).await?;
28 Ok(AppResponseData::ok(space))
29}
Added lib/space/src/handlers/delete_group.rs lib/space/src/handlers/delete_group.rs +31 −0 View lib/space/src/handlers/delete_group.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::get_group::GroupPath;
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/spaces/{space_name}/groups/{group_id}",
13 tag = "space",
14 params(GroupPath),
15 responses(
16 (status = 200, description = "group deleted", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_group(
22 State(service): State<SpaceService>,
23 session: Session,
24 Path(path): Path<GroupPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_group(uid, &path.space_name, path.group_id)
29 .await?;
30 Ok(AppResponseData::empty())
31}
Added lib/space/src/handlers/delete_space.rs lib/space/src/handlers/delete_space.rs +29 −0 View lib/space/src/handlers/delete_space.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::SpaceService;
7use crate::services::spaces::SpaceNamePath;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/spaces/{space_name}",
13 tag = "space",
14 params(SpaceNamePath),
15 responses(
16 (status = 200, description = "space deleted", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "space not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_space(
22 State(service): State<SpaceService>,
23 session: Session,
24 Path(path): Path<SpaceNamePath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.delete_space(uid, &path.space_name).await?;
28 Ok(AppResponseData::empty())
29}
Added lib/space/src/handlers/delete_space_avatar.rs lib/space/src/handlers/delete_space_avatar.rs +29 −0 View lib/space/src/handlers/delete_space_avatar.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::SpaceService;
7use crate::services::spaces::SpaceNamePath;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/spaces/{space_name}/avatar",
13 tag = "space",
14 params(SpaceNamePath),
15 responses(
16 (status = 200, description = "space avatar deleted", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "space not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_space_avatar(
22 State(service): State<SpaceService>,
23 session: Session,
24 Path(path): Path<SpaceNamePath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.delete_space_avatar(uid, &path.space_name).await?;
28 Ok(AppResponseData::empty())
29}
Added lib/space/src/handlers/delete_space_image.rs lib/space/src/handlers/delete_space_image.rs +31 −0 View lib/space/src/handlers/delete_space_image.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::images::SpaceImageKindPath;
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/spaces/{space_name}/images/{kind}",
13 tag = "space",
14 params(SpaceImageKindPath),
15 responses(
16 (status = 200, description = "space image deleted", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "space not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_space_image(
22 State(service): State<SpaceService>,
23 session: Session,
24 Path(path): Path<SpaceImageKindPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_space_image(uid, &path.space_name, path.kind)
29 .await?;
30 Ok(AppResponseData::empty())
31}
Added lib/space/src/handlers/get_ban.rs lib/space/src/handlers/get_ban.rs +30 −0 View lib/space/src/handlers/get_ban.rs
@@ -0,0 +1,30 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::get_member::MemberPath;
7use crate::services::bans::BanDto;
8use crate::services::service::SpaceService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/spaces/{space_name}/bans/{uid}",
14 tag = "space",
15 params(MemberPath),
16 responses(
17 (status = 200, description = "get ban", body = AppResponseData<BanDto>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "ban not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn get_ban(
23 State(service): State<SpaceService>,
24 session: Session,
25 Path(path): Path<MemberPath>,
26) -> Result<AppResponseData<BanDto>, ApiError> {
27 let caller = require_uid(&session)?;
28 let ban = service.get_ban(caller, &path.space_name, path.uid).await?;
29 Ok(AppResponseData::ok(ban))
30}
Added lib/space/src/handlers/get_group.rs lib/space/src/handlers/get_group.rs +38 −0 View lib/space/src/handlers/get_group.rs
@@ -0,0 +1,38 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use serde::Deserialize;
4use session::{Session, SessionUser};
5use utoipa::{IntoParams, ToSchema};
6use uuid::Uuid;
7
8use crate::errors::ApiError;
9use crate::services::groups::GroupDto;
10use crate::services::service::SpaceService;
11
12#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
13pub struct GroupPath {
14 pub space_name: String,
15 pub group_id: Uuid,
16}
17
18#[utoipa::path(
19 get,
20 path = "/spaces/{space_name}/groups/{group_id}",
21 tag = "space",
22 params(GroupPath),
23 responses(
24 (status = 200, description = "get group", body = AppResponseData<GroupDto>),
25 (status = 404, description = "group not found", body = AppResponseData<NoData>)
26 )
27)]
28pub async fn get_group(
29 State(service): State<SpaceService>,
30 session: Session,
31 Path(path): Path<GroupPath>,
32) -> Result<AppResponseData<GroupDto>, ApiError> {
33 let caller = session.uid();
34 let group = service
35 .get_group(caller, &path.space_name, path.group_id)
36 .await?;
37 Ok(AppResponseData::ok(group))
38}
Added lib/space/src/handlers/get_member.rs lib/space/src/handlers/get_member.rs +40 −0 View lib/space/src/handlers/get_member.rs
@@ -0,0 +1,40 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use serde::Deserialize;
4use session::Session;
5use utoipa::{IntoParams, ToSchema};
6use uuid::Uuid;
7
8use crate::errors::ApiError;
9use crate::services::members::MemberDto;
10use crate::services::service::SpaceService;
11use crate::share::session::require_uid;
12
13#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
14pub struct MemberPath {
15 pub space_name: String,
16 pub uid: Uuid,
17}
18
19#[utoipa::path(
20 get,
21 path = "/spaces/{space_name}/members/{uid}",
22 tag = "space",
23 params(MemberPath),
24 responses(
25 (status = 200, description = "get member", body = AppResponseData<MemberDto>),
26 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
27 (status = 404, description = "member not found", body = AppResponseData<NoData>)
28 )
29)]
30pub async fn get_member(
31 State(service): State<SpaceService>,
32 session: Session,
33 Path(path): Path<MemberPath>,
34) -> Result<AppResponseData<MemberDto>, ApiError> {
35 let uid = require_uid(&session)?;
36 let member = service
37 .get_member(uid, &path.space_name, path.uid)
38 .await?;
39 Ok(AppResponseData::ok(member))
40}
Added lib/space/src/handlers/get_space.rs lib/space/src/handlers/get_space.rs +27 −0 View lib/space/src/handlers/get_space.rs
@@ -0,0 +1,27 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::{Session, SessionUser};
4
5use crate::errors::ApiError;
6use crate::services::service::SpaceService;
7use crate::services::spaces::{SpaceDto, SpaceNamePath};
8
9#[utoipa::path(
10 get,
11 path = "/spaces/{space_name}",
12 tag = "space",
13 params(SpaceNamePath),
14 responses(
15 (status = 200, description = "get space", body = AppResponseData<SpaceDto>),
16 (status = 404, description = "space not found", body = AppResponseData<NoData>)
17 )
18)]
19pub async fn get_space(
20 State(service): State<SpaceService>,
21 session: Session,
22 Path(path): Path<SpaceNamePath>,
23) -> Result<AppResponseData<SpaceDto>, ApiError> {
24 let caller = session.uid();
25 let space = service.get_space(caller, &path.space_name).await?;
26 Ok(AppResponseData::ok(space))
27}
Added lib/space/src/handlers/get_space_avatar.rs lib/space/src/handlers/get_space_avatar.rs +25 −0 View lib/space/src/handlers/get_space_avatar.rs
@@ -0,0 +1,25 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3
4use crate::errors::ApiError;
5use crate::services::avatar::SpaceAvatarDto;
6use crate::services::service::SpaceService;
7use crate::services::spaces::SpaceNamePath;
8
9#[utoipa::path(
10 get,
11 path = "/spaces/{space_name}/avatar",
12 tag = "space",
13 params(SpaceNamePath),
14 responses(
15 (status = 200, description = "get space avatar", body = AppResponseData<Option<SpaceAvatarDto>>),
16 (status = 404, description = "space not found", body = AppResponseData<NoData>)
17 )
18)]
19pub async fn get_space_avatar(
20 State(service): State<SpaceService>,
21 Path(path): Path<SpaceNamePath>,
22) -> Result<AppResponseData<Option<SpaceAvatarDto>>, ApiError> {
23 let avatar = service.get_space_avatar(&path.space_name).await?;
24 Ok(AppResponseData::ok(avatar))
25}
Added lib/space/src/handlers/get_space_dir_tree.rs lib/space/src/handlers/get_space_dir_tree.rs +30 −0 View lib/space/src/handlers/get_space_dir_tree.rs
@@ -0,0 +1,30 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::dir::DirNodeDto;
7use crate::services::service::SpaceService;
8use crate::services::spaces::SpaceNamePath;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/spaces/{space_name}/dir",
14 tag = "space",
15 params(SpaceNamePath),
16 responses(
17 (status = 200, description = "get space dir tree", body = AppResponseData<Vec<DirNodeDto>>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "space not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn get_space_dir_tree(
23 State(service): State<SpaceService>,
24 session: Session,
25 Path(path): Path<SpaceNamePath>,
26) -> Result<AppResponseData<Vec<DirNodeDto>>, ApiError> {
27 let uid = require_uid(&session)?;
28 let tree = service.get_space_dir_tree(uid, &path.space_name).await?;
29 Ok(AppResponseData::ok(tree))
30}
Added lib/space/src/handlers/get_space_image.rs lib/space/src/handlers/get_space_image.rs +24 −0 View lib/space/src/handlers/get_space_image.rs
@@ -0,0 +1,24 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3
4use crate::errors::ApiError;
5use crate::services::images::{SpaceImageDto, SpaceImageKindPath};
6use crate::services::service::SpaceService;
7
8#[utoipa::path(
9 get,
10 path = "/spaces/{space_name}/images/{kind}",
11 tag = "space",
12 params(SpaceImageKindPath),
13 responses(
14 (status = 200, description = "get space image", body = AppResponseData<Option<SpaceImageDto>>),
15 (status = 404, description = "space not found", body = AppResponseData<NoData>)
16 )
17)]
18pub async fn get_space_image(
19 State(service): State<SpaceService>,
20 Path(path): Path<SpaceImageKindPath>,
21) -> Result<AppResponseData<Option<SpaceImageDto>>, ApiError> {
22 let img = service.get_space_image(&path.space_name, path.kind).await?;
23 Ok(AppResponseData::ok(img))
24}
Added lib/space/src/handlers/join_group_self.rs lib/space/src/handlers/join_group_self.rs +32 −0 View lib/space/src/handlers/join_group_self.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::get_group::GroupPath;
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 post,
12 path = "/spaces/{space_name}/groups/{group_id}/join",
13 tag = "space",
14 params(GroupPath),
15 responses(
16 (status = 200, description = "joined group", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>),
19 (status = 409, description = "already in group", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn join_group_self(
23 State(service): State<SpaceService>,
24 session: Session,
25 Path(path): Path<GroupPath>,
26) -> Result<AppResponseData<NoData>, ApiError> {
27 let uid = require_uid(&session)?;
28 service
29 .join_group_self(uid, &path.space_name, path.group_id)
30 .await?;
31 Ok(AppResponseData::empty())
32}
Added lib/space/src/handlers/leave_group_self.rs lib/space/src/handlers/leave_group_self.rs +31 −0 View lib/space/src/handlers/leave_group_self.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::get_group::GroupPath;
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/spaces/{space_name}/groups/{group_id}/join",
13 tag = "space",
14 params(GroupPath),
15 responses(
16 (status = 200, description = "left group", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn leave_group_self(
22 State(service): State<SpaceService>,
23 session: Session,
24 Path(path): Path<GroupPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .leave_group_self(uid, &path.space_name, path.group_id)
29 .await?;
30 Ok(AppResponseData::empty())
31}
Added lib/space/src/handlers/list_bans.rs lib/space/src/handlers/list_bans.rs +31 −0 View lib/space/src/handlers/list_bans.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::bans::BanPage;
7use crate::services::service::SpaceService;
8use crate::services::spaces::{PageQuery, SpaceNamePath};
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/spaces/{space_name}/bans",
14 tag = "space",
15 params(SpaceNamePath, PageQuery),
16 responses(
17 (status = 200, description = "list space bans", body = AppResponseData<BanPage>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "space not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_bans(
23 State(service): State<SpaceService>,
24 session: Session,
25 Path(path): Path<SpaceNamePath>,
26 Query(query): Query<PageQuery>,
27) -> Result<AppResponseData<BanPage>, ApiError> {
28 let uid = require_uid(&session)?;
29 let page = service.list_bans(uid, &path.space_name, query).await?;
30 Ok(AppResponseData::ok(page))
31}
Added lib/space/src/handlers/list_dir_group_overwrites.rs lib/space/src/handlers/list_dir_group_overwrites.rs +31 −0 View lib/space/src/handlers/list_dir_group_overwrites.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::dir::{DirPath, GroupOverwriteDto};
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/spaces/{space_name}/dir/{dir_id}/overwrites/groups",
13 tag = "space",
14 params(DirPath),
15 responses(
16 (status = 200, description = "list dir group overwrites", body = AppResponseData<Vec<GroupOverwriteDto>>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "space or dir not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn list_dir_group_overwrites(
22 State(service): State<SpaceService>,
23 session: Session,
24 Path(path): Path<DirPath>,
25) -> Result<AppResponseData<Vec<GroupOverwriteDto>>, ApiError> {
26 let uid = require_uid(&session)?;
27 let list = service
28 .list_dir_group_overwrites(uid, &path.space_name, path.dir_id)
29 .await?;
30 Ok(AppResponseData::ok(list))
31}
Added lib/space/src/handlers/list_dir_user_overwrites.rs lib/space/src/handlers/list_dir_user_overwrites.rs +31 −0 View lib/space/src/handlers/list_dir_user_overwrites.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::dir::{DirPath, UserOverwriteDto};
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/spaces/{space_name}/dir/{dir_id}/overwrites/users",
13 tag = "space",
14 params(DirPath),
15 responses(
16 (status = 200, description = "list dir user overwrites", body = AppResponseData<Vec<UserOverwriteDto>>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "space or dir not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn list_dir_user_overwrites(
22 State(service): State<SpaceService>,
23 session: Session,
24 Path(path): Path<DirPath>,
25) -> Result<AppResponseData<Vec<UserOverwriteDto>>, ApiError> {
26 let uid = require_uid(&session)?;
27 let list = service
28 .list_dir_user_overwrites(uid, &path.space_name, path.dir_id)
29 .await?;
30 Ok(AppResponseData::ok(list))
31}
Added lib/space/src/handlers/list_groups.rs lib/space/src/handlers/list_groups.rs +31 −0 View lib/space/src/handlers/list_groups.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::groups::GroupPage;
7use crate::services::service::SpaceService;
8use crate::services::spaces::{PageQuery, SpaceNamePath};
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/spaces/{space_name}/groups",
14 tag = "space",
15 params(SpaceNamePath, PageQuery),
16 responses(
17 (status = 200, description = "list space groups", body = AppResponseData<GroupPage>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "space not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_groups(
23 State(service): State<SpaceService>,
24 session: Session,
25 Path(path): Path<SpaceNamePath>,
26 Query(query): Query<PageQuery>,
27) -> Result<AppResponseData<GroupPage>, ApiError> {
28 let uid = require_uid(&session)?;
29 let page = service.list_groups(uid, &path.space_name, query).await?;
30 Ok(AppResponseData::ok(page))
31}
Added lib/space/src/handlers/list_invites.rs lib/space/src/handlers/list_invites.rs +31 −0 View lib/space/src/handlers/list_invites.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::invites::InvitePage;
7use crate::services::service::SpaceService;
8use crate::services::spaces::{PageQuery, SpaceNamePath};
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/spaces/{space_name}/invites",
14 tag = "space",
15 params(SpaceNamePath, PageQuery),
16 responses(
17 (status = 200, description = "list space invites", body = AppResponseData<InvitePage>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "space not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_invites(
23 State(service): State<SpaceService>,
24 session: Session,
25 Path(path): Path<SpaceNamePath>,
26 Query(query): Query<PageQuery>,
27) -> Result<AppResponseData<InvitePage>, ApiError> {
28 let uid = require_uid(&session)?;
29 let page = service.list_invites(uid, &path.space_name, query).await?;
30 Ok(AppResponseData::ok(page))
31}
Added lib/space/src/handlers/list_members.rs lib/space/src/handlers/list_members.rs +31 −0 View lib/space/src/handlers/list_members.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::members::MemberPage;
7use crate::services::service::SpaceService;
8use crate::services::spaces::{PageQuery, SpaceNamePath};
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/spaces/{space_name}/members",
14 tag = "space",
15 params(SpaceNamePath, PageQuery),
16 responses(
17 (status = 200, description = "list space members", body = AppResponseData<MemberPage>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_members(
23 State(service): State<SpaceService>,
24 session: Session,
25 Path(path): Path<SpaceNamePath>,
26 Query(query): Query<PageQuery>,
27) -> Result<AppResponseData<MemberPage>, ApiError> {
28 let uid = require_uid(&session)?;
29 let page = service.list_members(uid, &path.space_name, query).await?;
30 Ok(AppResponseData::ok(page))
31}
Added lib/space/src/handlers/list_spaces.rs lib/space/src/handlers/list_spaces.rs +27 −0 View lib/space/src/handlers/list_spaces.rs
@@ -0,0 +1,27 @@
1use axum::extract::{Query, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::SpaceService;
7use crate::services::spaces::{PageQuery, SpacePage};
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/spaces",
13 tag = "space",
14 params(PageQuery),
15 responses(
16 (status = 200, description = "list owned spaces", body = AppResponseData<SpacePage>)
17 )
18)]
19pub async fn list_spaces(
20 State(service): State<SpaceService>,
21 session: Session,
22 Query(query): Query<PageQuery>,
23) -> Result<AppResponseData<SpacePage>, ApiError> {
24 let uid = require_uid(&session)?;
25 let page = service.list_spaces(uid, query).await?;
26 Ok(AppResponseData::ok(page))
27}
Added lib/space/src/handlers/mod.rs lib/space/src/handlers/mod.rs +45 −0 View lib/space/src/handlers/mod.rs
@@ -0,0 +1,45 @@
1pub mod accept_invite;
2pub mod add_member;
3pub mod assign_group;
4pub mod ban_user;
5pub mod create_dir_node;
6pub mod create_group;
7pub mod create_invite;
8pub mod create_space;
9pub mod delete_group;
10pub mod delete_space;
11pub mod delete_space_avatar;
12pub mod delete_space_image;
13pub mod get_ban;
14pub mod get_group;
15pub mod get_member;
16pub mod get_space;
17pub mod get_space_avatar;
18pub mod get_space_dir_tree;
19pub mod get_space_image;
20pub mod join_group_self;
21pub mod leave_group_self;
22pub mod list_bans;
23pub mod list_dir_group_overwrites;
24pub mod list_dir_user_overwrites;
25pub mod list_groups;
26pub mod list_invites;
27pub mod list_members;
28pub mod list_spaces;
29pub mod move_dir_node;
30pub mod remove_dir_group_overwrite;
31pub mod remove_dir_user_overwrite;
32pub mod remove_member;
33pub mod repair_dir_parents;
34pub mod revoke_invite;
35pub mod set_dir_group_overwrite;
36pub mod set_dir_user_overwrite;
37pub mod unassign_group;
38pub mod unban_user;
39pub mod update_ban;
40pub mod update_dir_node;
41pub mod update_group;
42pub mod update_member;
43pub mod update_space;
44pub mod upload_space_avatar;
45pub mod upload_space_image;
Added lib/space/src/handlers/move_dir_node.rs lib/space/src/handlers/move_dir_node.rs +34 −0 View lib/space/src/handlers/move_dir_node.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::dir::{DirPath, DirNodeDto, MoveDirCmd};
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 patch,
12 path = "/spaces/{space_name}/dir/{dir_id}/position",
13 tag = "space",
14 params(DirPath),
15 request_body = MoveDirCmd,
16 responses(
17 (status = 200, description = "dir node moved", body = AppResponseData<DirNodeDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "dir node not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn move_dir_node(
24 State(service): State<SpaceService>,
25 session: Session,
26 Path(path): Path<DirPath>,
27 Json(cmd): Json<MoveDirCmd>,
28) -> Result<AppResponseData<DirNodeDto>, ApiError> {
29 let uid = require_uid(&session)?;
30 let node = service
31 .move_dir_node(uid, &path.space_name, path.dir_id, cmd)
32 .await?;
33 Ok(AppResponseData::ok(node))
34}
Added lib/space/src/handlers/remove_dir_group_overwrite.rs lib/space/src/handlers/remove_dir_group_overwrite.rs +31 −0 View lib/space/src/handlers/remove_dir_group_overwrite.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::set_dir_group_overwrite::DirGroupPath;
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/spaces/{space_name}/dir/{dir_id}/overwrites/groups/{group_id}",
13 tag = "space",
14 params(DirGroupPath),
15 responses(
16 (status = 200, description = "remove dir group overwrite", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn remove_dir_group_overwrite(
22 State(service): State<SpaceService>,
23 session: Session,
24 Path(path): Path<DirGroupPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .remove_dir_group_overwrite(uid, &path.space_name, path.dir_id, path.group_id)
29 .await?;
30 Ok(AppResponseData::empty())
31}
Added lib/space/src/handlers/remove_dir_user_overwrite.rs lib/space/src/handlers/remove_dir_user_overwrite.rs +31 −0 View lib/space/src/handlers/remove_dir_user_overwrite.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::set_dir_user_overwrite::DirUserPath;
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/spaces/{space_name}/dir/{dir_id}/overwrites/users/{user_id}",
13 tag = "space",
14 params(DirUserPath),
15 responses(
16 (status = 200, description = "remove dir user overwrite", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn remove_dir_user_overwrite(
22 State(service): State<SpaceService>,
23 session: Session,
24 Path(path): Path<DirUserPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .remove_dir_user_overwrite(uid, &path.space_name, path.dir_id, path.user_id)
29 .await?;
30 Ok(AppResponseData::empty())
31}
Added lib/space/src/handlers/remove_member.rs lib/space/src/handlers/remove_member.rs +32 −0 View lib/space/src/handlers/remove_member.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::get_member::MemberPath;
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/spaces/{space_name}/members/{uid}",
13 tag = "space",
14 params(MemberPath),
15 responses(
16 (status = 200, description = "member removed", body = AppResponseData<NoData>),
17 (status = 400, description = "bad request", body = AppResponseData<NoData>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn remove_member(
23 State(service): State<SpaceService>,
24 session: Session,
25 Path(path): Path<MemberPath>,
26) -> Result<AppResponseData<NoData>, ApiError> {
27 let caller = require_uid(&session)?;
28 service
29 .remove_member(caller, &path.space_name, path.uid)
30 .await?;
31 Ok(AppResponseData::empty())
32}
Added lib/space/src/handlers/repair_dir_parents.rs lib/space/src/handlers/repair_dir_parents.rs +30 −0 View lib/space/src/handlers/repair_dir_parents.rs
@@ -0,0 +1,30 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::dir::RepairDirResp;
7use crate::services::service::SpaceService;
8use crate::services::spaces::SpaceNamePath;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/spaces/{space_name}/dir/repair-parents",
14 tag = "space",
15 params(SpaceNamePath),
16 responses(
17 (status = 200, description = "repair dir parents", body = AppResponseData<RepairDirResp>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "space not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn repair_dir_parents(
23 State(service): State<SpaceService>,
24 session: Session,
25 Path(path): Path<SpaceNamePath>,
26) -> Result<AppResponseData<RepairDirResp>, ApiError> {
27 let uid = require_uid(&session)?;
28 let resp = service.repair_dir_parents(uid, &path.space_name).await?;
29 Ok(AppResponseData::ok(resp))
30}
Added lib/space/src/handlers/revoke_invite.rs lib/space/src/handlers/revoke_invite.rs +40 −0 View lib/space/src/handlers/revoke_invite.rs
@@ -0,0 +1,40 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use serde::Deserialize;
4use session::Session;
5use utoipa::{IntoParams, ToSchema};
6use uuid::Uuid;
7
8use crate::errors::ApiError;
9use crate::services::invites::InviteDto;
10use crate::services::service::SpaceService;
11use crate::share::session::require_uid;
12
13#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
14pub struct InvitePath {
15 pub space_name: String,
16 pub invite_id: Uuid,
17}
18
19#[utoipa::path(
20 delete,
21 path = "/spaces/{space_name}/invites/{invite_id}",
22 tag = "space",
23 params(InvitePath),
24 responses(
25 (status = 200, description = "invite revoked", body = AppResponseData<InviteDto>),
26 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
27 (status = 404, description = "invite not found", body = AppResponseData<NoData>)
28 )
29)]
30pub async fn revoke_invite(
31 State(service): State<SpaceService>,
32 session: Session,
33 Path(path): Path<InvitePath>,
34) -> Result<AppResponseData<InviteDto>, ApiError> {
35 let uid = require_uid(&session)?;
36 let invite = service
37 .revoke_invite(uid, &path.space_name, path.invite_id)
38 .await?;
39 Ok(AppResponseData::ok(invite))
40}
Added lib/space/src/handlers/set_dir_group_overwrite.rs lib/space/src/handlers/set_dir_group_overwrite.rs +44 −0 View lib/space/src/handlers/set_dir_group_overwrite.rs
@@ -0,0 +1,44 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use serde::Deserialize;
4use session::Session;
5use utoipa::{IntoParams, ToSchema};
6use uuid::Uuid;
7
8use crate::errors::ApiError;
9use crate::services::dir::{GroupOverwriteDto, SetOverwriteCmd};
10use crate::services::service::SpaceService;
11use crate::share::session::require_uid;
12
13#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
14pub struct DirGroupPath {
15 pub space_name: String,
16 pub dir_id: Uuid,
17 pub group_id: Uuid,
18}
19
20#[utoipa::path(
21 put,
22 path = "/spaces/{space_name}/dir/{dir_id}/overwrites/groups/{group_id}",
23 tag = "space",
24 params(DirGroupPath),
25 request_body = SetOverwriteCmd,
26 responses(
27 (status = 200, description = "set dir group overwrite", body = AppResponseData<GroupOverwriteDto>),
28 (status = 400, description = "bad request", body = AppResponseData<NoData>),
29 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
30 (status = 404, description = "not found", body = AppResponseData<NoData>)
31 )
32)]
33pub async fn set_dir_group_overwrite(
34 State(service): State<SpaceService>,
35 session: Session,
36 Path(path): Path<DirGroupPath>,
37 Json(cmd): Json<SetOverwriteCmd>,
38) -> Result<AppResponseData<GroupOverwriteDto>, ApiError> {
39 let uid = require_uid(&session)?;
40 let dto = service
41 .set_dir_group_overwrite(uid, &path.space_name, path.dir_id, path.group_id, cmd)
42 .await?;
43 Ok(AppResponseData::ok(dto))
44}
Added lib/space/src/handlers/set_dir_user_overwrite.rs lib/space/src/handlers/set_dir_user_overwrite.rs +44 −0 View lib/space/src/handlers/set_dir_user_overwrite.rs
@@ -0,0 +1,44 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use serde::Deserialize;
4use session::Session;
5use utoipa::{IntoParams, ToSchema};
6use uuid::Uuid;
7
8use crate::errors::ApiError;
9use crate::services::dir::{SetOverwriteCmd, UserOverwriteDto};
10use crate::services::service::SpaceService;
11use crate::share::session::require_uid;
12
13#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
14pub struct DirUserPath {
15 pub space_name: String,
16 pub dir_id: Uuid,
17 pub user_id: Uuid,
18}
19
20#[utoipa::path(
21 put,
22 path = "/spaces/{space_name}/dir/{dir_id}/overwrites/users/{user_id}",
23 tag = "space",
24 params(DirUserPath),
25 request_body = SetOverwriteCmd,
26 responses(
27 (status = 200, description = "set dir user overwrite", body = AppResponseData<UserOverwriteDto>),
28 (status = 400, description = "bad request", body = AppResponseData<NoData>),
29 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
30 (status = 404, description = "not found", body = AppResponseData<NoData>)
31 )
32)]
33pub async fn set_dir_user_overwrite(
34 State(service): State<SpaceService>,
35 session: Session,
36 Path(path): Path<DirUserPath>,
37 Json(cmd): Json<SetOverwriteCmd>,
38) -> Result<AppResponseData<UserOverwriteDto>, ApiError> {
39 let uid = require_uid(&session)?;
40 let dto = service
41 .set_dir_user_overwrite(uid, &path.space_name, path.dir_id, path.user_id, cmd)
42 .await?;
43 Ok(AppResponseData::ok(dto))
44}
Added lib/space/src/handlers/unassign_group.rs lib/space/src/handlers/unassign_group.rs +40 −0 View lib/space/src/handlers/unassign_group.rs
@@ -0,0 +1,40 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use serde::Deserialize;
4use session::Session;
5use utoipa::{IntoParams, ToSchema};
6use uuid::Uuid;
7
8use crate::errors::ApiError;
9use crate::services::service::SpaceService;
10use crate::share::session::require_uid;
11
12#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
13pub struct MemberGroupPath {
14 pub space_name: String,
15 pub uid: Uuid,
16 pub group_id: Uuid,
17}
18
19#[utoipa::path(
20 delete,
21 path = "/spaces/{space_name}/members/{uid}/groups/{group_id}",
22 tag = "space",
23 params(MemberGroupPath),
24 responses(
25 (status = 200, description = "group unassigned", body = AppResponseData<NoData>),
26 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
27 (status = 404, description = "not found", body = AppResponseData<NoData>)
28 )
29)]
30pub async fn unassign_group(
31 State(service): State<SpaceService>,
32 session: Session,
33 Path(path): Path<MemberGroupPath>,
34) -> Result<AppResponseData<NoData>, ApiError> {
35 let caller = require_uid(&session)?;
36 service
37 .unassign_group(caller, &path.space_name, path.uid, path.group_id)
38 .await?;
39 Ok(AppResponseData::empty())
40}
Added lib/space/src/handlers/unban_user.rs lib/space/src/handlers/unban_user.rs +29 −0 View lib/space/src/handlers/unban_user.rs
@@ -0,0 +1,29 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::get_member::MemberPath;
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/spaces/{space_name}/bans/{uid}",
13 tag = "space",
14 params(MemberPath),
15 responses(
16 (status = 200, description = "user unbanned", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "ban not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn unban_user(
22 State(service): State<SpaceService>,
23 session: Session,
24 Path(path): Path<MemberPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let caller = require_uid(&session)?;
27 service.unban_user(caller, &path.space_name, path.uid).await?;
28 Ok(AppResponseData::empty())
29}
Added lib/space/src/handlers/update_ban.rs lib/space/src/handlers/update_ban.rs +34 −0 View lib/space/src/handlers/update_ban.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::get_member::MemberPath;
7use crate::services::bans::{BanDto, UpdateBanCmd};
8use crate::services::service::SpaceService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 patch,
13 path = "/spaces/{space_name}/bans/{uid}",
14 tag = "space",
15 params(MemberPath),
16 request_body = UpdateBanCmd,
17 responses(
18 (status = 200, description = "ban updated", body = AppResponseData<BanDto>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "ban not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn update_ban(
24 State(service): State<SpaceService>,
25 session: Session,
26 Path(path): Path<MemberPath>,
27 Json(cmd): Json<UpdateBanCmd>,
28) -> Result<AppResponseData<BanDto>, ApiError> {
29 let caller = require_uid(&session)?;
30 let ban = service
31 .update_ban(caller, &path.space_name, path.uid, cmd)
32 .await?;
33 Ok(AppResponseData::ok(ban))
34}
Added lib/space/src/handlers/update_dir_node.rs lib/space/src/handlers/update_dir_node.rs +34 −0 View lib/space/src/handlers/update_dir_node.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::dir::{DirPath, DirNodeDto, UpdateDirCmd};
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 patch,
12 path = "/spaces/{space_name}/dir/{dir_id}",
13 tag = "space",
14 params(DirPath),
15 request_body = UpdateDirCmd,
16 responses(
17 (status = 200, description = "dir node updated", body = AppResponseData<DirNodeDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "dir node not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn update_dir_node(
24 State(service): State<SpaceService>,
25 session: Session,
26 Path(path): Path<DirPath>,
27 Json(cmd): Json<UpdateDirCmd>,
28) -> Result<AppResponseData<DirNodeDto>, ApiError> {
29 let uid = require_uid(&session)?;
30 let node = service
31 .update_dir_node(uid, &path.space_name, path.dir_id, cmd)
32 .await?;
33 Ok(AppResponseData::ok(node))
34}
Added lib/space/src/handlers/update_group.rs lib/space/src/handlers/update_group.rs +36 −0 View lib/space/src/handlers/update_group.rs
@@ -0,0 +1,36 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::get_group::GroupPath;
7use crate::services::groups::{GroupDto, UpdateGroupCmd};
8use crate::services::service::SpaceService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 patch,
13 path = "/spaces/{space_name}/groups/{group_id}",
14 tag = "space",
15 params(GroupPath),
16 request_body = UpdateGroupCmd,
17 responses(
18 (status = 200, description = "group updated", body = AppResponseData<GroupDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>),
22 (status = 409, description = "group name conflict", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn update_group(
26 State(service): State<SpaceService>,
27 session: Session,
28 Path(path): Path<GroupPath>,
29 Json(cmd): Json<UpdateGroupCmd>,
30) -> Result<AppResponseData<GroupDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let group = service
33 .update_group(uid, &path.space_name, path.group_id, cmd)
34 .await?;
35 Ok(AppResponseData::ok(group))
36}
Added lib/space/src/handlers/update_member.rs lib/space/src/handlers/update_member.rs +35 −0 View lib/space/src/handlers/update_member.rs
@@ -0,0 +1,35 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::get_member::MemberPath;
7use crate::services::members::{MemberDto, UpdateMemberCmd};
8use crate::services::service::SpaceService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 patch,
13 path = "/spaces/{space_name}/members/{uid}",
14 tag = "space",
15 params(MemberPath),
16 request_body = UpdateMemberCmd,
17 responses(
18 (status = 200, description = "member updated", body = AppResponseData<MemberDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn update_member(
25 State(service): State<SpaceService>,
26 session: Session,
27 Path(path): Path<MemberPath>,
28 Json(cmd): Json<UpdateMemberCmd>,
29) -> Result<AppResponseData<MemberDto>, ApiError> {
30 let caller = require_uid(&session)?;
31 let member = service
32 .update_member(caller, &path.space_name, path.uid, cmd)
33 .await?;
34 Ok(AppResponseData::ok(member))
35}
Added lib/space/src/handlers/update_space.rs lib/space/src/handlers/update_space.rs +33 −0 View lib/space/src/handlers/update_space.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::SpaceService;
7use crate::services::spaces::{SpaceDto, SpaceNamePath, UpdateSpaceCmd};
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 patch,
12 path = "/spaces/{space_name}",
13 tag = "space",
14 params(SpaceNamePath),
15 request_body = UpdateSpaceCmd,
16 responses(
17 (status = 200, description = "space updated", body = AppResponseData<SpaceDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "space not found", body = AppResponseData<NoData>),
21 (status = 409, description = "space name conflict", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn update_space(
25 State(service): State<SpaceService>,
26 session: Session,
27 Path(path): Path<SpaceNamePath>,
28 Json(cmd): Json<UpdateSpaceCmd>,
29) -> Result<AppResponseData<SpaceDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let space = service.update_space(uid, &path.space_name, cmd).await?;
32 Ok(AppResponseData::ok(space))
33}
Added lib/space/src/handlers/upload_space_avatar.rs lib/space/src/handlers/upload_space_avatar.rs +35 −0 View lib/space/src/handlers/upload_space_avatar.rs
@@ -0,0 +1,35 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::avatar::{SpaceAvatarDto, UploadSpaceAvatarCmd};
7use crate::services::service::SpaceService;
8use crate::services::spaces::SpaceNamePath;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 put,
13 path = "/spaces/{space_name}/avatar",
14 tag = "space",
15 params(SpaceNamePath),
16 request_body = UploadSpaceAvatarCmd,
17 responses(
18 (status = 200, description = "space avatar uploaded", body = AppResponseData<SpaceAvatarDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "space not found", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn upload_space_avatar(
25 State(service): State<SpaceService>,
26 session: Session,
27 Path(path): Path<SpaceNamePath>,
28 Json(cmd): Json<UploadSpaceAvatarCmd>,
29) -> Result<AppResponseData<SpaceAvatarDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let avatar = service
32 .upload_space_avatar(uid, &path.space_name, cmd)
33 .await?;
34 Ok(AppResponseData::ok(avatar))
35}
Added lib/space/src/handlers/upload_space_image.rs lib/space/src/handlers/upload_space_image.rs +34 −0 View lib/space/src/handlers/upload_space_image.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::images::{SpaceImageDto, SpaceImageKindPath, UploadSpaceImageCmd};
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 put,
12 path = "/spaces/{space_name}/images/{kind}",
13 tag = "space",
14 params(SpaceImageKindPath),
15 request_body = UploadSpaceImageCmd,
16 responses(
17 (status = 200, description = "space image uploaded", body = AppResponseData<SpaceImageDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "space not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn upload_space_image(
24 State(service): State<SpaceService>,
25 session: Session,
26 Path(path): Path<SpaceImageKindPath>,
27 Json(cmd): Json<UploadSpaceImageCmd>,
28) -> Result<AppResponseData<SpaceImageDto>, ApiError> {
29 let uid = require_uid(&session)?;
30 let img = service
31 .upload_space_image(uid, &path.space_name, path.kind, cmd)
32 .await?;
33 Ok(AppResponseData::ok(img))
34}
Added lib/space/src/services/avatar.rs lib/space/src/services/avatar.rs +198 −0 View lib/space/src/services/avatar.rs
@@ -0,0 +1,198 @@
1use base64::engine::general_purpose::STANDARD;
2use base64::Engine;
3use models::space::spaces;
4use sea_orm::{ActiveModelTrait, Set};
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use utoipa::ToSchema;
8use uuid::Uuid;
9
10use crate::errors::SpaceError;
11use crate::services::service::SpaceService;
12use crate::share::lookup::lookup_space_by_name;
13
14pub const MAX_SPACE_AVATAR_BYTES: usize = 5 * 1024 * 1024;
15pub const SPACE_AVATAR_PREFIX: &str = "space-avatars";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
18#[serde(rename_all = "lowercase")]
19pub enum AvatarFormat {
20 Jpg,
21 Png,
22 Webp,
23 Gif,
24}
25
26impl AvatarFormat {
27 pub fn extension(&self) -> &'static str {
28 match self {
29 Self::Jpg => "jpg",
30 Self::Png => "png",
31 Self::Webp => "webp",
32 Self::Gif => "gif",
33 }
34 }
35
36 pub fn content_type(&self) -> &'static str {
37 match self {
38 Self::Jpg => "image/jpeg",
39 Self::Png => "image/png",
40 Self::Webp => "image/webp",
41 Self::Gif => "image/gif",
42 }
43 }
44
45 pub fn from_content_type(ct: &str) -> Option<Self> {
46 let clean = ct.trim().to_lowercase();
47 match clean.as_str() {
48 "image/jpeg" | "image/jpg" => Some(Self::Jpg),
49 "image/png" => Some(Self::Png),
50 "image/webp" => Some(Self::Webp),
51 "image/gif" => Some(Self::Gif),
52 _ => None,
53 }
54 }
55
56 pub fn from_extension(ext: &str) -> Option<Self> {
57 let clean = ext.trim().trim_start_matches('.').to_lowercase();
58 match clean.as_str() {
59 "jpg" | "jpeg" => Some(Self::Jpg),
60 "png" => Some(Self::Png),
61 "webp" => Some(Self::Webp),
62 "gif" => Some(Self::Gif),
63 _ => None,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
69pub struct SpaceAvatarDto {
70 pub space_id: Uuid,
71 pub url: String,
72 pub format: AvatarFormat,
73 pub bytes: u64,
74 pub updated_at: OffsetDateTime,
75}
76
77#[derive(Debug, Clone, Deserialize, ToSchema)]
78pub struct UploadSpaceAvatarCmd {
79 pub content_type: String,
80 pub data_base64: String,
81}
82
83impl SpaceService {
84 pub async fn upload_space_avatar(
85 &self,
86 caller: Uuid,
87 space_name: &str,
88 cmd: UploadSpaceAvatarCmd,
89 ) -> Result<SpaceAvatarDto, SpaceError> {
90 let space = lookup_space_by_name(self, space_name).await?;
91 if space.owner_id != caller {
92 return Err(SpaceError::Forbidden);
93 }
94
95 let fmt = AvatarFormat::from_content_type(&cmd.content_type).ok_or_else(|| {
96 SpaceError::BadRequest(format!("unsupported content-type: {}", cmd.content_type))
97 })?;
98
99 let bytes = STANDARD
100 .decode(cmd.data_base64.trim())
101 .map_err(|e| SpaceError::BadRequest(format!("invalid base64: {e}")))?;
102
103 if bytes.is_empty() || bytes.len() > MAX_SPACE_AVATAR_BYTES {
104 return Err(SpaceError::BadRequest(
105 "avatar payload is empty or exceeds 5MB limit".to_string(),
106 ));
107 }
108
109 let space_id = space.id;
110 let key = format!("{SPACE_AVATAR_PREFIX}/{}.{}", space_id, fmt.extension());
111 self.storage()
112 .client()
113 .put_with_content_type(&key, &bytes, fmt.content_type())
114 .await?;
115
116 let url = format!("storage://{key}");
117 let now = OffsetDateTime::now_utc();
118
119 let mut am: spaces::ActiveModel = space.into();
120 am.avatar_url = Set(Some(url.clone()));
121 am.updated_at = Set(now);
122 am.update(self.db().write()).await?;
123
124 let meta_key = format!("{SPACE_AVATAR_PREFIX}/{}.meta.json", space_id);
125 let meta = serde_json::json!({
126 "space_id": space_id,
127 "format": fmt.extension(),
128 "bytes": bytes.len(),
129 "uploaded_at": now,
130 });
131 let _ = self
132 .storage()
133 .client()
134 .put_with_content_type(&meta_key, meta.to_string().as_bytes(), "application/json")
135 .await;
136
137 Ok(SpaceAvatarDto {
138 space_id,
139 url,
140 format: fmt,
141 bytes: bytes.len() as u64,
142 updated_at: now,
143 })
144 }
145
146 pub async fn delete_space_avatar(
147 &self,
148 caller: Uuid,
149 space_name: &str,
150 ) -> Result<(), SpaceError> {
151 let space = lookup_space_by_name(self, space_name).await?;
152 if space.owner_id != caller {
153 return Err(SpaceError::Forbidden);
154 }
155
156 let space_id = space.id;
157 let old_url = space.avatar_url.clone();
158 let now = OffsetDateTime::now_utc();
159
160 let mut am: spaces::ActiveModel = space.into();
161 am.avatar_url = Set(None);
162 am.updated_at = Set(now);
163 am.update(self.db().write()).await?;
164
165 if let Some(url) = old_url
166 && let Some(key) = url.strip_prefix("storage://")
167 {
168 let _ = self.storage().client().delete(key).await;
169 let meta_key = format!("{SPACE_AVATAR_PREFIX}/{}.meta.json", space_id);
170 let _ = self.storage().client().delete(&meta_key).await;
171 }
172 Ok(())
173 }
174
175 pub async fn get_space_avatar(
176 &self,
177 space_name: &str,
178 ) -> Result<Option<SpaceAvatarDto>, SpaceError> {
179 let space = lookup_space_by_name(self, space_name).await?;
180 let Some(url) = space.avatar_url else {
181 return Ok(None);
182 };
183 if url.is_empty() {
184 return Ok(None);
185 }
186
187 let ext = url.split('.').next_back().unwrap_or("jpg");
188 let format = AvatarFormat::from_extension(ext).unwrap_or(AvatarFormat::Jpg);
189
190 Ok(Some(SpaceAvatarDto {
191 space_id: space.id,
192 url,
193 format,
194 bytes: 0,
195 updated_at: space.updated_at,
196 }))
197 }
198}
Added lib/space/src/services/bans.rs lib/space/src/services/bans.rs +309 −0 View lib/space/src/services/bans.rs
@@ -0,0 +1,309 @@
1use models::space::space_bans;
2use models::users::user;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
5 QuerySelect, Set,
6};
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use utoipa::ToSchema;
10use uuid::Uuid;
11
12use crate::errors::SpaceError;
13use crate::services::service::SpaceService;
14use crate::services::spaces::{PageQuery, UserBriefDto};
15use crate::share::lookup::lookup_space_by_name;
16
17#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
18pub struct BanDto {
19 pub space_id: Uuid,
20 pub user: Option<UserBriefDto>,
21 pub reason: Option<String>,
22 pub banned_by: Option<UserBriefDto>,
23 pub created_at: OffsetDateTime,
24 pub updated_at: OffsetDateTime,
25 pub expires_at: Option<OffsetDateTime>,
26 pub allow_agent_join: bool,
27 pub allow_speak: bool,
28 pub allow_push_repo: bool,
29 pub allow_comment_any: bool,
30}
31
32#[derive(Debug, Clone, Deserialize, ToSchema)]
33pub struct BanUserCmd {
34 pub user_id: Uuid,
35 #[serde(default)]
36 pub reason: Option<String>,
37 #[serde(default)]
38 pub expires_at: Option<OffsetDateTime>,
39 #[serde(default)]
40 pub allow_agent_join: Option<bool>,
41 #[serde(default)]
42 pub allow_speak: Option<bool>,
43 #[serde(default)]
44 pub allow_push_repo: Option<bool>,
45 #[serde(default)]
46 pub allow_comment_any: Option<bool>,
47}
48
49#[derive(Debug, Clone, Deserialize, ToSchema)]
50pub struct UpdateBanCmd {
51 #[serde(default)]
52 pub reason: Option<Option<String>>,
53 #[serde(default)]
54 pub expires_at: Option<Option<OffsetDateTime>>,
55 #[serde(default)]
56 pub allow_agent_join: Option<bool>,
57 #[serde(default)]
58 pub allow_speak: Option<bool>,
59 #[serde(default)]
60 pub allow_push_repo: Option<bool>,
61 #[serde(default)]
62 pub allow_comment_any: Option<bool>,
63}
64
65#[derive(Debug, Clone, Serialize, ToSchema)]
66pub struct BanPage {
67 pub items: Vec<BanDto>,
68 pub total: i64,
69}
70
71impl SpaceService {
72 pub async fn ban_user(
73 &self,
74 caller: Uuid,
75 space_name: &str,
76 cmd: BanUserCmd,
77 ) -> Result<BanDto, SpaceError> {
78 let space = lookup_space_by_name(self, space_name).await?;
79 if space.owner_id != caller {
80 return Err(SpaceError::Forbidden);
81 }
82
83 user::Entity::find_by_id(cmd.user_id)
84 .one(self.db().read())
85 .await?
86 .ok_or(SpaceError::NotFound)?;
87
88 let existing = space_bans::Entity::find_by_id((space.id, cmd.user_id))
89 .one(self.db().read())
90 .await?;
91 if existing.is_some() {
92 return Err(SpaceError::Conflict(
93 "user is already banned from this space".to_string(),
94 ));
95 }
96
97 let now = OffsetDateTime::now_utc();
98 let ban_row = space_bans::ActiveModel {
99 space_id: Set(space.id),
100 user_id: Set(cmd.user_id),
101 reason: Set(cmd.reason),
102 banned_by: Set(caller),
103 created_at: Set(now),
104 updated_at: Set(now),
105 expires_at: Set(cmd.expires_at),
106 allow_agent_join: Set(cmd.allow_agent_join.unwrap_or(false)),
107 allow_speak: Set(cmd.allow_speak.unwrap_or(false)),
108 allow_push_repo: Set(cmd.allow_push_repo.unwrap_or(false)),
109 allow_comment_any: Set(cmd.allow_comment_any.unwrap_or(false)),
110 };
111
112 let inserted = ban_row.insert(self.db().write()).await?;
113
114 let user_map = self.load_user_briefs(&[inserted.user_id, caller]).await?;
115 let user_brief = user_map.get(&inserted.user_id).cloned();
116 let actor_brief = user_map.get(&caller).cloned();
117
118 Ok(BanDto {
119 space_id: inserted.space_id,
120 user: user_brief,
121 reason: inserted.reason,
122 banned_by: actor_brief,
123 created_at: inserted.created_at,
124 updated_at: inserted.updated_at,
125 expires_at: inserted.expires_at,
126 allow_agent_join: inserted.allow_agent_join,
127 allow_speak: inserted.allow_speak,
128 allow_push_repo: inserted.allow_push_repo,
129 allow_comment_any: inserted.allow_comment_any,
130 })
131 }
132
133 pub async fn get_ban(
134 &self,
135 caller: Uuid,
136 space_name: &str,
137 uid: Uuid,
138 ) -> Result<BanDto, SpaceError> {
139 let space = lookup_space_by_name(self, space_name).await?;
140 if space.owner_id != caller {
141 return Err(SpaceError::Forbidden);
142 }
143
144 let ban = space_bans::Entity::find_by_id((space.id, uid))
145 .one(self.db().read())
146 .await?
147 .ok_or(SpaceError::NotFound)?;
148
149 let user_map = self.load_user_briefs(&[ban.user_id, ban.banned_by]).await?;
150 let user_brief = user_map.get(&ban.user_id).cloned();
151 let actor_brief = user_map.get(&ban.banned_by).cloned();
152
153 Ok(BanDto {
154 space_id: ban.space_id,
155 user: user_brief,
156 reason: ban.reason,
157 banned_by: actor_brief,
158 created_at: ban.created_at,
159 updated_at: ban.updated_at,
160 expires_at: ban.expires_at,
161 allow_agent_join: ban.allow_agent_join,
162 allow_speak: ban.allow_speak,
163 allow_push_repo: ban.allow_push_repo,
164 allow_comment_any: ban.allow_comment_any,
165 })
166 }
167
168 pub async fn update_ban(
169 &self,
170 caller: Uuid,
171 space_name: &str,
172 uid: Uuid,
173 cmd: UpdateBanCmd,
174 ) -> Result<BanDto, SpaceError> {
175 let space = lookup_space_by_name(self, space_name).await?;
176 if space.owner_id != caller {
177 return Err(SpaceError::Forbidden);
178 }
179
180 let ban = space_bans::Entity::find_by_id((space.id, uid))
181 .one(self.db().read())
182 .await?
183 .ok_or(SpaceError::NotFound)?;
184
185 let mut am: space_bans::ActiveModel = ban.into();
186
187 if let Some(reason) = cmd.reason {
188 am.reason = Set(reason);
189 }
190 if let Some(expires_at) = cmd.expires_at {
191 am.expires_at = Set(expires_at);
192 }
193 if let Some(v) = cmd.allow_agent_join {
194 am.allow_agent_join = Set(v);
195 }
196 if let Some(v) = cmd.allow_speak {
197 am.allow_speak = Set(v);
198 }
199 if let Some(v) = cmd.allow_push_repo {
200 am.allow_push_repo = Set(v);
201 }
202 if let Some(v) = cmd.allow_comment_any {
203 am.allow_comment_any = Set(v);
204 }
205
206 let now = OffsetDateTime::now_utc();
207 am.updated_at = Set(now);
208
209 let updated = am.update(self.db().write()).await?;
210
211 let user_map = self
212 .load_user_briefs(&[updated.user_id, updated.banned_by])
213 .await?;
214 let user_brief = user_map.get(&updated.user_id).cloned();
215 let actor_brief = user_map.get(&updated.banned_by).cloned();
216
217 Ok(BanDto {
218 space_id: updated.space_id,
219 user: user_brief,
220 reason: updated.reason,
221 banned_by: actor_brief,
222 created_at: updated.created_at,
223 updated_at: updated.updated_at,
224 expires_at: updated.expires_at,
225 allow_agent_join: updated.allow_agent_join,
226 allow_speak: updated.allow_speak,
227 allow_push_repo: updated.allow_push_repo,
228 allow_comment_any: updated.allow_comment_any,
229 })
230 }
231
232 pub async fn unban_user(
233 &self,
234 caller: Uuid,
235 space_name: &str,
236 uid: Uuid,
237 ) -> Result<(), SpaceError> {
238 let space = lookup_space_by_name(self, space_name).await?;
239 if space.owner_id != caller {
240 return Err(SpaceError::Forbidden);
241 }
242
243 let res = space_bans::Entity::delete_by_id((space.id, uid))
244 .exec(self.db().write())
245 .await?;
246 if res.rows_affected == 0 {
247 return Err(SpaceError::NotFound);
248 }
249 Ok(())
250 }
251
252 pub async fn list_bans(
253 &self,
254 caller: Uuid,
255 space_name: &str,
256 q: PageQuery,
257 ) -> Result<BanPage, SpaceError> {
258 let space = lookup_space_by_name(self, space_name).await?;
259 if space.owner_id != caller {
260 return Err(SpaceError::Forbidden);
261 }
262
263 let max_pagination = self.config().space.max_pagination;
264 let default_pagination = self.config().space.default_pagination;
265 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
266 let offset = q.offset.unwrap_or(0);
267
268 let select = space_bans::Entity::find()
269 .filter(space_bans::Column::SpaceId.eq(space.id))
270 .order_by_desc(space_bans::Column::CreatedAt);
271
272 let total = select.clone().count(self.db().read()).await? as i64;
273 let rows = select
274 .limit(limit)
275 .offset(offset)
276 .all(self.db().read())
277 .await?;
278
279 let mut uids: Vec<Uuid> = Vec::new();
280 for b in &rows {
281 uids.push(b.user_id);
282 uids.push(b.banned_by);
283 }
284 let user_map = self.load_user_briefs(&uids).await?;
285
286 let items = rows
287 .into_iter()
288 .map(|b| {
289 let user_brief = user_map.get(&b.user_id).cloned();
290 let actor_brief = user_map.get(&b.banned_by).cloned();
291 BanDto {
292 space_id: b.space_id,
293 user: user_brief,
294 reason: b.reason,
295 banned_by: actor_brief,
296 created_at: b.created_at,
297 updated_at: b.updated_at,
298 expires_at: b.expires_at,
299 allow_agent_join: b.allow_agent_join,
300 allow_speak: b.allow_speak,
301 allow_push_repo: b.allow_push_repo,
302 allow_comment_any: b.allow_comment_any,
303 }
304 })
305 .collect();
306
307 Ok(BanPage { items, total })
308 }
309}
Added lib/space/src/services/dir.rs lib/space/src/services/dir.rs +779 −0 View lib/space/src/services/dir.rs
@@ -0,0 +1,779 @@
1use std::collections::{HashMap, HashSet};
2
3use models::dir::dirs::{self, DirKind};
4use models::dir::dir_overwrite_group;
5use models::dir::dir_overwrite_users;
6use models::space::space_groups;
7use models::space::space_member_groups;
8use models::users::user;
9use sea_orm::{
10 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set,
11 TransactionTrait,
12};
13use serde::{Deserialize, Serialize};
14use time::OffsetDateTime;
15use utoipa::{IntoParams, ToSchema};
16use uuid::Uuid;
17
18use crate::errors::SpaceError;
19use crate::services::service::SpaceService;
20use crate::services::spaces::UserBriefDto;
21use crate::share::authz::{effective_permissions, require_perm, MANAGE_OVERWRITES};
22use crate::share::lookup::lookup_space_by_name;
23
24pub const MAX_DIR_NODE_NAME: usize = 255;
25pub const PERM_DIR_VISIBLE: i64 = 1 << 0;
26
27#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
28pub struct DirNodeDto {
29 pub id: Uuid,
30 pub name: String,
31 pub kind: DirKind,
32 pub parent_dir_id: Option<Uuid>,
33 pub color: Option<String>,
34 pub position: i64,
35 pub icon: Option<String>,
36 pub topic: Option<String>,
37 pub purpose: Option<String>,
38 pub is_archived: bool,
39 pub archived_at: Option<OffsetDateTime>,
40 pub created_at: OffsetDateTime,
41 pub updated_at: OffsetDateTime,
42 pub created_by_uid: Uuid,
43 pub children: Vec<DirNodeDto>,
44}
45
46impl From<dirs::Model> for DirNodeDto {
47 fn from(m: dirs::Model) -> Self {
48 Self {
49 id: m.id,
50 name: m.name,
51 kind: m.kind,
52 parent_dir_id: m.parent_dir_id,
53 color: m.color,
54 position: m.position,
55 icon: m.icon,
56 topic: m.topic,
57 purpose: m.purpose,
58 is_archived: m.is_archived,
59 archived_at: m.archived_at,
60 created_at: m.created_at,
61 updated_at: m.updated_at,
62 created_by_uid: m.created_by_uid,
63 children: Vec::new(),
64 }
65 }
66}
67
68#[derive(Debug, Clone, Deserialize, ToSchema)]
69pub struct CreateDirCmd {
70 pub name: String,
71 #[serde(default)]
72 pub parent_dir_id: Option<Uuid>,
73 #[serde(default)]
74 pub position: Option<i64>,
75}
76
77#[derive(Debug, Clone, Deserialize, ToSchema)]
78pub struct UpdateDirCmd {
79 pub name: String,
80}
81
82#[derive(Debug, Clone, Deserialize, ToSchema)]
83pub struct MoveDirCmd {
84 #[serde(default)]
85 pub parent_dir_id: Option<Option<Uuid>>,
86 #[serde(default)]
87 pub position: Option<i64>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
91pub struct RepairDirResp {
92 pub scanned: i64,
93 pub fixed: i64,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
97pub struct GroupOverwriteDto {
98 pub dir_id: Uuid,
99 pub group_id: Uuid,
100 pub allow_mask: i64,
101 pub deny_mask: i64,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
105pub struct UserOverwriteDto {
106 pub dir_id: Uuid,
107 pub user_id: Uuid,
108 pub user: Option<UserBriefDto>,
109 pub allow_mask: i64,
110 pub deny_mask: i64,
111}
112
113#[derive(Debug, Clone, Deserialize, ToSchema)]
114pub struct SetOverwriteCmd {
115 pub allow_mask: i64,
116 pub deny_mask: i64,
117}
118
119#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
120pub struct DirPath {
121 pub space_name: String,
122 pub dir_id: Uuid,
123}
124
125impl SpaceService {
126 async fn dir_in_space(&self, space_id: Uuid, dir_id: Uuid) -> Result<dirs::Model, SpaceError> {
127 dirs::Entity::find_by_id(dir_id)
128 .filter(dirs::Column::SpaceId.eq(space_id))
129 .one(self.db().read())
130 .await?
131 .ok_or(SpaceError::NotFound)
132 }
133
134 pub async fn create_dir_node(
135 &self,
136 caller: Uuid,
137 space_name: &str,
138 cmd: CreateDirCmd,
139 ) -> Result<DirNodeDto, SpaceError> {
140 let space = lookup_space_by_name(self, space_name).await?;
141 if space.owner_id != caller {
142 return Err(SpaceError::Forbidden);
143 }
144
145 let name = cmd.name.trim();
146 if name.is_empty() || name.chars().count() > MAX_DIR_NODE_NAME {
147 return Err(SpaceError::BadRequest("dir name is empty or too long".to_string()));
148 }
149
150 if let Some(p) = cmd.parent_dir_id {
151 let parent_node = self.dir_in_space(space.id, p).await?;
152 if parent_node.kind != DirKind::Dir {
153 return Err(SpaceError::BadRequest("parent node must be a dir".to_string()));
154 }
155 }
156
157 let position = match cmd.position {
158 Some(pos) => {
159 if pos < 0 {
160 return Err(SpaceError::BadRequest("position must be non-negative".to_string()));
161 }
162 pos
163 }
164 None => {
165 let max_node = dirs::Entity::find()
166 .filter(dirs::Column::SpaceId.eq(space.id))
167 .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
168 .order_by_desc(dirs::Column::Position)
169 .one(self.db().read())
170 .await?;
171 max_node.map(|n| n.position + 1).unwrap_or(0)
172 }
173 };
174
175 let now = OffsetDateTime::now_utc();
176 let row = dirs::ActiveModel {
177 id: Set(Uuid::now_v7()),
178 space_id: Set(Some(space.id)),
179 name: Set(name.to_string()),
180 kind: Set(DirKind::Dir),
181 parent_dir_id: Set(cmd.parent_dir_id),
182 color: Set(None),
183 position: Set(position),
184 icon: Set(None),
185 topic: Set(None),
186 purpose: Set(None),
187 is_archived: Set(false),
188 archived_at: Set(None),
189 archived_by: Set(None),
190 created_at: Set(now),
191 updated_at: Set(now),
192 created_by_uid: Set(caller),
193 };
194
195 let inserted = row.insert(self.db().write()).await?;
196 Ok(DirNodeDto::from(inserted))
197 }
198
199 pub async fn get_space_dir_tree(
200 &self,
201 caller: Uuid,
202 space_name: &str,
203 ) -> Result<Vec<DirNodeDto>, SpaceError> {
204 let space = lookup_space_by_name(self, space_name).await?;
205 effective_permissions(self.db().read(), &space, caller).await?;
206
207 let is_owner = space.owner_id == caller;
208
209 let nodes = dirs::Entity::find()
210 .filter(dirs::Column::SpaceId.eq(space.id))
211 .order_by_asc(dirs::Column::Position)
212 .all(self.db().read())
213 .await?;
214
215 if nodes.is_empty() {
216 return Ok(Vec::new());
217 }
218
219 let node_ids: Vec<Uuid> = nodes.iter().map(|n| n.id).collect();
220
221 let group_ows = dir_overwrite_group::Entity::find()
222 .filter(dir_overwrite_group::Column::DirId.is_in(node_ids.clone()))
223 .all(self.db().read())
224 .await?;
225
226 let user_ows = dir_overwrite_users::Entity::find()
227 .filter(dir_overwrite_users::Column::DirId.is_in(node_ids.clone()))
228 .all(self.db().read())
229 .await?;
230
231 let my_member_groups = space_member_groups::Entity::find()
232 .filter(space_member_groups::Column::SpaceId.eq(space.id))
233 .filter(space_member_groups::Column::UserId.eq(caller))
234 .all(self.db().read())
235 .await?;
236 let my_group_ids: HashSet<Uuid> = my_member_groups.into_iter().map(|mg| mg.group_id).collect();
237
238 let mut group_ow_map: HashMap<Uuid, Vec<dir_overwrite_group::Model>> = HashMap::new();
239 for go in group_ows {
240 group_ow_map.entry(go.dir_id).or_default().push(go);
241 }
242
243 let mut user_ow_map: HashMap<Uuid, Vec<dir_overwrite_users::Model>> = HashMap::new();
244 for uo in user_ows {
245 user_ow_map.entry(uo.dir_id).or_default().push(uo);
246 }
247
248 let mut direct_visible: HashMap<Uuid, bool> = HashMap::new();
249 let mut parent_map: HashMap<Uuid, Option<Uuid>> = HashMap::new();
250 let mut node_map: HashMap<Uuid, dirs::Model> = HashMap::new();
251
252 for n in &nodes {
253 parent_map.insert(n.id, n.parent_dir_id);
254 node_map.insert(n.id, n.clone());
255
256 if is_owner {
257 direct_visible.insert(n.id, true);
258 continue;
259 }
260
261 let has_group_ow = group_ow_map.contains_key(&n.id);
262 let has_user_ow = user_ow_map.contains_key(&n.id);
263
264 if !has_group_ow && !has_user_ow {
265 direct_visible.insert(n.id, true);
266 continue;
267 }
268
269 let mut visible = false;
270 let mut resolved = false;
271
272 if let Some(uos) = user_ow_map.get(&n.id)
273 && let Some(uo) = uos.iter().find(|u| u.user_id == caller)
274 {
275 if (uo.deny_mask & PERM_DIR_VISIBLE) != 0 {
276 visible = false;
277 resolved = true;
278 } else if (uo.allow_mask & PERM_DIR_VISIBLE) != 0 {
279 visible = true;
280 resolved = true;
281 }
282 }
283
284 if !resolved
285 && let Some(gos) = group_ow_map.get(&n.id)
286 {
287 let matching: Vec<&dir_overwrite_group::Model> =
288 gos.iter().filter(|g| my_group_ids.contains(&g.group_id)).collect();
289 if !matching.is_empty() {
290 let denied = matching.iter().any(|g| (g.deny_mask & PERM_DIR_VISIBLE) != 0);
291 if denied {
292 visible = false;
293 } else {
294 let allowed =
295 matching.iter().any(|g| (g.allow_mask & PERM_DIR_VISIBLE) != 0);
296 if allowed {
297 visible = true;
298 }
299 }
300 }
301 }
302
303 direct_visible.insert(n.id, visible);
304 }
305
306 fn is_truly_visible(
307 id: Uuid,
308 parent_map: &HashMap<Uuid, Option<Uuid>>,
309 direct_visible: &HashMap<Uuid, bool>,
310 memo: &mut HashMap<Uuid, bool>,
311 visiting: &mut HashSet<Uuid>,
312 ) -> bool {
313 if let Some(&vis) = memo.get(&id) {
314 return vis;
315 }
316 if visiting.contains(&id) {
317 return false;
318 }
319 if !direct_visible.get(&id).copied().unwrap_or(false) {
320 memo.insert(id, false);
321 return false;
322 }
323 visiting.insert(id);
324 let parent_opt = parent_map.get(&id).copied().flatten();
325 let parent_vis = match parent_opt {
326 Some(pid) if parent_map.contains_key(&pid) => {
327 is_truly_visible(pid, parent_map, direct_visible, memo, visiting)
328 }
329 _ => true,
330 };
331 visiting.remove(&id);
332 memo.insert(id, parent_vis);
333 parent_vis
334 }
335
336 let mut memo = HashMap::new();
337 let mut truly_visible_set = HashSet::new();
338 for &nid in &node_ids {
339 let mut visiting = HashSet::new();
340 if is_truly_visible(nid, &parent_map, &direct_visible, &mut memo, &mut visiting) {
341 truly_visible_set.insert(nid);
342 }
343 }
344
345 let mut children_map: HashMap<Option<Uuid>, Vec<DirNodeDto>> = HashMap::new();
346 for n in nodes {
347 if !truly_visible_set.contains(&n.id) {
348 continue;
349 }
350 let parent_id = match n.parent_dir_id {
351 Some(p) if truly_visible_set.contains(&p) => Some(p),
352 _ => None,
353 };
354 children_map.entry(parent_id).or_default().push(DirNodeDto::from(n));
355 }
356
357 fn assemble_tree(
358 parent_id: Option<Uuid>,
359 children_map: &mut HashMap<Option<Uuid>, Vec<DirNodeDto>>,
360 ) -> Vec<DirNodeDto> {
361 let mut list = children_map.remove(&parent_id).unwrap_or_default();
362 list.sort_by(|a, b| {
363 a.position
364 .cmp(&b.position)
365 .then_with(|| a.created_at.cmp(&b.created_at))
366 });
367 for item in &mut list {
368 item.children = assemble_tree(Some(item.id), children_map);
369 }
370 list
371 }
372
373 let forest = assemble_tree(None, &mut children_map);
374 Ok(forest)
375 }
376
377 pub async fn update_dir_node(
378 &self,
379 caller: Uuid,
380 space_name: &str,
381 dir_id: Uuid,
382 cmd: UpdateDirCmd,
383 ) -> Result<DirNodeDto, SpaceError> {
384 let space = lookup_space_by_name(self, space_name).await?;
385 if space.owner_id != caller {
386 return Err(SpaceError::Forbidden);
387 }
388
389 let node = self.dir_in_space(space.id, dir_id).await?;
390
391 let name = cmd.name.trim();
392 if name.is_empty() || name.chars().count() > MAX_DIR_NODE_NAME {
393 return Err(SpaceError::BadRequest("dir name is empty or too long".to_string()));
394 }
395
396 let now = OffsetDateTime::now_utc();
397 let mut am: dirs::ActiveModel = node.into();
398 am.name = Set(name.to_string());
399 am.updated_at = Set(now);
400 let updated = am.update(self.db().write()).await?;
401
402 Ok(DirNodeDto::from(updated))
403 }
404
405 pub async fn move_dir_node(
406 &self,
407 caller: Uuid,
408 space_name: &str,
409 dir_id: Uuid,
410 cmd: MoveDirCmd,
411 ) -> Result<DirNodeDto, SpaceError> {
412 let space = lookup_space_by_name(self, space_name).await?;
413 if space.owner_id != caller {
414 return Err(SpaceError::Forbidden);
415 }
416
417 let node = self.dir_in_space(space.id, dir_id).await?;
418
419 let new_parent_id = match cmd.parent_dir_id {
420 None => node.parent_dir_id,
421 Some(None) => None,
422 Some(Some(p)) => {
423 if p == dir_id {
424 return Err(SpaceError::BadRequest("cannot set parent to self".to_string()));
425 }
426 let parent_node = self.dir_in_space(space.id, p).await?;
427 if parent_node.kind != DirKind::Dir {
428 return Err(SpaceError::BadRequest("parent node must be a dir".to_string()));
429 }
430
431 let mut curr = parent_node.parent_dir_id;
432 while let Some(curr_p) = curr {
433 if curr_p == dir_id {
434 return Err(SpaceError::BadRequest(
435 "cannot move node into its own descendant".to_string(),
436 ));
437 }
438 let next = dirs::Entity::find_by_id(curr_p)
439 .filter(dirs::Column::SpaceId.eq(space.id))
440 .one(self.db().read())
441 .await?;
442 curr = next.and_then(|n| n.parent_dir_id);
443 }
444
445 Some(p)
446 }
447 };
448
449 let new_position = match cmd.position {
450 Some(pos) => {
451 if pos < 0 {
452 return Err(SpaceError::BadRequest("position must be non-negative".to_string()));
453 }
454 pos
455 }
456 None if new_parent_id != node.parent_dir_id => {
457 let max_node = dirs::Entity::find()
458 .filter(dirs::Column::SpaceId.eq(space.id))
459 .filter(dirs::Column::ParentDirId.eq(new_parent_id))
460 .order_by_desc(dirs::Column::Position)
461 .one(self.db().read())
462 .await?;
463 max_node.map(|n| n.position + 1).unwrap_or(0)
464 }
465 None => node.position,
466 };
467
468 let now = OffsetDateTime::now_utc();
469 let mut am: dirs::ActiveModel = node.into();
470 am.parent_dir_id = Set(new_parent_id);
471 am.position = Set(new_position);
472 am.updated_at = Set(now);
473 let updated = am.update(self.db().write()).await?;
474
475 Ok(DirNodeDto::from(updated))
476 }
477
478 pub async fn repair_dir_parents(
479 &self,
480 caller: Uuid,
481 space_name: &str,
482 ) -> Result<RepairDirResp, SpaceError> {
483 let space = lookup_space_by_name(self, space_name).await?;
484 if space.owner_id != caller {
485 return Err(SpaceError::Forbidden);
486 }
487
488 let nodes = dirs::Entity::find()
489 .filter(dirs::Column::SpaceId.eq(space.id))
490 .all(self.db().read())
491 .await?;
492
493 let scanned = nodes.len() as i64;
494 let mut parent_map: HashMap<Uuid, Option<Uuid>> = HashMap::new();
495 for n in &nodes {
496 parent_map.insert(n.id, n.parent_dir_id);
497 }
498
499 let mut fixed_nodes: HashSet<Uuid> = HashSet::new();
500
501 for n in &nodes {
502 if let Some(p) = n.parent_dir_id
503 && (p == n.id || !parent_map.contains_key(&p))
504 {
505 fixed_nodes.insert(n.id);
506 parent_map.insert(n.id, None);
507 }
508 }
509
510 let mut state: HashMap<Uuid, u8> = HashMap::new();
511 let keys: Vec<Uuid> = parent_map.keys().copied().collect();
512
513 for nid in keys {
514 if state.get(&nid).copied().unwrap_or(0) != 0 {
515 continue;
516 }
517
518 let mut curr = Some(nid);
519 let mut path = Vec::new();
520
521 while let Some(c) = curr {
522 let st = state.get(&c).copied().unwrap_or(0);
523 if st == 1 {
524 fixed_nodes.insert(c);
525 parent_map.insert(c, None);
526 break;
527 } else if st == 2 {
528 break;
529 }
530
531 state.insert(c, 1);
532 path.push(c);
533 curr = parent_map.get(&c).copied().flatten();
534 }
535
536 for p in path {
537 state.insert(p, 2);
538 }
539 }
540
541 let now = OffsetDateTime::now_utc();
542 let fixed = fixed_nodes.len() as i64;
543
544 if !fixed_nodes.is_empty() {
545 self.db()
546 .primary()
547 .transaction::<_, (), SpaceError>(|txn| {
548 Box::pin(async move {
549 for fid in fixed_nodes {
550 let node = dirs::Entity::find_by_id(fid).one(txn).await?;
551 if let Some(n) = node {
552 let mut am: dirs::ActiveModel = n.into();
553 am.parent_dir_id = Set(None);
554 am.updated_at = Set(now);
555 am.update(txn).await?;
556 }
557 }
558 Ok(())
559 })
560 })
561 .await?;
562 }
563
564 Ok(RepairDirResp { scanned, fixed })
565 }
566
567 pub async fn list_dir_group_overwrites(
568 &self,
569 caller: Uuid,
570 space_name: &str,
571 dir_id: Uuid,
572 ) -> Result<Vec<GroupOverwriteDto>, SpaceError> {
573 let space = lookup_space_by_name(self, space_name).await?;
574 effective_permissions(self.db().read(), &space, caller).await?;
575 self.dir_in_space(space.id, dir_id).await?;
576
577 let rows = dir_overwrite_group::Entity::find()
578 .filter(dir_overwrite_group::Column::DirId.eq(dir_id))
579 .all(self.db().read())
580 .await?;
581
582 Ok(rows
583 .into_iter()
584 .map(|r| GroupOverwriteDto {
585 dir_id: r.dir_id,
586 group_id: r.group_id,
587 allow_mask: r.allow_mask,
588 deny_mask: r.deny_mask,
589 })
590 .collect())
591 }
592
593 pub async fn set_dir_group_overwrite(
594 &self,
595 caller: Uuid,
596 space_name: &str,
597 dir_id: Uuid,
598 group_id: Uuid,
599 cmd: SetOverwriteCmd,
600 ) -> Result<GroupOverwriteDto, SpaceError> {
601 let space = lookup_space_by_name(self, space_name).await?;
602 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
603 self.dir_in_space(space.id, dir_id).await?;
604
605 space_groups::Entity::find_by_id(group_id)
606 .filter(space_groups::Column::SpaceId.eq(space.id))
607 .one(self.db().read())
608 .await?
609 .ok_or(SpaceError::NotFound)?;
610
611 if cmd.allow_mask < 0 || cmd.deny_mask < 0 {
612 return Err(SpaceError::BadRequest("masks must be non-negative".to_string()));
613 }
614
615 let existing = dir_overwrite_group::Entity::find_by_id((dir_id, group_id))
616 .one(self.db().read())
617 .await?;
618
619 if let Some(row) = existing {
620 let mut am: dir_overwrite_group::ActiveModel = row.into();
621 am.allow_mask = Set(cmd.allow_mask);
622 am.deny_mask = Set(cmd.deny_mask);
623 let updated = am.update(self.db().write()).await?;
624 Ok(GroupOverwriteDto {
625 dir_id: updated.dir_id,
626 group_id: updated.group_id,
627 allow_mask: updated.allow_mask,
628 deny_mask: updated.deny_mask,
629 })
630 } else {
631 let am = dir_overwrite_group::ActiveModel {
632 dir_id: Set(dir_id),
633 group_id: Set(group_id),
634 allow_mask: Set(cmd.allow_mask),
635 deny_mask: Set(cmd.deny_mask),
636 };
637 let inserted = am.insert(self.db().write()).await?;
638 Ok(GroupOverwriteDto {
639 dir_id: inserted.dir_id,
640 group_id: inserted.group_id,
641 allow_mask: inserted.allow_mask,
642 deny_mask: inserted.deny_mask,
643 })
644 }
645 }
646
647 pub async fn remove_dir_group_overwrite(
648 &self,
649 caller: Uuid,
650 space_name: &str,
651 dir_id: Uuid,
652 group_id: Uuid,
653 ) -> Result<(), SpaceError> {
654 let space = lookup_space_by_name(self, space_name).await?;
655 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
656 self.dir_in_space(space.id, dir_id).await?;
657
658 let res = dir_overwrite_group::Entity::delete_many()
659 .filter(dir_overwrite_group::Column::DirId.eq(dir_id))
660 .filter(dir_overwrite_group::Column::GroupId.eq(group_id))
661 .exec(self.db().write())
662 .await?;
663
664 if res.rows_affected == 0 {
665 return Err(SpaceError::NotFound);
666 }
667 Ok(())
668 }
669
670 pub async fn list_dir_user_overwrites(
671 &self,
672 caller: Uuid,
673 space_name: &str,
674 dir_id: Uuid,
675 ) -> Result<Vec<UserOverwriteDto>, SpaceError> {
676 let space = lookup_space_by_name(self, space_name).await?;
677 effective_permissions(self.db().read(), &space, caller).await?;
678 self.dir_in_space(space.id, dir_id).await?;
679
680 let rows = dir_overwrite_users::Entity::find()
681 .filter(dir_overwrite_users::Column::DirId.eq(dir_id))
682 .all(self.db().read())
683 .await?;
684
685 let user_ids: Vec<Uuid> = rows.iter().map(|r| r.user_id).collect();
686 let user_map = self.load_user_briefs(&user_ids).await?;
687
688 Ok(rows
689 .into_iter()
690 .map(|r| {
691 let user_brief = user_map.get(&r.user_id).cloned();
692 UserOverwriteDto {
693 dir_id: r.dir_id,
694 user_id: r.user_id,
695 user: user_brief,
696 allow_mask: r.allow_mask,
697 deny_mask: r.deny_mask,
698 }
699 })
700 .collect())
701 }
702
703 pub async fn set_dir_user_overwrite(
704 &self,
705 caller: Uuid,
706 space_name: &str,
707 dir_id: Uuid,
708 user_id: Uuid,
709 cmd: SetOverwriteCmd,
710 ) -> Result<UserOverwriteDto, SpaceError> {
711 let space = lookup_space_by_name(self, space_name).await?;
712 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
713 self.dir_in_space(space.id, dir_id).await?;
714
715 user::Entity::find_by_id(user_id)
716 .one(self.db().read())
717 .await?
718 .ok_or(SpaceError::NotFound)?;
719
720 if cmd.allow_mask < 0 || cmd.deny_mask < 0 {
721 return Err(SpaceError::BadRequest("masks must be non-negative".to_string()));
722 }
723
724 let existing = dir_overwrite_users::Entity::find_by_id((dir_id, user_id))
725 .one(self.db().read())
726 .await?;
727
728 let (final_allow, final_deny) = if let Some(row) = existing {
729 let mut am: dir_overwrite_users::ActiveModel = row.into();
730 am.allow_mask = Set(cmd.allow_mask);
731 am.deny_mask = Set(cmd.deny_mask);
732 let updated = am.update(self.db().write()).await?;
733 (updated.allow_mask, updated.deny_mask)
734 } else {
735 let am = dir_overwrite_users::ActiveModel {
736 dir_id: Set(dir_id),
737 user_id: Set(user_id),
738 allow_mask: Set(cmd.allow_mask),
739 deny_mask: Set(cmd.deny_mask),
740 };
741 let inserted = am.insert(self.db().write()).await?;
742 (inserted.allow_mask, inserted.deny_mask)
743 };
744
745 let user_map = self.load_user_briefs(&[user_id]).await?;
746 let user_brief = user_map.get(&user_id).cloned();
747
748 Ok(UserOverwriteDto {
749 dir_id,
750 user_id,
751 user: user_brief,
752 allow_mask: final_allow,
753 deny_mask: final_deny,
754 })
755 }
756
757 pub async fn remove_dir_user_overwrite(
758 &self,
759 caller: Uuid,
760 space_name: &str,
761 dir_id: Uuid,
762 user_id: Uuid,
763 ) -> Result<(), SpaceError> {
764 let space = lookup_space_by_name(self, space_name).await?;
765 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
766 self.dir_in_space(space.id, dir_id).await?;
767
768 let res = dir_overwrite_users::Entity::delete_many()
769 .filter(dir_overwrite_users::Column::DirId.eq(dir_id))
770 .filter(dir_overwrite_users::Column::UserId.eq(user_id))
771 .exec(self.db().write())
772 .await?;
773
774 if res.rows_affected == 0 {
775 return Err(SpaceError::NotFound);
776 }
777 Ok(())
778 }
779}
Added lib/space/src/services/groups.rs lib/space/src/services/groups.rs +547 −0 View lib/space/src/services/groups.rs
@@ -0,0 +1,547 @@
1use std::collections::HashMap;
2
3use models::space::space_group_permissions;
4use models::space::space_groups;
5use models::space::space_member_groups;
6use models::space::space_members;
7use sea_orm::{
8 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter,
9 QueryOrder, QuerySelect, Set, TransactionTrait,
10};
11use serde::{Deserialize, Serialize};
12use time::OffsetDateTime;
13use utoipa::ToSchema;
14use uuid::Uuid;
15
16use crate::errors::SpaceError;
17use crate::services::service::SpaceService;
18use crate::services::spaces::{PageQuery, UserBriefDto};
19use crate::share::authz::{effective_permissions, require_perm, MANAGE_ROLES};
20use crate::share::lookup::lookup_space_by_name;
21
22#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
23pub struct GroupDto {
24 pub id: Uuid,
25 pub space_id: Uuid,
26 pub name: String,
27 pub position: i64,
28 pub color: String,
29 pub unicode_emoji: Option<String>,
30 pub icon_hash: Option<String>,
31 pub version: i32,
32 pub created_at: OffsetDateTime,
33 pub updated_at: Option<OffsetDateTime>,
34 pub created_by: Option<UserBriefDto>,
35 pub permissions: Vec<String>,
36}
37
38#[derive(Debug, Clone, Deserialize, ToSchema)]
39pub struct CreateGroupCmd {
40 pub name: String,
41 #[serde(default)]
42 pub color: Option<String>,
43 #[serde(default)]
44 pub unicode_emoji: Option<String>,
45 #[serde(default)]
46 pub icon_hash: Option<String>,
47 #[serde(default)]
48 pub position: Option<i64>,
49 #[serde(default)]
50 pub permissions: Vec<String>,
51}
52
53#[derive(Debug, Clone, Deserialize, ToSchema)]
54pub struct UpdateGroupCmd {
55 #[serde(default)]
56 pub name: Option<String>,
57 #[serde(default)]
58 pub color: Option<String>,
59 #[serde(default)]
60 pub unicode_emoji: Option<Option<String>>,
61 #[serde(default)]
62 pub icon_hash: Option<Option<String>>,
63 #[serde(default)]
64 pub position: Option<i64>,
65 #[serde(default)]
66 pub permissions: Option<Vec<String>>,
67}
68
69#[derive(Debug, Clone, Deserialize, ToSchema)]
70pub struct AssignGroupCmd {
71 pub group_id: Uuid,
72}
73
74#[derive(Debug, Clone, Serialize, ToSchema)]
75pub struct GroupPage {
76 pub items: Vec<GroupDto>,
77 pub total: i64,
78}
79
80async fn replace_permissions(
81 db: &impl ConnectionTrait,
82 group_id: Uuid,
83 permissions: &[String],
84) -> Result<(), SpaceError> {
85 space_group_permissions::Entity::delete_many()
86 .filter(space_group_permissions::Column::GroupId.eq(group_id))
87 .exec(db)
88 .await?;
89
90 for perm in permissions {
91 let row = space_group_permissions::ActiveModel {
92 group_id: Set(group_id),
93 permission: Set(perm.clone()),
94 };
95 row.insert(db).await?;
96 }
97 Ok(())
98}
99
100impl SpaceService {
101 pub async fn create_group(
102 &self,
103 caller: Uuid,
104 space_name: &str,
105 cmd: CreateGroupCmd,
106 ) -> Result<GroupDto, SpaceError> {
107 let space = lookup_space_by_name(self, space_name).await?;
108 if space.owner_id != caller {
109 return Err(SpaceError::Forbidden);
110 }
111
112 let name = cmd.name.trim();
113 if name.is_empty() || name.chars().count() > self.config().space.max_group_name_len {
114 return Err(SpaceError::BadRequest("group name is empty or too long".to_string()));
115 }
116
117 let existing = space_groups::Entity::find()
118 .filter(space_groups::Column::SpaceId.eq(space.id))
119 .filter(space_groups::Column::Name.eq(name))
120 .one(self.db().read())
121 .await?;
122 if existing.is_some() {
123 return Err(SpaceError::Conflict(format!(
124 "group name {name} already exists in this space"
125 )));
126 }
127
128 let now = OffsetDateTime::now_utc();
129 let group_id = Uuid::now_v7();
130 let group_row = space_groups::ActiveModel {
131 id: Set(group_id),
132 space_id: Set(space.id),
133 name: Set(name.to_string()),
134 position: Set(cmd.position.unwrap_or(0)),
135 color: Set(cmd.color.unwrap_or_default()),
136 unicode_emoji: Set(cmd.unicode_emoji),
137 icon_hash: Set(cmd.icon_hash),
138 version: Set(1),
139 created_at: Set(now),
140 updated_at: Set(None),
141 created_by: Set(caller),
142 };
143
144 let permissions = cmd.permissions;
145 let perms_clone = permissions.clone();
146 let inserted = self
147 .db()
148 .primary()
149 .transaction::<_, space_groups::Model, SpaceError>(|txn| {
150 Box::pin(async move {
151 let g = group_row.insert(txn).await?;
152 replace_permissions(txn, group_id, &perms_clone).await?;
153 Ok(g)
154 })
155 })
156 .await?;
157
158 let user_map = self.load_user_briefs(&[caller]).await?;
159 let creator_brief = user_map.get(&caller).cloned();
160
161 Ok(GroupDto {
162 id: inserted.id,
163 space_id: inserted.space_id,
164 name: inserted.name,
165 position: inserted.position,
166 color: inserted.color,
167 unicode_emoji: inserted.unicode_emoji,
168 icon_hash: inserted.icon_hash,
169 version: inserted.version,
170 created_at: inserted.created_at,
171 updated_at: inserted.updated_at,
172 created_by: creator_brief,
173 permissions,
174 })
175 }
176
177 pub async fn get_group(
178 &self,
179 caller: Option<Uuid>,
180 space_name: &str,
181 group_id: Uuid,
182 ) -> Result<GroupDto, SpaceError> {
183 let space = lookup_space_by_name(self, space_name).await?;
184 if !space.is_active {
185 return Err(SpaceError::NotFound);
186 }
187
188 let group = space_groups::Entity::find_by_id(group_id)
189 .filter(space_groups::Column::SpaceId.eq(space.id))
190 .one(self.db().read())
191 .await?
192 .ok_or(SpaceError::NotFound)?;
193
194 if space.is_private {
195 let Some(uid) = caller else {
196 return Err(SpaceError::NotFound);
197 };
198 if require_perm(self, &space, uid, MANAGE_ROLES).await.is_err() {
199 return Err(SpaceError::NotFound);
200 }
201 }
202
203 let perms = space_group_permissions::Entity::find()
204 .filter(space_group_permissions::Column::GroupId.eq(group.id))
205 .all(self.db().read())
206 .await?;
207 let permissions = perms.into_iter().map(|p| p.permission).collect();
208
209 let user_map = self.load_user_briefs(&[group.created_by]).await?;
210 let creator_brief = user_map.get(&group.created_by).cloned();
211
212 Ok(GroupDto {
213 id: group.id,
214 space_id: group.space_id,
215 name: group.name,
216 position: group.position,
217 color: group.color,
218 unicode_emoji: group.unicode_emoji,
219 icon_hash: group.icon_hash,
220 version: group.version,
221 created_at: group.created_at,
222 updated_at: group.updated_at,
223 created_by: creator_brief,
224 permissions,
225 })
226 }
227
228 pub async fn update_group(
229 &self,
230 caller: Uuid,
231 space_name: &str,
232 group_id: Uuid,
233 cmd: UpdateGroupCmd,
234 ) -> Result<GroupDto, SpaceError> {
235 let space = lookup_space_by_name(self, space_name).await?;
236 if space.owner_id != caller {
237 return Err(SpaceError::Forbidden);
238 }
239
240 let group = space_groups::Entity::find_by_id(group_id)
241 .filter(space_groups::Column::SpaceId.eq(space.id))
242 .one(self.db().read())
243 .await?
244 .ok_or(SpaceError::NotFound)?;
245
246 let mut am: space_groups::ActiveModel = group.into();
247
248 if let Some(new_name) = cmd.name {
249 let name = new_name.trim();
250 if name.is_empty() || name.chars().count() > self.config().space.max_group_name_len {
251 return Err(SpaceError::BadRequest("group name is empty or too long".to_string()));
252 }
253 let existing = space_groups::Entity::find()
254 .filter(space_groups::Column::SpaceId.eq(space.id))
255 .filter(space_groups::Column::Name.eq(name))
256 .filter(space_groups::Column::Id.ne(group_id))
257 .one(self.db().read())
258 .await?;
259 if existing.is_some() {
260 return Err(SpaceError::Conflict(format!(
261 "group name {name} already exists in this space"
262 )));
263 }
264 am.name = Set(name.to_string());
265 }
266
267 if let Some(color) = cmd.color {
268 am.color = Set(color);
269 }
270 if let Some(unicode_emoji) = cmd.unicode_emoji {
271 am.unicode_emoji = Set(unicode_emoji);
272 }
273 if let Some(icon_hash) = cmd.icon_hash {
274 am.icon_hash = Set(icon_hash);
275 }
276 if let Some(position) = cmd.position {
277 am.position = Set(position);
278 }
279
280 let curr_ver = am.version.clone().unwrap();
281 am.version = Set(curr_ver + 1);
282 let now = OffsetDateTime::now_utc();
283 am.updated_at = Set(Some(now));
284
285 let perms_to_update = cmd.permissions.clone();
286
287 let updated = self
288 .db()
289 .primary()
290 .transaction::<_, space_groups::Model, SpaceError>(|txn| {
291 Box::pin(async move {
292 let g = am.update(txn).await?;
293 if let Some(ref perms) = perms_to_update {
294 replace_permissions(txn, group_id, perms).await?;
295 }
296 Ok(g)
297 })
298 })
299 .await?;
300
301 let perms = space_group_permissions::Entity::find()
302 .filter(space_group_permissions::Column::GroupId.eq(updated.id))
303 .all(self.db().read())
304 .await?;
305 let permissions = perms.into_iter().map(|p| p.permission).collect();
306
307 let user_map = self.load_user_briefs(&[updated.created_by]).await?;
308 let creator_brief = user_map.get(&updated.created_by).cloned();
309
310 Ok(GroupDto {
311 id: updated.id,
312 space_id: updated.space_id,
313 name: updated.name,
314 position: updated.position,
315 color: updated.color,
316 unicode_emoji: updated.unicode_emoji,
317 icon_hash: updated.icon_hash,
318 version: updated.version,
319 created_at: updated.created_at,
320 updated_at: updated.updated_at,
321 created_by: creator_brief,
322 permissions,
323 })
324 }
325
326 pub async fn delete_group(
327 &self,
328 caller: Uuid,
329 space_name: &str,
330 group_id: Uuid,
331 ) -> Result<(), SpaceError> {
332 let space = lookup_space_by_name(self, space_name).await?;
333 if space.owner_id != caller {
334 return Err(SpaceError::Forbidden);
335 }
336
337 let group = space_groups::Entity::find_by_id(group_id)
338 .filter(space_groups::Column::SpaceId.eq(space.id))
339 .one(self.db().read())
340 .await?
341 .ok_or(SpaceError::NotFound)?;
342
343 self.db()
344 .primary()
345 .transaction::<_, (), SpaceError>(|txn| {
346 Box::pin(async move {
347 space_member_groups::Entity::delete_many()
348 .filter(space_member_groups::Column::GroupId.eq(group.id))
349 .exec(txn)
350 .await?;
351
352 space_group_permissions::Entity::delete_many()
353 .filter(space_group_permissions::Column::GroupId.eq(group.id))
354 .exec(txn)
355 .await?;
356
357 space_groups::Entity::delete_by_id(group.id)
358 .exec(txn)
359 .await?;
360
361 Ok(())
362 })
363 })
364 .await?;
365
366 Ok(())
367 }
368
369 pub async fn list_groups(
370 &self,
371 caller: Uuid,
372 space_name: &str,
373 q: PageQuery,
374 ) -> Result<GroupPage, SpaceError> {
375 let space = lookup_space_by_name(self, space_name).await?;
376 require_perm(self, &space, caller, MANAGE_ROLES).await?;
377
378 let max_pagination = self.config().space.max_pagination;
379 let default_pagination = self.config().space.default_pagination;
380 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
381 let offset = q.offset.unwrap_or(0);
382
383 let select = space_groups::Entity::find()
384 .filter(space_groups::Column::SpaceId.eq(space.id))
385 .order_by_asc(space_groups::Column::Position);
386
387 let total = select.clone().count(self.db().read()).await? as i64;
388 let rows = select
389 .limit(limit)
390 .offset(offset)
391 .all(self.db().read())
392 .await?;
393
394 let group_ids: Vec<Uuid> = rows.iter().map(|g| g.id).collect();
395 let creator_uids: Vec<Uuid> = rows.iter().map(|g| g.created_by).collect();
396
397 let all_perms = space_group_permissions::Entity::find()
398 .filter(space_group_permissions::Column::GroupId.is_in(group_ids))
399 .all(self.db().read())
400 .await?;
401
402 let mut perms_map: HashMap<Uuid, Vec<String>> = HashMap::new();
403 for p in all_perms {
404 perms_map.entry(p.group_id).or_default().push(p.permission);
405 }
406
407 let user_map = self.load_user_briefs(&creator_uids).await?;
408
409 let items = rows
410 .into_iter()
411 .map(|g| {
412 let creator_brief = user_map.get(&g.created_by).cloned();
413 let permissions = perms_map.remove(&g.id).unwrap_or_default();
414 GroupDto {
415 id: g.id,
416 space_id: g.space_id,
417 name: g.name,
418 position: g.position,
419 color: g.color,
420 unicode_emoji: g.unicode_emoji,
421 icon_hash: g.icon_hash,
422 version: g.version,
423 created_at: g.created_at,
424 updated_at: g.updated_at,
425 created_by: creator_brief,
426 permissions,
427 }
428 })
429 .collect();
430
431 Ok(GroupPage { items, total })
432 }
433
434 pub async fn assign_group(
435 &self,
436 caller: Uuid,
437 space_name: &str,
438 uid: Uuid,
439 cmd: AssignGroupCmd,
440 ) -> Result<(), SpaceError> {
441 let space = lookup_space_by_name(self, space_name).await?;
442 if space.owner_id != caller {
443 return Err(SpaceError::Forbidden);
444 }
445
446 space_groups::Entity::find_by_id(cmd.group_id)
447 .filter(space_groups::Column::SpaceId.eq(space.id))
448 .one(self.db().read())
449 .await?
450 .ok_or(SpaceError::NotFound)?;
451
452 space_members::Entity::find_by_id((space.id, uid))
453 .one(self.db().read())
454 .await?
455 .ok_or(SpaceError::NotFound)?;
456
457 let now = OffsetDateTime::now_utc();
458 let row = space_member_groups::ActiveModel {
459 space_id: Set(space.id),
460 user_id: Set(uid),
461 group_id: Set(cmd.group_id),
462 created_at: Set(now),
463 };
464 row.insert(self.db().write()).await?;
465 Ok(())
466 }
467
468 pub async fn unassign_group(
469 &self,
470 caller: Uuid,
471 space_name: &str,
472 uid: Uuid,
473 group_id: Uuid,
474 ) -> Result<(), SpaceError> {
475 let space = lookup_space_by_name(self, space_name).await?;
476 if space.owner_id != caller {
477 return Err(SpaceError::Forbidden);
478 }
479
480 space_member_groups::Entity::delete_many()
481 .filter(space_member_groups::Column::SpaceId.eq(space.id))
482 .filter(space_member_groups::Column::UserId.eq(uid))
483 .filter(space_member_groups::Column::GroupId.eq(group_id))
484 .exec(self.db().write())
485 .await?;
486 Ok(())
487 }
488
489 pub async fn join_group_self(
490 &self,
491 caller: Uuid,
492 space_name: &str,
493 group_id: Uuid,
494 ) -> Result<(), SpaceError> {
495 let space = lookup_space_by_name(self, space_name).await?;
496 effective_permissions(self.db().read(), &space, caller).await?;
497
498 if !self.config().space.allow_self_join_groups {
499 return Err(SpaceError::Forbidden);
500 }
501
502 space_groups::Entity::find_by_id(group_id)
503 .filter(space_groups::Column::SpaceId.eq(space.id))
504 .one(self.db().read())
505 .await?
506 .ok_or(SpaceError::NotFound)?;
507
508 let existing = space_member_groups::Entity::find_by_id((space.id, caller, group_id))
509 .one(self.db().read())
510 .await?;
511 if existing.is_some() {
512 return Err(SpaceError::Conflict("already in group".to_string()));
513 }
514
515 let now = OffsetDateTime::now_utc();
516 let row = space_member_groups::ActiveModel {
517 space_id: Set(space.id),
518 user_id: Set(caller),
519 group_id: Set(group_id),
520 created_at: Set(now),
521 };
522 row.insert(self.db().write()).await?;
523 Ok(())
524 }
525
526 pub async fn leave_group_self(
527 &self,
528 caller: Uuid,
529 space_name: &str,
530 group_id: Uuid,
531 ) -> Result<(), SpaceError> {
532 let space = lookup_space_by_name(self, space_name).await?;
533 effective_permissions(self.db().read(), &space, caller).await?;
534
535 let res = space_member_groups::Entity::delete_many()
536 .filter(space_member_groups::Column::SpaceId.eq(space.id))
537 .filter(space_member_groups::Column::UserId.eq(caller))
538 .filter(space_member_groups::Column::GroupId.eq(group_id))
539 .exec(self.db().write())
540 .await?;
541
542 if res.rows_affected == 0 {
543 return Err(SpaceError::NotFound);
544 }
545 Ok(())
546 }
547}
Added lib/space/src/services/images.rs lib/space/src/services/images.rs +266 −0 View lib/space/src/services/images.rs
@@ -0,0 +1,266 @@
1use base64::engine::general_purpose::STANDARD;
2use base64::Engine;
3use models::space::spaces;
4use sea_orm::{ActiveModelTrait, Set};
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use utoipa::{IntoParams, ToSchema};
8use uuid::Uuid;
9
10use crate::errors::SpaceError;
11use crate::services::avatar::AvatarFormat;
12use crate::services::service::SpaceService;
13use crate::share::lookup::lookup_space_by_name;
14
15pub const MAX_SPACE_IMAGE_BYTES: usize = 5 * 1024 * 1024;
16pub const SPACE_IMAGE_PREFIX: &str = "space-images";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
19#[serde(rename_all = "kebab-case")]
20pub enum SpaceImageKind {
21 Banner,
22 InviteBanner,
23 EmbedSplash,
24}
25
26impl SpaceImageKind {
27 pub fn as_str(&self) -> &'static str {
28 match self {
29 Self::Banner => "banner",
30 Self::InviteBanner => "invite-banner",
31 Self::EmbedSplash => "embed-splash",
32 }
33 }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
37pub struct SpaceImageDto {
38 pub space_id: Uuid,
39 pub kind: SpaceImageKind,
40 pub url: String,
41 pub format: AvatarFormat,
42 pub width: Option<i32>,
43 pub height: Option<i32>,
44 pub bytes: u64,
45 pub updated_at: OffsetDateTime,
46}
47
48#[derive(Debug, Clone, Deserialize, ToSchema)]
49pub struct UploadSpaceImageCmd {
50 pub content_type: String,
51 pub data_base64: String,
52 #[serde(default)]
53 pub width: Option<i32>,
54 #[serde(default)]
55 pub height: Option<i32>,
56}
57
58#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
59pub struct SpaceImageKindPath {
60 pub space_name: String,
61 pub kind: SpaceImageKind,
62}
63
64impl SpaceService {
65 pub async fn upload_space_image(
66 &self,
67 caller: Uuid,
68 space_name: &str,
69 kind: SpaceImageKind,
70 cmd: UploadSpaceImageCmd,
71 ) -> Result<SpaceImageDto, SpaceError> {
72 let space = lookup_space_by_name(self, space_name).await?;
73 if space.owner_id != caller {
74 return Err(SpaceError::Forbidden);
75 }
76
77 let fmt = AvatarFormat::from_content_type(&cmd.content_type).ok_or_else(|| {
78 SpaceError::BadRequest(format!("unsupported content-type: {}", cmd.content_type))
79 })?;
80
81 let bytes = STANDARD
82 .decode(cmd.data_base64.trim())
83 .map_err(|e| SpaceError::BadRequest(format!("invalid base64: {e}")))?;
84
85 if bytes.is_empty() || bytes.len() > MAX_SPACE_IMAGE_BYTES {
86 return Err(SpaceError::BadRequest(
87 "image payload is empty or exceeds 5MB limit".to_string(),
88 ));
89 }
90
91 if let Some(w) = cmd.width
92 && w < 0
93 {
94 return Err(SpaceError::BadRequest("width must be non-negative".to_string()));
95 }
96 if let Some(h) = cmd.height
97 && h < 0
98 {
99 return Err(SpaceError::BadRequest("height must be non-negative".to_string()));
100 }
101
102 let space_id = space.id;
103 let key = format!(
104 "{SPACE_IMAGE_PREFIX}/{}/{}.{}",
105 space_id,
106 kind.as_str(),
107 fmt.extension()
108 );
109 self.storage()
110 .client()
111 .put_with_content_type(&key, &bytes, fmt.content_type())
112 .await?;
113
114 let url = format!("storage://{key}");
115 let now = OffsetDateTime::now_utc();
116
117 let mut am: spaces::ActiveModel = space.into();
118 match kind {
119 SpaceImageKind::Banner => {
120 am.banner_url = Set(Some(url.clone()));
121 am.banner_width = Set(cmd.width);
122 am.banner_height = Set(cmd.height);
123 }
124 SpaceImageKind::InviteBanner => {
125 am.invite_banner_url = Set(Some(url.clone()));
126 am.invite_banner_width = Set(cmd.width);
127 am.invite_banner_height = Set(cmd.height);
128 }
129 SpaceImageKind::EmbedSplash => {
130 am.embed_splash_url = Set(Some(url.clone()));
131 am.embed_splash_width = Set(cmd.width);
132 am.embed_splash_height = Set(cmd.height);
133 }
134 }
135 am.updated_at = Set(now);
136 am.update(self.db().write()).await?;
137
138 let meta_key = format!(
139 "{SPACE_IMAGE_PREFIX}/{}/{}.meta.json",
140 space_id,
141 kind.as_str()
142 );
143 let meta = serde_json::json!({
144 "space_id": space_id,
145 "kind": kind.as_str(),
146 "format": fmt.extension(),
147 "width": cmd.width,
148 "height": cmd.height,
149 "bytes": bytes.len(),
150 "uploaded_at": now,
151 });
152 let _ = self
153 .storage()
154 .client()
155 .put_with_content_type(&meta_key, meta.to_string().as_bytes(), "application/json")
156 .await;
157
158 Ok(SpaceImageDto {
159 space_id,
160 kind,
161 url,
162 format: fmt,
163 width: cmd.width,
164 height: cmd.height,
165 bytes: bytes.len() as u64,
166 updated_at: now,
167 })
168 }
169
170 pub async fn delete_space_image(
171 &self,
172 caller: Uuid,
173 space_name: &str,
174 kind: SpaceImageKind,
175 ) -> Result<(), SpaceError> {
176 let space = lookup_space_by_name(self, space_name).await?;
177 if space.owner_id != caller {
178 return Err(SpaceError::Forbidden);
179 }
180
181 let space_id = space.id;
182 let old_url = match kind {
183 SpaceImageKind::Banner => space.banner_url.clone(),
184 SpaceImageKind::InviteBanner => space.invite_banner_url.clone(),
185 SpaceImageKind::EmbedSplash => space.embed_splash_url.clone(),
186 };
187
188 let now = OffsetDateTime::now_utc();
189 let mut am: spaces::ActiveModel = space.into();
190 match kind {
191 SpaceImageKind::Banner => {
192 am.banner_url = Set(None);
193 am.banner_width = Set(None);
194 am.banner_height = Set(None);
195 }
196 SpaceImageKind::InviteBanner => {
197 am.invite_banner_url = Set(None);
198 am.invite_banner_width = Set(None);
199 am.invite_banner_height = Set(None);
200 }
201 SpaceImageKind::EmbedSplash => {
202 am.embed_splash_url = Set(None);
203 am.embed_splash_width = Set(None);
204 am.embed_splash_height = Set(None);
205 }
206 }
207 am.updated_at = Set(now);
208 am.update(self.db().write()).await?;
209
210 if let Some(url) = old_url
211 && let Some(key) = url.strip_prefix("storage://")
212 {
213 let _ = self.storage().client().delete(key).await;
214 let meta_key = format!(
215 "{SPACE_IMAGE_PREFIX}/{}/{}.meta.json",
216 space_id,
217 kind.as_str()
218 );
219 let _ = self.storage().client().delete(&meta_key).await;
220 }
221 Ok(())
222 }
223
224 pub async fn get_space_image(
225 &self,
226 space_name: &str,
227 kind: SpaceImageKind,
228 ) -> Result<Option<SpaceImageDto>, SpaceError> {
229 let space = lookup_space_by_name(self, space_name).await?;
230
231 let (url_opt, width, height) = match kind {
232 SpaceImageKind::Banner => (space.banner_url, space.banner_width, space.banner_height),
233 SpaceImageKind::InviteBanner => (
234 space.invite_banner_url,
235 space.invite_banner_width,
236 space.invite_banner_height,
237 ),
238 SpaceImageKind::EmbedSplash => (
239 space.embed_splash_url,
240 space.embed_splash_width,
241 space.embed_splash_height,
242 ),
243 };
244
245 let Some(url) = url_opt else {
246 return Ok(None);
247 };
248 if url.is_empty() {
249 return Ok(None);
250 }
251
252 let ext = url.split('.').next_back().unwrap_or("jpg");
253 let format = AvatarFormat::from_extension(ext).unwrap_or(AvatarFormat::Jpg);
254
255 Ok(Some(SpaceImageDto {
256 space_id: space.id,
257 kind,
258 url,
259 format,
260 width,
261 height,
262 bytes: 0,
263 updated_at: space.updated_at,
264 }))
265 }
266}
Added lib/space/src/services/invites.rs lib/space/src/services/invites.rs +526 −0 View lib/space/src/services/invites.rs
@@ -0,0 +1,526 @@
1use std::collections::HashMap;
2
3use models::space::space_invite;
4use models::space::space_invite_groups;
5use models::space::space_member_groups;
6use models::space::space_members::{self, MemberStatus, MessageNotifications};
7use models::space::spaces;
8use models::space::space_groups;
9use models::users::user;
10use models::users::user_emails;
11use sea_orm::sea_query::Expr;
12use sea_orm::{
13 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
14 QuerySelect, Set, TransactionTrait,
15};
16use serde::{Deserialize, Serialize};
17use time::OffsetDateTime;
18use utoipa::ToSchema;
19use uuid::Uuid;
20
21use crate::errors::SpaceError;
22use crate::services::service::SpaceService;
23use crate::services::spaces::{PageQuery, UserBriefDto};
24use crate::share::authz::{require_perm, MANAGE_INVITES};
25use crate::share::lookup::lookup_space_by_name;
26
27#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
28pub struct InviteDto {
29 pub id: Uuid,
30 pub space_id: Uuid,
31 pub user: Option<UserBriefDto>,
32 pub email: String,
33 pub groups: Vec<Uuid>,
34 pub created_at: OffsetDateTime,
35 pub expires_at: Option<OffsetDateTime>,
36 pub accepted_at: Option<OffsetDateTime>,
37 pub revoked_at: Option<OffsetDateTime>,
38}
39
40#[derive(Debug, Clone, Deserialize, ToSchema)]
41pub struct CreateInviteCmd {
42 #[serde(default)]
43 pub user_id: Option<Uuid>,
44 #[serde(default)]
45 pub email: Option<String>,
46 #[serde(default)]
47 pub group_ids: Vec<Uuid>,
48 #[serde(default)]
49 pub expires_at: Option<OffsetDateTime>,
50}
51
52#[derive(Debug, Clone, Deserialize, ToSchema)]
53pub struct AcceptInviteCmd {
54 pub accept_code: String,
55}
56
57#[derive(Debug, Clone, Serialize, ToSchema)]
58pub struct InvitePage {
59 pub items: Vec<InviteDto>,
60 pub total: i64,
61}
62
63fn validate_email(email: &str, max_len: usize) -> bool {
64 if email.is_empty() || email.chars().count() > max_len {
65 return false;
66 }
67 if email.chars().any(|c| c.is_whitespace()) {
68 return false;
69 }
70 let parts: Vec<&str> = email.split('@').collect();
71 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
72 return false;
73 }
74 true
75}
76
77impl SpaceService {
78 pub async fn create_invite(
79 &self,
80 caller: Uuid,
81 space_name: &str,
82 cmd: CreateInviteCmd,
83 ) -> Result<InviteDto, SpaceError> {
84 let space = lookup_space_by_name(self, space_name).await?;
85 require_perm(self, &space, caller, MANAGE_INVITES).await?;
86
87 let email_norm = cmd
88 .email
89 .map(|e| e.trim().to_lowercase())
90 .filter(|e| !e.is_empty());
91
92 if let Some(ref em) = email_norm
93 && !validate_email(em, self.config().space.max_invite_email_len)
94 {
95 return Err(SpaceError::BadRequest("invalid invite email".to_string()));
96 }
97
98 if cmd.user_id.is_none() && email_norm.is_none() {
99 return Err(SpaceError::BadRequest(
100 "either user_id or email must be provided".to_string(),
101 ));
102 }
103
104 let mut group_ids = cmd.group_ids;
105 group_ids.sort();
106 group_ids.dedup();
107 if group_ids.is_empty() {
108 return Err(SpaceError::BadRequest(
109 "at least one group is required".to_string(),
110 ));
111 }
112
113 let mut target_uid = cmd.user_id;
114
115 if let Some(uid) = target_uid {
116 user::Entity::find_by_id(uid)
117 .one(self.db().read())
118 .await?
119 .ok_or(SpaceError::NotFound)?;
120
121 if let Some(ref em) = email_norm {
122 let match_email = user_emails::Entity::find()
123 .filter(user_emails::Column::UserId.eq(uid))
124 .filter(user_emails::Column::LowerEmail.eq(em))
125 .filter(user_emails::Column::IsActivated.eq(true))
126 .filter(user_emails::Column::DeletedAt.is_null())
127 .one(self.db().read())
128 .await?;
129 if match_email.is_none() {
130 return Err(SpaceError::BadRequest(
131 "email does not match user's activated email".to_string(),
132 ));
133 }
134 }
135 } else if let Some(ref em) = email_norm {
136 let email_row = user_emails::Entity::find()
137 .filter(user_emails::Column::LowerEmail.eq(em))
138 .filter(user_emails::Column::IsActivated.eq(true))
139 .filter(user_emails::Column::DeletedAt.is_null())
140 .one(self.db().read())
141 .await?;
142 if let Some(er) = email_row {
143 target_uid = Some(er.user_id);
144 }
145 }
146
147 let group_count = space_groups::Entity::find()
148 .filter(space_groups::Column::SpaceId.eq(space.id))
149 .filter(space_groups::Column::Id.is_in(group_ids.clone()))
150 .count(self.db().read())
151 .await?;
152 if group_count != group_ids.len() as u64 {
153 return Err(SpaceError::BadRequest(
154 "some groups do not belong to this space".to_string(),
155 ));
156 }
157
158 if let Some(uid) = target_uid {
159 let active_user_inv = space_invite::Entity::find()
160 .filter(space_invite::Column::SpaceId.eq(space.id))
161 .filter(space_invite::Column::UserId.eq(uid))
162 .filter(space_invite::Column::AcceptedAt.is_null())
163 .filter(space_invite::Column::RevokedAt.is_null())
164 .one(self.db().read())
165 .await?;
166 if active_user_inv.is_some() {
167 return Err(SpaceError::Conflict(
168 "active invite for this user already exists".to_string(),
169 ));
170 }
171 }
172
173 if let Some(ref em) = email_norm {
174 let active_email_inv = space_invite::Entity::find()
175 .filter(space_invite::Column::SpaceId.eq(space.id))
176 .filter(space_invite::Column::Email.eq(em))
177 .filter(space_invite::Column::AcceptedAt.is_null())
178 .filter(space_invite::Column::RevokedAt.is_null())
179 .one(self.db().read())
180 .await?;
181 if active_email_inv.is_some() {
182 return Err(SpaceError::Conflict(
183 "active invite for this email already exists".to_string(),
184 ));
185 }
186 }
187
188 let now = OffsetDateTime::now_utc();
189 let invite_id = Uuid::now_v7();
190 let accept_code = format!("{}-{}", Uuid::now_v7().simple(), Uuid::now_v7().simple());
191 let final_email = email_norm.clone().unwrap_or_default();
192
193 let invite_row = space_invite::ActiveModel {
194 id: Set(invite_id),
195 space_id: Set(space.id),
196 user_id: Set(target_uid),
197 email: Set(final_email.clone()),
198 accept_code: Set(accept_code.clone()),
199 created_at: Set(now),
200 expires_at: Set(cmd.expires_at),
201 accepted_at: Set(None),
202 revoked_at: Set(None),
203 };
204
205 let group_ids_clone = group_ids.clone();
206 let inserted = self
207 .db()
208 .primary()
209 .transaction::<_, space_invite::Model, SpaceError>(|txn| {
210 Box::pin(async move {
211 let inv = invite_row.insert(txn).await?;
212 for gid in group_ids_clone {
213 let grp_row = space_invite_groups::ActiveModel {
214 invite_id: Set(invite_id),
215 group_id: Set(gid),
216 };
217 grp_row.insert(txn).await?;
218 }
219 Ok(inv)
220 })
221 })
222 .await?;
223
224 let mut recipient_email: Option<String> = email_norm;
225 if recipient_email.is_none()
226 && let Some(uid) = target_uid
227 {
228 let primary = user_emails::Entity::find()
229 .filter(user_emails::Column::UserId.eq(uid))
230 .filter(user_emails::Column::IsPrimary.eq(true))
231 .filter(user_emails::Column::IsActivated.eq(true))
232 .filter(user_emails::Column::DeletedAt.is_null())
233 .one(self.db().read())
234 .await?;
235 if let Some(p) = primary {
236 recipient_email = Some(p.lower_email);
237 }
238 }
239
240 if let Some(recipient) = recipient_email {
241 let subject = format!("You're invited to join {}", space.name);
242 let accept_url = format!(
243 "{}/spaces/invites/accept?code={accept_code}",
244 self.config().web.web_base_url
245 );
246 let text = format!(
247 "You have been invited to join {}\n\nAccept invite: {}\n",
248 space.name, accept_url
249 );
250 let html = format!(
251 r#"<p>You have been invited to join <strong>{}</strong>.</p><p><a href="{}" style="display:inline-block;padding:8px 16px;background:#0066cc;color:#fff;text-decoration:none;border-radius:4px;">Accept Invite</a></p><p>Or copy this link: {}</p>"#,
252 space.name, accept_url, accept_url
253 );
254 if let Err(err) = self.send_email(&recipient, &subject, &text, &html).await {
255 tracing::warn!(error = %err, "failed to send space invite email");
256 }
257 }
258
259 let user_map = match inserted.user_id {
260 Some(uid) => self.load_user_briefs(&[uid]).await?,
261 None => HashMap::new(),
262 };
263 let user_brief = inserted.user_id.and_then(|uid| user_map.get(&uid).cloned());
264
265 Ok(InviteDto {
266 id: inserted.id,
267 space_id: inserted.space_id,
268 user: user_brief,
269 email: inserted.email,
270 groups: group_ids,
271 created_at: inserted.created_at,
272 expires_at: inserted.expires_at,
273 accepted_at: inserted.accepted_at,
274 revoked_at: inserted.revoked_at,
275 })
276 }
277
278 pub async fn revoke_invite(
279 &self,
280 caller: Uuid,
281 space_name: &str,
282 invite_id: Uuid,
283 ) -> Result<InviteDto, SpaceError> {
284 let space = lookup_space_by_name(self, space_name).await?;
285 require_perm(self, &space, caller, MANAGE_INVITES).await?;
286
287 let invite = space_invite::Entity::find_by_id(invite_id)
288 .filter(space_invite::Column::SpaceId.eq(space.id))
289 .one(self.db().read())
290 .await?
291 .ok_or(SpaceError::NotFound)?;
292
293 let now = OffsetDateTime::now_utc();
294 let mut am: space_invite::ActiveModel = invite.into();
295 am.revoked_at = Set(Some(now));
296 let updated = am.update(self.db().write()).await?;
297
298 let groups = space_invite_groups::Entity::find()
299 .filter(space_invite_groups::Column::InviteId.eq(updated.id))
300 .all(self.db().read())
301 .await?;
302 let group_ids = groups.into_iter().map(|g| g.group_id).collect();
303
304 let user_map = match updated.user_id {
305 Some(uid) => self.load_user_briefs(&[uid]).await?,
306 None => HashMap::new(),
307 };
308 let user_brief = updated.user_id.and_then(|uid| user_map.get(&uid).cloned());
309
310 Ok(InviteDto {
311 id: updated.id,
312 space_id: updated.space_id,
313 user: user_brief,
314 email: updated.email,
315 groups: group_ids,
316 created_at: updated.created_at,
317 expires_at: updated.expires_at,
318 accepted_at: updated.accepted_at,
319 revoked_at: updated.revoked_at,
320 })
321 }
322
323 pub async fn accept_invite(&self, caller: Uuid, cmd: AcceptInviteCmd) -> Result<(), SpaceError> {
324 let invite = space_invite::Entity::find()
325 .filter(space_invite::Column::AcceptCode.eq(&cmd.accept_code))
326 .one(self.db().read())
327 .await?
328 .ok_or(SpaceError::NotFound)?;
329
330 let is_assigned_user = invite.user_id == Some(caller);
331 let is_assigned_email = if !invite.email.is_empty() {
332 user_emails::Entity::find()
333 .filter(user_emails::Column::UserId.eq(caller))
334 .filter(user_emails::Column::LowerEmail.eq(&invite.email))
335 .filter(user_emails::Column::IsActivated.eq(true))
336 .filter(user_emails::Column::DeletedAt.is_null())
337 .one(self.db().read())
338 .await?
339 .is_some()
340 } else {
341 false
342 };
343
344 if !is_assigned_user && !is_assigned_email {
345 return Err(SpaceError::Forbidden);
346 }
347
348 if invite.revoked_at.is_some() {
349 return Err(SpaceError::Conflict("invite has been revoked".to_string()));
350 }
351 if invite.accepted_at.is_some() {
352 return Err(SpaceError::Conflict("invite already accepted".to_string()));
353 }
354 if let Some(exp) = invite.expires_at
355 && exp < OffsetDateTime::now_utc()
356 {
357 return Err(SpaceError::Conflict("invite has expired".to_string()));
358 }
359
360 let inv_groups = space_invite_groups::Entity::find()
361 .filter(space_invite_groups::Column::InviteId.eq(invite.id))
362 .all(self.db().read())
363 .await?;
364 let group_ids: Vec<Uuid> = inv_groups.into_iter().map(|g| g.group_id).collect();
365 if group_ids.is_empty() {
366 return Err(SpaceError::Conflict("invite has no valid groups".to_string()));
367 }
368
369 let valid_count = space_groups::Entity::find()
370 .filter(space_groups::Column::SpaceId.eq(invite.space_id))
371 .filter(space_groups::Column::Id.is_in(group_ids.clone()))
372 .count(self.db().read())
373 .await?;
374 if valid_count != group_ids.len() as u64 {
375 return Err(SpaceError::Conflict("invite has invalid groups".to_string()));
376 }
377
378 let is_member = space_members::Entity::find_by_id((invite.space_id, caller))
379 .one(self.db().read())
380 .await?;
381 if is_member.is_some() {
382 return Err(SpaceError::Conflict(
383 "user is already a member of this space".to_string(),
384 ));
385 }
386
387 let space = spaces::Entity::find_by_id(invite.space_id)
388 .one(self.db().read())
389 .await?
390 .ok_or(SpaceError::NotFound)?;
391 if !space.is_active {
392 return Err(SpaceError::NotFound);
393 }
394
395 if space.member_must_enable_2fa {
396 let caller_user = user::Entity::find_by_id(caller)
397 .one(self.db().read())
398 .await?
399 .ok_or(SpaceError::NotFound)?;
400 if !caller_user.totp_enabled {
401 return Err(SpaceError::Forbidden);
402 }
403 }
404
405 let now = OffsetDateTime::now_utc();
406 let member_row = space_members::ActiveModel {
407 space_id: Set(invite.space_id),
408 user_id: Set(caller),
409 alias_name: Set(None),
410 alias_avatar_url: Set(None),
411 status: Set(MemberStatus::Active),
412 last_active_at: Set(None),
413 left_at: Set(None),
414 invited_at: Set(Some(invite.created_at)),
415 invited_by: Set(None),
416 joined_at: Set(now),
417 created_at: Set(now),
418 updated_at: Set(Some(now)),
419 message_notifications: Set(MessageNotifications::All),
420 muted: Set(false),
421 mute_until: Set(None),
422 mobile_push: Set(false),
423 suppress_everyone: Set(false),
424 suppress_roles: Set(false),
425 hide_muted_channels: Set(false),
426 };
427
428 let mut invite_am: space_invite::ActiveModel = invite.into();
429 invite_am.accepted_at = Set(Some(now));
430
431 self.db()
432 .primary()
433 .transaction::<_, (), SpaceError>(|txn| {
434 Box::pin(async move {
435 member_row.insert(txn).await?;
436
437 for gid in group_ids {
438 let mg = space_member_groups::ActiveModel {
439 space_id: Set(space.id),
440 user_id: Set(caller),
441 group_id: Set(gid),
442 created_at: Set(now),
443 };
444 mg.insert(txn).await?;
445 }
446
447 spaces::Entity::update_many()
448 .filter(spaces::Column::Id.eq(space.id))
449 .col_expr(
450 spaces::Column::NumsMembers,
451 Expr::cust("nums_members + 1"),
452 )
453 .exec(txn)
454 .await?;
455
456 invite_am.update(txn).await?;
457
458 Ok(())
459 })
460 })
461 .await?;
462
463 Ok(())
464 }
465
466 pub async fn list_invites(
467 &self,
468 caller: Uuid,
469 space_name: &str,
470 q: PageQuery,
471 ) -> Result<InvitePage, SpaceError> {
472 let space = lookup_space_by_name(self, space_name).await?;
473 require_perm(self, &space, caller, MANAGE_INVITES).await?;
474
475 let max_pagination = self.config().space.max_pagination;
476 let default_pagination = self.config().space.default_pagination;
477 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
478 let offset = q.offset.unwrap_or(0);
479
480 let select = space_invite::Entity::find()
481 .filter(space_invite::Column::SpaceId.eq(space.id))
482 .order_by_desc(space_invite::Column::CreatedAt);
483
484 let total = select.clone().count(self.db().read()).await? as i64;
485 let rows = select
486 .limit(limit)
487 .offset(offset)
488 .all(self.db().read())
489 .await?;
490
491 let invite_ids: Vec<Uuid> = rows.iter().map(|i| i.id).collect();
492 let all_invite_groups = space_invite_groups::Entity::find()
493 .filter(space_invite_groups::Column::InviteId.is_in(invite_ids))
494 .all(self.db().read())
495 .await?;
496
497 let mut groups_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
498 for ig in all_invite_groups {
499 groups_map.entry(ig.invite_id).or_default().push(ig.group_id);
500 }
501
502 let user_ids: Vec<Uuid> = rows.iter().filter_map(|i| i.user_id).collect();
503 let user_map = self.load_user_briefs(&user_ids).await?;
504
505 let items = rows
506 .into_iter()
507 .map(|i| {
508 let user_brief = i.user_id.and_then(|uid| user_map.get(&uid).cloned());
509 let groups = groups_map.remove(&i.id).unwrap_or_default();
510 InviteDto {
511 id: i.id,
512 space_id: i.space_id,
513 user: user_brief,
514 email: i.email,
515 groups,
516 created_at: i.created_at,
517 expires_at: i.expires_at,
518 accepted_at: i.accepted_at,
519 revoked_at: i.revoked_at,
520 }
521 })
522 .collect();
523
524 Ok(InvitePage { items, total })
525 }
526}
Added lib/space/src/services/members.rs lib/space/src/services/members.rs +456 −0 View lib/space/src/services/members.rs
@@ -0,0 +1,456 @@
1use models::space::space_member_groups;
2use models::space::space_members::{self, MemberStatus, MessageNotifications};
3use models::space::spaces;
4use models::users::user;
5use sea_orm::sea_query::Expr;
6use sea_orm::{
7 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
8 QuerySelect, Set, TransactionTrait,
9};
10use serde::{Deserialize, Serialize};
11use time::OffsetDateTime;
12use utoipa::ToSchema;
13use uuid::Uuid;
14
15use crate::errors::SpaceError;
16use crate::services::service::SpaceService;
17use crate::services::spaces::{PageQuery, UserBriefDto};
18use crate::share::authz::{require_perm, MANAGE_MEMBERS};
19use crate::share::lookup::lookup_space_by_name;
20
21#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
22pub struct MemberDto {
23 pub space_id: Uuid,
24 pub user: Option<UserBriefDto>,
25 pub alias_name: Option<String>,
26 pub alias_avatar_url: Option<String>,
27 pub status: MemberStatus,
28 pub last_active_at: Option<OffsetDateTime>,
29 pub left_at: Option<OffsetDateTime>,
30 pub invited_at: Option<OffsetDateTime>,
31 pub invited_by: Option<UserBriefDto>,
32 pub joined_at: OffsetDateTime,
33 pub created_at: OffsetDateTime,
34 pub updated_at: Option<OffsetDateTime>,
35 pub message_notifications: MessageNotifications,
36 pub muted: bool,
37 pub mute_until: Option<OffsetDateTime>,
38 pub mobile_push: bool,
39 pub suppress_everyone: bool,
40 pub suppress_roles: bool,
41 pub hide_muted_channels: bool,
42}
43
44#[derive(Debug, Clone, Deserialize, ToSchema)]
45pub struct AddMemberCmd {
46 pub user_id: Uuid,
47 #[serde(default)]
48 pub alias_name: Option<String>,
49 #[serde(default)]
50 pub alias_avatar_url: Option<String>,
51 #[serde(default)]
52 pub message_notifications: Option<MessageNotifications>,
53 #[serde(default)]
54 pub muted: Option<bool>,
55 #[serde(default)]
56 pub mute_until: Option<OffsetDateTime>,
57 #[serde(default)]
58 pub mobile_push: Option<bool>,
59 #[serde(default)]
60 pub suppress_everyone: Option<bool>,
61 #[serde(default)]
62 pub suppress_roles: Option<bool>,
63 #[serde(default)]
64 pub hide_muted_channels: Option<bool>,
65}
66
67#[derive(Debug, Clone, Deserialize, ToSchema)]
68pub struct UpdateMemberCmd {
69 #[serde(default)]
70 pub alias_name: Option<Option<String>>,
71 #[serde(default)]
72 pub alias_avatar_url: Option<Option<String>>,
73 #[serde(default)]
74 pub message_notifications: Option<MessageNotifications>,
75 #[serde(default)]
76 pub muted: Option<bool>,
77 #[serde(default)]
78 pub mute_until: Option<Option<OffsetDateTime>>,
79 #[serde(default)]
80 pub mobile_push: Option<bool>,
81 #[serde(default)]
82 pub suppress_everyone: Option<bool>,
83 #[serde(default)]
84 pub suppress_roles: Option<bool>,
85 #[serde(default)]
86 pub hide_muted_channels: Option<bool>,
87}
88
89#[derive(Debug, Clone, Serialize, ToSchema)]
90pub struct MemberPage {
91 pub items: Vec<MemberDto>,
92 pub total: i64,
93}
94
95impl SpaceService {
96 pub async fn add_member(
97 &self,
98 caller: Uuid,
99 space_name: &str,
100 cmd: AddMemberCmd,
101 ) -> Result<MemberDto, SpaceError> {
102 let space = lookup_space_by_name(self, space_name).await?;
103 if space.owner_id != caller {
104 return Err(SpaceError::Forbidden);
105 }
106
107 let existing = space_members::Entity::find_by_id((space.id, cmd.user_id))
108 .one(self.db().read())
109 .await?;
110 if existing.is_some() {
111 return Err(SpaceError::Conflict("user is already a member of this space".to_string()));
112 }
113
114 if space.member_must_enable_2fa {
115 let target_user = user::Entity::find_by_id(cmd.user_id)
116 .one(self.db().read())
117 .await?
118 .ok_or(SpaceError::NotFound)?;
119 if !target_user.totp_enabled {
120 return Err(SpaceError::Forbidden);
121 }
122 }
123
124 if let Some(alias) = &cmd.alias_name
125 && alias.chars().count() > self.config().space.max_alias_name_len
126 {
127 return Err(SpaceError::BadRequest("alias name is too long".to_string()));
128 }
129
130 let now = OffsetDateTime::now_utc();
131 let member_row = space_members::ActiveModel {
132 space_id: Set(space.id),
133 user_id: Set(cmd.user_id),
134 alias_name: Set(cmd.alias_name),
135 alias_avatar_url: Set(cmd.alias_avatar_url),
136 status: Set(MemberStatus::Active),
137 last_active_at: Set(None),
138 left_at: Set(None),
139 invited_at: Set(None),
140 invited_by: Set(None),
141 joined_at: Set(now),
142 created_at: Set(now),
143 updated_at: Set(Some(now)),
144 message_notifications: Set(cmd
145 .message_notifications
146 .unwrap_or(MessageNotifications::All)),
147 muted: Set(cmd.muted.unwrap_or(false)),
148 mute_until: Set(cmd.mute_until),
149 mobile_push: Set(cmd.mobile_push.unwrap_or(false)),
150 suppress_everyone: Set(cmd.suppress_everyone.unwrap_or(false)),
151 suppress_roles: Set(cmd.suppress_roles.unwrap_or(false)),
152 hide_muted_channels: Set(cmd.hide_muted_channels.unwrap_or(false)),
153 };
154
155 let inserted = self
156 .db()
157 .primary()
158 .transaction::<_, space_members::Model, SpaceError>(|txn| {
159 Box::pin(async move {
160 let m = member_row.insert(txn).await?;
161 spaces::Entity::update_many()
162 .filter(spaces::Column::Id.eq(space.id))
163 .col_expr(
164 spaces::Column::NumsMembers,
165 Expr::cust("nums_members + 1"),
166 )
167 .exec(txn)
168 .await?;
169 Ok(m)
170 })
171 })
172 .await?;
173
174 let user_map = self.load_user_briefs(&[inserted.user_id]).await?;
175 let user_brief = user_map.get(&inserted.user_id).cloned();
176
177 Ok(MemberDto {
178 space_id: inserted.space_id,
179 user: user_brief,
180 alias_name: inserted.alias_name,
181 alias_avatar_url: inserted.alias_avatar_url,
182 status: inserted.status,
183 last_active_at: inserted.last_active_at,
184 left_at: inserted.left_at,
185 invited_at: inserted.invited_at,
186 invited_by: None,
187 joined_at: inserted.joined_at,
188 created_at: inserted.created_at,
189 updated_at: inserted.updated_at,
190 message_notifications: inserted.message_notifications,
191 muted: inserted.muted,
192 mute_until: inserted.mute_until,
193 mobile_push: inserted.mobile_push,
194 suppress_everyone: inserted.suppress_everyone,
195 suppress_roles: inserted.suppress_roles,
196 hide_muted_channels: inserted.hide_muted_channels,
197 })
198 }
199
200 pub async fn get_member(
201 &self,
202 caller: Uuid,
203 space_name: &str,
204 uid: Uuid,
205 ) -> Result<MemberDto, SpaceError> {
206 let space = lookup_space_by_name(self, space_name).await?;
207 if space.owner_id != caller {
208 return Err(SpaceError::Forbidden);
209 }
210
211 let member = space_members::Entity::find_by_id((space.id, uid))
212 .one(self.db().read())
213 .await?
214 .ok_or(SpaceError::NotFound)?;
215
216 let mut uids = vec![member.user_id];
217 if let Some(inv_by) = member.invited_by {
218 uids.push(inv_by);
219 }
220 let user_map = self.load_user_briefs(&uids).await?;
221 let user_brief = user_map.get(&member.user_id).cloned();
222 let inviter_brief = member.invited_by.and_then(|ib| user_map.get(&ib).cloned());
223
224 Ok(MemberDto {
225 space_id: member.space_id,
226 user: user_brief,
227 alias_name: member.alias_name,
228 alias_avatar_url: member.alias_avatar_url,
229 status: member.status,
230 last_active_at: member.last_active_at,
231 left_at: member.left_at,
232 invited_at: member.invited_at,
233 invited_by: inviter_brief,
234 joined_at: member.joined_at,
235 created_at: member.created_at,
236 updated_at: member.updated_at,
237 message_notifications: member.message_notifications,
238 muted: member.muted,
239 mute_until: member.mute_until,
240 mobile_push: member.mobile_push,
241 suppress_everyone: member.suppress_everyone,
242 suppress_roles: member.suppress_roles,
243 hide_muted_channels: member.hide_muted_channels,
244 })
245 }
246
247 pub async fn update_member(
248 &self,
249 caller: Uuid,
250 space_name: &str,
251 uid: Uuid,
252 cmd: UpdateMemberCmd,
253 ) -> Result<MemberDto, SpaceError> {
254 let space = lookup_space_by_name(self, space_name).await?;
255 if space.owner_id != caller {
256 return Err(SpaceError::Forbidden);
257 }
258
259 let member = space_members::Entity::find_by_id((space.id, uid))
260 .one(self.db().read())
261 .await?
262 .ok_or(SpaceError::NotFound)?;
263
264 let mut am: space_members::ActiveModel = member.into();
265
266 if let Some(alias) = cmd.alias_name {
267 if let Some(a) = &alias
268 && a.chars().count() > self.config().space.max_alias_name_len
269 {
270 return Err(SpaceError::BadRequest("alias name is too long".to_string()));
271 }
272 am.alias_name = Set(alias);
273 }
274 if let Some(url) = cmd.alias_avatar_url {
275 am.alias_avatar_url = Set(url);
276 }
277 if let Some(mn) = cmd.message_notifications {
278 am.message_notifications = Set(mn);
279 }
280 if let Some(muted) = cmd.muted {
281 am.muted = Set(muted);
282 }
283 if let Some(mute_until) = cmd.mute_until {
284 am.mute_until = Set(mute_until);
285 }
286 if let Some(mp) = cmd.mobile_push {
287 am.mobile_push = Set(mp);
288 }
289 if let Some(se) = cmd.suppress_everyone {
290 am.suppress_everyone = Set(se);
291 }
292 if let Some(sr) = cmd.suppress_roles {
293 am.suppress_roles = Set(sr);
294 }
295 if let Some(hmc) = cmd.hide_muted_channels {
296 am.hide_muted_channels = Set(hmc);
297 }
298
299 let now = OffsetDateTime::now_utc();
300 am.updated_at = Set(Some(now));
301
302 let updated = am.update(self.db().write()).await?;
303
304 let mut uids = vec![updated.user_id];
305 if let Some(inv_by) = updated.invited_by {
306 uids.push(inv_by);
307 }
308 let user_map = self.load_user_briefs(&uids).await?;
309 let user_brief = user_map.get(&updated.user_id).cloned();
310 let inviter_brief = updated.invited_by.and_then(|ib| user_map.get(&ib).cloned());
311
312 Ok(MemberDto {
313 space_id: updated.space_id,
314 user: user_brief,
315 alias_name: updated.alias_name,
316 alias_avatar_url: updated.alias_avatar_url,
317 status: updated.status,
318 last_active_at: updated.last_active_at,
319 left_at: updated.left_at,
320 invited_at: updated.invited_at,
321 invited_by: inviter_brief,
322 joined_at: updated.joined_at,
323 created_at: updated.created_at,
324 updated_at: updated.updated_at,
325 message_notifications: updated.message_notifications,
326 muted: updated.muted,
327 mute_until: updated.mute_until,
328 mobile_push: updated.mobile_push,
329 suppress_everyone: updated.suppress_everyone,
330 suppress_roles: updated.suppress_roles,
331 hide_muted_channels: updated.hide_muted_channels,
332 })
333 }
334
335 pub async fn remove_member(
336 &self,
337 caller: Uuid,
338 space_name: &str,
339 uid: Uuid,
340 ) -> Result<(), SpaceError> {
341 let space = lookup_space_by_name(self, space_name).await?;
342 if space.owner_id != caller {
343 return Err(SpaceError::Forbidden);
344 }
345 if caller == uid {
346 return Err(SpaceError::BadRequest(
347 "owner cannot remove themselves".to_string(),
348 ));
349 }
350
351 let member = space_members::Entity::find_by_id((space.id, uid))
352 .one(self.db().read())
353 .await?
354 .ok_or(SpaceError::NotFound)?;
355
356 let now = OffsetDateTime::now_utc();
357 let mut am: space_members::ActiveModel = member.into();
358 am.status = Set(MemberStatus::Removed);
359 am.left_at = Set(Some(now));
360 am.updated_at = Set(Some(now));
361
362 self.db()
363 .primary()
364 .transaction::<_, (), SpaceError>(|txn| {
365 Box::pin(async move {
366 space_member_groups::Entity::delete_many()
367 .filter(space_member_groups::Column::SpaceId.eq(space.id))
368 .filter(space_member_groups::Column::UserId.eq(uid))
369 .exec(txn)
370 .await?;
371
372 am.update(txn).await?;
373
374 spaces::Entity::update_many()
375 .filter(spaces::Column::Id.eq(space.id))
376 .col_expr(
377 spaces::Column::NumsMembers,
378 Expr::cust("nums_members - 1"),
379 )
380 .exec(txn)
381 .await?;
382
383 Ok(())
384 })
385 })
386 .await?;
387
388 Ok(())
389 }
390
391 pub async fn list_members(
392 &self,
393 caller: Uuid,
394 space_name: &str,
395 q: PageQuery,
396 ) -> Result<MemberPage, SpaceError> {
397 let space = lookup_space_by_name(self, space_name).await?;
398 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
399
400 let max_pagination = self.config().space.max_pagination;
401 let default_pagination = self.config().space.default_pagination;
402 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
403 let offset = q.offset.unwrap_or(0);
404
405 let select = space_members::Entity::find()
406 .filter(space_members::Column::SpaceId.eq(space.id))
407 .order_by_asc(space_members::Column::JoinedAt);
408
409 let total = select.clone().count(self.db().read()).await? as i64;
410 let rows = select
411 .limit(limit)
412 .offset(offset)
413 .all(self.db().read())
414 .await?;
415
416 let mut uids: Vec<Uuid> = Vec::new();
417 for m in &rows {
418 uids.push(m.user_id);
419 if let Some(ib) = m.invited_by {
420 uids.push(ib);
421 }
422 }
423 let user_map = self.load_user_briefs(&uids).await?;
424
425 let items = rows
426 .into_iter()
427 .map(|m| {
428 let user_brief = user_map.get(&m.user_id).cloned();
429 let inviter_brief = m.invited_by.and_then(|ib| user_map.get(&ib).cloned());
430 MemberDto {
431 space_id: m.space_id,
432 user: user_brief,
433 alias_name: m.alias_name,
434 alias_avatar_url: m.alias_avatar_url,
435 status: m.status,
436 last_active_at: m.last_active_at,
437 left_at: m.left_at,
438 invited_at: m.invited_at,
439 invited_by: inviter_brief,
440 joined_at: m.joined_at,
441 created_at: m.created_at,
442 updated_at: m.updated_at,
443 message_notifications: m.message_notifications,
444 muted: m.muted,
445 mute_until: m.mute_until,
446 mobile_push: m.mobile_push,
447 suppress_everyone: m.suppress_everyone,
448 suppress_roles: m.suppress_roles,
449 hide_muted_channels: m.hide_muted_channels,
450 }
451 })
452 .collect();
453
454 Ok(MemberPage { items, total })
455 }
456}
Added lib/space/src/services/mod.rs lib/space/src/services/mod.rs +9 −0 View lib/space/src/services/mod.rs
@@ -0,0 +1,9 @@
1pub mod avatar;
2pub mod bans;
3pub mod dir;
4pub mod groups;
5pub mod images;
6pub mod invites;
7pub mod members;
8pub mod service;
9pub mod spaces;
Added lib/space/src/services/service.rs lib/space/src/services/service.rs +58 −0 View lib/space/src/services/service.rs
@@ -0,0 +1,58 @@
1use std::sync::Arc;
2
3use config::AppConfig;
4use export::{AppDatabase, AppEmail, AppState, AppStorage};
5use mailbaby::Email;
6
7use crate::errors::SpaceError;
8
9#[derive(Clone)]
10pub struct SpaceService {
11 pub state: AppState,
12}
13
14impl SpaceService {
15 pub fn new(state: AppState) -> Self {
16 Self { state }
17 }
18
19 pub fn config(&self) -> &AppConfig {
20 &self.state.config
21 }
22
23 pub fn db(&self) -> &Arc<AppDatabase> {
24 &self.state.database
25 }
26
27 pub fn storage(&self) -> &Arc<AppStorage> {
28 &self.state.storage
29 }
30
31 pub fn email(&self) -> &AppEmail {
32 &self.state.email
33 }
34
35 pub(crate) async fn send_email(
36 &self,
37 to: &str,
38 subject: &str,
39 text: &str,
40 html: &str,
41 ) -> Result<(), SpaceError> {
42 let builder = Email::builder(subject).to([to]).text_body(text);
43 let builder = if html.is_empty() {
44 builder
45 } else {
46 builder.html_body(html)
47 };
48 let email = builder
49 .build()
50 .map_err(|error| SpaceError::Internal(format!("mail build failed: {error}")))?;
51 self.state
52 .email
53 .send(&email)
54 .await
55 .map(|_| ())
56 .map_err(SpaceError::from)
57 }
58}
Added lib/space/src/services/spaces.rs lib/space/src/services/spaces.rs +501 −0 View lib/space/src/services/spaces.rs
@@ -0,0 +1,501 @@
1use std::collections::HashMap;
2
3use models::space::spaces::{self, AllowJoinLevel};
4use models::space::space_members::{self, MemberStatus, MessageNotifications};
5use models::users::user;
6use sea_orm::{
7 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
8 QuerySelect, Set, TransactionTrait,
9};
10use serde::{Deserialize, Serialize};
11use time::OffsetDateTime;
12use utoipa::{IntoParams, ToSchema};
13use uuid::Uuid;
14
15use crate::errors::SpaceError;
16use crate::services::service::SpaceService;
17use crate::share::authz::effective_permissions;
18use crate::share::lookup::lookup_space_by_name;
19
20fn default_true() -> bool {
21 true
22}
23
24fn is_valid_lower_name(name: &str) -> bool {
25 !name.is_empty()
26 && name
27 .chars()
28 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
32pub struct UserBriefDto {
33 pub uid: Uuid,
34 pub username: String,
35 pub display_name: String,
36 pub avatar_url: String,
37}
38
39impl From<user::Model> for UserBriefDto {
40 fn from(u: user::Model) -> Self {
41 Self {
42 uid: u.uid,
43 username: u.username,
44 display_name: u.display_name,
45 avatar_url: u.avatar_url,
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
51pub struct SpaceDto {
52 pub id: Uuid,
53 pub lower_name: String,
54 pub name: String,
55 pub description: Option<String>,
56 pub owner: Option<UserBriefDto>,
57 pub avatar_url: Option<String>,
58 pub is_private: bool,
59 pub is_active: bool,
60 pub allow_join: bool,
61 pub allow_invite: bool,
62 pub nums_members: i64,
63 pub welcome_message: Option<String>,
64 pub allow_join_level: AllowJoinLevel,
65 pub member_must_enable_2fa: bool,
66 pub welcome_object: Option<Uuid>,
67 pub created_at: OffsetDateTime,
68 pub updated_at: OffsetDateTime,
69}
70
71#[derive(Debug, Clone, Deserialize, ToSchema)]
72pub struct CreateSpaceCmd {
73 pub name: String,
74 #[serde(default)]
75 pub description: Option<String>,
76 #[serde(default)]
77 pub avatar_url: Option<String>,
78 #[serde(default)]
79 pub is_private: bool,
80 #[serde(default = "default_true")]
81 pub allow_join: bool,
82 #[serde(default = "default_true")]
83 pub allow_invite: bool,
84 #[serde(default)]
85 pub welcome_message: Option<String>,
86 #[serde(default)]
87 pub allow_join_level: Option<AllowJoinLevel>,
88 #[serde(default)]
89 pub member_must_enable_2fa: bool,
90 #[serde(default)]
91 pub welcome_object: Option<Uuid>,
92}
93
94#[derive(Debug, Clone, Deserialize, ToSchema)]
95pub struct UpdateSpaceCmd {
96 #[serde(default)]
97 pub name: Option<String>,
98 #[serde(default)]
99 pub description: Option<Option<String>>,
100 #[serde(default)]
101 pub avatar_url: Option<Option<String>>,
102 #[serde(default)]
103 pub is_private: Option<bool>,
104 #[serde(default)]
105 pub allow_join: Option<bool>,
106 #[serde(default)]
107 pub allow_invite: Option<bool>,
108 #[serde(default)]
109 pub welcome_message: Option<Option<String>>,
110 #[serde(default)]
111 pub allow_join_level: Option<AllowJoinLevel>,
112 #[serde(default)]
113 pub member_must_enable_2fa: Option<bool>,
114 #[serde(default)]
115 pub welcome_object: Option<Option<Uuid>>,
116}
117
118#[derive(Debug, Clone, Serialize, ToSchema)]
119pub struct SpacePage {
120 pub items: Vec<SpaceDto>,
121 pub total: i64,
122}
123
124#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
125pub struct PageQuery {
126 pub limit: Option<u64>,
127 pub offset: Option<u64>,
128}
129
130#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
131pub struct SpaceNamePath {
132 pub space_name: String,
133}
134
135impl SpaceService {
136 pub async fn load_user_briefs(
137 &self,
138 uids: &[Uuid],
139 ) -> Result<HashMap<Uuid, UserBriefDto>, SpaceError> {
140 if uids.is_empty() {
141 return Ok(HashMap::new());
142 }
143 let unique_uids: Vec<Uuid> = {
144 let mut set: Vec<Uuid> = uids.to_vec();
145 set.sort();
146 set.dedup();
147 set
148 };
149 let users = user::Entity::find()
150 .filter(user::Column::Uid.is_in(unique_uids))
151 .all(self.db().read())
152 .await?;
153 let map = users
154 .into_iter()
155 .map(|u| (u.uid, UserBriefDto::from(u)))
156 .collect();
157 Ok(map)
158 }
159
160 pub async fn create_space(&self, caller: Uuid, cmd: CreateSpaceCmd) -> Result<SpaceDto, SpaceError> {
161 let name = cmd.name.trim();
162 if name.is_empty() || name.chars().count() > self.config().space.max_space_name_len {
163 return Err(SpaceError::BadRequest("space name is empty or too long".to_string()));
164 }
165 if let Some(desc) = &cmd.description
166 && desc.chars().count() > self.config().space.max_space_desc_len
167 {
168 return Err(SpaceError::BadRequest("space description is too long".to_string()));
169 }
170 let lower = name.to_lowercase();
171 if !is_valid_lower_name(&lower) {
172 return Err(SpaceError::BadRequest(
173 "invalid space name: must contain only lowercase letters, digits, and hyphens"
174 .to_string(),
175 ));
176 }
177
178 let existing = spaces::Entity::find()
179 .filter(spaces::Column::LowerName.eq(&lower))
180 .one(self.db().read())
181 .await?;
182 if existing.is_some() {
183 return Err(SpaceError::Conflict(format!(
184 "space name {lower} is already taken"
185 )));
186 }
187
188 if cmd.member_must_enable_2fa {
189 let caller_user = user::Entity::find_by_id(caller)
190 .one(self.db().read())
191 .await?
192 .ok_or(SpaceError::NotFound)?;
193 if !caller_user.totp_enabled {
194 return Err(SpaceError::Forbidden);
195 }
196 }
197
198 let now = OffsetDateTime::now_utc();
199 let space_id = Uuid::now_v7();
200 let allow_join_level = cmd.allow_join_level.unwrap_or(AllowJoinLevel::None);
201
202 let space_row = spaces::ActiveModel {
203 id: Set(space_id),
204 lower_name: Set(lower.clone()),
205 name: Set(name.to_string()),
206 description: Set(cmd.description.clone()),
207 owner_id: Set(caller),
208 avatar_url: Set(cmd.avatar_url.clone()),
209 is_private: Set(cmd.is_private),
210 is_active: Set(true),
211 allow_join: Set(cmd.allow_join),
212 allow_invite: Set(cmd.allow_invite),
213 invite_sort_url_hash: Set(None),
214 nums_members: Set(1),
215 banner_url: Set(None),
216 banner_width: Set(None),
217 banner_height: Set(None),
218 invite_banner_url: Set(None),
219 invite_banner_width: Set(None),
220 invite_banner_height: Set(None),
221 embed_splash_url: Set(None),
222 embed_splash_width: Set(None),
223 embed_splash_height: Set(None),
224 welcome_message: Set(cmd.welcome_message.clone()),
225 allow_join_level: Set(allow_join_level),
226 member_must_enable_2fa: Set(cmd.member_must_enable_2fa),
227 welcome_object: Set(cmd.welcome_object),
228 created_at: Set(now),
229 updated_at: Set(now),
230 };
231
232 let member_row = space_members::ActiveModel {
233 space_id: Set(space_id),
234 user_id: Set(caller),
235 alias_name: Set(None),
236 alias_avatar_url: Set(None),
237 status: Set(MemberStatus::Active),
238 last_active_at: Set(None),
239 left_at: Set(None),
240 invited_at: Set(None),
241 invited_by: Set(None),
242 joined_at: Set(now),
243 created_at: Set(now),
244 updated_at: Set(Some(now)),
245 message_notifications: Set(MessageNotifications::All),
246 muted: Set(false),
247 mute_until: Set(None),
248 mobile_push: Set(false),
249 suppress_everyone: Set(false),
250 suppress_roles: Set(false),
251 hide_muted_channels: Set(false),
252 };
253
254 let inserted_space = self
255 .db()
256 .primary()
257 .transaction::<_, spaces::Model, SpaceError>(|txn| {
258 Box::pin(async move {
259 let s = space_row.insert(txn).await?;
260 member_row.insert(txn).await?;
261 Ok(s)
262 })
263 })
264 .await?;
265
266 let owner_map = self.load_user_briefs(&[caller]).await?;
267 let owner = owner_map.get(&caller).cloned();
268
269 Ok(SpaceDto {
270 id: inserted_space.id,
271 lower_name: inserted_space.lower_name,
272 name: inserted_space.name,
273 description: inserted_space.description,
274 owner,
275 avatar_url: inserted_space.avatar_url,
276 is_private: inserted_space.is_private,
277 is_active: inserted_space.is_active,
278 allow_join: inserted_space.allow_join,
279 allow_invite: inserted_space.allow_invite,
280 nums_members: inserted_space.nums_members,
281 welcome_message: inserted_space.welcome_message,
282 allow_join_level: inserted_space.allow_join_level,
283 member_must_enable_2fa: inserted_space.member_must_enable_2fa,
284 welcome_object: inserted_space.welcome_object,
285 created_at: inserted_space.created_at,
286 updated_at: inserted_space.updated_at,
287 })
288 }
289
290 pub async fn get_space(
291 &self,
292 caller: Option<Uuid>,
293 space_name: &str,
294 ) -> Result<SpaceDto, SpaceError> {
295 let space = lookup_space_by_name(self, space_name).await?;
296 if !space.is_active {
297 return Err(SpaceError::NotFound);
298 }
299
300 if space.is_private {
301 let Some(uid) = caller else {
302 return Err(SpaceError::NotFound);
303 };
304 match effective_permissions(self.db().read(), &space, uid).await {
305 Ok(_) => {}
306 Err(SpaceError::Forbidden) => return Err(SpaceError::NotFound),
307 Err(e) => return Err(e),
308 }
309 }
310
311 let owner_map = self.load_user_briefs(&[space.owner_id]).await?;
312 let owner = owner_map.get(&space.owner_id).cloned();
313
314 Ok(SpaceDto {
315 id: space.id,
316 lower_name: space.lower_name,
317 name: space.name,
318 description: space.description,
319 owner,
320 avatar_url: space.avatar_url,
321 is_private: space.is_private,
322 is_active: space.is_active,
323 allow_join: space.allow_join,
324 allow_invite: space.allow_invite,
325 nums_members: space.nums_members,
326 welcome_message: space.welcome_message,
327 allow_join_level: space.allow_join_level,
328 member_must_enable_2fa: space.member_must_enable_2fa,
329 welcome_object: space.welcome_object,
330 created_at: space.created_at,
331 updated_at: space.updated_at,
332 })
333 }
334
335 pub async fn update_space(
336 &self,
337 caller: Uuid,
338 space_name: &str,
339 cmd: UpdateSpaceCmd,
340 ) -> Result<SpaceDto, SpaceError> {
341 let space = lookup_space_by_name(self, space_name).await?;
342 if space.owner_id != caller {
343 return Err(SpaceError::Forbidden);
344 }
345
346 let mut am: spaces::ActiveModel = space.into();
347
348 if let Some(new_name) = cmd.name {
349 let name = new_name.trim();
350 if name.is_empty() || name.chars().count() > self.config().space.max_space_name_len {
351 return Err(SpaceError::BadRequest("space name is empty or too long".to_string()));
352 }
353 let lower = name.to_lowercase();
354 if !is_valid_lower_name(&lower) {
355 return Err(SpaceError::BadRequest(
356 "invalid space name: must contain only lowercase letters, digits, and hyphens"
357 .to_string(),
358 ));
359 }
360 let existing = spaces::Entity::find()
361 .filter(spaces::Column::LowerName.eq(&lower))
362 .filter(spaces::Column::Id.ne(am.id.clone().unwrap()))
363 .one(self.db().read())
364 .await?;
365 if existing.is_some() {
366 return Err(SpaceError::Conflict(format!(
367 "space name {lower} is already taken"
368 )));
369 }
370 am.name = Set(name.to_string());
371 am.lower_name = Set(lower);
372 }
373
374 if let Some(desc) = cmd.description {
375 if let Some(d) = &desc
376 && d.chars().count() > self.config().space.max_space_desc_len
377 {
378 return Err(SpaceError::BadRequest("space description is too long".to_string()));
379 }
380 am.description = Set(desc);
381 }
382
383 if let Some(avatar) = cmd.avatar_url {
384 am.avatar_url = Set(avatar);
385 }
386 if let Some(is_private) = cmd.is_private {
387 am.is_private = Set(is_private);
388 }
389 if let Some(allow_join) = cmd.allow_join {
390 am.allow_join = Set(allow_join);
391 }
392 if let Some(allow_invite) = cmd.allow_invite {
393 am.allow_invite = Set(allow_invite);
394 }
395 if let Some(welcome) = cmd.welcome_message {
396 if let Some(w) = &welcome
397 && w.chars().count() > self.config().space.max_space_desc_len
398 {
399 return Err(SpaceError::BadRequest("welcome message is too long".to_string()));
400 }
401 am.welcome_message = Set(welcome);
402 }
403 if let Some(allow_join_level) = cmd.allow_join_level {
404 am.allow_join_level = Set(allow_join_level);
405 }
406 if let Some(member_must_enable_2fa) = cmd.member_must_enable_2fa {
407 am.member_must_enable_2fa = Set(member_must_enable_2fa);
408 }
409 if let Some(welcome_object) = cmd.welcome_object {
410 am.welcome_object = Set(welcome_object);
411 }
412
413 let now = OffsetDateTime::now_utc();
414 am.updated_at = Set(now);
415
416 let updated = am.update(self.db().write()).await?;
417 let owner_map = self.load_user_briefs(&[updated.owner_id]).await?;
418 let owner = owner_map.get(&updated.owner_id).cloned();
419
420 Ok(SpaceDto {
421 id: updated.id,
422 lower_name: updated.lower_name,
423 name: updated.name,
424 description: updated.description,
425 owner,
426 avatar_url: updated.avatar_url,
427 is_private: updated.is_private,
428 is_active: updated.is_active,
429 allow_join: updated.allow_join,
430 allow_invite: updated.allow_invite,
431 nums_members: updated.nums_members,
432 welcome_message: updated.welcome_message,
433 allow_join_level: updated.allow_join_level,
434 member_must_enable_2fa: updated.member_must_enable_2fa,
435 welcome_object: updated.welcome_object,
436 created_at: updated.created_at,
437 updated_at: updated.updated_at,
438 })
439 }
440
441 pub async fn delete_space(&self, caller: Uuid, space_name: &str) -> Result<(), SpaceError> {
442 let space = lookup_space_by_name(self, space_name).await?;
443 if space.owner_id != caller {
444 return Err(SpaceError::Forbidden);
445 }
446
447 let now = OffsetDateTime::now_utc();
448 let mut am: spaces::ActiveModel = space.into();
449 am.is_active = Set(false);
450 am.updated_at = Set(now);
451 am.update(self.db().write()).await?;
452 Ok(())
453 }
454
455 pub async fn list_spaces(&self, caller: Uuid, q: PageQuery) -> Result<SpacePage, SpaceError> {
456 let max_pagination = self.config().space.max_pagination;
457 let default_pagination = self.config().space.default_pagination;
458 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
459 let offset = q.offset.unwrap_or(0);
460
461 let select = spaces::Entity::find()
462 .filter(spaces::Column::OwnerId.eq(caller))
463 .filter(spaces::Column::IsActive.eq(true))
464 .order_by_desc(spaces::Column::CreatedAt);
465
466 let total = select.clone().count(self.db().read()).await? as i64;
467 let rows = select
468 .limit(limit)
469 .offset(offset)
470 .all(self.db().read())
471 .await?;
472
473 let owner_map = self.load_user_briefs(&[caller]).await?;
474 let owner = owner_map.get(&caller).cloned();
475
476 let items = rows
477 .into_iter()
478 .map(|s| SpaceDto {
479 id: s.id,
480 lower_name: s.lower_name,
481 name: s.name,
482 description: s.description,
483 owner: owner.clone(),
484 avatar_url: s.avatar_url,
485 is_private: s.is_private,
486 is_active: s.is_active,
487 allow_join: s.allow_join,
488 allow_invite: s.allow_invite,
489 nums_members: s.nums_members,
490 welcome_message: s.welcome_message,
491 allow_join_level: s.allow_join_level,
492 member_must_enable_2fa: s.member_must_enable_2fa,
493 welcome_object: s.welcome_object,
494 created_at: s.created_at,
495 updated_at: s.updated_at,
496 })
497 .collect();
498
499 Ok(SpacePage { items, total })
500 }
501}
Added lib/space/src/share/authz.rs lib/space/src/share/authz.rs +124 −0 View lib/space/src/share/authz.rs
@@ -0,0 +1,124 @@
1use std::collections::HashSet;
2
3use models::space::space_members::MemberStatus;
4use models::space::{
5 space_bans, space_group_permissions, space_member_groups, space_members, spaces,
6};
7use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter};
8use time::OffsetDateTime;
9use uuid::Uuid;
10
11use crate::errors::SpaceError;
12use crate::services::service::SpaceService;
13
14pub const VIEW_SPACE: &str = "view_space";
15pub const READ: &str = "read";
16pub const MESSAGE: &str = "message";
17pub const MANAGE_MEMBERS: &str = "manage_members";
18pub const MANAGE_ROLES: &str = "manage_roles";
19pub const MANAGE_INVITES: &str = "manage_invites";
20pub const MANAGE_BANS: &str = "manage_bans";
21pub const MANAGE_CHANNELS: &str = "manage_channels";
22pub const MANAGE_OVERWRITES: &str = "manage_overwrites";
23
24pub const ALL_PERMISSIONS: [&str; 9] = [
25 VIEW_SPACE,
26 READ,
27 MESSAGE,
28 MANAGE_MEMBERS,
29 MANAGE_ROLES,
30 MANAGE_INVITES,
31 MANAGE_BANS,
32 MANAGE_CHANNELS,
33 MANAGE_OVERWRITES,
34];
35
36#[derive(Debug, Clone, Default)]
37pub struct Permissions {
38 granted: HashSet<String>,
39}
40
41impl Permissions {
42 pub fn owner() -> Self {
43 Self {
44 granted: ALL_PERMISSIONS.into_iter().map(String::from).collect(),
45 }
46 }
47
48 pub fn has(&self, action: &str) -> bool {
49 self.granted.contains(action)
50 }
51
52 pub fn into_vec(self) -> Vec<String> {
53 let mut v: Vec<String> = self.granted.into_iter().collect();
54 v.sort();
55 v.dedup();
56 v
57 }
58}
59
60pub async fn effective_permissions(
61 db: &impl ConnectionTrait,
62 space: &spaces::Model,
63 user_id: Uuid,
64) -> Result<Permissions, SpaceError> {
65 if space.owner_id == user_id {
66 return Ok(Permissions::owner());
67 }
68
69 let now = OffsetDateTime::now_utc();
70 if let Some(ban) = space_bans::Entity::find_by_id((space.id, user_id))
71 .one(db)
72 .await?
73 && (ban.expires_at.is_none() || ban.expires_at > Some(now))
74 {
75 return Err(SpaceError::Forbidden);
76 }
77
78 let Some(member) = space_members::Entity::find_by_id((space.id, user_id))
79 .one(db)
80 .await?
81 else {
82 return Err(SpaceError::Forbidden);
83 };
84
85 if member.status != MemberStatus::Active {
86 return Err(SpaceError::Forbidden);
87 }
88
89 let member_groups = space_member_groups::Entity::find()
90 .filter(space_member_groups::Column::SpaceId.eq(space.id))
91 .filter(space_member_groups::Column::UserId.eq(user_id))
92 .all(db)
93 .await?;
94
95 if member_groups.is_empty() {
96 return Ok(Permissions::default());
97 }
98
99 let group_ids: Vec<Uuid> = member_groups.into_iter().map(|mg| mg.group_id).collect();
100 let group_perms = space_group_permissions::Entity::find()
101 .filter(space_group_permissions::Column::GroupId.is_in(group_ids))
102 .all(db)
103 .await?;
104
105 let mut granted = HashSet::new();
106 for gp in group_perms {
107 granted.insert(gp.permission);
108 }
109
110 Ok(Permissions { granted })
111}
112
113pub async fn require_perm(
114 service: &SpaceService,
115 space: &spaces::Model,
116 caller: Uuid,
117 action: &str,
118) -> Result<(), SpaceError> {
119 let perms = effective_permissions(service.db().read(), space, caller).await?;
120 if !perms.has(action) {
121 return Err(SpaceError::Forbidden);
122 }
123 Ok(())
124}
Added lib/space/src/share/guard.rs lib/space/src/share/guard.rs +45 −0 View lib/space/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, SpaceError};
11use crate::services::service::SpaceService;
12use crate::share::session::{pwd_version_matches, session_token_key};
13
14pub async fn space_guard(
15 State(service): State<SpaceService>,
16 session: Session,
17 request: Request,
18 next: Next,
19) -> Response {
20 let Some(uid) = session.uid() else {
21 return ApiError::from(SpaceError::Unauthorized).into_response();
22 };
23 let Some(token_hash) = session.get::<String>(&session_token_key(uid)) else {
24 return ApiError::from(SpaceError::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(SpaceError::from(error)).into_response(),
39 };
40 if !authorized {
41 session.logout_user(uid);
42 return ApiError::from(SpaceError::Unauthorized).into_response();
43 }
44 next.run(request).await
45}
Added lib/space/src/share/lookup.rs lib/space/src/share/lookup.rs +17 −0 View lib/space/src/share/lookup.rs
@@ -0,0 +1,17 @@
1use models::space::spaces;
2use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
3
4use crate::errors::SpaceError;
5use crate::services::service::SpaceService;
6
7pub async fn lookup_space_by_name(
8 service: &SpaceService,
9 space_name: &str,
10) -> Result<spaces::Model, SpaceError> {
11 let lower_name = space_name.to_lowercase();
12 spaces::Entity::find()
13 .filter(spaces::Column::LowerName.eq(&lower_name))
14 .one(service.db().read())
15 .await?
16 .ok_or(SpaceError::NotFound)
17}
Added lib/space/src/share/mod.rs lib/space/src/share/mod.rs +4 −0 View lib/space/src/share/mod.rs
@@ -0,0 +1,4 @@
1pub mod authz;
2pub mod guard;
3pub mod lookup;
4pub mod session;
Added lib/space/src/share/session.rs lib/space/src/share/session.rs +26 −0 View lib/space/src/share/session.rs
@@ -0,0 +1,26 @@
1use session::{Session, SessionUser};
2use uuid::Uuid;
3
4use crate::errors::{ApiError, SpaceError};
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(SpaceError::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/space/src/errors.rs lib/space/src/errors.rs +175 −0 View lib/space/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 SpaceError {
12 BadRequest(String),
13 Unauthorized,
14 Forbidden,
15 NotFound,
16 Conflict(String),
17 Internal(String),
18}
19
20impl fmt::Display for SpaceError {
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 SpaceError {}
34
35impl From<sea_orm::DbErr> for SpaceError {
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 SpaceError {
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<SpaceError>> for SpaceError {
56 fn from(error: sea_orm::TransactionError<SpaceError>) -> Self {
57 match error {
58 sea_orm::TransactionError::Connection(db_err) => Self::from(db_err),
59 sea_orm::TransactionError::Transaction(space_err) => space_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 SpaceError {
70 fn from(error: storage::StorageError) -> Self {
71 Self::Internal(format!("storage error: {error}"))
72 }
73}
74
75impl From<export::ExportError> for SpaceError {
76 fn from(error: export::ExportError) -> Self {
77 Self::Internal(format!("infrastructure error: {error}"))
78 }
79}
80
81impl From<serde_json::Error> for SpaceError {
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<SpaceError> for ApiError {
132 fn from(error: SpaceError) -> Self {
133 match error {
134 SpaceError::BadRequest(msg) => Self::BadRequest(AppResponseData::err(400, msg)),
135 SpaceError::Unauthorized => Self::Unauthorized(Self::payload(401, "unauthorized")),
136 SpaceError::Forbidden => Self::Forbidden(Self::payload(403, "forbidden")),
137 SpaceError::NotFound => Self::NotFound(Self::payload(404, "not found")),
138 SpaceError::Conflict(msg) => Self::Conflict(AppResponseData::err(409, msg)),
139 SpaceError::Internal(message) => {
140 tracing::warn!(error = %message, "space 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/space/src/lib.rs lib/space/src/lib.rs +6 −0 View lib/space/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/space/src/openapi.rs lib/space/src/openapi.rs +122 −0 View lib/space/src/openapi.rs
@@ -0,0 +1,122 @@
1use export::rest::AppResponseData;
2use utoipa::OpenApi;
3
4#[derive(OpenApi)]
5#[openapi(
6 paths(
7 crate::handlers::get_space::get_space,
8 crate::handlers::get_group::get_group,
9 crate::handlers::get_space_avatar::get_space_avatar,
10 crate::handlers::get_space_image::get_space_image,
11 crate::handlers::create_space::create_space,
12 crate::handlers::list_spaces::list_spaces,
13 crate::handlers::update_space::update_space,
14 crate::handlers::delete_space::delete_space,
15 crate::handlers::upload_space_avatar::upload_space_avatar,
16 crate::handlers::delete_space_avatar::delete_space_avatar,
17 crate::handlers::upload_space_image::upload_space_image,
18 crate::handlers::delete_space_image::delete_space_image,
19 crate::handlers::create_dir_node::create_dir_node,
20 crate::handlers::get_space_dir_tree::get_space_dir_tree,
21 crate::handlers::repair_dir_parents::repair_dir_parents,
22 crate::handlers::update_dir_node::update_dir_node,
23 crate::handlers::move_dir_node::move_dir_node,
24 crate::handlers::list_dir_group_overwrites::list_dir_group_overwrites,
25 crate::handlers::set_dir_group_overwrite::set_dir_group_overwrite,
26 crate::handlers::remove_dir_group_overwrite::remove_dir_group_overwrite,
27 crate::handlers::list_dir_user_overwrites::list_dir_user_overwrites,
28 crate::handlers::set_dir_user_overwrite::set_dir_user_overwrite,
29 crate::handlers::remove_dir_user_overwrite::remove_dir_user_overwrite,
30 crate::handlers::add_member::add_member,
31 crate::handlers::list_members::list_members,
32 crate::handlers::get_member::get_member,
33 crate::handlers::update_member::update_member,
34 crate::handlers::remove_member::remove_member,
35 crate::handlers::create_group::create_group,
36 crate::handlers::list_groups::list_groups,
37 crate::handlers::update_group::update_group,
38 crate::handlers::delete_group::delete_group,
39 crate::handlers::assign_group::assign_group,
40 crate::handlers::unassign_group::unassign_group,
41 crate::handlers::join_group_self::join_group_self,
42 crate::handlers::leave_group_self::leave_group_self,
43 crate::handlers::create_invite::create_invite,
44 crate::handlers::list_invites::list_invites,
45 crate::handlers::revoke_invite::revoke_invite,
46 crate::handlers::accept_invite::accept_invite,
47 crate::handlers::ban_user::ban_user,
48 crate::handlers::list_bans::list_bans,
49 crate::handlers::get_ban::get_ban,
50 crate::handlers::update_ban::update_ban,
51 crate::handlers::unban_user::unban_user,
52 ),
53 components(
54 schemas(
55 AppResponseData<crate::services::spaces::SpaceDto>,
56 AppResponseData<crate::services::spaces::SpacePage>,
57 AppResponseData<crate::services::groups::GroupDto>,
58 AppResponseData<crate::services::groups::GroupPage>,
59 AppResponseData<crate::services::members::MemberDto>,
60 AppResponseData<crate::services::members::MemberPage>,
61 AppResponseData<crate::services::invites::InviteDto>,
62 AppResponseData<crate::services::invites::InvitePage>,
63 AppResponseData<crate::services::bans::BanDto>,
64 AppResponseData<crate::services::bans::BanPage>,
65 AppResponseData<crate::services::avatar::SpaceAvatarDto>,
66 AppResponseData<Option<crate::services::avatar::SpaceAvatarDto>>,
67 AppResponseData<crate::services::images::SpaceImageDto>,
68 AppResponseData<Option<crate::services::images::SpaceImageDto>>,
69 AppResponseData<crate::services::dir::DirNodeDto>,
70 AppResponseData<Vec<crate::services::dir::DirNodeDto>>,
71 AppResponseData<crate::services::dir::RepairDirResp>,
72 AppResponseData<crate::services::dir::GroupOverwriteDto>,
73 AppResponseData<Vec<crate::services::dir::GroupOverwriteDto>>,
74 AppResponseData<crate::services::dir::UserOverwriteDto>,
75 AppResponseData<Vec<crate::services::dir::UserOverwriteDto>>,
76 crate::services::spaces::SpaceDto,
77 crate::services::spaces::SpacePage,
78 crate::services::spaces::CreateSpaceCmd,
79 crate::services::spaces::UpdateSpaceCmd,
80 crate::services::spaces::UserBriefDto,
81 crate::services::groups::GroupDto,
82 crate::services::groups::GroupPage,
83 crate::services::groups::CreateGroupCmd,
84 crate::services::groups::UpdateGroupCmd,
85 crate::services::groups::AssignGroupCmd,
86 crate::services::members::MemberDto,
87 crate::services::members::MemberPage,
88 crate::services::members::AddMemberCmd,
89 crate::services::members::UpdateMemberCmd,
90 crate::services::invites::InviteDto,
91 crate::services::invites::InvitePage,
92 crate::services::invites::CreateInviteCmd,
93 crate::services::invites::AcceptInviteCmd,
94 crate::services::bans::BanDto,
95 crate::services::bans::BanPage,
96 crate::services::bans::BanUserCmd,
97 crate::services::bans::UpdateBanCmd,
98 crate::services::avatar::SpaceAvatarDto,
99 crate::services::avatar::UploadSpaceAvatarCmd,
100 crate::services::avatar::AvatarFormat,
101 crate::services::images::SpaceImageDto,
102 crate::services::images::UploadSpaceImageCmd,
103 crate::services::images::SpaceImageKind,
104 crate::services::dir::DirNodeDto,
105 crate::services::dir::CreateDirCmd,
106 crate::services::dir::UpdateDirCmd,
107 crate::services::dir::MoveDirCmd,
108 crate::services::dir::RepairDirResp,
109 crate::services::dir::GroupOverwriteDto,
110 crate::services::dir::UserOverwriteDto,
111 crate::services::dir::SetOverwriteCmd,
112 models::space::spaces::AllowJoinLevel,
113 models::space::space_members::MemberStatus,
114 models::space::space_members::MessageNotifications,
115 models::dir::dirs::DirKind,
116 )
117 ),
118 tags(
119 (name = "space", description = "Space management endpoints")
120 )
121)]
122pub struct SpaceApiDoc;
Added lib/space/src/routes.rs lib/space/src/routes.rs +170 −0 View lib/space/src/routes.rs
@@ -0,0 +1,170 @@
1use axum::routing::{delete, get, patch, post, put};
2use axum::Router;
3
4use crate::handlers::accept_invite::accept_invite;
5use crate::handlers::add_member::add_member;
6use crate::handlers::assign_group::assign_group;
7use crate::handlers::ban_user::ban_user;
8use crate::handlers::create_dir_node::create_dir_node;
9use crate::handlers::create_group::create_group;
10use crate::handlers::create_invite::create_invite;
11use crate::handlers::create_space::create_space;
12use crate::handlers::delete_group::delete_group;
13use crate::handlers::delete_space::delete_space;
14use crate::handlers::delete_space_avatar::delete_space_avatar;
15use crate::handlers::delete_space_image::delete_space_image;
16use crate::handlers::get_ban::get_ban;
17use crate::handlers::get_group::get_group;
18use crate::handlers::get_member::get_member;
19use crate::handlers::get_space::get_space;
20use crate::handlers::get_space_avatar::get_space_avatar;
21use crate::handlers::get_space_dir_tree::get_space_dir_tree;
22use crate::handlers::get_space_image::get_space_image;
23use crate::handlers::join_group_self::join_group_self;
24use crate::handlers::leave_group_self::leave_group_self;
25use crate::handlers::list_bans::list_bans;
26use crate::handlers::list_dir_group_overwrites::list_dir_group_overwrites;
27use crate::handlers::list_dir_user_overwrites::list_dir_user_overwrites;
28use crate::handlers::list_groups::list_groups;
29use crate::handlers::list_invites::list_invites;
30use crate::handlers::list_members::list_members;
31use crate::handlers::list_spaces::list_spaces;
32use crate::handlers::move_dir_node::move_dir_node;
33use crate::handlers::remove_dir_group_overwrite::remove_dir_group_overwrite;
34use crate::handlers::remove_dir_user_overwrite::remove_dir_user_overwrite;
35use crate::handlers::remove_member::remove_member;
36use crate::handlers::repair_dir_parents::repair_dir_parents;
37use crate::handlers::revoke_invite::revoke_invite;
38use crate::handlers::set_dir_group_overwrite::set_dir_group_overwrite;
39use crate::handlers::set_dir_user_overwrite::set_dir_user_overwrite;
40use crate::handlers::unassign_group::unassign_group;
41use crate::handlers::unban_user::unban_user;
42use crate::handlers::update_ban::update_ban;
43use crate::handlers::update_dir_node::update_dir_node;
44use crate::handlers::update_group::update_group;
45use crate::handlers::update_member::update_member;
46use crate::handlers::update_space::update_space;
47use crate::handlers::upload_space_avatar::upload_space_avatar;
48use crate::handlers::upload_space_image::upload_space_image;
49use crate::services::service::SpaceService;
50use crate::share::guard::space_guard;
51
52pub fn router(service: SpaceService) -> Router<SpaceService> {
53 let public: Router<SpaceService> = Router::new()
54 .route("/spaces/{space_name}", get(get_space))
55 .route(
56 "/spaces/{space_name}/groups/{group_id}",
57 get(get_group),
58 )
59 .route(
60 "/spaces/{space_name}/avatar",
61 get(get_space_avatar),
62 )
63 .route(
64 "/spaces/{space_name}/images/{kind}",
65 get(get_space_image),
66 );
67
68 let protected = Router::new()
69 .route("/spaces", post(create_space).get(list_spaces))
70 .route(
71 "/spaces/invites/accept",
72 post(accept_invite),
73 )
74 .route(
75 "/spaces/{space_name}",
76 patch(update_space).delete(delete_space),
77 )
78 .route(
79 "/spaces/{space_name}/avatar",
80 put(upload_space_avatar).delete(delete_space_avatar),
81 )
82 .route(
83 "/spaces/{space_name}/images/{kind}",
84 put(upload_space_image).delete(delete_space_image),
85 )
86 .route(
87 "/spaces/{space_name}/dir",
88 post(create_dir_node).get(get_space_dir_tree),
89 )
90 .route(
91 "/spaces/{space_name}/dir/repair-parents",
92 post(repair_dir_parents),
93 )
94 .route(
95 "/spaces/{space_name}/dir/{dir_id}",
96 patch(update_dir_node),
97 )
98 .route(
99 "/spaces/{space_name}/dir/{dir_id}/position",
100 patch(move_dir_node),
101 )
102 .route(
103 "/spaces/{space_name}/dir/{dir_id}/overwrites/groups",
104 get(list_dir_group_overwrites),
105 )
106 .route(
107 "/spaces/{space_name}/dir/{dir_id}/overwrites/groups/{group_id}",
108 put(set_dir_group_overwrite).delete(remove_dir_group_overwrite),
109 )
110 .route(
111 "/spaces/{space_name}/dir/{dir_id}/overwrites/users",
112 get(list_dir_user_overwrites),
113 )
114 .route(
115 "/spaces/{space_name}/dir/{dir_id}/overwrites/users/{user_id}",
116 put(set_dir_user_overwrite).delete(remove_dir_user_overwrite),
117 )
118 .route(
119 "/spaces/{space_name}/members",
120 post(add_member).get(list_members),
121 )
122 .route(
123 "/spaces/{space_name}/members/{uid}",
124 get(get_member)
125 .patch(update_member)
126 .delete(remove_member),
127 )
128 .route(
129 "/spaces/{space_name}/groups",
130 post(create_group).get(list_groups),
131 )
132 .route(
133 "/spaces/{space_name}/groups/{group_id}",
134 patch(update_group).delete(delete_group),
135 )
136 .route(
137 "/spaces/{space_name}/members/{uid}/groups",
138 post(assign_group),
139 )
140 .route(
141 "/spaces/{space_name}/groups/{group_id}/join",
142 post(join_group_self).delete(leave_group_self),
143 )
144 .route(
145 "/spaces/{space_name}/members/{uid}/groups/{group_id}",
146 delete(unassign_group),
147 )
148 .route(
149 "/spaces/{space_name}/invites",
150 post(create_invite).get(list_invites),
151 )
152 .route(
153 "/spaces/{space_name}/invites/{invite_id}",
154 delete(revoke_invite),
155 )
156 .route(
157 "/spaces/{space_name}/bans",
158 post(ban_user).get(list_bans),
159 )
160 .route(
161 "/spaces/{space_name}/bans/{uid}",
162 get(get_ban).patch(update_ban).delete(unban_user),
163 )
164 .route_layer(axum::middleware::from_fn_with_state(
165 service.clone(),
166 space_guard,
167 ));
168
169 public.merge(protected)
170}
Added lib/space/Cargo.toml lib/space/Cargo.toml +21 −0 View lib/space/Cargo.toml
@@ -0,0 +1,21 @@
1[package]
2name = "space"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7config = { path = "../config" }
8export = { path = "../export" }
9models = { path = "../models" }
10session = { path = "../session" }
11storage = { path = "../storage" }
12axum = { workspace = true }
13sea-orm = { workspace = true }
14serde = { workspace = true, features = ["derive"] }
15serde_json = { workspace = true }
16utoipa = { version = "5.5", features = ["axum_extras", "time", "uuid"] }
17uuid = { version = "1", features = ["v7", "serde"] }
18time = { version = "0.3", features = ["serde"] }
19base64 = { workspace = true }
20mailbaby = { git = "https://github.com/mailbabys/mailbaby-rs", rev = "e408acf48a19e4fd554b020a865d144706f7c2de", features = ["rest", "grpc"] }
21tracing = { workspace = true }
Modified Cargo.lock Cargo.lock +21 −0 View Cargo.lock
@@ -4737,6 +4737,27 @@
47374737]
47384738
47394739[[package]]
4740name = "space"
4741version = "1.0.0"
4742dependencies = [
4743 "axum",
4744 "base64 0.23.1",
4745 "config",
4746 "export",
4747 "mailbaby",
4748 "models",
4749 "sea-orm",
4750 "serde",
4751 "serde_json",
4752 "session",
4753 "storage",
4754 "time 0.3.55",
4755 "tracing",
4756 "utoipa",
4757 "uuid",
4758]
4759
4760[[package]]
47404761name = "spin"
47414762version = "0.9.9"
47424763source = "registry+https://github.com/rust-lang/crates.io-index"
Modified Cargo.toml Cargo.toml +3 −2 View Cargo.toml
@@ -4,8 +4,9 @@
44 "lib/models",
55 "lib/pow-solver",
66 "lib/session",
7 "lib/storage"
8, "lib/user"]
7 "lib/storage",
8 "lib/user",
9 "lib/space"]
910
1011resolver = "3"
1112