Skip to content
Clone

feat(lib/space): add join rule handlers and service, refresh handlers/services/share/openapi

ZhenYi committed
parent 656e389 commit cb2a33b Browse files

21 files changed

+786 −137

File tree 21
Added lib/space/src/handlers/create_join_rule.rs View lib/space/src/handlers/create_join_rule.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::services::join_rules::{CreateJoinRuleCmd, JoinRuleDto};
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}/join-rules",
14 tag = "space",
15 params(SpaceNamePath),
16 request_body = CreateJoinRuleCmd,
17 responses(
18 (status = 200, description = "join rule created", body = AppResponseData<JoinRuleDto>),
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 = "conflict", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn create_join_rule(
26 State(service): State<SpaceService>,
27 session: Session,
28 Path(path): Path<SpaceNamePath>,
29 Json(cmd): Json<CreateJoinRuleCmd>,
30) -> Result<AppResponseData<JoinRuleDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let dto = service
33 .create_join_rule(uid, &path.space_name, cmd)
34 .await?;
35 Ok(AppResponseData::ok(dto))
36}
Added lib/space/src/handlers/delete_join_rule.rs View lib/space/src/handlers/delete_join_rule.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::join_rules::JoinRulePath;
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/spaces/{space_name}/join-rules/{rule_id}",
13 tag = "space",
14 params(JoinRulePath),
15 responses(
16 (status = 200, description = "join rule 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_join_rule(
22 State(service): State<SpaceService>,
23 session: Session,
24 Path(path): Path<JoinRulePath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_join_rule(uid, &path.space_name, path.rule_id)
29 .await?;
30 Ok(AppResponseData::empty())
31}
Modified lib/space/src/handlers/get_space_avatar.rs View lib/space/src/handlers/get_space_avatar.rs
@@ -1,5 +1,6 @@
11use axum::extract::{Path, State};
22use export::rest::{AppResponseData, NoData};
3use session::{Session, SessionUser};
34
45use crate::errors::ApiError;
56use crate::services::avatar::SpaceAvatarDto;
@@ -18,8 +19,10 @@
1819)]
1920pub async fn get_space_avatar(
2021 State(service): State<SpaceService>,
22 session: Session,
2123 Path(path): Path<SpaceNamePath>,
2224) -> Result<AppResponseData<Option<SpaceAvatarDto>>, ApiError> {
23 let avatar = service.get_space_avatar(&path.space_name).await?;
25 let caller = session.uid();
26 let avatar = service.get_space_avatar(caller, &path.space_name).await?;
2427 Ok(AppResponseData::ok(avatar))
2528}
Modified lib/space/src/handlers/get_space_image.rs View lib/space/src/handlers/get_space_image.rs
@@ -1,5 +1,6 @@
11use axum::extract::{Path, State};
22use export::rest::{AppResponseData, NoData};
3use session::{Session, SessionUser};
34
45use crate::errors::ApiError;
56use crate::services::images::{SpaceImageDto, SpaceImageKindPath};
@@ -17,8 +18,10 @@
1718)]
1819pub async fn get_space_image(
1920 State(service): State<SpaceService>,
21 session: Session,
2022 Path(path): Path<SpaceImageKindPath>,
2123) -> Result<AppResponseData<Option<SpaceImageDto>>, ApiError> {
22 let img = service.get_space_image(&path.space_name, path.kind).await?;
24 let caller = session.uid();
25 let img = service.get_space_image(caller, &path.space_name, path.kind).await?;
2326 Ok(AppResponseData::ok(img))
2427}
Added lib/space/src/handlers/list_join_rules.rs View lib/space/src/handlers/list_join_rules.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::join_rules::JoinRuleDto;
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}/join-rules",
14 tag = "space",
15 params(SpaceNamePath),
16 responses(
17 (status = 200, description = "list space join rules", body = AppResponseData<Vec<JoinRuleDto>>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "space not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_join_rules(
23 State(service): State<SpaceService>,
24 session: Session,
25 Path(path): Path<SpaceNamePath>,
26) -> Result<AppResponseData<Vec<JoinRuleDto>>, ApiError> {
27 let uid = require_uid(&session)?;
28 let rules = service.list_join_rules(uid, &path.space_name).await?;
29 Ok(AppResponseData::ok(rules))
30}
Modified lib/space/src/handlers/mod.rs View lib/space/src/handlers/mod.rs
@@ -17,8 +17,12 @@
1717pub mod get_space_avatar;
1818pub mod get_space_dir_tree;
1919pub mod get_space_image;
20pub mod create_join_rule;
21pub mod delete_join_rule;
2022pub mod join_group_self;
2123pub mod leave_group_self;
24pub mod list_join_rules;
25pub mod update_join_rule;
2226pub mod list_bans;
2327pub mod list_dir_group_overwrites;
2428pub mod list_dir_user_overwrites;
Added lib/space/src/handlers/update_join_rule.rs View lib/space/src/handlers/update_join_rule.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::join_rules::{JoinRuleDto, JoinRulePath, UpdateJoinRuleCmd};
7use crate::services::service::SpaceService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 patch,
12 path = "/spaces/{space_name}/join-rules/{rule_id}",
13 tag = "space",
14 params(JoinRulePath),
15 request_body = UpdateJoinRuleCmd,
16 responses(
17 (status = 200, description = "join rule updated", body = AppResponseData<JoinRuleDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>),
21 (status = 409, description = "conflict", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn update_join_rule(
25 State(service): State<SpaceService>,
26 session: Session,
27 Path(path): Path<JoinRulePath>,
28 Json(cmd): Json<UpdateJoinRuleCmd>,
29) -> Result<AppResponseData<JoinRuleDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let dto = service
32 .update_join_rule(uid, &path.space_name, path.rule_id, cmd)
33 .await?;
34 Ok(AppResponseData::ok(dto))
35}
Modified lib/space/src/services/avatar.rs View lib/space/src/services/avatar.rs
@@ -9,6 +9,7 @@
99
1010use crate::errors::SpaceError;
1111use crate::services::service::SpaceService;
12use crate::share::authz::effective_permissions;
1213use crate::share::lookup::lookup_space_by_name;
1314
1415pub const MAX_SPACE_AVATAR_BYTES: usize = 5 * 1024 * 1024;
@@ -174,9 +175,25 @@
174175
175176 pub async fn get_space_avatar(
176177 &self,
178 caller: Option<Uuid>,
177179 space_name: &str,
178180 ) -> Result<Option<SpaceAvatarDto>, SpaceError> {
179181 let space = lookup_space_by_name(self, space_name).await?;
182 if !space.is_active {
183 return Err(SpaceError::NotFound);
184 }
185
186 if space.is_private {
187 let Some(uid) = caller else {
188 return Err(SpaceError::NotFound);
189 };
190 match effective_permissions(self.db().read(), &space, uid).await {
191 Ok(_) => {}
192 Err(SpaceError::Forbidden) => return Err(SpaceError::NotFound),
193 Err(e) => return Err(e),
194 }
195 }
196
180197 let Some(url) = space.avatar_url else {
181198 return Ok(None);
182199 };
Modified lib/space/src/services/bans.rs View lib/space/src/services/bans.rs
@@ -1,8 +1,10 @@
11use models::space::space_bans;
2use models::space::space_member_groups;
3use models::space::space_members::{self, MemberStatus};
24use models::users::user;
35use sea_orm::{
46 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
5 QuerySelect, Set,
7 QuerySelect, Set, TransactionTrait,
68};
79use serde::{Deserialize, Serialize};
810use time::OffsetDateTime;
@@ -94,6 +96,10 @@
9496 ));
9597 }
9698
99 let member = space_members::Entity::find_by_id((space.id, cmd.user_id))
100 .one(self.db().read())
101 .await?;
102
97103 let now = OffsetDateTime::now_utc();
98104 let ban_row = space_bans::ActiveModel {
99105 space_id: Set(space.id),
@@ -109,7 +115,31 @@
109115 allow_comment_any: Set(cmd.allow_comment_any.unwrap_or(false)),
110116 };
111117
112 let inserted = ban_row.insert(self.db().write()).await?;
118 let inserted = self
119 .db()
120 .primary()
121 .transaction::<_, space_bans::Model, SpaceError>(|txn| {
122 Box::pin(async move {
123 let ban = ban_row.insert(txn).await?;
124
125 if let Some(m) = member {
126 let mut am: space_members::ActiveModel = m.into();
127 am.status = Set(MemberStatus::Removed);
128 am.left_at = Set(Some(now));
129 am.updated_at = Set(Some(now));
130 am.update(txn).await?;
131 }
132
133 space_member_groups::Entity::delete_many()
134 .filter(space_member_groups::Column::SpaceId.eq(space.id))
135 .filter(space_member_groups::Column::UserId.eq(cmd.user_id))
136 .exec(txn)
137 .await?;
138
139 Ok(ban)
140 })
141 })
142 .await?;
113143
114144 let user_map = self.load_user_briefs(&[inserted.user_id, caller]).await?;
115145 let user_brief = user_map.get(&inserted.user_id).cloned();
Modified lib/space/src/services/dir.rs View lib/space/src/services/dir.rs
@@ -122,6 +122,38 @@
122122 pub dir_id: Uuid,
123123}
124124
125fn resolve_node_visible(
126 caller_uid: Uuid,
127 my_group_ids: &HashSet<Uuid>,
128 group_ows: &[dir_overwrite_group::Model],
129 user_ows: &[dir_overwrite_users::Model],
130) -> bool {
131 if group_ows.is_empty() && user_ows.is_empty() {
132 return true;
133 }
134
135 if let Some(uo) = user_ows.iter().find(|u| u.user_id == caller_uid) {
136 if (uo.deny_mask & PERM_DIR_VISIBLE) != 0 {
137 return false;
138 }
139 if (uo.allow_mask & PERM_DIR_VISIBLE) != 0 {
140 return true;
141 }
142 }
143
144 let matching: Vec<&dir_overwrite_group::Model> = group_ows
145 .iter()
146 .filter(|g| my_group_ids.contains(&g.group_id))
147 .collect();
148 if matching.iter().any(|g| (g.deny_mask & PERM_DIR_VISIBLE) != 0) {
149 return false;
150 }
151 if matching.iter().any(|g| (g.allow_mask & PERM_DIR_VISIBLE) != 0) {
152 return true;
153 }
154 true
155}
156
125157impl SpaceService {
126158 async fn dir_in_space(&self, space_id: Uuid, dir_id: Uuid) -> Result<dirs::Model, SpaceError> {
127159 dirs::Entity::find_by_id(dir_id)
@@ -258,49 +290,12 @@
258290 continue;
259291 }
260292
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);
293 let group_ows = group_ow_map.get(&n.id).map(Vec::as_slice).unwrap_or(&[]);
294 let user_ows = user_ow_map.get(&n.id).map(Vec::as_slice).unwrap_or(&[]);
295 direct_visible.insert(
296 n.id,
297 resolve_node_visible(caller, &my_group_ids, group_ows, user_ows),
298 );
304299 }
305300
306301 fn is_truly_visible(
@@ -571,7 +566,7 @@
571566 dir_id: Uuid,
572567 ) -> Result<Vec<GroupOverwriteDto>, SpaceError> {
573568 let space = lookup_space_by_name(self, space_name).await?;
574 effective_permissions(self.db().read(), &space, caller).await?;
569 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
575570 self.dir_in_space(space.id, dir_id).await?;
576571
577572 let rows = dir_overwrite_group::Entity::find()
@@ -674,7 +669,7 @@
674669 dir_id: Uuid,
675670 ) -> Result<Vec<UserOverwriteDto>, SpaceError> {
676671 let space = lookup_space_by_name(self, space_name).await?;
677 effective_permissions(self.db().read(), &space, caller).await?;
672 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
678673 self.dir_in_space(space.id, dir_id).await?;
679674
680675 let rows = dir_overwrite_users::Entity::find()
@@ -777,3 +772,86 @@
777772 Ok(())
778773 }
779774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779
780 fn group_ow(dir_id: Uuid, group_id: Uuid, allow: i64, deny: i64) -> dir_overwrite_group::Model {
781 dir_overwrite_group::Model {
782 dir_id,
783 group_id,
784 allow_mask: allow,
785 deny_mask: deny,
786 }
787 }
788
789 fn user_ow(dir_id: Uuid, user_id: Uuid, allow: i64, deny: i64) -> dir_overwrite_users::Model {
790 dir_overwrite_users::Model {
791 dir_id,
792 user_id,
793 allow_mask: allow,
794 deny_mask: deny,
795 }
796 }
797
798 #[test]
799 fn node_without_overwrite_is_visible() {
800 let caller = Uuid::now_v7();
801 assert!(resolve_node_visible(caller, &HashSet::new(), &[], &[]));
802 }
803
804 #[test]
805 fn user_allow_for_one_keeps_others_visible_by_default() {
806 let a = Uuid::now_v7();
807 let b = Uuid::now_v7();
808 let dir_id = Uuid::now_v7();
809 let ows = vec![user_ow(dir_id, a, PERM_DIR_VISIBLE, 0)];
810
811 assert!(resolve_node_visible(a, &HashSet::new(), &[], &ows));
812 assert!(resolve_node_visible(b, &HashSet::new(), &[], &ows));
813 }
814
815 #[test]
816 fn user_deny_hides_target() {
817 let a = Uuid::now_v7();
818 let dir_id = Uuid::now_v7();
819 let ows = vec![user_ow(dir_id, a, 0, PERM_DIR_VISIBLE)];
820
821 assert!(!resolve_node_visible(a, &HashSet::new(), &[], &ows));
822 }
823
824 #[test]
825 fn group_deny_hides_member() {
826 let member = Uuid::now_v7();
827 let group = Uuid::now_v7();
828 let dir_id = Uuid::now_v7();
829 let ows = vec![group_ow(dir_id, group, 0, PERM_DIR_VISIBLE)];
830 let my_groups = HashSet::from([group]);
831
832 assert!(!resolve_node_visible(member, &my_groups, &ows, &[]));
833 }
834
835 #[test]
836 fn group_allow_grants_visibility() {
837 let member = Uuid::now_v7();
838 let group = Uuid::now_v7();
839 let dir_id = Uuid::now_v7();
840 let ows = vec![group_ow(dir_id, group, PERM_DIR_VISIBLE, 0)];
841 let my_groups = HashSet::from([group]);
842
843 assert!(resolve_node_visible(member, &my_groups, &ows, &[]));
844 }
845
846 #[test]
847 fn unrelated_group_overwrite_defaults_visible() {
848 let member = Uuid::now_v7();
849 let group = Uuid::now_v7();
850 let other = Uuid::now_v7();
851 let dir_id = Uuid::now_v7();
852 let ows = vec![group_ow(dir_id, other, 0, PERM_DIR_VISIBLE)];
853 let my_groups = HashSet::from([group]);
854
855 assert!(resolve_node_visible(member, &my_groups, &ows, &[]));
856 }
857}
Modified lib/space/src/services/groups.rs View lib/space/src/services/groups.rs
@@ -16,7 +16,7 @@
1616use crate::errors::SpaceError;
1717use crate::services::service::SpaceService;
1818use crate::services::spaces::{PageQuery, UserBriefDto};
19use crate::share::authz::{effective_permissions, require_perm, MANAGE_ROLES};
19use crate::share::authz::{effective_permissions, require_perm, ALL_PERMISSIONS, MANAGE_ROLES};
2020use crate::share::lookup::lookup_space_by_name;
2121
2222#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -82,15 +82,25 @@
8282 group_id: Uuid,
8383 permissions: &[String],
8484) -> Result<(), SpaceError> {
85 let mut perms: Vec<&str> = permissions.iter().map(String::as_str).collect();
86 perms.sort();
87 perms.dedup();
88
89 for perm in &perms {
90 if !ALL_PERMISSIONS.contains(perm) {
91 return Err(SpaceError::BadRequest(format!("unknown permission: {perm}")));
92 }
93 }
94
8595 space_group_permissions::Entity::delete_many()
8696 .filter(space_group_permissions::Column::GroupId.eq(group_id))
8797 .exec(db)
8898 .await?;
8999
90 for perm in permissions {
100 for perm in perms {
91101 let row = space_group_permissions::ActiveModel {
92102 group_id: Set(group_id),
93 permission: Set(perm.clone()),
103 permission: Set(perm.to_string()),
94104 };
95105 row.insert(db).await?;
96106 }
Modified lib/space/src/services/images.rs View lib/space/src/services/images.rs
@@ -10,6 +10,7 @@
1010use crate::errors::SpaceError;
1111use crate::services::avatar::AvatarFormat;
1212use crate::services::service::SpaceService;
13use crate::share::authz::effective_permissions;
1314use crate::share::lookup::lookup_space_by_name;
1415
1516pub const MAX_SPACE_IMAGE_BYTES: usize = 5 * 1024 * 1024;
@@ -223,10 +224,25 @@
223224
224225 pub async fn get_space_image(
225226 &self,
227 caller: Option<Uuid>,
226228 space_name: &str,
227229 kind: SpaceImageKind,
228230 ) -> Result<Option<SpaceImageDto>, SpaceError> {
229231 let space = lookup_space_by_name(self, space_name).await?;
232 if !space.is_active {
233 return Err(SpaceError::NotFound);
234 }
235
236 if space.is_private {
237 let Some(uid) = caller else {
238 return Err(SpaceError::NotFound);
239 };
240 match effective_permissions(self.db().read(), &space, uid).await {
241 Ok(_) => {}
242 Err(SpaceError::Forbidden) => return Err(SpaceError::NotFound),
243 Err(e) => return Err(e),
244 }
245 }
230246
231247 let (url_opt, width, height) = match kind {
232248 SpaceImageKind::Banner => (space.banner_url, space.banner_width, space.banner_height),
Modified lib/space/src/services/invites.rs View lib/space/src/services/invites.rs
@@ -5,6 +5,7 @@
55use models::space::space_member_groups;
66use models::space::space_members::{self, MemberStatus, MessageNotifications};
77use models::space::spaces;
8use models::space::space_group_permissions;
89use models::space::space_groups;
910use models::users::user;
1011use models::users::user_emails;
@@ -21,7 +22,7 @@
2122use crate::errors::SpaceError;
2223use crate::services::service::SpaceService;
2324use crate::services::spaces::{PageQuery, UserBriefDto};
24use crate::share::authz::{require_perm, MANAGE_INVITES};
25use crate::share::authz::{assert_not_banned, effective_permissions, require_perm, MANAGE_INVITES};
2526use crate::share::lookup::lookup_space_by_name;
2627
2728#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -155,6 +156,22 @@
155156 ));
156157 }
157158
159 if caller != space.owner_id {
160 let caller_perms = effective_permissions(self.db().read(), &space, caller)
161 .await?
162 .into_vec();
163 let group_perms = space_group_permissions::Entity::find()
164 .filter(space_group_permissions::Column::GroupId.is_in(group_ids.clone()))
165 .all(self.db().read())
166 .await?;
167 if group_perms
168 .iter()
169 .any(|gp| !caller_perms.contains(&gp.permission))
170 {
171 return Err(SpaceError::Forbidden);
172 }
173 }
174
158175 if let Some(uid) = target_uid {
159176 let active_user_inv = space_invite::Entity::find()
160177 .filter(space_invite::Column::SpaceId.eq(space.id))
@@ -373,15 +390,6 @@
373390 .await?;
374391 if valid_count != group_ids.len() as u64 {
375392 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 ));
385393 }
386394
387395 let space = spaces::Entity::find_by_id(invite.space_id)
@@ -392,6 +400,8 @@
392400 return Err(SpaceError::NotFound);
393401 }
394402
403 assert_not_banned(self.db().read(), space.id, caller).await?;
404
395405 if space.member_must_enable_2fa {
396406 let caller_user = user::Entity::find_by_id(caller)
397407 .one(self.db().read())
@@ -402,7 +412,19 @@
402412 }
403413 }
404414
415 let existing_member = space_members::Entity::find_by_id((invite.space_id, caller))
416 .one(self.db().read())
417 .await?;
418 if let Some(m) = &existing_member
419 && m.status == MemberStatus::Active
420 {
421 return Err(SpaceError::Conflict(
422 "user is already a member of this space".to_string(),
423 ));
424 }
425
405426 let now = OffsetDateTime::now_utc();
427 let member_am: Option<space_members::ActiveModel> = existing_member.map(|m| m.into());
406428 let member_row = space_members::ActiveModel {
407429 space_id: Set(invite.space_id),
408430 user_id: Set(caller),
@@ -432,7 +454,15 @@
432454 .primary()
433455 .transaction::<_, (), SpaceError>(|txn| {
434456 Box::pin(async move {
435 member_row.insert(txn).await?;
457 if let Some(am) = member_am {
458 let mut am = am;
459 am.status = Set(MemberStatus::Active);
460 am.left_at = Set(None);
461 am.updated_at = Set(Some(now));
462 am.update(txn).await?;
463 } else {
464 member_row.insert(txn).await?;
465 }
436466
437467 for gid in group_ids {
438468 let mg = space_member_groups::ActiveModel {
Added lib/space/src/services/join_rules.rs View lib/space/src/services/join_rules.rs
@@ -0,0 +1,256 @@
1use sea_orm::{ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, EntityTrait, QueryFilter, Set};
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use utoipa::{IntoParams, ToSchema};
5use uuid::Uuid;
6
7use models::space::space_bans;
8use models::space::space_groups;
9use models::space::space_join_rule::{self, SpaceJoinTrigger};
10use models::space::space_member_groups;
11use models::space::space_members::{self, MemberStatus};
12
13use crate::errors::{is_unique_violation, SpaceError};
14use crate::services::service::SpaceService;
15use crate::share::authz::effective_permissions;
16use crate::share::lookup::lookup_space_by_name;
17
18pub const MAX_JOIN_RULE_MATCHER_LEN: usize = 64;
19
20#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
21pub struct JoinRuleDto {
22 pub id: Uuid,
23 pub group_id: Uuid,
24 pub trigger: SpaceJoinTrigger,
25 pub matcher: String,
26 pub enabled: bool,
27 pub created_at: OffsetDateTime,
28 pub updated_at: Option<OffsetDateTime>,
29}
30
31impl From<space_join_rule::Model> for JoinRuleDto {
32 fn from(m: space_join_rule::Model) -> Self {
33 Self {
34 id: m.id,
35 group_id: m.group_id,
36 trigger: m.trigger,
37 matcher: m.matcher,
38 enabled: m.enabled,
39 created_at: m.created_at,
40 updated_at: m.updated_at,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Deserialize, ToSchema)]
46pub struct CreateJoinRuleCmd {
47 pub group_id: Uuid,
48 #[serde(default)]
49 pub trigger: SpaceJoinTrigger,
50 #[serde(default)]
51 pub matcher: String,
52}
53
54#[derive(Debug, Clone, Deserialize, ToSchema)]
55pub struct UpdateJoinRuleCmd {
56 pub enabled: Option<bool>,
57 pub matcher: Option<String>,
58}
59
60#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
61pub struct JoinRulePath {
62 pub space_name: String,
63 pub rule_id: Uuid,
64}
65
66fn normalize_matcher(raw: &str) -> Result<String, SpaceError> {
67 let matcher = raw.trim().to_string();
68 if matcher.chars().count() > MAX_JOIN_RULE_MATCHER_LEN {
69 return Err(SpaceError::BadRequest(format!(
70 "join rule matcher must be at most {MAX_JOIN_RULE_MATCHER_LEN} characters"
71 )));
72 }
73 Ok(matcher)
74}
75
76/// Public entry point consumed by action sources (e.g. lib/issues reactions).
77/// Grants group membership for every enabled, matching join rule of the space.
78/// Idempotent: membership rows are unique per (space, user, group) and races
79/// resolve to a no-op via the unique-violation path.
80pub async fn apply_space_join_rules(
81 db: &impl ConnectionTrait,
82 space_id: Uuid,
83 user_id: Uuid,
84 trigger: SpaceJoinTrigger,
85 matcher: &str,
86) -> Result<(), SpaceError> {
87 let now = OffsetDateTime::now_utc();
88 let matcher = matcher.trim();
89
90 let rules = space_join_rule::Entity::find()
91 .filter(space_join_rule::Column::SpaceId.eq(space_id))
92 .filter(space_join_rule::Column::Trigger.eq(trigger))
93 .filter(space_join_rule::Column::Enabled.eq(true))
94 .filter(
95 Condition::any()
96 .add(space_join_rule::Column::Matcher.eq(""))
97 .add(space_join_rule::Column::Matcher.eq(matcher)),
98 )
99 .all(db)
100 .await?;
101 if rules.is_empty() {
102 return Ok(());
103 }
104
105 let Some(member) = space_members::Entity::find_by_id((space_id, user_id))
106 .one(db)
107 .await?
108 else {
109 return Ok(());
110 };
111 if member.status != MemberStatus::Active {
112 return Ok(());
113 }
114 if let Some(ban) = space_bans::Entity::find_by_id((space_id, user_id))
115 .one(db)
116 .await?
117 && (ban.expires_at.is_none() || ban.expires_at > Some(now))
118 {
119 return Ok(());
120 }
121
122 for rule in &rules {
123 let group_exists = space_groups::Entity::find_by_id(rule.group_id)
124 .filter(space_groups::Column::SpaceId.eq(space_id))
125 .one(db)
126 .await?
127 .is_some();
128 if !group_exists {
129 continue;
130 }
131 let existing = space_member_groups::Entity::find_by_id((space_id, user_id, rule.group_id))
132 .one(db)
133 .await?;
134 if existing.is_some() {
135 continue;
136 }
137 let row = space_member_groups::ActiveModel {
138 space_id: Set(space_id),
139 user_id: Set(user_id),
140 group_id: Set(rule.group_id),
141 created_at: Set(now),
142 };
143 match row.insert(db).await {
144 Ok(_) => {}
145 Err(err) if is_unique_violation(&err) => {}
146 Err(err) => return Err(SpaceError::from(err)),
147 }
148 }
149 Ok(())
150}
151
152impl SpaceService {
153 pub async fn create_join_rule(
154 &self,
155 caller: Uuid,
156 space_name: &str,
157 cmd: CreateJoinRuleCmd,
158 ) -> Result<JoinRuleDto, SpaceError> {
159 let space = lookup_space_by_name(self, space_name).await?;
160 if space.owner_id != caller {
161 return Err(SpaceError::Forbidden);
162 }
163 let matcher = normalize_matcher(&cmd.matcher)?;
164
165 space_groups::Entity::find_by_id(cmd.group_id)
166 .filter(space_groups::Column::SpaceId.eq(space.id))
167 .one(self.db().read())
168 .await?
169 .ok_or(SpaceError::NotFound)?;
170
171 let now = OffsetDateTime::now_utc();
172 let row = space_join_rule::ActiveModel {
173 id: Set(Uuid::now_v7()),
174 space_id: Set(space.id),
175 group_id: Set(cmd.group_id),
176 trigger: Set(cmd.trigger),
177 matcher: Set(matcher),
178 enabled: Set(true),
179 created_at: Set(now),
180 updated_at: Set(None),
181 };
182 let inserted = row.insert(self.db().write()).await?;
183 Ok(JoinRuleDto::from(inserted))
184 }
185
186 pub async fn update_join_rule(
187 &self,
188 caller: Uuid,
189 space_name: &str,
190 rule_id: Uuid,
191 cmd: UpdateJoinRuleCmd,
192 ) -> Result<JoinRuleDto, SpaceError> {
193 let space = lookup_space_by_name(self, space_name).await?;
194 if space.owner_id != caller {
195 return Err(SpaceError::Forbidden);
196 }
197 let Some(row) = space_join_rule::Entity::find_by_id(rule_id)
198 .filter(space_join_rule::Column::SpaceId.eq(space.id))
199 .one(self.db().read())
200 .await?
201 else {
202 return Err(SpaceError::NotFound);
203 };
204
205 let matcher = match cmd.matcher {
206 Some(raw) => Some(normalize_matcher(&raw)?),
207 None => None,
208 };
209 let now = OffsetDateTime::now_utc();
210 let mut active: space_join_rule::ActiveModel = row.into();
211 if let Some(enabled) = cmd.enabled {
212 active.enabled = Set(enabled);
213 }
214 if let Some(matcher) = matcher {
215 active.matcher = Set(matcher);
216 }
217 active.updated_at = Set(Some(now));
218 let updated = active.update(self.db().write()).await?;
219 Ok(JoinRuleDto::from(updated))
220 }
221
222 pub async fn delete_join_rule(
223 &self,
224 caller: Uuid,
225 space_name: &str,
226 rule_id: Uuid,
227 ) -> Result<(), SpaceError> {
228 let space = lookup_space_by_name(self, space_name).await?;
229 if space.owner_id != caller {
230 return Err(SpaceError::Forbidden);
231 }
232 let res = space_join_rule::Entity::delete_many()
233 .filter(space_join_rule::Column::Id.eq(rule_id))
234 .filter(space_join_rule::Column::SpaceId.eq(space.id))
235 .exec(self.db().write())
236 .await?;
237 if res.rows_affected == 0 {
238 return Err(SpaceError::NotFound);
239 }
240 Ok(())
241 }
242
243 pub async fn list_join_rules(
244 &self,
245 caller: Uuid,
246 space_name: &str,
247 ) -> Result<Vec<JoinRuleDto>, SpaceError> {
248 let space = lookup_space_by_name(self, space_name).await?;
249 effective_permissions(self.db().read(), &space, caller).await?;
250 let rows = space_join_rule::Entity::find()
251 .filter(space_join_rule::Column::SpaceId.eq(space.id))
252 .all(self.db().read())
253 .await?;
254 Ok(rows.into_iter().map(JoinRuleDto::from).collect())
255 }
256}
Modified lib/space/src/services/members.rs View lib/space/src/services/members.rs
@@ -15,7 +15,7 @@
1515use crate::errors::SpaceError;
1616use crate::services::service::SpaceService;
1717use crate::services::spaces::{PageQuery, UserBriefDto};
18use crate::share::authz::{require_perm, MANAGE_MEMBERS};
18use crate::share::authz::{assert_not_banned, require_perm, MANAGE_MEMBERS};
1919use crate::share::lookup::lookup_space_by_name;
2020
2121#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
@@ -104,21 +104,15 @@
104104 return Err(SpaceError::Forbidden);
105105 }
106106
107 let existing = space_members::Entity::find_by_id((space.id, cmd.user_id))
107 let target_user = user::Entity::find_by_id(cmd.user_id)
108108 .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 }
109 .await?
110 .ok_or(SpaceError::NotFound)?;
113111
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 }
112 assert_not_banned(self.db().read(), space.id, cmd.user_id).await?;
113
114 if space.member_must_enable_2fa && !target_user.totp_enabled {
115 return Err(SpaceError::Forbidden);
122116 }
123117
124118 if let Some(alias) = &cmd.alias_name
@@ -127,73 +121,108 @@
127121 return Err(SpaceError::BadRequest("alias name is too long".to_string()));
128122 }
129123
124 let existing = space_members::Entity::find_by_id((space.id, cmd.user_id))
125 .one(self.db().read())
126 .await?;
127
130128 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 };
154129
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)
130 let saved: space_members::Model = if let Some(member) = existing {
131 if member.status == MemberStatus::Active {
132 return Err(SpaceError::Conflict(
133 "user is already a member of this space".to_string(),
134 ));
135 }
136
137 let mut am: space_members::ActiveModel = member.into();
138 am.status = Set(MemberStatus::Active);
139 am.left_at = Set(None);
140 am.updated_at = Set(Some(now));
141
142 self.db()
143 .primary()
144 .transaction::<_, space_members::Model, SpaceError>(|txn| {
145 Box::pin(async move {
146 let m = am.update(txn).await?;
147 spaces::Entity::update_many()
148 .filter(spaces::Column::Id.eq(space.id))
149 .col_expr(
150 spaces::Column::NumsMembers,
151 Expr::cust("nums_members + 1"),
152 )
153 .exec(txn)
154 .await?;
155 Ok(m)
156 })
170157 })
171 })
172 .await?;
158 .await?
159 } else {
160 let member_row = space_members::ActiveModel {
161 space_id: Set(space.id),
162 user_id: Set(cmd.user_id),
163 alias_name: Set(cmd.alias_name),
164 alias_avatar_url: Set(cmd.alias_avatar_url),
165 status: Set(MemberStatus::Active),
166 last_active_at: Set(None),
167 left_at: Set(None),
168 invited_at: Set(None),
169 invited_by: Set(None),
170 joined_at: Set(now),
171 created_at: Set(now),
172 updated_at: Set(Some(now)),
173 message_notifications: Set(cmd
174 .message_notifications
175 .unwrap_or(MessageNotifications::All)),
176 muted: Set(cmd.muted.unwrap_or(false)),
177 mute_until: Set(cmd.mute_until),
178 mobile_push: Set(cmd.mobile_push.unwrap_or(false)),
179 suppress_everyone: Set(cmd.suppress_everyone.unwrap_or(false)),
180 suppress_roles: Set(cmd.suppress_roles.unwrap_or(false)),
181 hide_muted_channels: Set(cmd.hide_muted_channels.unwrap_or(false)),
182 };
173183
174 let user_map = self.load_user_briefs(&[inserted.user_id]).await?;
175 let user_brief = user_map.get(&inserted.user_id).cloned();
184 self.db()
185 .primary()
186 .transaction::<_, space_members::Model, SpaceError>(|txn| {
187 Box::pin(async move {
188 let m = member_row.insert(txn).await?;
189 spaces::Entity::update_many()
190 .filter(spaces::Column::Id.eq(space.id))
191 .col_expr(
192 spaces::Column::NumsMembers,
193 Expr::cust("nums_members + 1"),
194 )
195 .exec(txn)
196 .await?;
197 Ok(m)
198 })
199 })
200 .await?
201 };
202
203 let user_map = self.load_user_briefs(&[saved.user_id]).await?;
204 let user_brief = user_map.get(&saved.user_id).cloned();
176205
177206 Ok(MemberDto {
178 space_id: inserted.space_id,
207 space_id: saved.space_id,
179208 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,
209 alias_name: saved.alias_name,
210 alias_avatar_url: saved.alias_avatar_url,
211 status: saved.status,
212 last_active_at: saved.last_active_at,
213 left_at: saved.left_at,
214 invited_at: saved.invited_at,
186215 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,
216 joined_at: saved.joined_at,
217 created_at: saved.created_at,
218 updated_at: saved.updated_at,
219 message_notifications: saved.message_notifications,
220 muted: saved.muted,
221 mute_until: saved.mute_until,
222 mobile_push: saved.mobile_push,
223 suppress_everyone: saved.suppress_everyone,
224 suppress_roles: saved.suppress_roles,
225 hide_muted_channels: saved.hide_muted_channels,
197226 })
198227 }
199228
@@ -353,6 +382,10 @@
353382 .await?
354383 .ok_or(SpaceError::NotFound)?;
355384
385 if member.status != MemberStatus::Active {
386 return Ok(());
387 }
388
356389 let now = OffsetDateTime::now_utc();
357390 let mut am: space_members::ActiveModel = member.into();
358391 am.status = Set(MemberStatus::Removed);
Modified lib/space/src/services/mod.rs View lib/space/src/services/mod.rs
@@ -3,6 +3,7 @@
33pub mod dir;
44pub mod groups;
55pub mod images;
6pub mod join_rules;
67pub mod invites;
78pub mod members;
89pub mod service;
Modified lib/space/src/share/authz.rs View lib/space/src/share/authz.rs
@@ -57,6 +57,22 @@
5757 }
5858}
5959
60pub async fn assert_not_banned(
61 db: &impl ConnectionTrait,
62 space_id: Uuid,
63 user_id: Uuid,
64) -> Result<(), SpaceError> {
65 let now = OffsetDateTime::now_utc();
66 if let Some(ban) = space_bans::Entity::find_by_id((space_id, user_id))
67 .one(db)
68 .await?
69 && (ban.expires_at.is_none() || ban.expires_at > Some(now))
70 {
71 return Err(SpaceError::Forbidden);
72 }
73 Ok(())
74}
75
6076pub async fn effective_permissions(
6177 db: &impl ConnectionTrait,
6278 space: &spaces::Model,
Modified lib/space/src/share/guard.rs View lib/space/src/share/guard.rs
@@ -31,7 +31,11 @@
3131 let authorized = match outcome {
3232 Ok(Some((row, Some(current)))) => {
3333 !row.is_revoked
34 && row.user_id == uid
3435 && row.expires_at > OffsetDateTime::now_utc()
36 && current.is_active
37 && !current.prohibit_login
38 && !current.is_restricted
3539 && pwd_version_matches(&session, current.passwd_version)
3640 }
3741 Ok(_) => false,
Modified lib/space/src/errors.rs View lib/space/src/errors.rs
@@ -61,7 +61,7 @@
6161 }
6262}
6363
64fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
64pub(crate) fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
6565 let msg = err.to_string().to_lowercase();
6666 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
6767}
Modified lib/space/src/openapi.rs View lib/space/src/openapi.rs
@@ -40,6 +40,10 @@
4040 crate::handlers::unassign_group::unassign_group,
4141 crate::handlers::join_group_self::join_group_self,
4242 crate::handlers::leave_group_self::leave_group_self,
43 crate::handlers::create_join_rule::create_join_rule,
44 crate::handlers::list_join_rules::list_join_rules,
45 crate::handlers::update_join_rule::update_join_rule,
46 crate::handlers::delete_join_rule::delete_join_rule,
4347 crate::handlers::create_invite::create_invite,
4448 crate::handlers::list_invites::list_invites,
4549 crate::handlers::revoke_invite::revoke_invite,
Modified lib/space/src/routes.rs View lib/space/src/routes.rs
@@ -8,8 +8,10 @@
88use crate::handlers::create_dir_node::create_dir_node;
99use crate::handlers::create_group::create_group;
1010use crate::handlers::create_invite::create_invite;
11use crate::handlers::create_join_rule::create_join_rule;
1112use crate::handlers::create_space::create_space;
1213use crate::handlers::delete_group::delete_group;
14use crate::handlers::delete_join_rule::delete_join_rule;
1315use crate::handlers::delete_space::delete_space;
1416use crate::handlers::delete_space_avatar::delete_space_avatar;
1517use crate::handlers::delete_space_image::delete_space_image;
@@ -27,6 +29,7 @@
2729use crate::handlers::list_dir_user_overwrites::list_dir_user_overwrites;
2830use crate::handlers::list_groups::list_groups;
2931use crate::handlers::list_invites::list_invites;
32use crate::handlers::list_join_rules::list_join_rules;
3033use crate::handlers::list_members::list_members;
3134use crate::handlers::list_spaces::list_spaces;
3235use crate::handlers::move_dir_node::move_dir_node;
@@ -42,6 +45,7 @@
4245use crate::handlers::update_ban::update_ban;
4346use crate::handlers::update_dir_node::update_dir_node;
4447use crate::handlers::update_group::update_group;
48use crate::handlers::update_join_rule::update_join_rule;
4549use crate::handlers::update_member::update_member;
4650use crate::handlers::update_space::update_space;
4751use crate::handlers::upload_space_avatar::upload_space_avatar;
@@ -142,6 +146,14 @@
142146 post(join_group_self).delete(leave_group_self),
143147 )
144148 .route(
149 "/spaces/{space_name}/join-rules",
150 post(create_join_rule).get(list_join_rules),
151 )
152 .route(
153 "/spaces/{space_name}/join-rules/{rule_id}",
154 patch(update_join_rule).delete(delete_join_rule),
155 )
156 .route(
145157 "/spaces/{space_name}/members/{uid}/groups/{group_id}",
146158 delete(unassign_group),
147159 )