Skip to content
Clone

feat(chat): drop per-channel membership and permission management

- delete channel member/invite/ban/role/transfer endpoints, services and models;
  channel access is now driven entirely by dir overwrites (space API)
- convert legacy room bans into expiring deny overwrites, drop the room_bans and
  room_invites tables and clear space-channel room_members rows (DM rows stay)
- derive @everyone/notification recipients and read-state listings from dir
  visibility, and count channel members from space membership
- stop writing room_members for space channels in creation/join flows
- replace room-role voice moderation with the space manage_channels capability
- regenerate openapi.json for the removed endpoints
ZhenYi committed
parent 8f30db6 commit 99b1f99 Browse files

25 files changed

+191 −1702

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 25
Modified lib/chat/src/handlers/mod.rs View lib/chat/src/handlers/mod.rs
@@ -9,7 +9,6 @@
99pub mod reactions;
1010pub mod read_state;
1111pub mod reminders;
12pub mod room_admin;
1312pub mod rooms;
1413pub mod settings;
1514pub mod threads;
Deleted lib/chat/src/handlers/room_admin.rs
@@ -1,184 +0,0 @@
1use ::session::Session;
2use ::session::share::session;
3use export::http::{Json, Path, Query, State};
4use export::rest::{AppResponseData, NoData};
5use uuid::Uuid;
6
7use crate::errors::ApiError;
8use crate::services::room_admin::{BanRoomMemberCmd, RoomBanDto, UpdateRoomMemberCmd};
9use crate::services::rooms::RoomMemberDto;
10use crate::services::service::ChatService;
11
12#[utoipa::path(
13 get,
14 path = "/chat/rooms/{room_id}/members/{user_id}",
15 tag = "chat",
16 description = "读取指定房间成员的昵称、角色与加入信息。",
17 params(("room_id" = Uuid, Path), ("user_id" = i64, Path)),
18 responses(
19 (status = 200, description = "room member", body = AppResponseData<RoomMemberDto>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>),
21 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn get_room_member(
25 State(service): State<ChatService>,
26 session: Session,
27 Path((room_id, user_id)): Path<(Uuid, i64)>,
28) -> Result<AppResponseData<RoomMemberDto>, ApiError> {
29 let uid = session::require_uid(&session)?;
30 let member = service.get_room_member(uid, room_id, user_id).await?;
31 Ok(AppResponseData::ok(member))
32}
33
34#[utoipa::path(
35 patch,
36 path = "/chat/rooms/{room_id}/members/{user_id}",
37 tag = "chat",
38 description = "更新房间成员昵称或角色;管理员修改角色受层级限制。",
39 params(("room_id" = Uuid, Path), ("user_id" = i64, Path)),
40 request_body = UpdateRoomMemberCmd,
41 responses(
42 (status = 200, description = "room member updated", body = AppResponseData<RoomMemberDto>),
43 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
44 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
45 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
46 )
47)]
48pub async fn update_room_member(
49 State(service): State<ChatService>,
50 session: Session,
51 Path((room_id, user_id)): Path<(Uuid, i64)>,
52 Json(cmd): Json<UpdateRoomMemberCmd>,
53) -> Result<AppResponseData<RoomMemberDto>, ApiError> {
54 let uid = session::require_uid(&session)?;
55 let member = service
56 .update_room_member(uid, room_id, user_id, cmd)
57 .await?;
58 Ok(AppResponseData::ok(member))
59}
60
61#[utoipa::path(
62 delete,
63 path = "/chat/rooms/{room_id}/members/{user_id}",
64 tag = "chat",
65 description = "将成员移出房间;调用者权限须高于目标且不能移除所有者。",
66 params(("room_id" = Uuid, Path), ("user_id" = i64, Path)),
67 responses(
68 (status = 200, description = "member removed", body = AppResponseData<NoData>),
69 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
70 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
71 )
72)]
73pub async fn kick_room_member(
74 State(service): State<ChatService>,
75 session: Session,
76 Path((room_id, user_id)): Path<(Uuid, i64)>,
77) -> Result<AppResponseData<NoData>, ApiError> {
78 let uid = session::require_uid(&session)?;
79 service.kick_room_member(uid, room_id, user_id).await?;
80 Ok(AppResponseData::empty())
81}
82
83#[utoipa::path(
84 get,
85 path = "/chat/rooms/{room_id}/bans",
86 tag = "chat",
87 description = "分页读取房间的成员封禁记录。",
88 params(("room_id" = Uuid, Path), export::pagination::PageQuery),
89 responses(
90 (status = 200, description = "room bans", body = AppResponseData<export::pagination::Page<RoomBanDto>>),
91 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
92 )
93)]
94pub async fn list_room_bans(
95 State(service): State<ChatService>,
96 session: Session,
97 Path(room_id): Path<Uuid>,
98 Query(page): Query<export::pagination::PageQuery>,
99) -> Result<AppResponseData<export::pagination::Page<RoomBanDto>>, ApiError> {
100 let uid = session::require_uid(&session)?;
101 let bans = service.list_room_bans(uid, room_id).await?;
102 Ok(AppResponseData::ok(export::pagination::paginate(
103 bans, &page,
104 )))
105}
106
107#[utoipa::path(
108 post,
109 path = "/chat/rooms/{room_id}/bans",
110 tag = "chat",
111 description = "封禁房间成员并立即终止其房间成员关系。",
112 params(("room_id" = Uuid, Path)),
113 request_body = BanRoomMemberCmd,
114 responses(
115 (status = 200, description = "member banned", body = AppResponseData<RoomBanDto>),
116 (status = 409, description = "already banned", body = AppResponseData<NoData>),
117 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
118 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
119 )
120)]
121pub async fn ban_room_member(
122 State(service): State<ChatService>,
123 session: Session,
124 Path(room_id): Path<Uuid>,
125 Json(cmd): Json<BanRoomMemberCmd>,
126) -> Result<AppResponseData<RoomBanDto>, ApiError> {
127 let uid = session::require_uid(&session)?;
128 let ban = service.ban_room_member(uid, room_id, cmd).await?;
129 Ok(AppResponseData::ok(ban))
130}
131
132#[utoipa::path(
133 delete,
134 path = "/chat/rooms/{room_id}/bans/{user_id}",
135 tag = "chat",
136 description = "删除指定用户的房间封禁记录。",
137 params(("room_id" = Uuid, Path), ("user_id" = i64, Path)),
138 responses(
139 (status = 200, description = "member unbanned", body = AppResponseData<NoData>),
140 (status = 404, description = "not banned", body = AppResponseData<NoData>),
141 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
142 )
143)]
144pub async fn unban_room_member(
145 State(service): State<ChatService>,
146 session: Session,
147 Path((room_id, user_id)): Path<(Uuid, i64)>,
148) -> Result<AppResponseData<NoData>, ApiError> {
149 let uid = session::require_uid(&session)?;
150 service.unban_room_member(uid, room_id, user_id).await?;
151 Ok(AppResponseData::empty())
152}
153
154#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
155pub struct TransferOwnerCmd {
156 pub user_id: i64,
157}
158
159#[utoipa::path(
160 put,
161 path = "/chat/rooms/{room_id}/owner",
162 tag = "chat",
163 description = "由当前所有者把房间所有权转移给有效成员。",
164 params(("room_id" = Uuid, Path)),
165 request_body = TransferOwnerCmd,
166 responses(
167 (status = 200, description = "ownership transferred", body = AppResponseData<NoData>),
168 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
169 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
170 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
171 )
172)]
173pub async fn transfer_room_owner(
174 State(service): State<ChatService>,
175 session: Session,
176 Path(room_id): Path<Uuid>,
177 Json(cmd): Json<TransferOwnerCmd>,
178) -> Result<AppResponseData<NoData>, ApiError> {
179 let uid = session::require_uid(&session)?;
180 service
181 .transfer_room_owner(uid, room_id, cmd.user_id)
182 .await?;
183 Ok(AppResponseData::empty())
184}
Modified lib/chat/src/handlers/rooms.rs View lib/chat/src/handlers/rooms.rs
@@ -1,14 +1,11 @@
11use ::session::Session;
22use ::session::share::session;
3use export::http::{Json, Path, Query, State};
3use export::http::{Json, Path, State};
44use export::rest::{AppResponseData, NoData};
55use uuid::Uuid;
66
77use crate::errors::ApiError;
8use crate::services::rooms::{
9 AcceptRoomInviteCmd, AddMembersCmd, CreateRoomInviteCmd, RoomBriefDto, RoomInviteDto,
10 RoomInviteListItemDto, RoomMemberDto, RoomMemberQuery, UpdateRoomCmd,
11};
8use crate::services::rooms::{RoomBriefDto, UpdateRoomCmd};
129use crate::services::service::ChatService;
1310
1411#[utoipa::path(
@@ -78,216 +75,4 @@
7875 let uid = session::require_uid(&session)?;
7976 let room = service.update_room(uid, room_id, cmd).await?;
8077 Ok(AppResponseData::ok(room))
81}
82
83#[utoipa::path(
84 put,
85 path = "/chat/rooms/{room_id}/members/@me",
86 tag = "chat",
87 description = "让当前用户自助加入可访问的公开空间房间。",
88 params(("room_id" = Uuid, Path, description = "room id")),
89 responses(
90 (status = 200, description = "joined", body = AppResponseData<NoData>),
91 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
92 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
93 )
94)]
95pub async fn join_room(
96 State(service): State<ChatService>,
97 session: Session,
98 Path(room_id): Path<Uuid>,
99) -> Result<AppResponseData<NoData>, ApiError> {
100 let uid = session::require_uid(&session)?;
101 service.join_room(uid, room_id).await?;
102 Ok(AppResponseData::empty())
103}
104
105#[utoipa::path(
106 delete,
107 path = "/chat/rooms/{room_id}/members/@me",
108 tag = "chat",
109 description = "退出房间;所有者退出时按规则转移或停用房间。",
110 params(("room_id" = Uuid, Path, description = "room id")),
111 responses(
112 (status = 200, description = "left", body = AppResponseData<NoData>),
113 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
114 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
115 )
116)]
117pub async fn leave_room(
118 State(service): State<ChatService>,
119 session: Session,
120 Path(room_id): Path<Uuid>,
121) -> Result<AppResponseData<NoData>, ApiError> {
122 let uid = session::require_uid(&session)?;
123 service.leave_room(uid, room_id).await?;
124 Ok(AppResponseData::empty())
125}
126
127#[utoipa::path(
128 post,
129 path = "/chat/rooms/{room_id}/members",
130 tag = "chat",
131 description = "批量将用户加入房间,并按房间类型执行权限和人数限制。",
132 params(("room_id" = Uuid, Path, description = "room id")),
133 request_body = AddMembersCmd,
134 responses(
135 (status = 200, description = "members added", body = AppResponseData<NoData>),
136 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
137 (status = 409, description = "resource already exists", body = AppResponseData<NoData>),
138 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
139 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
140 )
141)]
142pub async fn add_room_members(
143 State(service): State<ChatService>,
144 session: Session,
145 Path(room_id): Path<Uuid>,
146 Json(cmd): Json<AddMembersCmd>,
147) -> Result<AppResponseData<NoData>, ApiError> {
148 let uid = session::require_uid(&session)?;
149 service.add_room_members(uid, room_id, cmd).await?;
150 Ok(AppResponseData::empty())
151}
152
153#[utoipa::path(
154 get,
155 path = "/chat/rooms/{room_id}/members",
156 tag = "chat",
157 description = "按加入时间分页读取房间成员。",
158 params(("room_id" = Uuid, Path, description = "room id"), RoomMemberQuery),
159 responses(
160 (status = 200, description = "member list", body = AppResponseData<Vec<RoomMemberDto>>),
161 (status = 404, description = "not found", body = AppResponseData<NoData>),
162 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
163 )
164)]
165pub async fn list_room_members(
166 State(service): State<ChatService>,
167 session: Session,
168 Path(room_id): Path<Uuid>,
169 Query(q): Query<RoomMemberQuery>,
170) -> Result<AppResponseData<Vec<RoomMemberDto>>, ApiError> {
171 let uid = session::require_uid(&session)?;
172 let members = service.list_room_members(uid, room_id, q).await?;
173 Ok(AppResponseData::ok(members))
174}
175
176#[utoipa::path(
177 post,
178 path = "/chat/rooms/{room_id}/invites",
179 tag = "chat",
180 description = "创建房间邀请并返回可分享的一次性接受码。",
181 params(("room_id" = Uuid, Path, description = "room id")),
182 request_body = CreateRoomInviteCmd,
183 responses(
184 (status = 200, description = "invite created", body = AppResponseData<RoomInviteDto>),
185 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
186 (status = 409, description = "resource already exists", body = AppResponseData<NoData>),
187 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
188 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
189 )
190)]
191pub async fn create_room_invite(
192 State(service): State<ChatService>,
193 session: Session,
194 Path(room_id): Path<Uuid>,
195 Json(cmd): Json<CreateRoomInviteCmd>,
196) -> Result<AppResponseData<RoomInviteDto>, ApiError> {
197 let uid = session::require_uid(&session)?;
198 let invite = service.create_room_invite(uid, room_id, cmd).await?;
199 Ok(AppResponseData::ok(invite))
200}
201
202#[utoipa::path(
203 post,
204 path = "/chat/invites/accept",
205 tag = "chat",
206 description = "凭邀请码验证邀请状态和受邀身份后加入房间。",
207 request_body = AcceptRoomInviteCmd,
208 responses(
209 (status = 200, description = "joined via invite", body = AppResponseData<RoomBriefDto>),
210 (status = 404, description = "not found", body = AppResponseData<NoData>),
211 (status = 429, description = "rate limit exceeded", body = AppResponseData<NoData>),
212 (status = 409, description = "resource already exists", body = AppResponseData<NoData>),
213 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
214 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
215 )
216)]
217pub async fn accept_room_invite(
218 State(service): State<ChatService>,
219 session: Session,
220 Json(cmd): Json<AcceptRoomInviteCmd>,
221) -> Result<AppResponseData<RoomBriefDto>, ApiError> {
222 let uid = session::require_uid(&session)?;
223 let room = service.accept_room_invite(uid, &cmd.code).await?;
224 Ok(AppResponseData::ok(room))
225}
226
227#[utoipa::path(
228 post,
229 path = "/chat/invites/reject",
230 tag = "chat",
231 description = "受邀人拒绝邀请:邀请作废,不写入房间成员。",
232 request_body = AcceptRoomInviteCmd,
233 responses(
234 (status = 200, description = "invite rejected", body = AppResponseData<NoData>),
235 (status = 404, description = "not found", body = AppResponseData<NoData>),
236 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
237 (status = 409, description = "resource already exists", body = AppResponseData<NoData>),
238 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
239 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
240 )
241)]
242pub async fn reject_room_invite(
243 State(service): State<ChatService>,
244 session: Session,
245 Json(cmd): Json<AcceptRoomInviteCmd>,
246) -> Result<AppResponseData<NoData>, ApiError> {
247 let uid = session::require_uid(&session)?;
248 service.reject_room_invite(uid, &cmd.code).await?;
249 Ok(AppResponseData::empty())
250}
251
252#[utoipa::path(
253 get,
254 path = "/chat/rooms/{room_id}/invites",
255 tag = "chat",
256 description = "列出房间已创建的邀请及其使用状态。",
257 params(("room_id" = Uuid, Path)),
258 responses(
259 (status = 200, description = "room invites", body = AppResponseData<Vec<RoomInviteListItemDto>>),
260 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
261 )
262)]
263pub async fn list_room_invites(
264 State(service): State<ChatService>,
265 session: Session,
266 Path(room_id): Path<Uuid>,
267) -> Result<AppResponseData<Vec<RoomInviteListItemDto>>, ApiError> {
268 let uid = session::require_uid(&session)?;
269 let invites = service.list_room_invites(uid, room_id).await?;
270 Ok(AppResponseData::ok(invites))
271}
272
273#[utoipa::path(
274 delete,
275 path = "/chat/rooms/{room_id}/invites/{invite_id}",
276 tag = "chat",
277 description = "撤销尚未接受且仍有效的房间邀请。",
278 params(("room_id" = Uuid, Path), ("invite_id" = Uuid, Path)),
279 responses(
280 (status = 200, description = "invite revoked", body = AppResponseData<NoData>),
281 (status = 404, description = "not found", body = AppResponseData<NoData>),
282 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
283 )
284)]
285pub async fn revoke_room_invite(
286 State(service): State<ChatService>,
287 session: Session,
288 Path((room_id, invite_id)): Path<(Uuid, Uuid)>,
289) -> Result<AppResponseData<NoData>, ApiError> {
290 let uid = session::require_uid(&session)?;
291 service.revoke_room_invite(uid, room_id, invite_id).await?;
292 Ok(AppResponseData::empty())
29378}
Modified lib/chat/src/services/mod.rs View lib/chat/src/services/mod.rs
@@ -11,7 +11,6 @@
1111pub mod reactions;
1212pub mod read_state;
1313pub mod reminders;
14pub mod room_admin;
1514pub mod rooms;
1615pub mod service;
1716pub mod settings;
Modified lib/chat/src/services/read_state.rs View lib/chat/src/services/read_state.rs
@@ -1,3 +1,5 @@
1use std::collections::HashSet;
2
13use models::chat::{messages, read_states, room_seq};
24use models::room::rooms;
35use models::users::user_recent_mentions;
@@ -158,12 +160,19 @@
158160 .filter(rooms::Column::IsActive.eq(true))
159161 .all(self.database.read())
160162 .await?;
163 let dir_ids: Vec<Uuid> = space_rooms.iter().filter_map(|room| room.dir_id).collect();
164 let visible: HashSet<Uuid> = if dir_ids.is_empty() {
165 HashSet::new()
166 } else {
167 space.visible_dirs(caller, &dir_ids).await?
168 };
161169 let mut items = Vec::new();
162170 for room in space_rooms {
163 if room.is_private
164 && room.owner_id != caller
165 && !crate::share::lookup::is_room_member(self, room.id, caller).await?
166 {
171 let is_visible = match room.dir_id {
172 Some(dir_id) => visible.contains(&dir_id),
173 None => !room.is_private || room.owner_id == caller,
174 };
175 if !is_visible {
167176 continue;
168177 }
169178 items.push(self.get_read_state(caller, room.id).await?);
Deleted lib/chat/src/services/room_admin.rs
@@ -1,355 +0,0 @@
1use export::users::UserDto;
2use models::room::room_members::{self, RoomMemberRole};
3use models::room::{room_bans, rooms};
4use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use utoipa::ToSchema;
8use uuid::Uuid;
9
10use crate::errors::ChatError;
11use crate::services::rooms::RoomMemberDto;
12use crate::services::service::ChatService;
13use crate::share::lookup;
14use crate::share::realtime::RoomEvent;
15
16#[derive(Debug, Clone, Serialize, ToSchema)]
17pub struct RoomBanDto {
18 pub room_id: Uuid,
19 pub user: Option<UserDto>,
20 pub reason: Option<String>,
21 pub banned_by: Option<UserDto>,
22 pub expires_at: Option<OffsetDateTime>,
23 pub created_at: OffsetDateTime,
24}
25
26#[derive(Debug, Clone, Deserialize, ToSchema)]
27pub struct BanRoomMemberCmd {
28 pub user_id: i64,
29 #[serde(default)]
30 pub reason: Option<String>,
31 #[serde(
32 default,
33 deserialize_with = "export::serde_datetime::deserialize_opt_datetime"
34 )]
35 pub expires_at: Option<OffsetDateTime>,
36}
37
38#[derive(Debug, Clone, Deserialize, ToSchema)]
39pub struct UpdateRoomMemberCmd {
40 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
41 pub nick: Option<Option<String>>,
42 #[serde(default)]
43 pub role: Option<RoomMemberRole>,
44}
45
46pub async fn active_room_role(
47 service: &ChatService,
48 room_id: Uuid,
49 uid: i64,
50) -> Result<Option<RoomMemberRole>, ChatError> {
51 Ok(room_members::Entity::find_by_id((room_id, uid))
52 .one(service.database.read())
53 .await?
54 .filter(|m| m.left_at.is_none())
55 .map(|m| m.role))
56}
57
58async fn require_room_role(
59 service: &ChatService,
60 room_id: Uuid,
61 uid: i64,
62 min: RoomMemberRole,
63) -> Result<RoomMemberRole, ChatError> {
64 let Some(role) = active_room_role(service, room_id, uid).await? else {
65 return Err(ChatError::Forbidden);
66 };
67 if role < min {
68 return Err(ChatError::Forbidden);
69 }
70 Ok(role)
71}
72
73async fn set_left_at(service: &ChatService, room_id: Uuid, uid: i64) -> Result<(), ChatError> {
74 let Some(row) = room_members::Entity::find_by_id((room_id, uid))
75 .one(service.database.write())
76 .await?
77 else {
78 return Ok(());
79 };
80 if row.left_at.is_none() {
81 let mut am: room_members::ActiveModel = row.into();
82 am.left_at = Set(Some(OffsetDateTime::now_utc()));
83 am.updated_at = Set(Some(OffsetDateTime::now_utc()));
84 am.update(service.database.write()).await?;
85 }
86 Ok(())
87}
88
89impl ChatService {
90 async fn build_room_ban_dto(&self, row: room_bans::Model) -> Result<RoomBanDto, ChatError> {
91 let user_map = self
92 .audience
93 .load_user_briefs(&[row.user_id, row.banned_by])
94 .await?;
95 Ok(RoomBanDto {
96 room_id: row.room_id,
97 user: user_map.get(&row.user_id).cloned(),
98 reason: row.reason,
99 banned_by: user_map.get(&row.banned_by).cloned(),
100 expires_at: row.expires_at,
101 created_at: row.created_at,
102 })
103 }
104
105 pub async fn get_room_member(
106 &self,
107 caller: i64,
108 room_id: Uuid,
109 uid: i64,
110 ) -> Result<RoomMemberDto, ChatError> {
111 crate::share::lookup::require_room_readable(self, room_id, caller).await?;
112 let Some(row) = room_members::Entity::find_by_id((room_id, uid))
113 .one(self.database.read())
114 .await?
115 else {
116 return Err(ChatError::NotFound);
117 };
118 let user_map = self.audience.load_user_briefs(&[row.user_id]).await?;
119 let user = user_map
120 .get(&row.user_id)
121 .cloned()
122 .ok_or(ChatError::NotFound)?;
123 Ok(RoomMemberDto {
124 user,
125 role: row.role,
126 nick: row.nick,
127 joined_at: row.joined_at,
128 })
129 }
130
131 pub async fn kick_room_member(
132 &self,
133 caller: i64,
134 room_id: Uuid,
135 uid: i64,
136 ) -> Result<(), ChatError> {
137 let room = lookup::lookup_room(self, room_id).await?;
138 if caller == uid {
139 return Err(ChatError::BadRequest(
140 "use leave endpoint instead".to_string(),
141 ));
142 }
143 let caller_role =
144 require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?;
145 let target_role = active_room_role(self, room_id, uid)
146 .await?
147 .ok_or(ChatError::NotFound)?;
148 if uid == room.owner_id || target_role >= caller_role {
149 return Err(ChatError::Forbidden);
150 }
151 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 }
155 self.audience
156 .realtime
157 .publish_room(&RoomEvent::MemberLeft {
158 room_id,
159 user_id: uid,
160 })
161 .await;
162 Ok(())
163 }
164
165 pub async fn ban_room_member(
166 &self,
167 caller: i64,
168 room_id: Uuid,
169 cmd: BanRoomMemberCmd,
170 ) -> Result<RoomBanDto, ChatError> {
171 let room = lookup::lookup_room(self, room_id).await?;
172 require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?;
173 if cmd.user_id == room.owner_id {
174 return Err(ChatError::Forbidden);
175 }
176 if let Some(target_role) = active_room_role(self, room_id, cmd.user_id).await?
177 && target_role >= RoomMemberRole::Moderator
178 {
179 return Err(ChatError::Forbidden);
180 }
181 let existing = room_bans::Entity::find_by_id((room_id, cmd.user_id))
182 .one(self.database.read())
183 .await?;
184 if existing.is_some() {
185 return Err(ChatError::Conflict("user already banned".to_string()));
186 }
187 let now = OffsetDateTime::now_utc();
188 let row = room_bans::ActiveModel {
189 room_id: Set(room_id),
190 user_id: Set(cmd.user_id),
191 reason: Set(cmd.reason),
192 banned_by: Set(caller),
193 expires_at: Set(cmd.expires_at),
194 created_at: Set(now),
195 updated_at: Set(Some(now)),
196 }
197 .insert(self.database.write())
198 .await?;
199 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 }
211 self.audience
212 .realtime
213 .publish_room(&RoomEvent::MemberLeft {
214 room_id,
215 user_id: cmd.user_id,
216 })
217 .await;
218 self.build_room_ban_dto(row).await
219 }
220
221 pub async fn unban_room_member(
222 &self,
223 caller: i64,
224 room_id: Uuid,
225 uid: i64,
226 ) -> Result<(), ChatError> {
227 require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?;
228 let room = lookup::lookup_room(self, room_id).await?;
229 let deleted = room_bans::Entity::delete_by_id((room_id, uid))
230 .exec(self.database.write())
231 .await?;
232 if deleted.rows_affected == 0 {
233 return Err(ChatError::NotFound);
234 }
235 if let Some(dir_id) = room.dir_id {
236 dir::remove_user_overwrite(self.database.write(), dir_id, uid).await?;
237 }
238 Ok(())
239 }
240
241 pub async fn list_room_bans(
242 &self,
243 caller: i64,
244 room_id: Uuid,
245 ) -> Result<Vec<RoomBanDto>, ChatError> {
246 require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?;
247 let rows = room_bans::Entity::find()
248 .filter(room_bans::Column::RoomId.eq(room_id))
249 .order_by_desc(room_bans::Column::CreatedAt)
250 .all(self.database.read())
251 .await?;
252 let mut out = Vec::with_capacity(rows.len());
253 for row in rows {
254 out.push(self.build_room_ban_dto(row).await?);
255 }
256 Ok(out)
257 }
258
259 pub async fn transfer_room_owner(
260 &self,
261 caller: i64,
262 room_id: Uuid,
263 new_owner: i64,
264 ) -> Result<(), ChatError> {
265 let room = lookup::lookup_room(self, room_id).await?;
266 if room.owner_id != caller {
267 return Err(ChatError::Forbidden);
268 }
269 if new_owner == caller {
270 return Err(ChatError::BadRequest(
271 "cannot transfer ownership to yourself".to_string(),
272 ));
273 }
274 let target = room_members::Entity::find_by_id((room_id, new_owner))
275 .one(self.database.read())
276 .await?
277 .filter(|m| m.left_at.is_none())
278 .ok_or(ChatError::NotFound)?;
279 let now = OffsetDateTime::now_utc();
280 let mut owner_am = rooms::ActiveModel::from(room);
281 owner_am.owner_id = Set(new_owner);
282 owner_am.updated_at = Set(now);
283 owner_am.update(self.database.write()).await?;
284
285 let mut new_owner_am: room_members::ActiveModel = target.into();
286 new_owner_am.role = Set(RoomMemberRole::Owner);
287 new_owner_am.updated_at = Set(Some(now));
288 new_owner_am.update(self.database.write()).await?;
289
290 if let Some(old) = room_members::Entity::find_by_id((room_id, caller))
291 .one(self.database.write())
292 .await?
293 && old.left_at.is_none()
294 {
295 let mut am: room_members::ActiveModel = old.into();
296 am.role = Set(RoomMemberRole::Admin);
297 am.updated_at = Set(Some(now));
298 am.update(self.database.write()).await?;
299 }
300 Ok(())
301 }
302
303 pub async fn update_room_member(
304 &self,
305 caller: i64,
306 room_id: Uuid,
307 uid: i64,
308 cmd: UpdateRoomMemberCmd,
309 ) -> Result<RoomMemberDto, ChatError> {
310 let row = room_members::Entity::find_by_id((room_id, uid))
311 .one(self.database.write())
312 .await?
313 .ok_or(ChatError::NotFound)?;
314 let is_self = caller == uid;
315 if !is_self {
316 require_room_role(self, room_id, caller, RoomMemberRole::Admin).await?;
317 }
318 if let Some(new_role) = cmd.role {
319 if is_self || new_role >= RoomMemberRole::Owner || row.role == RoomMemberRole::Owner {
320 return Err(ChatError::Forbidden);
321 }
322 if let Some(caller_role) = active_room_role(self, room_id, caller).await?
323 && new_role >= caller_role
324 {
325 return Err(ChatError::Forbidden);
326 }
327 }
328 let mut am: room_members::ActiveModel = row.into();
329 if let Some(nick) = cmd.nick {
330 let nick = nick.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
331 if let Some(n) = &nick
332 && n.chars().count() > 64
333 {
334 return Err(ChatError::BadRequest("nick too long".to_string()));
335 }
336 am.nick = Set(nick);
337 }
338 if let Some(role) = cmd.role {
339 am.role = Set(role);
340 }
341 am.updated_at = Set(Some(OffsetDateTime::now_utc()));
342 let updated = am.update(self.database.write()).await?;
343 let user_map = self.audience.load_user_briefs(&[updated.user_id]).await?;
344 let user = user_map
345 .get(&updated.user_id)
346 .cloned()
347 .ok_or(ChatError::NotFound)?;
348 Ok(RoomMemberDto {
349 user,
350 role: updated.role,
351 nick: updated.nick,
352 joined_at: updated.joined_at,
353 })
354 }
355}
Modified lib/chat/src/services/rooms.rs View lib/chat/src/services/rooms.rs
@@ -2,28 +2,21 @@
22use models::chat::messages::{self, ChatMessageType};
33use models::chat::{read_states, room_seq, room_user_settings};
44use models::dir::dirs;
5use models::room::room_invites;
6use models::room::room_members::{self, RoomMemberRole};
75use models::room::rooms::{self, RoomKind};
8use models::users::user_notifications::NotificationType;
9use rand::Rng;
106use sea_orm::sea_query::Expr;
117use sea_orm::{
12 ActiveModelTrait, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, QueryOrder, QuerySelect,
13 Set, TransactionTrait,
8 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
9 TransactionTrait,
1410};
1511use serde::{Deserialize, Serialize};
1612use time::OffsetDateTime;
17use utoipa::{IntoParams, ToSchema};
13use utoipa::ToSchema;
1814use uuid::Uuid;
1915
2016use crate::errors::ChatError;
2117use crate::services::service::ChatService;
2218use crate::share::authz;
23use crate::share::lookup::{
24 is_room_member, load_room_context, lookup_room, lookup_space_by_name, require_room_readable,
25 require_space_member,
26};
19use crate::share::lookup::{lookup_space_by_name, require_room_readable, require_space_member};
2720use crate::share::notify::next_room_seq;
2821use crate::share::realtime::RoomEvent;
2922
@@ -72,65 +65,6 @@
7265 pub parent_id: Option<Option<Uuid>>,
7366}
7467
75#[derive(Debug, Clone, Deserialize, ToSchema)]
76pub struct AcceptRoomInviteCmd {
77 pub code: String,
78}
79
80#[derive(Debug, Clone, Deserialize, ToSchema)]
81pub struct AddMembersCmd {
82 pub user_ids: Vec<i64>,
83}
84
85#[derive(Debug, Clone, Serialize, ToSchema)]
86pub struct RoomMemberDto {
87 pub user: export::users::UserDto,
88 pub role: RoomMemberRole,
89 pub nick: Option<String>,
90 pub joined_at: OffsetDateTime,
91}
92
93#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
94pub struct RoomMemberQuery {
95 pub limit: Option<u64>,
96 pub offset: Option<u64>,
97}
98
99#[derive(Debug, Clone, Deserialize, ToSchema)]
100pub struct CreateRoomInviteCmd {
101 #[serde(default)]
102 pub invitee_id: Option<i64>,
103 #[serde(default)]
104 pub expires_secs: Option<i64>,
105}
106
107#[derive(Debug, Clone, Serialize, ToSchema)]
108pub struct RoomInviteDto {
109 pub id: Uuid,
110 pub room_id: Uuid,
111 pub inviter_id: i64,
112 pub inviter: Option<UserDto>,
113 pub invitee_id: Option<i64>,
114 pub invitee: Option<UserDto>,
115 pub accept_code: String,
116 pub created_at: OffsetDateTime,
117 pub expires_at: Option<OffsetDateTime>,
118}
119
120#[derive(Debug, Clone, Serialize, ToSchema)]
121pub struct RoomInviteListItemDto {
122 pub id: Uuid,
123 pub room_id: Uuid,
124 pub inviter_id: i64,
125 pub inviter: Option<UserDto>,
126 pub invitee_id: Option<i64>,
127 pub invitee: Option<UserDto>,
128 pub created_at: OffsetDateTime,
129 pub expires_at: Option<OffsetDateTime>,
130 pub accepted_at: Option<OffsetDateTime>,
131 pub revoked_at: Option<OffsetDateTime>,
132}
133
13468impl ChatService {
13569 pub async fn list_space_rooms(
13670 &self,
@@ -148,16 +82,6 @@
14882 .await?;
14983
15084 let room_ids: Vec<Uuid> = rooms.iter().map(|r| r.id).collect();
151 let memberships = room_members::Entity::find()
152 .filter(room_members::Column::RoomId.is_in(room_ids.clone()))
153 .filter(room_members::Column::UserId.eq(caller))
154 .all(self.database.read())
155 .await?;
156 let member_of: std::collections::HashSet<Uuid> = memberships
157 .into_iter()
158 .filter(|m| m.left_at.is_none())
159 .map(|m| m.room_id)
160 .collect();
16185 let dir_ids: Vec<Uuid> = rooms.iter().filter_map(|r| r.dir_id).collect();
16286 let visible_dirs: std::collections::HashSet<Uuid> = if dir_ids.is_empty() {
16387 std::collections::HashSet::new()
@@ -208,17 +132,7 @@
208132 .push(created_at);
209133 }
210134
211 let member_counts: Vec<(Uuid, i64)> = room_members::Entity::find()
212 .filter(room_members::Column::RoomId.is_in(room_ids.clone()))
213 .filter(room_members::Column::LeftAt.is_null())
214 .select_only()
215 .column(room_members::Column::RoomId)
216 .column_as(Expr::col(room_members::Column::UserId).count(), "cnt")
217 .group_by(room_members::Column::RoomId)
218 .into_tuple()
219 .all(self.database.read())
220 .await?;
221 let count_map: std::collections::HashMap<Uuid, i64> = member_counts.into_iter().collect();
135 let space_member_count = space.members(caller).await?.len() as i64;
222136
223137 let owner_ids: Vec<i64> = rooms
224138 .iter()
@@ -231,7 +145,7 @@
231145 for room in rooms {
232146 let visible = match room.dir_id {
233147 Some(dir_id) => visible_dirs.contains(&dir_id),
234 None => !room.is_private || room.owner_id == caller || member_of.contains(&room.id),
148 None => !room.is_private || room.owner_id == caller,
235149 };
236150 if !visible {
237151 continue;
@@ -272,7 +186,7 @@
272186 favorite: setting.is_some_and(|s| s.favorite),
273187 muted: setting.is_some_and(|s| s.muted),
274188 hidden: setting.is_some_and(|s| s.hidden),
275 member_count: count_map.get(&room.id).copied().unwrap_or(0),
189 member_count: space_member_count,
276190 created_at: room.created_at,
277191 });
278192 }
@@ -295,7 +209,10 @@
295209 let setting = room_user_settings::Entity::find_by_id((caller, room.id))
296210 .one(self.database.read())
297211 .await?;
298 let member_count = self.room_member_count(room.id).await? as i64;
212 let member_count = match &ctx.space {
213 Some(space) => space.members(caller).await?.len() as i64,
214 None => self.room_member_count(room.id).await? as i64,
215 };
299216 let owner_map = self.audience.load_user_briefs(&[room.owner_id]).await?;
300217 Ok(RoomBriefDto {
301218 id: room.id,
@@ -461,8 +378,7 @@
461378 am.parent_id = Set(node.parent_dir_id);
462379 }
463380 if let Some(is_private) = privacy_change {
464 sync_channel_privacy(tx, dir_id, room_id, owner_id, is_private)
465 .await?;
381 sync_channel_privacy(tx, dir_id, owner_id, is_private).await?;
466382 }
467383 am.update(tx).await?;
468384 Ok(())
@@ -557,551 +473,18 @@
557473 }
558474 Ok(())
559475 }
476}
560477
561 pub async fn join_room(&self, caller: i64, room_id: Uuid) -> Result<(), ChatError> {
562 let room = lookup_room(self, room_id).await?;
563 let ctx = load_room_context(self, room).await?;
564 match &ctx.space {
565 Some(space) => {
566 require_space_member(self, space, caller).await?;
567 if ctx.room.is_private {
568 return Err(ChatError::Forbidden);
569 }
570 }
571 None => return Err(ChatError::Forbidden),
572 }
573
574 let now = OffsetDateTime::now_utc();
575 let existing = room_members::Entity::find_by_id((room_id, caller))
576 .one(self.database.write())
577 .await?;
578 match existing {
579 Some(row) if row.left_at.is_some() => {
580 let mut am: room_members::ActiveModel = row.into();
581 am.left_at = Set(None);
582 am.updated_at = Set(Some(now));
583 am.update(self.database.write()).await?;
584 }
585 Some(_) => return Ok(()),
586 None => {
587 room_members::ActiveModel {
588 room_id: Set(room_id),
589 user_id: Set(caller),
590 role: Set(RoomMemberRole::Member),
591 nick: Set(None),
592 joined_at: Set(now),
593 left_at: Set(None),
594 created_at: Set(now),
595 updated_at: Set(Some(now)),
596 }
597 .insert(self.database.write())
598 .await?;
599 }
600 }
601 let event = RoomEvent::MemberJoined {
602 room_id,
603 user_id: caller,
604 };
605 self.audience.realtime.publish_room(&event).await;
606 Ok(())
607 }
608
609 pub async fn leave_room(&self, caller: i64, room_id: Uuid) -> Result<(), ChatError> {
610 let room = lookup_room(self, room_id).await?;
611 if room.space_id.is_none() {
612 return Err(ChatError::BadRequest("use DM close instead".to_string()));
613 }
614 let now = OffsetDateTime::now_utc();
615 if room.owner_id == caller {
616 let successor = room_members::Entity::find()
617 .filter(room_members::Column::RoomId.eq(room_id))
618 .filter(room_members::Column::UserId.ne(caller))
619 .filter(room_members::Column::LeftAt.is_null())
620 .order_by_asc(room_members::Column::JoinedAt)
621 .one(self.database.write())
622 .await?;
623 match successor {
624 Some(next) => {
625 let next_uid = next.user_id;
626 let mut am: room_members::ActiveModel = next.into();
627 am.role = Set(RoomMemberRole::Owner);
628 am.updated_at = Set(Some(now));
629 am.update(self.database.write()).await?;
630 let mut room_am: rooms::ActiveModel = room.clone().into();
631 room_am.owner_id = Set(next_uid);
632 room_am.updated_at = Set(now);
633 room_am.update(self.database.write()).await?;
634 }
635 None => {
636 let dir_id = room.dir_id;
637 self.database
638 .write()
639 .transaction::<_, (), ChatError>(move |tx| {
640 Box::pin(async move {
641 let mut room_am: rooms::ActiveModel = room.into();
642 room_am.is_active = Set(false);
643 room_am.updated_at = Set(now);
644 room_am.update(tx).await?;
645 if let Some(dir_id) = dir_id {
646 dir::archive(tx, caller, dir_id).await?;
647 }
648 Ok(())
649 })
650 })
651 .await?;
652 }
653 }
654 }
655 let row = room_members::Entity::find_by_id((room_id, caller))
656 .one(self.database.write())
657 .await?
658 .ok_or(ChatError::NotFound)?;
659 if row.left_at.is_none() {
660 let mut am: room_members::ActiveModel = row.into();
661 am.left_at = Set(Some(now));
662 am.updated_at = Set(Some(now));
663 am.update(self.database.write()).await?;
664 let event = RoomEvent::MemberLeft {
665 room_id,
666 user_id: caller,
667 };
668 self.audience.realtime.publish_room(&event).await;
669 }
670 Ok(())
478async fn sync_channel_privacy(
479 tx: &impl sea_orm::ConnectionTrait,
480 dir_id: Uuid,
481 owner_id: i64,
482 is_private: bool,
483) -> Result<(), ChatError> {
484 if !is_private {
485 dir::clear_user_overwrites(tx, dir_id).await?;
486 return Ok(());
671487 }
672
673 pub async fn add_room_members(
674 &self,
675 caller: i64,
676 room_id: Uuid,
677 cmd: AddMembersCmd,
678 ) -> Result<(), ChatError> {
679 if cmd.user_ids.is_empty() || cmd.user_ids.len() > self.settings.chat.max_members_per_add {
680 return Err(ChatError::BadRequest(format!(
681 "user_ids must contain 1..={} entries",
682 self.settings.chat.max_members_per_add
683 )));
684 }
685 let ctx = require_room_readable(self, room_id, caller).await?;
686 match &ctx.space {
687 Some(space) => {
688 if !authz::can_manage_space(self, space, caller).await? {
689 return Err(ChatError::Forbidden);
690 }
691 }
692 None => {
693 if !is_room_member(self, room_id, caller).await? {
694 return Err(ChatError::Forbidden);
695 }
696 let limit = ctx.room.user_limit.unwrap_or(i32::MAX);
697 let mut active = self.room_member_count(room_id).await? as i64;
698 for uid in &cmd.user_ids {
699 if !is_room_member(self, room_id, *uid).await? {
700 active += 1;
701 }
702 if active > limit as i64 {
703 return Err(ChatError::BadRequest(
704 "room member limit reached".to_string(),
705 ));
706 }
707 }
708 }
709 }
710
711 let mut granted: Vec<i64> = Vec::new();
712 for uid in &cmd.user_ids {
713 if let Some(space) = &ctx.space
714 && require_space_member(self, space, *uid).await.is_err()
715 {
716 continue;
717 }
718 self.ensure_room_membership(room_id, *uid).await?;
719 granted.push(*uid);
720 }
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 }
736 Ok(())
737 }
738
739 pub(crate) async fn ensure_room_membership(
740 &self,
741 room_id: Uuid,
742 uid: i64,
743 ) -> Result<(), ChatError> {
744 let now = OffsetDateTime::now_utc();
745 let existing = room_members::Entity::find_by_id((room_id, uid))
746 .one(self.database.write())
747 .await?;
748 match existing {
749 Some(row) if row.left_at.is_some() => {
750 let mut am: room_members::ActiveModel = row.into();
751 am.left_at = Set(None);
752 am.updated_at = Set(Some(now));
753 am.update(self.database.write()).await?;
754 }
755 Some(_) => return Ok(()),
756 None => {
757 room_members::ActiveModel {
758 room_id: Set(room_id),
759 user_id: Set(uid),
760 role: Set(RoomMemberRole::Member),
761 nick: Set(None),
762 joined_at: Set(now),
763 left_at: Set(None),
764 created_at: Set(now),
765 updated_at: Set(Some(now)),
766 }
767 .insert(self.database.write())
768 .await?;
769 }
770 }
771 let event = RoomEvent::MemberJoined {
772 room_id,
773 user_id: uid,
774 };
775 self.audience.realtime.publish_room(&event).await;
776 self.audience
777 .realtime
778 .publish_user(
779 uid,
780 &serde_json::json!({"kind": "room_added", "room_id": room_id}),
781 )
782 .await;
783 Ok(())
784 }
785
786 pub async fn list_room_members(
787 &self,
788 caller: i64,
789 room_id: Uuid,
790 q: RoomMemberQuery,
791 ) -> Result<Vec<RoomMemberDto>, ChatError> {
792 let _ctx = require_room_readable(self, room_id, caller).await?;
793 let (limit, offset) = export::pagination::page_bounds(
794 q.limit,
795 q.offset,
796 self.settings.chat.default_pagination,
797 self.settings.chat.max_pagination,
798 );
799 let rows = room_members::Entity::find()
800 .filter(room_members::Column::RoomId.eq(room_id))
801 .filter(room_members::Column::LeftAt.is_null())
802 .order_by_asc(room_members::Column::JoinedAt)
803 .limit(limit)
804 .offset(offset)
805 .all(self.database.read())
806 .await?;
807 let uids: Vec<i64> = rows.iter().map(|r| r.user_id).collect();
808 let user_map = self.audience.load_user_briefs(&uids).await?;
809 Ok(rows
810 .into_iter()
811 .filter_map(|r| {
812 user_map.get(&r.user_id).map(|u| RoomMemberDto {
813 role: r.role,
814 user: u.clone(),
815 nick: r.nick,
816 joined_at: r.joined_at,
817 })
818 })
819 .collect())
820 }
821
822 pub async fn create_room_invite(
823 &self,
824 caller: i64,
825 room_id: Uuid,
826 cmd: CreateRoomInviteCmd,
827 ) -> Result<RoomInviteDto, ChatError> {
828 let ctx = require_room_readable(self, room_id, caller).await?;
829 let Some(space) = &ctx.space else {
830 return Err(ChatError::BadRequest("cannot invite to a DM".to_string()));
831 };
832 if !authz::can_manage_space(self, space, caller).await?
833 && !space.can(caller, space::Capability::ManageInvites).await?
834 {
835 return Err(ChatError::Forbidden);
836 }
837 let now = OffsetDateTime::now_utc();
838 let id = Uuid::now_v7();
839 let accept_code = {
840 let __inline = || -> String {
841 const CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789";
842 let mut rng = rand::thread_rng();
843 (0..12)
844 .map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
845 .collect()
846 };
847 __inline()
848 };
849 let expires_at = match cmd.expires_secs {
850 Some(secs) if secs > 0 && secs <= 30 * 86_400 => {
851 Some(now + time::Duration::seconds(secs))
852 }
853 Some(_) => {
854 return Err(ChatError::BadRequest(
855 "expires_secs must be within 1..=2592000".to_string(),
856 ));
857 }
858 None => None,
859 };
860 let row = room_invites::ActiveModel {
861 id: Set(id),
862 room_id: Set(room_id),
863 inviter_id: Set(caller),
864 invitee_user_id: Set(cmd.invitee_id),
865 invitee_email: Set(None),
866 accept_code: Set(accept_code.clone()),
867 created_at: Set(now),
868 expires_at: Set(expires_at),
869 accepted_at: Set(None),
870 revoked_at: Set(None),
871 }
872 .insert(self.database.write())
873 .await?;
874 if let Some(invitee) = cmd.invitee_id {
875 // The inbox renders real accept / reject controls, so the row has to
876 // carry the same credential the invite email links: `accept_code`.
877 // `space_name` lets the client deep-link into the channel once the
878 // invitee joins.
879 let data = serde_json::json!({
880 "room_id": room_id,
881 "invite_id": id,
882 "accept_code": accept_code,
883 "space_name": space.slug(),
884 });
885 self.audience
886 .notifications
887 .create(
888 invitee,
889 NotificationType::Invite,
890 i18n::catalog::notification_channel_invite_title(
891 self.audience.recipient_locale(invitee).await,
892 )
893 .to_string(),
894 None,
895 Some(data),
896 Some(caller),
897 )
898 .await?;
899 }
900 let mut uids = vec![row.inviter_id];
901 if let Some(invitee) = row.invitee_user_id {
902 uids.push(invitee);
903 }
904 let user_map = self.audience.load_user_briefs(&uids).await?;
905 Ok(RoomInviteDto {
906 id: row.id,
907 room_id: row.room_id,
908 inviter_id: row.inviter_id,
909 inviter: user_map.get(&row.inviter_id).cloned(),
910 invitee_id: row.invitee_user_id,
911 invitee: row.invitee_user_id.and_then(|i| user_map.get(&i).cloned()),
912 accept_code,
913 created_at: row.created_at,
914 expires_at: row.expires_at,
915 })
916 }
917
918 /// The invitee declines: revoke the row so the inviter's invite list stops
919 /// reporting it as pending. Only the addressed invitee may do this, and an
920 /// already-accepted invite cannot be walked back from here.
921 pub async fn reject_room_invite(
922 &self,
923 caller: i64,
924 accept_code: &str,
925 ) -> Result<(), ChatError> {
926 let invite = room_invites::Entity::find()
927 .filter(room_invites::Column::AcceptCode.eq(accept_code))
928 .one(self.database.read())
929 .await?
930 .ok_or(ChatError::NotFound)?;
931 if invite.invitee_user_id != Some(caller) {
932 return Err(ChatError::Forbidden);
933 }
934 if invite.accepted_at.is_some() {
935 return Err(ChatError::Conflict("invite already accepted".to_string()));
936 }
937 if invite.revoked_at.is_some() {
938 return Ok(());
939 }
940 room_invites::Entity::update_many()
941 .col_expr(
942 room_invites::Column::RevokedAt,
943 Expr::value(Some(OffsetDateTime::now_utc())),
944 )
945 .filter(room_invites::Column::Id.eq(invite.id))
946 .filter(room_invites::Column::AcceptedAt.is_null())
947 .filter(room_invites::Column::RevokedAt.is_null())
948 .exec(self.database.write())
949 .await?;
950 Ok(())
951 }
952
953 pub async fn list_room_invites(
954 &self,
955 caller: i64,
956 room_id: Uuid,
957 ) -> Result<Vec<RoomInviteListItemDto>, ChatError> {
958 require_room_readable(self, room_id, caller).await?;
959 let rows = room_invites::Entity::find()
960 .filter(room_invites::Column::RoomId.eq(room_id))
961 .order_by_desc(room_invites::Column::CreatedAt)
962 .all(self.database.read())
963 .await?;
964 let mut items: Vec<RoomInviteListItemDto> = rows
965 .into_iter()
966 .map(|r| RoomInviteListItemDto {
967 id: r.id,
968 room_id: r.room_id,
969 inviter_id: r.inviter_id,
970 inviter: None,
971 invitee_id: r.invitee_user_id,
972 invitee: None,
973 created_at: r.created_at,
974 expires_at: r.expires_at,
975 accepted_at: r.accepted_at,
976 revoked_at: r.revoked_at,
977 })
978 .collect();
979 let mut uids: Vec<i64> = items
980 .iter()
981 .flat_map(|i| {
982 let mut v = vec![i.inviter_id];
983 if let Some(id) = i.invitee_id {
984 v.push(id);
985 }
986 v
987 })
988 .collect();
989 uids.sort();
990 uids.dedup();
991 if !uids.is_empty() {
992 let user_map = self.audience.load_user_briefs(&uids).await?;
993 for item in items.iter_mut() {
994 item.inviter = user_map.get(&item.inviter_id).cloned();
995 item.invitee = item.invitee_id.and_then(|i| user_map.get(&i).cloned());
996 }
997 }
998 Ok(items)
999 }
1000
1001 pub async fn revoke_room_invite(
1002 &self,
1003 caller: i64,
1004 room_id: Uuid,
1005 invite_id: Uuid,
1006 ) -> Result<(), ChatError> {
1007 let ctx = require_room_readable(self, room_id, caller).await?;
1008 let row = room_invites::Entity::find_by_id(invite_id)
1009 .filter(room_invites::Column::RoomId.eq(room_id))
1010 .one(self.database.write())
1011 .await?
1012 .ok_or(ChatError::NotFound)?;
1013 if row.accepted_at.is_some() {
1014 return Err(ChatError::Conflict("invite already accepted".to_string()));
1015 }
1016 if row.inviter_id != caller {
1017 match &ctx.space {
1018 Some(space) => {
1019 if !authz::can_manage_space(self, space, caller).await? {
1020 return Err(ChatError::Forbidden);
1021 }
1022 }
1023 None => return Err(ChatError::Forbidden),
1024 }
1025 }
1026 if row.revoked_at.is_none() {
1027 let mut am: room_invites::ActiveModel = row.into();
1028 am.revoked_at = Set(Some(OffsetDateTime::now_utc()));
1029 am.update(self.database.write()).await?;
1030 }
1031 Ok(())
1032 }
1033
1034 pub async fn accept_room_invite(
1035 &self,
1036 caller: i64,
1037 accept_code: &str,
1038 ) -> Result<RoomBriefDto, ChatError> {
1039 self.participants.allow_invite_acceptance(caller).await?;
1040 let invite = room_invites::Entity::find()
1041 .filter(room_invites::Column::AcceptCode.eq(accept_code))
1042 .one(self.database.write())
1043 .await?
1044 .ok_or(ChatError::NotFound)?;
1045 if invite.accepted_at.is_some() || invite.revoked_at.is_some() {
1046 return Err(ChatError::NotFound);
1047 }
1048 if invite
1049 .expires_at
1050 .is_some_and(|at| at < OffsetDateTime::now_utc())
1051 {
1052 return Err(ChatError::NotFound);
1053 }
1054 if invite.invitee_user_id.is_some_and(|uid| uid != caller) {
1055 return Err(ChatError::Forbidden);
1056 }
1057 let room = lookup_room(self, invite.room_id).await?;
1058 if let Some(space_id) = room.space_id
1059 && require_space_member(
1060 self,
1061 &self.participants.access.by_id(space_id).await?,
1062 caller,
1063 )
1064 .await
1065 .is_err()
1066 {
1067 return Err(ChatError::Forbidden);
1068 }
1069
1070 let now = OffsetDateTime::now_utc();
1071 let claimed = room_invites::Entity::update_many()
1072 .col_expr(room_invites::Column::AcceptedAt, Expr::value(Some(now)))
1073 .filter(room_invites::Column::Id.eq(invite.id))
1074 .filter(room_invites::Column::AcceptedAt.is_null())
1075 .exec(self.database.write())
1076 .await?;
1077 if claimed.rows_affected == 0 {
1078 return Err(ChatError::NotFound);
1079 }
1080
1081 self.ensure_room_membership(room.id, caller).await?;
1082 self.get_room(caller, room.id).await
1083 }
1084}
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 }
1097488 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 }
1106489 Ok(())
1107490}
Modified lib/chat/src/services/voice.rs View lib/chat/src/services/voice.rs
@@ -11,7 +11,6 @@
1111use uuid::Uuid;
1212
1313use crate::errors::ChatError;
14use crate::services::room_admin;
1514use crate::services::service::ChatService;
1615use crate::share::lookup::{lookup_space_by_name, require_room_readable};
1716use crate::share::realtime::RoomEvent;
@@ -274,11 +273,6 @@
274273 room_id: Uuid,
275274 uid: i64,
276275) -> Result<bool, ChatError> {
277 if let Some(role) = room_admin::active_room_role(service, room_id, uid).await?
278 && role >= models::room::room_members::RoomMemberRole::Moderator
279 {
280 return Ok(true);
281 }
282276 let ctx = crate::share::lookup::lookup_room_context(service, room_id).await?;
283277 match &ctx.space {
284278 Some(space) => crate::share::authz::can_manage_space(service, space, uid).await,
Modified lib/chat/src/share/lookup.rs View lib/chat/src/share/lookup.rs
@@ -1,5 +1,5 @@
1use models::room::room_members;
12use models::room::rooms::{self};
2use models::room::{room_bans, room_members};
33use sea_orm::EntityTrait;
44use space::{Member, Space};
55
@@ -135,15 +135,6 @@
135135 uid: i64,
136136) -> Result<RoomContext, ChatError> {
137137 let ctx = lookup_room_context(service, room_id).await?;
138 if let Some(ban) = room_bans::Entity::find_by_id((room_id, uid))
139 .one(service.database.read())
140 .await?
141 {
142 let now = time::OffsetDateTime::now_utc();
143 if ban.expires_at.is_none() || ban.expires_at > Some(now) {
144 return Err(ChatError::Forbidden);
145 }
146 }
147138 match &ctx.space {
148139 Some(space) => {
149140 require_space_member(service, space, uid).await?;
Modified lib/chat/src/share/mention.rs View lib/chat/src/share/mention.rs
@@ -1,5 +1,3 @@
1use models::room::room_members;
2use sea_orm::EntityTrait;
31use space::MemberStatus;
42use uuid::Uuid;
53
@@ -95,13 +93,10 @@
9593 return Ok(Resolved::None);
9694 }
9795
98 if let Some(candidate) = candidate {
99 let member = room_members::Entity::find_by_id((ctx.room.id, candidate.user_id))
100 .one(service.database.read())
101 .await?;
102 if member.is_some_and(|m| m.left_at.is_none()) {
103 return Ok(Resolved::User(candidate.user_id));
104 }
96 if let Some(candidate) = candidate
97 && crate::share::lookup::is_room_member(service, ctx.room.id, candidate.user_id).await?
98 {
99 return Ok(Resolved::User(candidate.user_id));
105100 }
106101 Ok(Resolved::None)
107102}
Modified lib/chat/src/share/notify.rs View lib/chat/src/share/notify.rs
@@ -1,6 +1,6 @@
11use models::chat::room_seq;
22use models::chat::room_user_settings::{self, RoomNotificationLevel};
3use models::room::room_members;
3use models::room::{room_members, rooms};
44use models::users::user_notifications::NotificationType;
55use models::users::user_recent_mentions;
66use sea_orm::{
@@ -187,7 +187,7 @@
187187 actor,
188188 explicit,
189189 } = scope;
190 let candidates: Vec<i64> = if is_private || space_id.is_none() {
190 let candidates: Vec<i64> = if space_id.is_none() {
191191 room_members::Entity::find()
192192 .filter(room_members::Column::RoomId.eq(room_id))
193193 .filter(room_members::Column::LeftAt.is_null())
@@ -197,19 +197,35 @@
197197 .into_iter()
198198 .map(|m| m.user_id)
199199 .collect()
200 } else if let Some(space_id) = space_id {
201 service
202 .participants
203 .access
204 .members(space_id, actor)
200 } else {
201 let space_id = space_id.expect("space channel");
202 let Ok(space) = service.participants.access.by_id(space_id).await else {
203 return;
204 };
205 let mut ids: Vec<i64> = space
206 .members(actor)
205207 .await
206208 .unwrap_or_default()
207209 .into_iter()
208210 .filter(|m| !m.suppress_everyone)
209211 .map(|m| m.user_id)
210 .collect()
211 } else {
212 Vec::new()
212 .collect();
213 if is_private {
214 let dir_id = rooms::Entity::find_by_id(room_id)
215 .one(service.database.read())
216 .await
217 .ok()
218 .flatten()
219 .and_then(|room| room.dir_id);
220 match dir_id {
221 Some(dir_id) => match space.dir_visible_users(dir_id, &ids).await {
222 Ok(visible) => ids.retain(|uid| visible.contains(uid)),
223 Err(_) => ids.clear(),
224 },
225 None => ids.clear(),
226 }
227 }
228 ids
213229 };
214230
215231 let data = serde_json::json!({
Modified lib/chat/src/openapi.rs View lib/chat/src/openapi.rs
@@ -9,22 +9,6 @@
99 crate::handlers::rooms::list_space_rooms,
1010 crate::handlers::rooms::get_room,
1111 crate::handlers::rooms::update_room,
12 crate::handlers::rooms::join_room,
13 crate::handlers::rooms::leave_room,
14 crate::handlers::rooms::add_room_members,
15 crate::handlers::rooms::list_room_members,
16 crate::handlers::rooms::create_room_invite,
17 crate::handlers::rooms::list_room_invites,
18 crate::handlers::rooms::revoke_room_invite,
19 crate::handlers::room_admin::get_room_member,
20 crate::handlers::room_admin::update_room_member,
21 crate::handlers::room_admin::kick_room_member,
22 crate::handlers::room_admin::list_room_bans,
23 crate::handlers::room_admin::ban_room_member,
24 crate::handlers::room_admin::unban_room_member,
25 crate::handlers::room_admin::transfer_room_owner,
26 crate::handlers::rooms::accept_room_invite,
27 crate::handlers::rooms::reject_room_invite,
2812 crate::handlers::messages::send_message,
2913 crate::handlers::messages::list_messages,
3014 crate::handlers::messages::get_message,
@@ -112,12 +96,10 @@
11296 export::pagination::Page<crate::services::threads::ThreadDto>,
11397 export::pagination::Page<crate::services::drafts::DraftDto>,
11498 export::pagination::Page<crate::services::webhooks::WebhookDto>,
115 export::pagination::Page<crate::services::room_admin::RoomBanDto>,
11699 export::pagination::Page<crate::services::bookmarks::BookmarkDto>,
117100 export::pagination::Page<crate::services::emojis::EmojiDto>,
118101 export::pagination::Page<crate::services::messages::MessageDto>,
119102 export::pagination::Page<crate::services::rooms::RoomBriefDto>,
120 export::pagination::Page<crate::services::rooms::RoomInviteListItemDto>,
121103 AppResponseData<export::rest::NoData>,
122104 export::rest::NoData,
123105 export::users::UserDto,
@@ -129,7 +111,6 @@
129111 models::chat::presence_states::PresenceStatus,
130112 models::chat::message_reminders::ReminderStatus,
131113 models::room::rooms::RoomKind,
132 models::room::room_members::RoomMemberRole,
133114 crate::services::messages::MessageDto,
134115 crate::services::messages::MessagePage,
135116 crate::services::messages::MessageEditDto,
@@ -142,15 +123,6 @@
142123 crate::services::messages::MessagePath,
143124 crate::services::rooms::RoomBriefDto,
144125 crate::services::rooms::UpdateRoomCmd,
145 crate::services::rooms::AddMembersCmd,
146 crate::services::rooms::RoomMemberDto,
147 crate::services::rooms::RoomInviteListItemDto,
148 crate::services::room_admin::RoomBanDto,
149 crate::services::room_admin::BanRoomMemberCmd,
150 crate::services::room_admin::UpdateRoomMemberCmd,
151 crate::services::rooms::RoomMemberQuery,
152 crate::services::rooms::CreateRoomInviteCmd,
153 crate::services::rooms::RoomInviteDto,
154126 crate::services::threads::ThreadDto,
155127 crate::services::threads::ThreadPage,
156128 crate::services::threads::CreateThreadCmd,
Modified lib/chat/src/routes.rs View lib/chat/src/routes.rs
@@ -1,5 +1,5 @@
11use export::http::Router;
2use export::http::routing::{delete, get, post, put};
2use export::http::routing::{delete, get, post};
33
44use crate::handlers::attachments::{
55 create_attachment, download_attachment, finalize_attachment, get_attachment,
@@ -20,10 +20,7 @@
2020 get_read_state, list_space_read_states, mark_read, mark_thread_read,
2121};
2222use crate::handlers::reminders::{cancel_reminder, create_reminder, list_reminders};
23use crate::handlers::rooms::{
24 accept_room_invite, add_room_members, create_room_invite, get_room, join_room, leave_room,
25 list_room_members, list_space_rooms, reject_room_invite, update_room,
26};
23use crate::handlers::rooms::{get_room, list_space_rooms, update_room};
2724use crate::handlers::settings::{get_room_setting, update_room_setting};
2825use crate::handlers::threads::{
2926 create_thread, follow_thread, get_thread, list_my_threads, list_threads, unfollow_thread,
@@ -56,43 +53,6 @@
5653 let guarded = Router::<ChatService>::new()
5754 .route("/chat/spaces/{space_name}/rooms", get(list_space_rooms))
5855 .route("/chat/rooms/{room_id}", get(get_room).patch(update_room))
59 .route(
60 "/chat/rooms/{room_id}/members/@me",
61 export::http::routing::put(join_room).delete(leave_room),
62 )
63 .route(
64 "/chat/rooms/{room_id}/members",
65 get(list_room_members).post(add_room_members),
66 )
67 .route(
68 "/chat/rooms/{room_id}/members/{user_id}",
69 get(crate::handlers::room_admin::get_room_member)
70 .patch(crate::handlers::room_admin::update_room_member)
71 .delete(crate::handlers::room_admin::kick_room_member),
72 )
73 .route(
74 "/chat/rooms/{room_id}/bans",
75 get(crate::handlers::room_admin::list_room_bans)
76 .post(crate::handlers::room_admin::ban_room_member),
77 )
78 .route(
79 "/chat/rooms/{room_id}/bans/{user_id}",
80 delete(crate::handlers::room_admin::unban_room_member),
81 )
82 .route(
83 "/chat/rooms/{room_id}/owner",
84 put(crate::handlers::room_admin::transfer_room_owner),
85 )
86 .route(
87 "/chat/rooms/{room_id}/invites",
88 post(create_room_invite).get(crate::handlers::rooms::list_room_invites),
89 )
90 .route(
91 "/chat/rooms/{room_id}/invites/{invite_id}",
92 delete(crate::handlers::rooms::revoke_room_invite),
93 )
94 .route("/chat/invites/accept", post(accept_room_invite))
95 .route("/chat/invites/reject", post(reject_room_invite))
9656 .route(
9757 "/chat/rooms/{room_id}/messages",
9858 get(list_messages).post(send_message),
Modified lib/chat-room-store/src/lib.rs View lib/chat-room-store/src/lib.rs
@@ -1,5 +1,4 @@
11use models::dir::dirs::DirKind;
2use models::room::room_members::{self, RoomMemberRole};
32use models::room::rooms::{self, RoomKind};
43use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set};
54use serde::{Deserialize, Serialize};
@@ -112,7 +111,6 @@
112111 tx: &impl ConnectionTrait,
113112 space: &SpaceContext,
114113 actor: i64,
115 members: &[i64],
116114 cmd: CreateRoomCmd,
117115) -> Result<RoomDto, Error> {
118116 if !matches!(
@@ -202,16 +200,6 @@
202200 {
203201 dir::set_user_overwrite(tx, dir_id, actor, dir::PERM_VISIBLE, 0, None).await?;
204202 }
205 let mut users = if created.is_private {
206 Vec::new()
207 } else {
208 members.to_vec()
209 };
210 users.sort_unstable();
211 users.dedup();
212 for uid in users {
213 enroll(tx, &created, uid, actor, Some(space)).await?;
214 }
215203 Ok(created.into())
216204}
217205
@@ -246,76 +234,6 @@
246234 }),
247235 )
248236 .await?;
249 Ok(())
250}
251
252pub async fn grant_public_memberships(
253 tx: &impl ConnectionTrait,
254 space_id: Uuid,
255 user_id: i64,
256) -> Result<(), Error> {
257 let public = rooms::Entity::find()
258 .filter(rooms::Column::SpaceId.eq(space_id))
259 .filter(rooms::Column::IsPrivate.eq(false))
260 .filter(rooms::Column::IsActive.eq(true))
261 .all(tx)
262 .await?;
263 for room in public {
264 enroll(tx, &room, user_id, user_id, None).await?;
265 }
266 Ok(())
267}
268
269async fn enroll(
270 tx: &impl ConnectionTrait,
271 room: &rooms::Model,
272 user_id: i64,
273 actor: i64,
274 space: Option<&SpaceContext>,
275) -> Result<(), Error> {
276 let now = OffsetDateTime::now_utc();
277 let added = room_members::Entity::insert(room_members::ActiveModel {
278 role: Set(if user_id == room.owner_id {
279 RoomMemberRole::Owner
280 } else {
281 RoomMemberRole::Member
282 }),
283 room_id: Set(room.id),
284 user_id: Set(user_id),
285 nick: Set(None),
286 joined_at: Set(now),
287 left_at: Set(None),
288 created_at: Set(now),
289 updated_at: Set(Some(now)),
290 })
291 .on_conflict(
292 sea_orm::sea_query::OnConflict::columns([
293 room_members::Column::RoomId,
294 room_members::Column::UserId,
295 ])
296 .do_nothing()
297 .to_owned(),
298 )
299 .exec_without_returning(tx)
300 .await?;
301 if added == 0 {
302 return Ok(());
303 }
304 if user_id != actor || space.is_none() {
305 events::emit(
306 tx,
307 "chat.membership.create",
308 events::Labels::new()
309 .with(events::label::ROOM_ID, room.id)
310 .with(events::label::RECIPIENT_USER_ID, user_id)
311 .with(events::label::USER_ID, user_id)
312 .with(events::label::ACTOR_USER_ID, actor),
313 serde_json::json!({
314 "kind": "room_added", "room_id": room.id, "room": room_detail(room), "space": space,
315 }),
316 )
317 .await?;
318 }
319237 Ok(())
320238}
321239
Modified lib/models/src/room/mod.rs View lib/models/src/room/mod.rs
@@ -1,5 +1,3 @@
1pub mod room_bans;
2pub mod room_invites;
31pub mod room_members;
42pub mod rooms;
53
Deleted lib/models/src/room/room_bans.rs
@@ -1,24 +0,0 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "room_bans")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub room_id: Uuid,
11 #[sea_orm(primary_key, auto_increment = false)]
12 pub user_id: i64,
13 pub reason: Option<String>,
14 pub banned_by: i64,
15 pub expires_at: Option<OffsetDateTime>,
16 pub created_at: OffsetDateTime,
17 pub updated_at: Option<OffsetDateTime>,
18 #[sea_orm(belongs_to, from = "room_id", to = "id")]
19 pub room: BelongsTo<super::rooms::Entity>,
20 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
21 pub user: BelongsTo<super::super::users::user::Entity>,
22}
23
24impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/room/room_invites.rs
@@ -1,33 +0,0 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "room_invites")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 pub room_id: Uuid,
12 pub inviter_id: i64,
13 pub invitee_user_id: Option<i64>,
14 pub invitee_email: Option<String>,
15 pub accept_code: String,
16 pub created_at: OffsetDateTime,
17 pub expires_at: Option<OffsetDateTime>,
18 pub accepted_at: Option<OffsetDateTime>,
19 pub revoked_at: Option<OffsetDateTime>,
20 #[sea_orm(belongs_to, from = "room_id", to = "id")]
21 pub room: BelongsTo<super::rooms::Entity>,
22 #[sea_orm(belongs_to, relation_enum = "Inviter", from = "inviter_id", to = "uid")]
23 pub inviter: BelongsTo<super::super::users::user::Entity>,
24 #[sea_orm(
25 belongs_to,
26 relation_enum = "Invitee",
27 from = "invitee_user_id",
28 to = "uid"
29 )]
30 pub invitee: BelongsTo<Option<super::super::users::user::Entity>>,
31}
32
33impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/room/rooms.rs View lib/models/src/room/rooms.rs
@@ -85,8 +85,6 @@
8585 pub parent: BelongsTo<Option<super::super::dir::dirs::Entity>>,
8686 #[sea_orm(has_many)]
8787 pub members: HasMany<super::room_members::Entity>,
88 #[sea_orm(has_many)]
89 pub invites: HasMany<super::room_invites::Entity>,
9088}
9189
9290impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/schema_extras.sql View lib/models/schema_extras.sql
@@ -399,3 +399,26 @@
399399FROM rooms r
400400WHERE r.dir_id IS NOT NULL AND r.is_private
401401ON CONFLICT (dir_id, user_id) DO NOTHING;
402
403-- Channel moderation/access moved to dir overwrites: convert legacy bans and
404-- drop the per-channel membership tables (room_members stays for DMs only).
405DO $$
406BEGIN
407 IF to_regclass('room_bans') IS NOT NULL THEN
408 INSERT INTO dir_overwrite_user (dir_id, user_id, allow_mask, deny_mask, expires_at)
409 SELECT r.dir_id, b.user_id, 0, 1, b.expires_at
410 FROM room_bans b
411 JOIN rooms r ON r.id = b.room_id
412 WHERE r.dir_id IS NOT NULL
413 ON CONFLICT (dir_id, user_id) DO UPDATE
414 SET deny_mask = EXCLUDED.deny_mask,
415 expires_at = EXCLUDED.expires_at;
416 DROP TABLE room_bans;
417 END IF;
418 IF to_regclass('room_invites') IS NOT NULL THEN
419 DROP TABLE room_invites;
420 END IF;
421END $$;
422
423DELETE FROM room_members
424WHERE room_id IN (SELECT id FROM rooms WHERE space_id IS NOT NULL);
Modified lib/space/src/services/join_requests.rs View lib/space/src/services/join_requests.rs
@@ -497,10 +497,6 @@
497497 };
498498
499499 let applicant_id = request.user_id;
500 if approve {
501 self.grant_public_room_memberships(space.id, applicant_id)
502 .await;
503 }
504500
505501 let locale = self.policy.recipient_locale(Some(applicant_id)).await;
506502 let title = if approve {
Modified lib/space/src/services/rooms.rs View lib/space/src/services/rooms.rs
@@ -35,15 +35,6 @@
3535 if !authority::space_is_active(&space) {
3636 return Err(SpaceError::NotFound);
3737 }
38 let members = if cmd.is_private {
39 Vec::new()
40 } else {
41 authority::list_members(self, space.id, false, false)
42 .await?
43 .into_iter()
44 .map(|member| member.user_id)
45 .collect()
46 };
4738 let context = SpaceContext {
4839 id: space.id,
4940 lower_name: space.slug.clone(),
@@ -56,10 +47,7 @@
5647 .write()
5748 .transaction::<_, RoomDto, SpaceError>(move |tx| {
5849 Box::pin(async move {
59 Ok(
60 chat_room_store::create_space_room(tx, &context, caller, &members, cmd)
61 .await?,
62 )
50 Ok(chat_room_store::create_space_room(tx, &context, caller, cmd).await?)
6351 })
6452 })
6553 .await?;
Modified lib/space/src/services/service.rs View lib/space/src/services/service.rs
@@ -58,22 +58,6 @@
5858}
5959
6060impl SpaceService {
61 pub(crate) async fn grant_public_room_memberships(&self, space_id: Uuid, user_id: i64) {
62 let result = self
63 .access
64 .database
65 .write()
66 .transaction::<_, (), SpaceError>(move |tx| {
67 Box::pin(async move {
68 Ok(chat_room_store::grant_public_memberships(tx, space_id, user_id).await?)
69 })
70 })
71 .await;
72 if let Err(error) = result {
73 tracing::warn!(%error, %space_id, %user_id, "public room membership grant failed");
74 }
75 }
76
7761 pub async fn load_user_briefs(
7862 &self,
7963 uids: &[i64],
Modified lib/space/src/access.rs View lib/space/src/access.rs
@@ -305,6 +305,102 @@
305305 .map_err(AccessError::from)
306306 }
307307
308 /// 给定候选用户集合,返回其中能看到该目录的用户(含祖先链、过期覆盖项与归档节点)。
309 pub async fn dir_visible_users(
310 &self,
311 dir: Uuid,
312 users: &[i64],
313 ) -> Result<HashSet<i64>, AccessError> {
314 if users.is_empty() {
315 return Ok(HashSet::new());
316 }
317 let db = self.access.database.read();
318 let nodes = dirs::Entity::find()
319 .filter(dirs::Column::SpaceId.eq(self.id()))
320 .all(db)
321 .await?;
322 let mut parent_map: HashMap<Uuid, Option<Uuid>> = HashMap::new();
323 let mut archived: HashSet<Uuid> = HashSet::new();
324 for node in &nodes {
325 parent_map.insert(node.id, node.parent_dir_id);
326 if node.is_archived {
327 archived.insert(node.id);
328 }
329 }
330 if !parent_map.contains_key(&dir) {
331 return Ok(HashSet::new());
332 }
333 let node_ids: Vec<Uuid> = nodes.iter().map(|node| node.id).collect();
334 let member_groups = models::space::space_member_groups::Entity::find()
335 .filter(models::space::space_member_groups::Column::SpaceId.eq(self.id()))
336 .filter(models::space::space_member_groups::Column::UserId.is_in(users.to_vec()))
337 .all(db)
338 .await?;
339 let mut groups_by_user: HashMap<i64, HashSet<Uuid>> = HashMap::new();
340 for row in member_groups {
341 groups_by_user
342 .entry(row.user_id)
343 .or_default()
344 .insert(row.group_id);
345 }
346 let now = OffsetDateTime::now_utc();
347 let mut group_ows: HashMap<Uuid, Vec<models::dir::dir_overwrite_group::Model>> =
348 HashMap::new();
349 for row in models::dir::dir_overwrite_group::Entity::find()
350 .filter(models::dir::dir_overwrite_group::Column::DirId.is_in(node_ids.clone()))
351 .all(db)
352 .await?
353 {
354 group_ows.entry(row.dir_id).or_default().push(row);
355 }
356 let mut user_ows: HashMap<Uuid, Vec<models::dir::dir_overwrite_users::Model>> =
357 HashMap::new();
358 for row in models::dir::dir_overwrite_users::Entity::find()
359 .filter(models::dir::dir_overwrite_users::Column::DirId.is_in(node_ids))
360 .all(db)
361 .await?
362 {
363 if !crate::share::dir_visibility::user_overwrite_active(&row, now) {
364 continue;
365 }
366 user_ows.entry(row.dir_id).or_default().push(row);
367 }
368
369 let mut visible = HashSet::new();
370 for user in users {
371 if self.record.owner_user_id == *user {
372 visible.insert(*user);
373 continue;
374 }
375 let empty = HashSet::new();
376 let my_groups = groups_by_user.get(user).unwrap_or(&empty);
377 let mut current = Some(dir);
378 let mut seen = HashSet::new();
379 let mut allowed = true;
380 while let Some(id) = current {
381 if !seen.insert(id) || archived.contains(&id) {
382 allowed = false;
383 break;
384 }
385 let direct = crate::share::dir_visibility::node_visible(
386 *user,
387 my_groups,
388 group_ows.get(&id).map(Vec::as_slice).unwrap_or(&[]),
389 user_ows.get(&id).map(Vec::as_slice).unwrap_or(&[]),
390 );
391 if !direct {
392 allowed = false;
393 break;
394 }
395 current = parent_map.get(&id).copied().flatten();
396 }
397 if allowed {
398 visible.insert(*user);
399 }
400 }
401 Ok(visible)
402 }
403
308404 /// 批量计算目录可见性(含祖先链、过期覆盖项与归档节点),供列表类接口一次加载。
309405 pub async fn visible_dirs(
310406 &self,
Modified lib/webapi/src/cleanup.rs View lib/webapi/src/cleanup.rs
@@ -16,7 +16,7 @@
1616use models::dir::{dir_overwrite_group, dir_overwrite_users, dirs};
1717use models::issue::issues as boards;
1818use models::repo::repos;
19use models::room::{room_members, rooms};
19use models::room::rooms;
2020use sea_orm::ActiveValue::Set;
2121use sea_orm::{
2222 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, TransactionTrait,
@@ -451,7 +451,6 @@
451451 .ok_or_else(|| ApiError::not_found("room not found"))?;
452452 let dir_id = room.dir_id;
453453 let owner_id = room.owner_id;
454 let room_id = room.id;
455454 let mut am: rooms::ActiveModel = room.into();
456455 am.is_private = Set(cmd.is_private);
457456 am.updated_at = Set(time::OffsetDateTime::now_utc());
@@ -468,24 +467,6 @@
468467 )
469468 .await
470469 .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 }
489470 } else {
490471 dir::clear_user_overwrites(db.write(), dir_id)
491472 .await
Modified openapi.json View openapi.json

Diff too large to display.