feat(chat): derive channel visibility from dir overwrites
- add expiring user overwrites to the dir model and write APIs to the dir crate (`set_user_overwrite`/`remove_user_overwrite`/`clear_user_overwrites`) - align the space dir tree with access checks: non-matching overwrites now deny visibility and expired overwrites are ignored - add `Space::visible_dirs` batch visibility (ancestor aware) for list/access paths - backfill private channel members/owners as allow overwrites and create the owner overwrite when a private channel is created - route chat room readability and `list_space_rooms` through dir visibility while DM and legacy rooms keep `room_members` - mirror member add/kick/ban/unban and privacy toggles onto dir overwrites, and make cleanup visibility write both sides
ZhenYi committed
Modified lib/chat/src/services/room_admin.rs lib/chat/src/services/room_admin.rs +18 −0 View lib/chat/src/services/room_admin.rs
@@ -149,6 +149,9 @@
149149 return Err(ChatError::Forbidden);
150150 }
151151 set_left_at(self, room_id, uid).await?;
152 if let Some(dir_id) = room.dir_id {
153 dir::remove_user_overwrite(self.database.write(), dir_id, uid).await?;
154 }
152155 self.audience
153156 .realtime
154157 .publish_room(&RoomEvent::MemberLeft {
@@ -194,6 +197,17 @@
194197 .insert(self.database.write())
195198 .await?;
196199 set_left_at(self, room_id, cmd.user_id).await?;
200 if let Some(dir_id) = room.dir_id {
201 dir::set_user_overwrite(
202 self.database.write(),
203 dir_id,
204 cmd.user_id,
205 0,
206 dir::PERM_VISIBLE,
207 cmd.expires_at,
208 )
209 .await?;
210 }
197211 self.audience
198212 .realtime
199213 .publish_room(&RoomEvent::MemberLeft {
@@ -211,12 +225,16 @@
211225 uid: i64,
212226 ) -> Result<(), ChatError> {
213227 require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?;
228 let room = lookup::lookup_room(self, room_id).await?;
214229 let deleted = room_bans::Entity::delete_by_id((room_id, uid))
215230 .exec(self.database.write())
216231 .await?;
217232 if deleted.rows_affected == 0 {
218233 return Err(ChatError::NotFound);
219234 }
235 if let Some(dir_id) = room.dir_id {
236 dir::remove_user_overwrite(self.database.write(), dir_id, uid).await?;
237 }
220238 Ok(())
221239 }
222240
Modified lib/chat/src/services/rooms.rs lib/chat/src/services/rooms.rs +69 −8 View lib/chat/src/services/rooms.rs
@@ -158,6 +158,12 @@
158158 .filter(|m| m.left_at.is_none())
159159 .map(|m| m.room_id)
160160 .collect();
161 let dir_ids: Vec<Uuid> = rooms.iter().filter_map(|r| r.dir_id).collect();
162 let visible_dirs: std::collections::HashSet<Uuid> = if dir_ids.is_empty() {
163 std::collections::HashSet::new()
164 } else {
165 space.visible_dirs(caller, &dir_ids).await?
166 };
161167
162168 let settings = room_user_settings::Entity::find()
163169 .filter(room_user_settings::Column::UserId.eq(caller))
@@ -223,7 +229,11 @@
223229 let owner_map = self.audience.load_user_briefs(&owner_ids).await?;
224230 let mut result = Vec::new();
225231 for room in rooms {
226 if room.is_private && !member_of.contains(&room.id) && room.owner_id != caller {
232 let visible = match room.dir_id {
233 Some(dir_id) => visible_dirs.contains(&dir_id),
234 None => !room.is_private || room.owner_id == caller || member_of.contains(&room.id),
235 };
236 if !visible {
227237 continue;
228238 }
229239 let (last_read, last_viewed) = read_map.get(&room.id).copied().unwrap_or((0, None));
@@ -382,7 +392,9 @@
382392 am.topic = Set(stored.clone());
383393 dir_update.topic = Some(stored);
384394 }
395 let mut privacy_change: Option<bool> = None;
385396 if let Some(is_private) = cmd.is_private {
397 privacy_change = Some(is_private);
386398 am.is_private = Set(is_private);
387399 }
388400 if let Some(nsfw) = cmd.nsfw {
@@ -434,15 +446,24 @@
434446 || dir_update.icon.is_some()
435447 || dir_update.position.is_some()
436448 || dir_update.parent_dir_id.is_some();
437 match (room.dir_id, has_dir_update) {
449 let owner_id = room.owner_id;
450 let needs_privacy_sync = privacy_change.is_some();
451 match (room.dir_id, has_dir_update || needs_privacy_sync) {
438452 (Some(dir_id), true) => {
439453 self.database
440454 .write()
441455 .transaction::<_, (), ChatError>(move |tx| {
442456 Box::pin(async move {
443 let node = dir::update_object(tx, caller, dir_id, dir_update).await?;
444 am.position = Set(node.position);
445 am.parent_id = Set(node.parent_dir_id);
457 if has_dir_update {
458 let node =
459 dir::update_object(tx, caller, dir_id, dir_update).await?;
460 am.position = Set(node.position);
461 am.parent_id = Set(node.parent_dir_id);
462 }
463 if let Some(is_private) = privacy_change {
464 sync_channel_privacy(tx, dir_id, room_id, owner_id, is_private)
465 .await?;
466 }
446467 am.update(tx).await?;
447468 Ok(())
448469 })
@@ -687,14 +708,31 @@
687708 }
688709 }
689710
690 for uid in cmd.user_ids {
711 let mut granted: Vec<i64> = Vec::new();
712 for uid in &cmd.user_ids {
691713 if let Some(space) = &ctx.space
692 && require_space_member(self, space, uid).await.is_err()
714 && require_space_member(self, space, *uid).await.is_err()
693715 {
694716 continue;
695717 }
696 self.ensure_room_membership(room_id, uid).await?;
718 self.ensure_room_membership(room_id, *uid).await?;
719 granted.push(*uid);
697720 }
721 if ctx.space.is_some()
722 && let Some(dir_id) = ctx.room.dir_id
723 {
724 for uid in granted {
725 dir::set_user_overwrite(
726 self.database.write(),
727 dir_id,
728 uid,
729 dir::PERM_VISIBLE,
730 0,
731 None,
732 )
733 .await?;
734 }
735 }
698736 Ok(())
699737 }
700738
@@ -1044,3 +1082,26 @@
10441082 self.get_room(caller, room.id).await
10451083 }
10461084}
1085
1086async fn sync_channel_privacy(
1087 tx: &impl sea_orm::ConnectionTrait,
1088 dir_id: Uuid,
1089 room_id: Uuid,
1090 owner_id: i64,
1091 is_private: bool,
1092) -> Result<(), ChatError> {
1093 if !is_private {
1094 dir::clear_user_overwrites(tx, dir_id).await?;
1095 return Ok(());
1096 }
1097 dir::set_user_overwrite(tx, dir_id, owner_id, dir::PERM_VISIBLE, 0, None).await?;
1098 let members = room_members::Entity::find()
1099 .filter(room_members::Column::RoomId.eq(room_id))
1100 .filter(room_members::Column::LeftAt.is_null())
1101 .all(tx)
1102 .await?;
1103 for member in members {
1104 dir::set_user_overwrite(tx, dir_id, member.user_id, dir::PERM_VISIBLE, 0, None).await?;
1105 }
1106 Ok(())
1107}
Modified lib/chat/src/share/lookup.rs lib/chat/src/share/lookup.rs +36 −6 View lib/chat/src/share/lookup.rs
@@ -58,7 +58,31 @@
5858 load_room_context(service, room).await
5959}
6060
61pub async fn is_room_member(
61/// 空间频道的可读性由 dir 覆盖项决定(无覆盖项 = 全部空间成员可见);
62/// DM 与迁移前没有 dir 行的历史频道回退到 `room_members`。
63async fn room_visible(
64 service: &ChatService,
65 room: &rooms::Model,
66 space: Option<&Space>,
67 uid: i64,
68) -> Result<bool, ChatError> {
69 match (room.dir_id, space) {
70 (Some(dir_id), Some(space)) => Ok(space
71 .visible_dirs(uid, std::slice::from_ref(&dir_id))
72 .await?
73 .contains(&dir_id)),
74 (Some(_), None) => Ok(false),
75 (None, Some(_)) => {
76 if !room.is_private || room.owner_id == uid {
77 return Ok(true);
78 }
79 is_room_member_row(service, room.id, uid).await
80 }
81 (None, None) => is_room_member_row(service, room.id, uid).await,
82 }
83}
84
85async fn is_room_member_row(
6286 service: &ChatService,
6387 room_id: Uuid,
6488 uid: i64,
@@ -69,6 +93,15 @@
6993 .is_some_and(|m| m.left_at.is_none()))
7094}
7195
96pub async fn is_room_member(
97 service: &ChatService,
98 room_id: Uuid,
99 uid: i64,
100) -> Result<bool, ChatError> {
101 let ctx = lookup_room_context(service, room_id).await?;
102 room_visible(service, &ctx.room, ctx.space.as_ref(), uid).await
103}
104
72105pub async fn lookup_space_by_name(
73106 service: &ChatService,
74107 space_name: &str,
@@ -114,15 +147,12 @@
114147 match &ctx.space {
115148 Some(space) => {
116149 require_space_member(service, space, uid).await?;
117 if ctx.room.is_private
118 && ctx.room.owner_id != uid
119 && !is_room_member(service, room_id, uid).await?
120 {
150 if !room_visible(service, &ctx.room, Some(space), uid).await? {
121151 return Err(ChatError::Forbidden);
122152 }
123153 }
124154 None => {
125 if !is_room_member(service, room_id, uid).await? {
155 if !room_visible(service, &ctx.room, None, uid).await? {
126156 return Err(ChatError::Forbidden);
127157 }
128158 }
Modified lib/chat-room-store/src/lib.rs lib/chat-room-store/src/lib.rs +5 −0 View lib/chat-room-store/src/lib.rs
@@ -197,6 +197,11 @@
197197 }
198198 .insert(tx)
199199 .await?;
200 if created.is_private
201 && let Some(dir_id) = created.dir_id
202 {
203 dir::set_user_overwrite(tx, dir_id, actor, dir::PERM_VISIBLE, 0, None).await?;
204 }
200205 let mut users = if created.is_private {
201206 Vec::new()
202207 } else {
Modified lib/dir/src/lib.rs lib/dir/src/lib.rs +66 −0 View lib/dir/src/lib.rs
@@ -1,3 +1,4 @@
1use models::dir::dir_overwrite_users;
12use models::dir::dirs::{self, DirKind};
23use sea_orm::{
34 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder,
@@ -8,6 +9,9 @@
89use utoipa::ToSchema;
910use uuid::Uuid;
1011
12/// Bit used by `allow_mask` / `deny_mask` for directory visibility.
13pub const PERM_VISIBLE: i64 = 1 << 0;
14
1115const MAX_NAME_LEN: usize = 255;
1216
1317#[derive(Debug, thiserror::Error)]
@@ -319,3 +323,65 @@
319323 .await?;
320324 Ok(())
321325}
326
327pub async fn set_user_overwrite(
328 tx: &impl ConnectionTrait,
329 dir_id: Uuid,
330 user_id: i64,
331 allow_mask: i64,
332 deny_mask: i64,
333 expires_at: Option<OffsetDateTime>,
334) -> Result<(), Error> {
335 if allow_mask < 0 || deny_mask < 0 {
336 return Err(Error::InvalidInput(
337 "overwrite masks must be non-negative".to_owned(),
338 ));
339 }
340 dirs::Entity::find_by_id(dir_id)
341 .one(tx)
342 .await?
343 .ok_or(Error::NotFound)?;
344 dir_overwrite_users::Entity::insert(dir_overwrite_users::ActiveModel {
345 dir_id: Set(dir_id),
346 user_id: Set(user_id),
347 allow_mask: Set(allow_mask),
348 deny_mask: Set(deny_mask),
349 expires_at: Set(expires_at),
350 })
351 .on_conflict(
352 sea_orm::sea_query::OnConflict::columns([
353 dir_overwrite_users::Column::DirId,
354 dir_overwrite_users::Column::UserId,
355 ])
356 .update_columns([
357 dir_overwrite_users::Column::AllowMask,
358 dir_overwrite_users::Column::DenyMask,
359 dir_overwrite_users::Column::ExpiresAt,
360 ])
361 .to_owned(),
362 )
363 .exec_without_returning(tx)
364 .await?;
365 Ok(())
366}
367
368pub async fn remove_user_overwrite(
369 tx: &impl ConnectionTrait,
370 dir_id: Uuid,
371 user_id: i64,
372) -> Result<bool, Error> {
373 let result = dir_overwrite_users::Entity::delete_many()
374 .filter(dir_overwrite_users::Column::DirId.eq(dir_id))
375 .filter(dir_overwrite_users::Column::UserId.eq(user_id))
376 .exec(tx)
377 .await?;
378 Ok(result.rows_affected > 0)
379}
380
381pub async fn clear_user_overwrites(tx: &impl ConnectionTrait, dir_id: Uuid) -> Result<(), Error> {
382 dir_overwrite_users::Entity::delete_many()
383 .filter(dir_overwrite_users::Column::DirId.eq(dir_id))
384 .exec(tx)
385 .await?;
386 Ok(())
387}
Modified lib/models/src/dir/dir_overwrite_users.rs lib/models/src/dir/dir_overwrite_users.rs +3 −0 View lib/models/src/dir/dir_overwrite_users.rs
@@ -1,4 +1,5 @@
11use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
23use uuid::Uuid;
34
45#[sea_orm::model]
@@ -13,6 +14,8 @@
1314 pub allow_mask: i64,
1415 #[sea_orm(default_value = 0)]
1516 pub deny_mask: i64,
17 /// 到期时间;`NULL` 表示永久。过期的覆盖项在可见性计算中被忽略。
18 pub expires_at: Option<OffsetDateTime>,
1619 #[sea_orm(belongs_to, from = "dir_id", to = "id")]
1720 pub dir: BelongsTo<super::dirs::Entity>,
1821 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
Modified lib/models/schema_extras.sql lib/models/schema_extras.sql +16 −0 View lib/models/schema_extras.sql
@@ -383,3 +383,19 @@
383383 AND NOT EXISTS (SELECT 1 FROM dir d WHERE d.id = r.id);
384384
385385UPDATE rooms SET dir_id = id WHERE dir_id IS NULL AND space_id IS NOT NULL;
386
387-- Private channels move their access list onto dir overwrites: any row on a
388-- dir makes the default "visible to every space member" an allow-list, so the
389-- channel members and the owner need an explicit allow row.
390INSERT INTO dir_overwrite_user (dir_id, user_id, allow_mask, deny_mask, expires_at)
391SELECT r.dir_id, m.user_id, 1, 0, NULL
392FROM rooms r
393JOIN room_members m ON m.room_id = r.id
394WHERE r.dir_id IS NOT NULL AND r.is_private AND m.left_at IS NULL
395ON CONFLICT (dir_id, user_id) DO NOTHING;
396
397INSERT INTO dir_overwrite_user (dir_id, user_id, allow_mask, deny_mask, expires_at)
398SELECT r.dir_id, r.owner_id, 1, 0, NULL
399FROM rooms r
400WHERE r.dir_id IS NOT NULL AND r.is_private
401ON CONFLICT (dir_id, user_id) DO NOTHING;
Modified lib/space/src/services/dir.rs lib/space/src/services/dir.rs +11 −39 View lib/space/src/services/dir.rs
@@ -155,44 +155,6 @@
155155 pub dir_id: Uuid,
156156}
157157
158fn resolve_node_visible(
159 caller_uid: i64,
160 my_group_ids: &HashSet<Uuid>,
161 group_ows: &[dir_overwrite_group::Model],
162 user_ows: &[dir_overwrite_users::Model],
163) -> bool {
164 if group_ows.is_empty() && user_ows.is_empty() {
165 return true;
166 }
167
168 if let Some(uo) = user_ows.iter().find(|u| u.user_id == caller_uid) {
169 if (uo.deny_mask & PERM_DIR_VISIBLE) != 0 {
170 return false;
171 }
172 if (uo.allow_mask & PERM_DIR_VISIBLE) != 0 {
173 return true;
174 }
175 }
176
177 let matching: Vec<&dir_overwrite_group::Model> = group_ows
178 .iter()
179 .filter(|g| my_group_ids.contains(&g.group_id))
180 .collect();
181 if matching
182 .iter()
183 .any(|g| (g.deny_mask & PERM_DIR_VISIBLE) != 0)
184 {
185 return false;
186 }
187 if matching
188 .iter()
189 .any(|g| (g.allow_mask & PERM_DIR_VISIBLE) != 0)
190 {
191 return true;
192 }
193 true
194}
195
196158impl SpaceService {
197159 async fn dir_in_space(&self, space_id: Uuid, dir_id: Uuid) -> Result<dirs::Model, SpaceError> {
198160 dirs::Entity::find_by_id(dir_id)
@@ -309,8 +271,12 @@
309271 group_ow_map.entry(go.dir_id).or_default().push(go);
310272 }
311273
274 let now = OffsetDateTime::now_utc();
312275 let mut user_ow_map: HashMap<Uuid, Vec<dir_overwrite_users::Model>> = HashMap::new();
313276 for uo in user_ows {
277 if !crate::share::dir_visibility::user_overwrite_active(&uo, now) {
278 continue;
279 }
314280 user_ow_map.entry(uo.dir_id).or_default().push(uo);
315281 }
316282
@@ -331,7 +297,12 @@
331297 let user_ows = user_ow_map.get(&n.id).map(Vec::as_slice).unwrap_or(&[]);
332298 direct_visible.insert(
333299 n.id,
334 resolve_node_visible(caller, &my_group_ids, group_ows, user_ows),
300 crate::share::dir_visibility::node_visible(
301 caller,
302 &my_group_ids,
303 group_ows,
304 user_ows,
305 ),
335306 );
336307 }
337308
@@ -800,6 +771,7 @@
800771 user_id: Set(user_id),
801772 allow_mask: Set(cmd.allow_mask),
802773 deny_mask: Set(cmd.deny_mask),
774 expires_at: Set(None),
803775 };
804776 let inserted = am.insert(self.access.database.write()).await?;
805777 (inserted.allow_mask, inserted.deny_mask)
Modified lib/space/src/share/dir_visibility.rs lib/space/src/share/dir_visibility.rs +15 −2 View lib/space/src/share/dir_visibility.rs
@@ -3,13 +3,18 @@
33use models::dir::{dir_overwrite_group, dir_overwrite_users, dirs};
44use models::space::{space_member_groups, spaces};
55use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter};
6use time::OffsetDateTime;
67use uuid::Uuid;
78
89use crate::errors::SpaceError;
910
1011pub const PERM_DIR_VISIBLE: i64 = 1 << 0;
1112
12fn node_visible(
13pub(crate) fn user_overwrite_active(row: &dir_overwrite_users::Model, now: OffsetDateTime) -> bool {
14 row.expires_at.is_none_or(|expires_at| expires_at > now)
15}
16
17pub(crate) fn node_visible(
1318 caller: i64,
1419 my_groups: &HashSet<Uuid>,
1520 group_ows: &[dir_overwrite_group::Model],
@@ -77,18 +82,26 @@
7782 .all(db)
7883 .await?;
7984
85 let now = OffsetDateTime::now_utc();
8086 let mut gmap: HashMap<Uuid, Vec<dir_overwrite_group::Model>> = HashMap::new();
8187 for g in group_ows {
8288 gmap.entry(g.dir_id).or_default().push(g);
8389 }
8490 let mut umap: HashMap<Uuid, Vec<dir_overwrite_users::Model>> = HashMap::new();
8591 for u in user_ows {
92 if !user_overwrite_active(&u, now) {
93 continue;
94 }
8695 umap.entry(u.dir_id).or_default().push(u);
8796 }
8897
8998 let mut parent_map: HashMap<Uuid, Option<Uuid>> = HashMap::new();
99 let mut archived: HashSet<Uuid> = HashSet::new();
90100 for n in &nodes {
91101 parent_map.insert(n.id, n.parent_dir_id);
102 if n.is_archived {
103 archived.insert(n.id);
104 }
92105 }
93106
94107 let direct = |id: Uuid| -> bool {
@@ -103,7 +116,7 @@
103116 let mut current: Option<Uuid> = Some(target.id);
104117 let mut visited = HashSet::new();
105118 while let Some(id) = current {
106 if !visited.insert(id) {
119 if !visited.insert(id) || archived.contains(&id) {
107120 return Ok(false);
108121 }
109122 if !direct(id) {
Modified lib/space/src/access.rs lib/space/src/access.rs +91 −1 View lib/space/src/access.rs
@@ -4,7 +4,7 @@
44use models::repo::repo_collaborations::{self, CollaborationMode};
55use models::space::{SpaceStatus, space_extras, space_groups, space_members, spaces};
66use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
7use std::collections::HashSet;
7use std::collections::{HashMap, HashSet};
88use time::OffsetDateTime;
99use uuid::Uuid;
1010
@@ -304,6 +304,96 @@
304304 .await
305305 .map_err(AccessError::from)
306306 }
307
308 /// 批量计算目录可见性(含祖先链、过期覆盖项与归档节点),供列表类接口一次加载。
309 pub async fn visible_dirs(
310 &self,
311 user: i64,
312 dir_ids: &[Uuid],
313 ) -> Result<HashSet<Uuid>, AccessError> {
314 if dir_ids.is_empty() {
315 return Ok(HashSet::new());
316 }
317 if self.record.owner_user_id == user {
318 return Ok(dir_ids.iter().copied().collect());
319 }
320 require(self, user, Capability::Read).await?;
321 let db = self.access.database.read();
322 let nodes = dirs::Entity::find()
323 .filter(dirs::Column::SpaceId.eq(self.id()))
324 .all(db)
325 .await?;
326 let mut parent_map: HashMap<Uuid, Option<Uuid>> = HashMap::new();
327 let mut archived: HashSet<Uuid> = HashSet::new();
328 for node in &nodes {
329 parent_map.insert(node.id, node.parent_dir_id);
330 if node.is_archived {
331 archived.insert(node.id);
332 }
333 }
334 let node_ids: Vec<Uuid> = nodes.iter().map(|node| node.id).collect();
335 let my_groups: HashSet<Uuid> = models::space::space_member_groups::Entity::find()
336 .filter(models::space::space_member_groups::Column::SpaceId.eq(self.id()))
337 .filter(models::space::space_member_groups::Column::UserId.eq(user))
338 .all(db)
339 .await?
340 .into_iter()
341 .map(|member| member.group_id)
342 .collect();
343 let now = OffsetDateTime::now_utc();
344 let mut group_ows: HashMap<Uuid, Vec<models::dir::dir_overwrite_group::Model>> =
345 HashMap::new();
346 for row in models::dir::dir_overwrite_group::Entity::find()
347 .filter(models::dir::dir_overwrite_group::Column::DirId.is_in(node_ids.clone()))
348 .all(db)
349 .await?
350 {
351 group_ows.entry(row.dir_id).or_default().push(row);
352 }
353 let mut user_ows: HashMap<Uuid, Vec<models::dir::dir_overwrite_users::Model>> =
354 HashMap::new();
355 for row in models::dir::dir_overwrite_users::Entity::find()
356 .filter(models::dir::dir_overwrite_users::Column::DirId.is_in(node_ids))
357 .all(db)
358 .await?
359 {
360 if !crate::share::dir_visibility::user_overwrite_active(&row, now) {
361 continue;
362 }
363 user_ows.entry(row.dir_id).or_default().push(row);
364 }
365
366 let mut visible = HashSet::new();
367 for dir_id in dir_ids {
368 if !parent_map.contains_key(dir_id) {
369 continue;
370 }
371 let mut current = Some(*dir_id);
372 let mut seen = HashSet::new();
373 let mut allowed = true;
374 while let Some(id) = current {
375 if !seen.insert(id) || archived.contains(&id) {
376 allowed = false;
377 break;
378 }
379 let direct = crate::share::dir_visibility::node_visible(
380 user,
381 &my_groups,
382 group_ows.get(&id).map(Vec::as_slice).unwrap_or(&[]),
383 user_ows.get(&id).map(Vec::as_slice).unwrap_or(&[]),
384 );
385 if !direct {
386 allowed = false;
387 break;
388 }
389 current = parent_map.get(&id).copied().flatten();
390 }
391 if allowed {
392 visible.insert(*dir_id);
393 }
394 }
395 Ok(visible)
396 }
307397}
308398
309399pub async fn require(space: &Space, user: i64, capability: Capability) -> Result<(), AccessError> {
Modified lib/webapi/src/cleanup.rs lib/webapi/src/cleanup.rs +60 −8 View lib/webapi/src/cleanup.rs
@@ -1,11 +1,10 @@
11/**
22 * 空间「对象清理」:仅空间所有者可见的危险区。
33 *
4 * 空间对象分布在两棵树里 —— `dirs`(目录 / 仓库 / 问题集)与
5 * `rooms`(文字、语音、舞台等频道)。这里把它们拍成一张扁平清单,并提供
6 * 「删除 / 归档 / 可见性 / 清除可见性覆盖」操作。删除复用各模块自己的清理
7 * 逻辑(问题集连同 issue 与目录节点一起删、频道软删),
8 * 纯目录节点只有为空时才允许删除,避免留下孤儿对象。
4 * 空间对象都挂在 `dirs` 树上(目录 / 仓库 / 问题集 / 频道),频道另有
5 * `rooms` 行承载频道能力参数。这里把它们拍成一张扁平清单(频道仍按 room id
6 * 管理,其 dir 镜像跳过),并提供「删除 / 归档 / 可见性 / 清除可见性覆盖」
7 * 操作。频道的可见性以 dir 覆盖项为准,`rooms.is_private` 只是镜像。
98 */
109use std::collections::HashMap;
1110
@@ -17,7 +16,7 @@
1716use models::dir::{dir_overwrite_group, dir_overwrite_users, dirs};
1817use models::issue::issues as boards;
1918use models::repo::repos;
20use models::room::rooms;
19use models::room::{room_members, rooms};
2120use sea_orm::ActiveValue::Set;
2221use sea_orm::{
2322 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, TransactionTrait,
@@ -148,6 +147,14 @@
148147 }
149148}
150149
150fn dir_internal(error: dir::Error) -> ApiError {
151 match error {
152 dir::Error::InvalidInput(message) => ApiError::bad_request(message),
153 dir::Error::NotFound => ApiError::not_found("directory not found"),
154 dir::Error::Database(error) => db_internal(error),
155 }
156}
157
151158fn db_internal(error: sea_orm::DbErr) -> ApiError {
152159 ApiError::internal(error.to_string())
153160}
@@ -377,10 +384,16 @@
377384 .to_string(),
378385 name: room.name.clone(),
379386 path: room_path(room, &room_index),
380 is_private: Some(room.is_private),
387 is_private: Some(match room.dir_id {
388 Some(dir_id) => overwrite_counts.get(&dir_id).copied().unwrap_or(0) > 0,
389 None => room.is_private,
390 }),
381391 is_archived: false,
382392 child_count: room_child_counts.get(&room.id).copied().unwrap_or(0),
383 overwrite_count: 0,
393 overwrite_count: room
394 .dir_id
395 .and_then(|dir_id| overwrite_counts.get(&dir_id).copied())
396 .unwrap_or(0),
384397 });
385398 }
386399
@@ -436,10 +449,49 @@
436449 .await
437450 .map_err(db_internal)?
438451 .ok_or_else(|| ApiError::not_found("room not found"))?;
452 let dir_id = room.dir_id;
453 let owner_id = room.owner_id;
454 let room_id = room.id;
439455 let mut am: rooms::ActiveModel = room.into();
440456 am.is_private = Set(cmd.is_private);
441457 am.updated_at = Set(time::OffsetDateTime::now_utc());
442458 am.update(db.write()).await.map_err(db_internal)?;
459 if let Some(dir_id) = dir_id {
460 if cmd.is_private {
461 dir::set_user_overwrite(
462 db.write(),
463 dir_id,
464 owner_id,
465 dir::PERM_VISIBLE,
466 0,
467 None,
468 )
469 .await
470 .map_err(dir_internal)?;
471 let members = room_members::Entity::find()
472 .filter(room_members::Column::RoomId.eq(room_id))
473 .filter(room_members::Column::LeftAt.is_null())
474 .all(db.read())
475 .await
476 .map_err(db_internal)?;
477 for member in members {
478 dir::set_user_overwrite(
479 db.write(),
480 dir_id,
481 member.user_id,
482 dir::PERM_VISIBLE,
483 0,
484 None,
485 )
486 .await
487 .map_err(dir_internal)?;
488 }
489 } else {
490 dir::clear_user_overwrites(db.write(), dir_id)
491 .await
492 .map_err(dir_internal)?;
493 }
494 }
443495 }
444496 _ => {
445497 return Err(ApiError::bad_request(
Modified lib/webapi/Cargo.toml lib/webapi/Cargo.toml +1 −0 View lib/webapi/Cargo.toml
@@ -21,6 +21,7 @@
2121space = { workspace = true }
2222user = { workspace = true }
2323git = { workspace = true }
24dir = { workspace = true }
2425models = { workspace = true }
2526sea-orm = { workspace = true }
2627export = { workspace = true }
Modified Cargo.lock Cargo.lock +1 −0 View Cargo.lock
@@ -8735,6 +8735,7 @@
87358735 "chat",
87368736 "config 1.0.0",
87378737 "deletion",
8738 "dir",
87388739 "export",
87398740 "git",
87408741 "i18n",