Skip to content
Clone

feat: refactor event handling and remove unused space relationships

ZhenYi committed
parent dcba959 commit b4afb4b Browse files

73 files changed

+1647 −1657

File tree 73
Modified app/src/main.rs View app/src/main.rs
@@ -55,19 +55,15 @@
5555
5656 tokio::spawn(chat::worker::run_background_tasks(state.clone()));
5757
58 if cfg.rocketmq.enabled {
59 let conn = queue::RocketMqConn::init(&cfg.rocketmq)
60 .await
61 .map_err(|err| anyhow::anyhow!("failed to start the rocketmq producer: {err}"))?;
62 tokio::spawn(queue::relay::run(
63 state.database.primary().clone(),
64 queue::Publisher::new(conn),
65 CancellationToken::new(),
66 ));
67 info!("event outbox relay started");
68 } else {
69 info!("rocketmq is disabled; chat message events are not published");
70 }
58 let conn = queue::RocketMqConn::init(&cfg.rocketmq)
59 .await
60 .map_err(|err| anyhow::anyhow!("failed to start the rocketmq producer: {err}"))?;
61 tokio::spawn(queue::relay::run(
62 state.database.primary().clone(),
63 queue::Publisher::new(conn),
64 CancellationToken::new(),
65 ));
66 info!("event outbox relay started");
7167
7268 if cfg.push.enabled {
7369 for _ in 0..cfg.push.workers.max(1) {
Modified charts/core/templates/apihost.yaml View charts/core/templates/apihost.yaml
@@ -62,7 +62,7 @@
6262 - name: apihost
6363 image: {{ include "gitdata.image" . }}
6464 imagePullPolicy: {{ .Values.image.pullPolicy }}
65 args: ["serve"]
65 args: ["kenapi", "serve"]
6666 {{- include "gitdata.envFrom" . | nindent 10 }}
6767 env:
6868 {{- include "gitdata.env" . | nindent 12 }}
Modified config/default.toml View config/default.toml
@@ -161,7 +161,7 @@
161161graphql_complexity_limit = 1000
162162graphql_introspection = true
163163kenshell_endpoint = "http://127.0.0.1:7013"
164kenshell_rpc_key = ""
164kenshell_rpc_key = "dev-kenshell-rpc-key-change-in-production"
165165kenshell_timeout_secs = 3600
166166
167167[git.ssh]
@@ -226,8 +226,9 @@
226226http_bind_addr = "0.0.0.0"
227227http_port = 7052
228228
229# KenApi 的领域事件只走 RocketMQ:nameserver 不可达时进程启动失败。
230# 不提供 Redis pub/sub 兜底。
229231[rocketmq]
230enabled = false
231232name_server = "127.0.0.1:9876"
232233producer_group = "gitdata"
233234topic_prefix = "gitdata"
Modified lib/auth/src/services/profile.rs View lib/auth/src/services/profile.rs
@@ -69,9 +69,36 @@
6969impl AuthService {
7070 pub async fn me(&self, uid: i64) -> Result<(UserMeDto, Option<ProfileDto>), AuthError> {
7171 let db = self.db();
72 let Some(user) = user::Entity::find_by_id(uid).one(db.read()).await? else {
72 let Some(mut user) = user::Entity::find_by_id(uid).one(db.read()).await? else {
7373 return Err(AuthError::NotFound);
7474 };
75 // Identity fields are owned by KenOAuth. Refresh the local projection on
76 // every /me so changes made in KenAdmin or another app show up here
77 // without waiting for the next login callback. Fall back to the local
78 // projection if the authority plane is briefly unavailable.
79 if let Some(client) = self.state.kenauth.as_ref() {
80 match client.get_user_public(uid).await {
81 Ok(public) => {
82 let display_name = if public.displayname.trim().is_empty() {
83 public.username.clone()
84 } else {
85 public.displayname.clone()
86 };
87 if user.username != public.username || user.display_name != display_name {
88 let now = OffsetDateTime::now_utc();
89 let mut active = user.clone().into_active_model();
90 active.username = Set(public.username.clone());
91 active.lower_name = Set(public.username.to_lowercase());
92 active.display_name = Set(display_name);
93 active.updated_at = Set(now);
94 user = active.update(db.write()).await?;
95 }
96 }
97 Err(error) => {
98 tracing::warn!(%error, %uid, "authority profile refresh failed; using local projection");
99 }
100 }
101 }
75102 let profile = user_profiles::Entity::find_by_id(uid)
76103 .one(db.read())
77104 .await?;
@@ -118,13 +145,44 @@
118145 };
119146 let now = OffsetDateTime::now_utc();
120147 let mut user_active = user.into_active_model();
121 if let Some(display_name) = cmd.display_name.filter(|name| !name.trim().is_empty()) {
122 let display_name = display_name.trim().to_string();
123 if display_name.len() > 64 {
124 return Err(AuthError::BadRequest(
125 "display_name must be at most 64 characters".to_string(),
126 ));
148 let authoritative_display_name = match cmd
149 .display_name
150 .as_deref()
151 .map(str::trim)
152 .filter(|name| !name.is_empty())
153 {
154 Some(display_name) => {
155 if display_name.chars().count() > 64 {
156 return Err(AuthError::BadRequest(
157 "display_name must be at most 64 characters".to_string(),
158 ));
159 }
160 let client =
161 self.state.kenauth.as_ref().ok_or_else(|| {
162 AuthError::Internal("kenoauth is not configured".to_string())
163 })?;
164 let current = client.get_authority_user(uid).await.map_err(map_kenauth)?;
165 let updated = client
166 .update_authority_user(
167 uid,
168 None,
169 Some(display_name.to_string()),
170 false,
171 None,
172 current.version,
173 )
174 .await
175 .map_err(map_kenauth)?;
176 Some(
177 updated
178 .displayname
179 .filter(|value| !value.trim().is_empty())
180 .unwrap_or(updated.username),
181 )
127182 }
183 None => None,
184 };
185 if let Some(display_name) = authoritative_display_name {
128186 user_active.display_name = Set(display_name);
129187 }
130188 if let Some(locale) = cmd.locale {
@@ -228,31 +286,71 @@
228286 new_username: &str,
229287 ) -> Result<UserPublic, AuthError> {
230288 let username = crate::share::validate::validate_username(new_username.trim())?;
231 let lower = username.to_lowercase();
232289 let db = self.db();
233290 let user = user::Entity::find_by_id(uid)
234291 .one(db.read())
235292 .await?
236293 .ok_or(AuthError::NotFound)?;
237 if user.username != username {
238 let exists = user::Entity::find()
239 .filter(user::Column::LowerName.eq(&lower))
240 .one(db.read())
241 .await?;
242 if exists.is_some() {
243 return Err(AuthError::Conflict("username already taken".to_string()));
244 }
245 let mut active = user.clone().into_active_model();
246 active.username = Set(username.clone());
247 active.lower_name = Set(lower);
248 active.updated_at = Set(OffsetDateTime::now_utc());
249 active.update(db.write()).await?;
250 }
294 let avatar_url = space::services::spaces::resolve_user_avatar_url(uid, &user.avatar_url);
295
296 let client = self
297 .state
298 .kenauth
299 .as_ref()
300 .ok_or_else(|| AuthError::Internal("kenoauth is not configured".to_string()))?;
301 let current = client.get_authority_user(uid).await.map_err(map_kenauth)?;
302 let updated = if current.username != username {
303 client
304 .update_authority_user(
305 uid,
306 Some(username.clone()),
307 None,
308 false,
309 None,
310 current.version,
311 )
312 .await
313 .map_err(map_kenauth)?
314 } else {
315 current
316 };
317
318 let display_name = updated
319 .displayname
320 .clone()
321 .filter(|value| !value.trim().is_empty())
322 .unwrap_or_else(|| updated.username.clone());
323 let mut active = user.into_active_model();
324 active.username = Set(updated.username.clone());
325 active.lower_name = Set(updated.lowername.clone());
326 active.display_name = Set(display_name.clone());
327 active.updated_at = Set(OffsetDateTime::now_utc());
328 active.update(db.write()).await?;
329
251330 Ok(UserPublic {
252331 user_id: uid,
253 username: username.clone(),
254 display_name: user.display_name.clone(),
255 avatar_url: space::services::spaces::resolve_user_avatar_url(uid, &user.avatar_url),
332 username: updated.username,
333 display_name,
334 avatar_url,
256335 })
257336 }
258337}
338
339fn map_kenauth(error: kenauth::KenAuthError) -> AuthError {
340 use kenauth::KenAuthError;
341 match error {
342 KenAuthError::NotFound => AuthError::NotFound,
343 KenAuthError::Unauthenticated => AuthError::Unauthorized,
344 KenAuthError::Rejected(message) => {
345 let lower = message.to_lowercase();
346 if lower.contains("conflict") || lower.contains("taken") || lower.contains("already") {
347 AuthError::Conflict(message)
348 } else {
349 AuthError::BadRequest(message)
350 }
351 }
352 KenAuthError::Unavailable(message)
353 | KenAuthError::Config(message)
354 | KenAuthError::Internal(message) => AuthError::Internal(message),
355 }
356}
Modified lib/chat/src/services/presence.rs View lib/chat/src/services/presence.rs
@@ -335,23 +335,13 @@
335335
336336 async fn shares_room_with(&self, caller: i64, target: i64) -> Result<bool, ChatError> {
337337 use models::room::room_members;
338 use models::space::space_members as sm;
339 let my_spaces: Vec<Uuid> = sm::Entity::find()
340 .filter(sm::Column::UserId.eq(caller))
341 .select_only()
342 .column(sm::Column::SpaceId)
343 .into_tuple()
344 .all(self.db().read())
345 .await?;
346 if !my_spaces.is_empty() {
347 let shared = sm::Entity::find()
348 .filter(sm::Column::UserId.eq(target))
349 .filter(sm::Column::SpaceId.is_in(my_spaces))
350 .count(self.db().read())
351 .await?;
352 if shared > 0 {
353 return Ok(true);
354 }
338 let mine = space::authority::list_user_spaces(&self.state, caller, caller).await?;
339 let theirs = space::authority::list_user_spaces(&self.state, target, target).await?;
340 if mine
341 .iter()
342 .any(|left| theirs.iter().any(|right| left.id == right.id))
343 {
344 return Ok(true);
355345 }
356346 use models::room::rooms;
357347 use sea_orm::Condition;
@@ -471,11 +461,8 @@
471461 ) -> Result<Vec<PresenceDto>, ChatError> {
472462 let space = lookup_space_by_name(self, space_name).await?;
473463 require_space_member(self, &space, caller).await?;
474 let members = space_members::Entity::find()
475 .filter(space_members::Column::SpaceId.eq(space.id))
476 .filter(space_members::Column::Status.eq(MemberStatus::Active))
477 .all(self.db().read())
478 .await?;
464 let members =
465 space::authority::list_members(&self.state, caller, space.id, false, false).await?;
479466 let mut uids: Vec<i64> = members.into_iter().map(|m| m.user_id).collect();
480467 if !uids.contains(&space.owner_id) {
481468 uids.push(space.owner_id);
Modified lib/chat/src/services/rooms.rs View lib/chat/src/services/rooms.rs
@@ -986,10 +986,7 @@
986986 if let Some(space_id) = room.space_id
987987 && require_space_member(
988988 self,
989 &models::space::spaces::Entity::find_by_id(space_id)
990 .one(self.db().read())
991 .await?
992 .ok_or(ChatError::NotFound)?,
989 &space::authority::space_by_id(&self.state, 0, space_id).await?,
993990 caller,
994991 )
995992 .await
Modified lib/chat/src/services/service.rs View lib/chat/src/services/service.rs
@@ -20,7 +20,7 @@
2020
2121impl ChatService {
2222 pub fn new(state: AppState) -> Self {
23 let realtime = Realtime::new(state.database.write().clone(), state.events_enabled);
23 let realtime = Realtime::new(state.database.write().clone());
2424 Self { state, realtime }
2525 }
2626
Modified lib/chat/src/share/events.rs View lib/chat/src/share/events.rs
@@ -76,15 +76,7 @@
7676 }
7777}
7878
79pub async fn enqueue_room(
80 db: &DatabaseConnection,
81 enabled: bool,
82 event: &RoomEvent,
83) -> Result<(), ChatError> {
84 if !enabled {
85 return Ok(());
86 }
87
79pub async fn enqueue_room(db: &DatabaseConnection, event: &RoomEvent) -> Result<(), ChatError> {
8880 let mut labels = queue::Labels::new().with("room_id", event.room_id());
8981 if let Some(actor) = room_actor(event) {
9082 labels.insert("user_id", actor);
@@ -101,7 +93,6 @@
10193
10294 export::events::enqueue(
10395 db,
104 enabled,
10596 room_kind(event),
10697 labels,
10798 event,
@@ -113,13 +104,9 @@
113104
114105pub async fn enqueue_user(
115106 db: &DatabaseConnection,
116 enabled: bool,
117107 uid: i64,
118108 event: &Value,
119109) -> Result<(), ChatError> {
120 if !enabled {
121 return Ok(());
122 }
123110 let Some(kind) = user_kind(event) else {
124111 let unknown = event
125112 .get("kind")
@@ -137,6 +124,6 @@
137124 labels.insert("request_id", request_id);
138125 }
139126
140 export::events::enqueue(db, enabled, kind, labels, event, format!("chat:user:{uid}")).await?;
127 export::events::enqueue(db, kind, labels, event, format!("chat:user:{uid}")).await?;
141128 Ok(())
142129}
Modified lib/chat/src/share/lookup.rs View lib/chat/src/share/lookup.rs
@@ -1,7 +1,7 @@
11use models::room::room_bans;
22use models::room::room_members;
33use models::room::rooms::{self, RoomKind};
4use models::space::space_members::{self, MemberStatus};
4use models::space::space_members;
55use models::space::spaces;
66use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
77use space::share::authz::{MESSAGE, effective_permissions};
@@ -27,11 +27,9 @@
2727/// `/app/{slug}/chat/{room}` without a second round trip.
2828pub async fn space_slug_for(service: &ChatService, space_id: Option<Uuid>) -> Option<String> {
2929 let space_id = space_id?;
30 spaces::Entity::find_by_id(space_id)
31 .one(service.db().read())
30 space::authority::space_by_id(&service.state, 0, space_id)
3231 .await
3332 .ok()
34 .flatten()
3533 .map(|space| space.lower_name)
3634}
3735
@@ -49,12 +47,7 @@
4947) -> Result<RoomContext, ChatError> {
5048 let is_dm = room.space_id.is_none();
5149 let space = match room.space_id {
52 Some(space_id) => Some(
53 spaces::Entity::find_by_id(space_id)
54 .one(service.db().read())
55 .await?
56 .ok_or(ChatError::NotFound)?,
57 ),
50 Some(space_id) => Some(space::authority::space_by_id(&service.state, 0, space_id).await?),
5851 None => None,
5952 };
6053 Ok(RoomContext { room, space, is_dm })
@@ -83,13 +76,15 @@
8376 service: &ChatService,
8477 space_name: &str,
8578) -> Result<spaces::Model, ChatError> {
86 let lower_name = space_name.to_lowercase();
87 spaces::Entity::find()
88 .filter(spaces::Column::LowerName.eq(&lower_name))
89 .one(service.db().read())
90 .await?
91 .filter(|s| s.is_active)
92 .ok_or(ChatError::NotFound)
79 let lower_name = space_name.trim().to_lowercase();
80 if lower_name.is_empty() {
81 return Err(ChatError::NotFound);
82 }
83 let space = space::authority::space_by_slug(&service.state, 0, &lower_name).await?;
84 if !space.is_active {
85 return Err(ChatError::NotFound);
86 }
87 Ok(space)
9388}
9489
9590pub async fn require_space_member(
@@ -97,43 +92,9 @@
9792 space: &spaces::Model,
9893 uid: i64,
9994) -> Result<space_members::Model, ChatError> {
100 if space.owner_id == uid {
101 return Ok(space_members::Model {
102 space_id: space.id,
103 user_id: uid,
104 alias_name: None,
105 alias_avatar_url: None,
106 status: MemberStatus::Active,
107 last_active_at: None,
108 left_at: None,
109 invited_at: None,
110 invited_by: None,
111 joined_at: space.created_at,
112 created_at: space.created_at,
113 updated_at: None,
114 message_notifications: Default::default(),
115 muted: false,
116 mute_until: None,
117 mobile_push: false,
118 suppress_everyone: false,
119 suppress_roles: false,
120 hide_muted_channels: false,
121 banned_at: None,
122 ban_reason: None,
123 ban_expires_at: None,
124 ban_allow_speak: false,
125 ban_allow_push_repo: false,
126 ban_allow_comment_any: false,
127 });
128 }
129 let member = space_members::Entity::find_by_id((space.id, uid))
130 .one(service.db().read())
131 .await?
132 .ok_or(ChatError::Forbidden)?;
133 if member.status != MemberStatus::Active {
134 return Err(ChatError::Forbidden);
135 }
136 Ok(member)
95 space::authority::require_member(&service.state, space.id, uid)
96 .await
97 .map_err(|_| ChatError::Forbidden)
13798}
13899
139100pub async fn require_room_writable(
Modified lib/chat/src/share/mention.rs View lib/chat/src/share/mention.rs
@@ -89,9 +89,9 @@
8989 if space.owner_id == candidate.uid {
9090 return Ok(Resolved::User(candidate.uid));
9191 }
92 let member = space_members::Entity::find_by_id((space.id, candidate.uid))
93 .one(service.db().read())
94 .await?;
92 let member =
93 space::authority::member(&service.state, space.owner_id, space.id, candidate.uid)
94 .await?;
9595 if member.is_some_and(|m| m.status == MemberStatus::Active) {
9696 return Ok(Resolved::User(candidate.uid));
9797 }
Modified lib/chat/src/share/notify.rs View lib/chat/src/share/notify.rs
@@ -154,9 +154,7 @@
154154 }
155155
156156 if let Some(space_id) = space_id
157 && let Some(member) = space_members::Entity::find_by_id((space_id, uid))
158 .one(service.db().read())
159 .await?
157 && let Some(member) = space::authority::member(&service.state, uid, space_id, uid).await?
160158 {
161159 if mute_active(member.muted, member.mute_until) && !(mentioned && !everyone) {
162160 return Ok(NotifyDecision::Skip);
@@ -212,10 +210,7 @@
212210 .map(|m| m.user_id)
213211 .collect()
214212 } else if let Some(space_id) = space_id {
215 space_members::Entity::find()
216 .filter(space_members::Column::SpaceId.eq(space_id))
217 .filter(space_members::Column::Status.eq(MemberStatus::Active))
218 .all(service.db().read())
213 space::authority::list_members(&service.state, actor, space_id, false, false)
219214 .await
220215 .unwrap_or_default()
221216 .into_iter()
@@ -295,9 +290,7 @@
295290 }
296291 }
297292 if let Some(space_id) = space_id
298 && let Some(member) = space_members::Entity::find_by_id((space_id, uid))
299 .one(service.db().read())
300 .await?
293 && let Some(member) = space::authority::member(&service.state, uid, space_id, uid).await?
301294 {
302295 if mute_active(member.muted, member.mute_until) {
303296 return Ok(false);
Modified lib/chat/src/share/realtime.rs View lib/chat/src/share/realtime.rs
@@ -116,26 +116,22 @@
116116#[derive(Clone)]
117117pub struct Realtime {
118118 db: sea_orm::DatabaseConnection,
119 events_enabled: bool,
120119}
121120
122121impl Realtime {
123 pub fn new(db: sea_orm::DatabaseConnection, events_enabled: bool) -> Self {
124 Self { db, events_enabled }
122 pub fn new(db: sea_orm::DatabaseConnection) -> Self {
123 Self { db }
125124 }
126125
127126 pub async fn publish_room(&self, event: &RoomEvent) {
128 if let Err(error) =
129 crate::share::events::enqueue_room(&self.db, self.events_enabled, event).await
130 {
127 if let Err(error) = crate::share::events::enqueue_room(&self.db, event).await {
131128 tracing::warn!(%error, room_id = %event.room_id(), "chat room event enqueue failed");
132129 }
133130 }
134131
135132 pub async fn publish_user<T: Serialize>(&self, uid: i64, event: &T) {
136133 if let Ok(value) = serde_json::to_value(event)
137 && let Err(error) =
138 crate::share::events::enqueue_user(&self.db, self.events_enabled, uid, &value).await
134 && let Err(error) = crate::share::events::enqueue_user(&self.db, uid, &value).await
139135 {
140136 tracing::warn!(%error, %uid, "chat user event enqueue failed");
141137 }
Modified lib/config/src/rocketmq.rs View lib/config/src/rocketmq.rs
@@ -30,7 +30,6 @@
3030#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
3131#[serde(default)]
3232pub struct RocketMqConfig {
33 pub enabled: bool,
3433 #[serde(default = "default_name_server")]
3534 pub name_server: String,
3635 #[serde(default = "default_producer_group")]
@@ -46,7 +45,6 @@
4645impl Default for RocketMqConfig {
4746 fn default() -> Self {
4847 Self {
49 enabled: false,
5048 name_server: default_name_server(),
5149 producer_group: default_producer_group(),
5250 topic_prefix: default_topic_prefix(),
@@ -70,9 +68,6 @@
7068 }
7169
7270 pub fn is_valid(&self) -> Result<(), String> {
73 if !self.enabled {
74 return Ok(());
75 }
7671 let servers = self.name_servers();
7772 if servers.is_empty() {
7873 return Err("rocketmq.name_server must not be blank".to_string());
Modified lib/export/src/events.rs View lib/export/src/events.rs
@@ -34,16 +34,11 @@
3434
3535pub async fn enqueue<T: Serialize>(
3636 db: &impl ConnectionTrait,
37 enabled: bool,
3837 kind: &str,
3938 labels: Labels,
4039 payload: T,
4140 ordering_key: impl Into<String>,
4241) -> Result<(), DbErr> {
43 if !enabled {
44 return Ok(());
45 }
46
4742 active_model(kind, labels, &payload, &ordering_key.into())?
4843 .insert(db)
4944 .await?;
@@ -69,15 +64,10 @@
6964/// 避免它们为了一个标签类型去直接依赖 `queue`。
7065pub async fn enqueue_room<T: Serialize>(
7166 db: &impl ConnectionTrait,
72 enabled: bool,
7367 kind: &str,
7468 room_id: Uuid,
7569 payload: T,
7670) -> Result<(), DbErr> {
77 if !enabled {
78 return Ok(());
79 }
80
8171 active_model(
8272 kind,
8373 room_labels(room_id),
@@ -96,13 +86,12 @@
9686/// 活跃成员数——逐条写入会变成 N 次往返。
9787pub async fn enqueue_chat_users<T: Serialize>(
9888 db: &impl ConnectionTrait,
99 enabled: bool,
10089 kind: &str,
10190 uids: &[i64],
10291 room_id: Uuid,
10392 payload: &T,
10493) -> Result<(), DbErr> {
105 if !enabled || uids.is_empty() {
94 if uids.is_empty() {
10695 return Ok(());
10796 }
10897
Modified lib/export/src/lib.rs View lib/export/src/lib.rs
@@ -36,7 +36,6 @@
3636 pub config: AppConfig,
3737 pub kenauth: Option<Arc<kenauth::KenAuthClient>>,
3838 pub email_rpc: Option<Arc<kenauth::KenAuthClient>>,
39 pub events_enabled: bool,
4039}
4140
4241impl AppState {
@@ -78,8 +77,6 @@
7877 kenauth.clone()
7978 };
8079
81 let events_enabled = config.rocketmq.enabled;
82
8380 Ok(Self {
8481 database,
8582 redis,
@@ -87,7 +84,6 @@
8784 config,
8885 kenauth,
8986 email_rpc,
90 events_enabled,
9187 })
9288 }
9389}
Modified lib/export/src/notify.rs View lib/export/src/notify.rs
@@ -5,12 +5,6 @@
55
66use crate::AppState;
77
8/// The chat WS per-user channel; every crate that writes a notification
9/// publishes to it so connected clients receive the row in real time.
10pub fn user_channel(uid: i64) -> String {
11 format!("chat:user:{uid}")
12}
13
148pub fn notification_event_json(notification: Value) -> Value {
159 serde_json::json!({
1610 "kind": "notification",
@@ -20,17 +14,8 @@
2014
2115pub async fn publish_user_event(state: &AppState, uid: i64, event: &Value) {
2216 let redis = &state.redis;
23 let payload = match serde_json::to_string(event) {
24 Ok(payload) => payload,
25 Err(error) => {
26 tracing::warn!(%error, %uid, "user event serialize failed");
27 return;
28 }
29 };
30 let channel = user_channel(uid);
31 if let Err(error) = redis.publish(&channel, &payload).await {
32 tracing::warn!(%error, %channel, "user event publish failed");
33 }
17 // Real-time fan-out is RocketMQ only: the event is written to the outbox and
18 // delivered to KenEvent. Redis is used here solely as the OS-push task queue.
3419 // Every `notification` user event is also a candidate for an OS push.
3520 // Enqueueing here — rather than at each of the nine writers — is what makes
3621 // "websocket fan-out and push fan-out never drift" true by construction.
@@ -45,7 +30,6 @@
4530 .with_opt("request_id", crate::events::request_id());
4631 if let Err(error) = crate::events::enqueue(
4732 state.database.write(),
48 state.events_enabled,
4933 "notify.notification.create",
5034 labels,
5135 event,
Modified lib/export/src/push.rs View lib/export/src/push.rs
@@ -5,9 +5,10 @@
55use crate::error::ExportError;
66use crate::redis::AppRedis;
77
8/// 推送任务队列(LPUSH/BRPOP)与死信队列的固定键名,与 `chat:user:{uid}`
8/// 推送任务队列(LPUSH/BRPOP)与死信队列的固定键名,与 `chat:ws:{uid}`
99/// 一类键同属服务内部约定,不做配置化——入队点遍布各业务 crate,只有固定
10/// 键名才能让它们无需感知配置。
10/// 键名才能让它们无需感知配置。注意:这是 OS 推送的任务队列,不是实时事件通道;
11/// 实时事件只走 RocketMQ。
1112pub const QUEUE_TOPIC: &str = "push:jobs";
1213pub const DLQ_TOPIC: &str = "push:jobs:dlq";
1314
Modified lib/git/src/service/context.rs View lib/git/src/service/context.rs
@@ -21,12 +21,10 @@
2121 if space_lower.is_empty() {
2222 return Err(GitError::NotFound);
2323 }
24 let space = spaces::Entity::find()
25 .filter(spaces::Column::LowerName.eq(&space_lower))
26 .filter(spaces::Column::IsActive.eq(true))
27 .one(self.db_read())
28 .await?
29 .ok_or(GitError::NotFound)?;
24 let space = space::authority::space_by_slug(&self.state, 0, &space_lower).await?;
25 if !space.is_active {
26 return Err(GitError::NotFound);
27 }
3028
3129 let repo_lower = repo_name.trim().to_lowercase();
3230 if repo_lower.is_empty() {
@@ -39,7 +37,7 @@
3937 let repo = {
4038 let mut found = None;
4139 for candidate in candidates {
42 if space_for_dir(self.db_read(), candidate.dir_id)
40 if space_for_dir(&self.state, candidate.dir_id)
4341 .await?
4442 .is_some_and(|s| s.id == space.id)
4543 {
Modified lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -283,7 +283,7 @@
283283 }
284284
285285 pub async fn repo_dto(&self, m: repos::Model) -> Result<RepoDto, GitError> {
286 let space = crate::share::access::space_for_dir(self.db_read(), m.dir_id)
286 let space = crate::share::access::space_for_dir(&self.state, m.dir_id)
287287 .await?
288288 .ok_or(GitError::NotFound)?;
289289 let mut dto = RepoDto::from(m);
@@ -296,12 +296,11 @@
296296 if lower.is_empty() {
297297 return Err(GitError::NotFound);
298298 }
299 spaces::Entity::find()
300 .filter(spaces::Column::LowerName.eq(&lower))
301 .filter(spaces::Column::IsActive.eq(true))
302 .one(self.db_read())
303 .await?
304 .ok_or(GitError::NotFound)
299 let space = space::authority::space_by_slug(&self.state, 0, &lower).await?;
300 if !space.is_active {
301 return Err(GitError::NotFound);
302 }
303 Ok(space)
305304 }
306305
307306 pub async fn create_repo(
@@ -657,7 +656,7 @@
657656 }
658657
659658 async fn repo_space(&self, ctx: &GitContext) -> Result<spaces::Model, GitError> {
660 crate::share::access::space_for_dir(self.db_read(), ctx.model.dir_id)
659 crate::share::access::space_for_dir(&self.state, ctx.model.dir_id)
661660 .await?
662661 .ok_or(GitError::NotFound)
663662 }
Modified lib/git/src/share/access.rs View lib/git/src/share/access.rs
@@ -158,7 +158,7 @@
158158
159159 // Walk dir_id up to the owning space row, then load the space to
160160 // compare owner_id. Mirrors `ssh/handler/command.rs::space_id_for`.
161 let space = find_space_for_repo(db, repo).await?;
161 let space = find_space_for_repo(&svc.state, repo).await?;
162162 if space.owner_id == uid {
163163 return Ok(());
164164 }
@@ -194,10 +194,10 @@
194194}
195195
196196async fn find_space_for_repo(
197 db: &sea_orm::DatabaseConnection,
197 state: &export::AppState,
198198 repo: &models::repo::repos::Model,
199199) -> Result<models::space::spaces::Model, GitError> {
200 space_for_dir(db, repo.dir_id)
200 space_for_dir(state, repo.dir_id)
201201 .await?
202202 .ok_or_else(|| GitError::Internal(format!("space for repo {} not found", repo.id)))
203203}
@@ -205,19 +205,16 @@
205205pub const MAX_DIR_DEPTH: usize = 64;
206206
207207pub async fn space_for_dir(
208 db: &sea_orm::DatabaseConnection,
208 state: &export::AppState,
209209 mut dir_id: Uuid,
210210) -> Result<Option<models::space::spaces::Model>, GitError> {
211211 for _ in 0..MAX_DIR_DEPTH {
212212 let row = models::dir::dirs::Entity::find_by_id(dir_id)
213 .one(db)
213 .one(state.database.read())
214214 .await?
215215 .ok_or_else(|| GitError::Internal(format!("dir {dir_id} not found")))?;
216216 if let Some(space_id) = row.space_id {
217 let space = models::space::spaces::Entity::find_by_id(space_id)
218 .one(db)
219 .await?
220 .ok_or_else(|| GitError::Internal(format!("space {space_id} not found")))?;
217 let space = space::authority::space_by_id(state, 0, space_id).await?;
221218 return Ok(Some(space));
222219 }
223220 match row.parent_dir_id {
Modified lib/git/src/share/errors.rs View lib/git/src/share/errors.rs
@@ -22,6 +22,19 @@
2222 Internal(String),
2323}
2424
25impl From<space::errors::SpaceError> for GitError {
26 fn from(error: space::errors::SpaceError) -> Self {
27 match error {
28 space::errors::SpaceError::BadRequest(message) => Self::InvalidArgument(message),
29 space::errors::SpaceError::Unauthorized => Self::Unauthorized,
30 space::errors::SpaceError::Forbidden => Self::Forbidden,
31 space::errors::SpaceError::NotFound => Self::NotFound,
32 space::errors::SpaceError::Conflict(message) => Self::Conflict(message),
33 other => Self::Internal(other.to_string()),
34 }
35 }
36}
37
2538impl From<sea_orm::DbErr> for GitError {
2639 fn from(err: sea_orm::DbErr) -> Self {
2740 if is_unique_violation(&err) {
Modified lib/issues/src/services/issues.rs View lib/issues/src/services/issues.rs
@@ -490,8 +490,7 @@
490490
491491 for a_uid in &cmd.assignee_ids {
492492 let is_member = *a_uid == space.owner_id
493 || space_members::Entity::find_by_id((space.id, *a_uid))
494 .one(self.db().read())
493 || space::authority::member(&self.state, space.owner_id, space.id, *a_uid)
495494 .await?
496495 .is_some_and(|m| m.status == MemberStatus::Active);
497496 if !is_member {
@@ -911,8 +910,7 @@
911910
912911 for a_uid in cmd.assignee_ids.iter().flatten() {
913912 let is_member = *a_uid == space.owner_id
914 || space_members::Entity::find_by_id((space.id, *a_uid))
915 .one(self.db().read())
913 || space::authority::member(&self.state, space.owner_id, space.id, *a_uid)
916914 .await?
917915 .is_some_and(|m| m.status == MemberStatus::Active);
918916 if !is_member {
Modified lib/issues/src/services/members.rs View lib/issues/src/services/members.rs
@@ -69,8 +69,7 @@
6969 let target_is_member = if cmd.user_id == space.owner_id {
7070 true
7171 } else {
72 space_members::Entity::find_by_id((space.id, cmd.user_id))
73 .one(self.db().read())
72 space::authority::member(&self.state, space.owner_id, space.id, cmd.user_id)
7473 .await?
7574 .is_some_and(|m| m.status == MemberStatus::Active)
7675 };
Modified lib/issues/src/services/reactions.rs View lib/issues/src/services/reactions.rs
@@ -132,7 +132,7 @@
132132
133133 let inserted = row.insert(self.db().write()).await?;
134134 if let Err(e) = apply_space_join_rules(
135 self.db().write(),
135 &self.state,
136136 space.id,
137137 caller,
138138 SpaceJoinTrigger::IssueReaction,
@@ -199,7 +199,7 @@
199199
200200 let inserted = row.insert(self.db().write()).await?;
201201 if let Err(e) = apply_space_join_rules(
202 self.db().write(),
202 &self.state,
203203 space.id,
204204 caller,
205205 SpaceJoinTrigger::IssueCommentReaction,
Modified lib/issues/src/services/service.rs View lib/issues/src/services/service.rs
@@ -145,7 +145,6 @@
145145 }
146146 export::events::enqueue(
147147 txn,
148 self.state.events_enabled,
149148 event_kind(event_type),
150149 labels,
151150 payload,
@@ -190,11 +189,9 @@
190189 text: &str,
191190 exclude: i64,
192191 ) -> Result<Vec<i64>, IssueError> {
193 let members = space_members::Entity::find()
194 .filter(space_members::Column::SpaceId.eq(space.id))
195 .filter(space_members::Column::Status.eq(MemberStatus::Active))
196 .all(self.db().read())
197 .await?;
192 let members =
193 space::authority::list_members(&self.state, space.owner_id, space.id, false, false)
194 .await?;
198195
199196 let mut member_uids: Vec<i64> = members.into_iter().map(|m| m.user_id).collect();
200197 if !member_uids.contains(&space.owner_id) {
Modified lib/issues/src/share/lookup.rs View lib/issues/src/share/lookup.rs
@@ -17,12 +17,11 @@
1717 if lower.is_empty() {
1818 return Err(IssueError::NotFound);
1919 }
20 spaces::Entity::find()
21 .filter(spaces::Column::LowerName.eq(&lower))
22 .filter(spaces::Column::IsActive.eq(true))
23 .one(service.db().read())
24 .await?
25 .ok_or(IssueError::NotFound)
20 let space = space::authority::space_by_slug(&service.state, 0, &lower).await?;
21 if !space.is_active {
22 return Err(IssueError::NotFound);
23 }
24 Ok(space)
2625}
2726
2827pub async fn lookup_board(
@@ -187,11 +186,12 @@
187186 .one(service.db().read())
188187 .await?
189188 .ok_or(IssueError::NotFound)?;
190 let space = spaces::Entity::find_by_id(dir.space_id.ok_or(IssueError::NotFound)?)
191 .filter(spaces::Column::IsActive.eq(true))
192 .one(service.db().read())
193 .await?
194 .ok_or(IssueError::NotFound)?;
189 let space =
190 space::authority::space_by_id(&service.state, 0, dir.space_id.ok_or(IssueError::NotFound)?)
191 .await?;
192 if !space.is_active {
193 return Err(IssueError::NotFound);
194 }
195195 require_member(service, &space, caller).await?;
196196 let board = lookup_board(service, &space, board.id, caller).await?;
197197 Ok((space, board, item))
Modified lib/kenauth/proto/v1/organization.proto View lib/kenauth/proto/v1/organization.proto
@@ -29,6 +29,10 @@
2929 // 列出某个用户加入的组织。actor 与 user_id 不一致时要求 actor 是平台管理员。
3030 rpc ListOrganizations(ListOrganizationsRequest) returns (ListOrganizationsResponse);
3131
32 // 公开目录:列出所有 ACTIVE 且非 PRIVATE 的组织,供探索页/加入申请使用。
33 // 不要求成员身份;返回的是公开字段(OrganizationInfo 的非成员视图)。
34 rpc ListPublicOrganizations(ListPublicOrganizationsRequest) returns (ListPublicOrganizationsResponse);
35
3236 // 修改名称、描述、头像。slug 不可改(它是外部引用的稳定键)。
3337 // mutation: request_id 通过 gitdata-request-id 头携带
3438 rpc UpdateOrganization(UpdateOrganizationRequest) returns (UpdateOrganizationResponse);
@@ -249,6 +253,11 @@
249253 bool require_mfa = 17;
250254 string welcome_message = 18;
251255 bool member_must_enable_2fa = 19;
256 // 是否允许成员创建邀请。
257 bool allow_invite = 20;
258 // 加入验证等级:0 = 无要求;1 = 邮箱已验证;2 = 邮箱已验证且注册满 6 个月;
259 // 3 = 邮箱已验证且注册满 1 小时;4 = 邮箱与手机号均已验证。
260 int32 allow_join_level = 21;
252261}
253262
254263message OrganizationMemberInfo {
@@ -281,7 +290,7 @@
281290 // 封禁附加信息。
282291 string ban_reason = 23;
283292 google.protobuf.Timestamp ban_expires_at = 24;
284 reserved 25;
293 bool ban_allow_agent_join = 25;
285294 bool ban_allow_speak = 26;
286295 bool ban_allow_push_repo = 27;
287296 bool ban_allow_comment_any = 28;
@@ -348,6 +357,8 @@
348357 optional bool require_mfa = 9;
349358 optional string welcome_message = 10;
350359 optional bool member_must_enable_2fa = 11;
360 optional bool allow_invite = 12;
361 optional int32 allow_join_level = 13;
351362}
352363
353364message CreateOrganizationResponse {
@@ -379,6 +390,17 @@
379390 auth.common.v1.PageResponse page = 2;
380391}
381392
393message ListPublicOrganizationsRequest {
394 // 匹配 slug 或名称的前缀;空表示不过滤。
395 string query = 1;
396 auth.common.v1.PageRequest page = 2;
397}
398
399message ListPublicOrganizationsResponse {
400 repeated OrganizationInfo organizations = 1;
401 auth.common.v1.PageResponse page = 2;
402}
403
382404message UpdateOrganizationRequest {
383405 int64 actor_user_id = 1;
384406 string organization_id = 2;
@@ -394,6 +416,8 @@
394416 optional bool require_mfa = 10;
395417 optional string welcome_message = 11;
396418 optional bool member_must_enable_2fa = 12;
419 optional bool allow_invite = 13;
420 optional int32 allow_join_level = 14;
397421}
398422
399423message UpdateOrganizationResponse {
@@ -723,7 +747,7 @@
723747 optional string reason = 4;
724748 optional bool clear_expires_at = 5;
725749 google.protobuf.Timestamp expires_at = 6;
726 reserved 7;
750 optional bool allow_agent_join = 7;
727751 optional bool allow_speak = 8;
728752 optional bool allow_push_repo = 9;
729753 optional bool allow_comment_any = 10;
Modified lib/kenauth/src/client.rs View lib/kenauth/src/client.rs
@@ -62,7 +62,11 @@
6262 if !config.enabled {
6363 return Err(KenAuthError::Config("oauth.enabled is false".into()));
6464 }
65 Self::connect_rpc(&config.grpc_endpoint, &config.rpc_key, config.http_timeout_ms)
65 Self::connect_rpc(
66 &config.grpc_endpoint,
67 &config.rpc_key,
68 config.http_timeout_ms,
69 )
6670 }
6771
6872 pub fn connect_rpc(
@@ -107,7 +111,9 @@
107111 Ok(())
108112 }
109113
110 pub fn identity(&self) -> proto::v1::identity_service_client::IdentityServiceClient<Intercepted> {
114 pub fn identity(
115 &self,
116 ) -> proto::v1::identity_service_client::IdentityServiceClient<Intercepted> {
111117 proto::v1::identity_service_client::IdentityServiceClient::with_interceptor(
112118 self.channel.clone(),
113119 self.interceptor.clone(),
@@ -196,7 +202,7 @@
196202 )
197203 }
198204
199#[allow(clippy::too_many_arguments)]
205 #[allow(clippy::too_many_arguments)]
200206 pub async fn change_password(
201207 &self,
202208 user_id: i64,
@@ -295,13 +301,13 @@
295301 ) -> Result<proto::v1::RegenerateRecoveryCodesResponse, KenAuthError> {
296302 let mut client = self.mfa();
297303 client
298 .regenerate_recovery_codes(
299 self.write_request(proto::v1::RegenerateRecoveryCodesRequest {
304 .regenerate_recovery_codes(self.write_request(
305 proto::v1::RegenerateRecoveryCodesRequest {
300306 user_id,
301307 password_hash: password_hash.to_string(),
302308 client_ip: client_ip.to_string(),
303 }),
304 )
309 },
310 ))
305311 .await
306312 .map_err(KenAuthError::from_status)
307313 .map(|response| response.into_inner())
@@ -475,14 +481,12 @@
475481 ) -> Result<proto::v1::RevokeUserSessionsResponse, KenAuthError> {
476482 let mut client = self.session();
477483 client
478 .revoke_user_sessions(
479 self.write_request(proto::v1::RevokeUserSessionsRequest {
480 user_id,
481 except_session_id: except_session_id.to_string(),
482 reason: reason.to_string(),
483 bump_security_version,
484 }),
485 )
484 .revoke_user_sessions(self.write_request(proto::v1::RevokeUserSessionsRequest {
485 user_id,
486 except_session_id: except_session_id.to_string(),
487 reason: reason.to_string(),
488 bump_security_version,
489 }))
486490 .await
487491 .map_err(KenAuthError::from_status)
488492 .map(|response| response.into_inner())
@@ -554,6 +558,51 @@
554558 .map(|response| response.into_inner().access.unwrap_or_default())
555559 }
556560
561 pub async fn get_authority_user(
562 &self,
563 user_id: i64,
564 ) -> Result<proto::internal::v1::AuthorityUser, KenAuthError> {
565 let mut client = self.authority();
566 client
567 .get_user(self.read_request(proto::internal::v1::GetUserRequest { user_id }))
568 .await
569 .map_err(KenAuthError::from_status)?
570 .into_inner()
571 .user
572 .ok_or(KenAuthError::NotFound)
573 }
574
575 /// The only supported write path for `username` / `displayname`.
576 /// `version` is the account's current optimistic-lock version.
577 #[allow(clippy::too_many_arguments)]
578 pub async fn update_authority_user(
579 &self,
580 user_id: i64,
581 username: Option<String>,
582 displayname: Option<String>,
583 clear_displayname: bool,
584 public_show_email: Option<bool>,
585 version: i32,
586 ) -> Result<proto::internal::v1::AuthorityUser, KenAuthError> {
587 let mut client = self.authority();
588 client
589 .update_user(self.write_request(proto::internal::v1::UpdateUserRequest {
590 user_id,
591 patch: Some(proto::internal::v1::AuthorityUserPatch {
592 version,
593 username,
594 displayname,
595 clear_displayname,
596 public_show_email,
597 }),
598 }))
599 .await
600 .map_err(KenAuthError::from_status)?
601 .into_inner()
602 .user
603 .ok_or(KenAuthError::NotFound)
604 }
605
557606 pub async fn get_user_public(
558607 &self,
559608 user_id: i64,
@@ -734,6 +783,88 @@
734783 .map(|response| response.into_inner())
735784 }
736785
786 pub async fn get_member(
787 &self,
788 actor_user_id: i64,
789 organization_id: &str,
790 user_id: i64,
791 ) -> Result<Option<proto::v1::OrganizationMemberInfo>, KenAuthError> {
792 let mut client = self.organization();
793 match client
794 .get_member(self.read_request(proto::v1::GetMemberRequest {
795 actor_user_id,
796 organization_id: organization_id.to_string(),
797 member_id: String::new(),
798 user_id,
799 }))
800 .await
801 {
802 Ok(response) => Ok(response.into_inner().member),
803 Err(status) if status.code() == tonic::Code::NotFound => Ok(None),
804 Err(status) => Err(KenAuthError::from_status(status)),
805 }
806 }
807
808 pub async fn list_members(
809 &self,
810 actor_user_id: i64,
811 organization_id: &str,
812 include_inactive: bool,
813 include_banned: bool,
814 page: proto::common::v1::PageRequest,
815 ) -> Result<proto::v1::ListMembersResponse, KenAuthError> {
816 let mut client = self.organization();
817 client
818 .list_members(self.read_request(proto::v1::ListMembersRequest {
819 actor_user_id,
820 organization_id: organization_id.to_string(),
821 include_inactive,
822 include_banned,
823 page: Some(page),
824 }))
825 .await
826 .map_err(KenAuthError::from_status)
827 .map(|response| response.into_inner())
828 }
829
830 pub async fn list_organizations(
831 &self,
832 actor_user_id: i64,
833 user_id: i64,
834 include_archived: bool,
835 page: proto::common::v1::PageRequest,
836 ) -> Result<proto::v1::ListOrganizationsResponse, KenAuthError> {
837 let mut client = self.organization();
838 client
839 .list_organizations(self.read_request(proto::v1::ListOrganizationsRequest {
840 actor_user_id,
841 user_id,
842 include_archived,
843 page: Some(page),
844 }))
845 .await
846 .map_err(KenAuthError::from_status)
847 .map(|response| response.into_inner())
848 }
849
850 pub async fn list_public_organizations(
851 &self,
852 query: &str,
853 page: proto::common::v1::PageRequest,
854 ) -> Result<proto::v1::ListPublicOrganizationsResponse, KenAuthError> {
855 let mut client = self.organization();
856 client
857 .list_public_organizations(self.read_request(
858 proto::v1::ListPublicOrganizationsRequest {
859 query: query.to_string(),
860 page: Some(page),
861 },
862 ))
863 .await
864 .map_err(KenAuthError::from_status)
865 .map(|response| response.into_inner())
866 }
867
737868 pub async fn check_permissions(
738869 &self,
739870 actor_user_id: i64,
@@ -767,6 +898,8 @@
767898 require_mfa: Option<bool>,
768899 welcome_message: Option<String>,
769900 member_must_enable_2fa: Option<bool>,
901 allow_invite: Option<bool>,
902 allow_join_level: Option<i32>,
770903 ) -> Result<proto::v1::CreateOrganizationResponse, KenAuthError> {
771904 let mut client = self.organization();
772905 client
@@ -782,6 +915,8 @@
782915 require_mfa,
783916 welcome_message,
784917 member_must_enable_2fa,
918 allow_invite,
919 allow_join_level,
785920 }))
786921 .await
787922 .map_err(KenAuthError::from_status)
@@ -803,6 +938,8 @@
803938 require_mfa: Option<bool>,
804939 welcome_message: Option<String>,
805940 member_must_enable_2fa: Option<bool>,
941 allow_invite: Option<bool>,
942 allow_join_level: Option<i32>,
806943 ) -> Result<proto::v1::OrganizationInfo, KenAuthError> {
807944 let mut client = self.organization();
808945 client
@@ -819,6 +956,8 @@
819956 require_mfa,
820957 welcome_message,
821958 member_must_enable_2fa,
959 allow_invite,
960 allow_join_level,
822961 }))
823962 .await
824963 .map_err(KenAuthError::from_status)?
@@ -1058,6 +1197,7 @@
10581197 reason: reason.map(|value| value.unwrap_or_default()),
10591198 clear_expires_at,
10601199 expires_at,
1200 allow_agent_join: None,
10611201 allow_speak,
10621202 allow_push_repo,
10631203 allow_comment_any,
@@ -1284,7 +1424,6 @@
12841424 .map_err(KenAuthError::from_status)
12851425 .map(|response| response.into_inner())
12861426 }
1287
12881427
12891428 pub async fn create_join_rule(
12901429 &self,
@@ -1404,8 +1543,13 @@
14041543 actor_user_id: i64,
14051544 organization_id: &str,
14061545 request_id: &str,
1407 ) -> Result<(proto::v1::JoinRequestInfo, Option<proto::v1::OrganizationMemberInfo>), KenAuthError>
1408 {
1546 ) -> Result<
1547 (
1548 proto::v1::JoinRequestInfo,
1549 Option<proto::v1::OrganizationMemberInfo>,
1550 ),
1551 KenAuthError,
1552 > {
14091553 let mut client = self.organization();
14101554 let response = client
14111555 .approve_join_request(self.write_request(proto::v1::ApproveJoinRequestRequest {
@@ -1441,7 +1585,6 @@
14411585 .request
14421586 .ok_or(KenAuthError::NotFound)
14431587 }
1444
14451588}
14461589
14471590fn allow_grant(permission: String) -> proto::v1::OrganizationPermissionGrant {
@@ -1449,5 +1592,4 @@
14491592 permission,
14501593 effect: proto::v1::PermissionEffect::Allow as i32,
14511594 }
1452
14531595}
Modified lib/models/src/chat/emojis.rs View lib/models/src/chat/emojis.rs
@@ -21,8 +21,6 @@
2121 #[sea_orm(default_value = "false")]
2222 pub disabled: bool,
2323 pub created_at: OffsetDateTime,
24 #[sea_orm(belongs_to, from = "space_id", to = "id")]
25 pub space: BelongsTo<super::super::space::spaces::Entity>,
2624 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
2725 pub user: BelongsTo<super::super::users::user::Entity>,
2826}
Modified lib/models/src/dir/dirs.rs View lib/models/src/dir/dirs.rs
@@ -52,8 +52,6 @@
5252 pub updated_at: OffsetDateTime,
5353 #[sea_orm(indexed)]
5454 pub created_by_uid: i64,
55 #[sea_orm(belongs_to, from = "space_id", to = "id")]
56 pub space: BelongsTo<Option<super::super::space::spaces::Entity>>,
5755 #[sea_orm(
5856 self_ref,
5957 relation_enum = "Parent",
Modified lib/models/src/products/product_installs.rs View lib/models/src/products/product_installs.rs
@@ -31,8 +31,6 @@
3131 pub status: InstallStatus,
3232 pub installed_at: OffsetDateTime,
3333 pub uninstalled_at: Option<OffsetDateTime>,
34 #[sea_orm(belongs_to, from = "space_id", to = "id")]
35 pub space: BelongsTo<super::super::space::spaces::Entity>,
3634 #[sea_orm(belongs_to, from = "product_id", to = "id")]
3735 pub product: BelongsTo<super::products::Entity>,
3836 #[sea_orm(belongs_to, from = "installed_by", to = "uid")]
Modified lib/models/src/products/products.rs View lib/models/src/products/products.rs
@@ -42,8 +42,6 @@
4242 pub price_currency: Option<String>,
4343 pub created_at: OffsetDateTime,
4444 pub updated_at: OffsetDateTime,
45 #[sea_orm(belongs_to, from = "space_id", to = "id")]
46 pub space: BelongsTo<Option<super::super::space::spaces::Entity>>,
4745 #[sea_orm(belongs_to, from = "owner_id", to = "uid")]
4846 pub owner: BelongsTo<super::super::users::user::Entity>,
4947}
Modified lib/models/src/room/rooms.rs View lib/models/src/room/rooms.rs
@@ -72,8 +72,6 @@
7272 pub is_active: bool,
7373 pub created_at: OffsetDateTime,
7474 pub updated_at: OffsetDateTime,
75 #[sea_orm(belongs_to, from = "space_id", to = "id")]
76 pub space: BelongsTo<Option<super::super::space::spaces::Entity>>,
7775 #[sea_orm(belongs_to, from = "owner_id", to = "uid")]
7876 pub owner: BelongsTo<super::super::users::user::Entity>,
7977 /// 频道所在目录节点。`parent_id` 指向 `dir(id)` 而非本表:侧边栏的
Modified lib/models/src/space/mod.rs View lib/models/src/space/mod.rs
@@ -1,10 +1,12 @@
11pub mod space_bans;
2pub mod space_extras;
23pub mod space_group_permissions;
34pub mod space_groups;
45pub mod space_invite;
56pub mod space_invite_groups;
67pub mod space_join_request;
78pub mod space_join_rule;
9pub mod space_member_extras;
810pub mod space_member_groups;
911pub mod space_members;
1012pub mod space_warn;
Modified lib/models/src/space/space_bans.rs View lib/models/src/space/space_bans.rs
@@ -18,8 +18,6 @@
1818 pub allow_speak: bool,
1919 pub allow_push_repo: bool,
2020 pub allow_comment_any: bool,
21 #[sea_orm(belongs_to, from = "space_id", to = "id")]
22 pub space: BelongsTo<super::spaces::Entity>,
2321 #[sea_orm(belongs_to, relation_enum = "BannedUser", from = "user_id", to = "uid")]
2422 pub user: BelongsTo<super::super::users::user::Entity>,
2523 #[sea_orm(belongs_to, relation_enum = "BannedBy", from = "banned_by", to = "uid")]
Added lib/models/src/space/space_extras.rs View lib/models/src/space/space_extras.rs
@@ -0,0 +1,28 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5/// 仅属于 KenApi 的空间展示配置;KenOAuth 的 OrganizationInfo 没有这些字段。
6#[sea_orm::model]
7#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
8#[sea_orm(table_name = "space_extras")]
9pub struct Model {
10 #[sea_orm(primary_key, auto_increment = false)]
11 pub space_id: Uuid,
12 pub avatar_url: Option<String>,
13 pub invite_sort_url_hash: Option<String>,
14 pub banner_url: Option<String>,
15 pub banner_width: Option<i32>,
16 pub banner_height: Option<i32>,
17 pub invite_banner_url: Option<String>,
18 pub invite_banner_width: Option<i32>,
19 pub invite_banner_height: Option<i32>,
20 pub embed_splash_url: Option<String>,
21 pub embed_splash_width: Option<i32>,
22 pub embed_splash_height: Option<i32>,
23 pub welcome_object: Option<Uuid>,
24 pub created_at: OffsetDateTime,
25 pub updated_at: OffsetDateTime,
26}
27
28impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/space/space_groups.rs View lib/models/src/space/space_groups.rs
@@ -20,8 +20,6 @@
2020 pub created_at: OffsetDateTime,
2121 pub updated_at: Option<OffsetDateTime>,
2222 pub created_by: i64,
23 #[sea_orm(belongs_to, from = "space_id", to = "id")]
24 pub space: BelongsTo<super::spaces::Entity>,
2523 #[sea_orm(belongs_to, from = "created_by", to = "uid")]
2624 pub creator: BelongsTo<super::super::users::user::Entity>,
2725}
Modified lib/models/src/space/space_invite.rs View lib/models/src/space/space_invite.rs
@@ -17,8 +17,6 @@
1717 pub expires_at: Option<OffsetDateTime>,
1818 pub accepted_at: Option<OffsetDateTime>,
1919 pub revoked_at: Option<OffsetDateTime>,
20 #[sea_orm(belongs_to, from = "space_id", to = "id")]
21 pub space: BelongsTo<super::spaces::Entity>,
2220 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
2321 pub user: BelongsTo<Option<super::super::users::user::Entity>>,
2422}
Modified lib/models/src/space/space_join_request.rs View lib/models/src/space/space_join_request.rs
@@ -44,8 +44,6 @@
4444 pub decided_at: Option<OffsetDateTime>,
4545 pub created_at: OffsetDateTime,
4646 pub updated_at: OffsetDateTime,
47 #[sea_orm(belongs_to, from = "space_id", to = "id")]
48 pub space: BelongsTo<super::spaces::Entity>,
4947 #[sea_orm(belongs_to, relation_enum = "Applicant", from = "user_id", to = "uid")]
5048 pub applicant: BelongsTo<super::super::users::user::Entity>,
5149 #[sea_orm(belongs_to, relation_enum = "Decider", from = "decided_by", to = "uid")]
Modified lib/models/src/space/space_join_rule.rs View lib/models/src/space/space_join_rule.rs
@@ -44,8 +44,6 @@
4444 pub enabled: bool,
4545 pub created_at: OffsetDateTime,
4646 pub updated_at: Option<OffsetDateTime>,
47 #[sea_orm(belongs_to, from = "space_id", to = "id")]
48 pub space: BelongsTo<super::spaces::Entity>,
4947}
5048
5149impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/space/space_member_extras.rs View lib/models/src/space/space_member_extras.rs
@@ -0,0 +1,20 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5/// KenOAuth 的 OrganizationMemberInfo 没有 `invited_at` 与本地行时间戳;
6/// 这里只保存这些本地字段,会籍本身不落库。
7#[sea_orm::model]
8#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
9#[sea_orm(table_name = "space_member_extras")]
10pub struct Model {
11 #[sea_orm(primary_key, auto_increment = false)]
12 pub space_id: Uuid,
13 #[sea_orm(primary_key, auto_increment = false)]
14 pub user_id: i64,
15 pub invited_at: Option<OffsetDateTime>,
16 pub created_at: OffsetDateTime,
17 pub updated_at: OffsetDateTime,
18}
19
20impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/space/space_member_groups.rs View lib/models/src/space/space_member_groups.rs
@@ -13,8 +13,6 @@
1313 #[sea_orm(primary_key, auto_increment = false)]
1414 pub group_id: Uuid,
1515 pub created_at: OffsetDateTime,
16 #[sea_orm(belongs_to, from = "space_id", to = "id")]
17 pub space: BelongsTo<super::spaces::Entity>,
1816 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
1917 pub user: BelongsTo<super::super::users::user::Entity>,
2018 #[sea_orm(belongs_to, from = "group_id", to = "id")]
Modified lib/models/src/space/space_members.rs View lib/models/src/space/space_members.rs
@@ -1,67 +1,49 @@
1use sea_orm::entity::prelude::*;
21use serde::{Deserialize, Serialize};
32use time::OffsetDateTime;
43use uuid::Uuid;
54
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
5/// A member view assembled from KenOAuth's `OrganizationMemberInfo` plus the
6/// local `space_member_extras` row. Membership, roles, bans and preferences are
7/// owned by KenOAuth; no local table mirrors them.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)]
209pub enum MemberStatus {
2110 #[default]
22 #[sea_orm(num_value = 0)]
2311 Active,
24 #[sea_orm(num_value = 1)]
2512 Inactive,
26 #[sea_orm(num_value = 2)]
2713 Left,
28 #[sea_orm(num_value = 3)]
2914 Removed,
30 #[sea_orm(num_value = 4)]
3115 Banned,
3216}
3317
34#[derive(
35 Debug,
36 Clone,
37 Copy,
38 PartialEq,
39 Eq,
40 Default,
41 EnumIter,
42 DeriveActiveEnum,
43 Serialize,
44 Deserialize,
45 utoipa::ToSchema,
46)]
47#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)]
4819pub enum MessageNotifications {
4920 #[default]
50 #[sea_orm(num_value = 0)]
5121 All,
52 #[sea_orm(num_value = 1)]
5322 Mentions,
54 #[sea_orm(num_value = 2)]
5523 Nothing,
5624}
5725
58#[sea_orm::model]
59#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
60#[sea_orm(table_name = "space_members")]
26impl MessageNotifications {
27 pub fn as_str(self) -> &'static str {
28 match self {
29 Self::All => "all",
30 Self::Mentions => "mentions",
31 Self::Nothing => "nothing",
32 }
33 }
34
35 pub fn from_str(value: &str) -> Self {
36 match value.trim() {
37 "mentions" => Self::Mentions,
38 "nothing" => Self::Nothing,
39 _ => Self::All,
40 }
41 }
42}
43
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
6145pub struct Model {
62 #[sea_orm(primary_key, auto_increment = false)]
6346 pub space_id: Uuid,
64 #[sea_orm(primary_key, auto_increment = false)]
6547 pub user_id: i64,
6648 pub alias_name: Option<String>,
6749 pub alias_avatar_url: Option<String>,
@@ -69,38 +51,21 @@
6951 pub last_active_at: Option<OffsetDateTime>,
7052 pub left_at: Option<OffsetDateTime>,
7153 pub invited_at: Option<OffsetDateTime>,
72 pub banned_at: Option<OffsetDateTime>,
73 pub ban_reason: Option<String>,
74 pub ban_expires_at: Option<OffsetDateTime>,
75 #[sea_orm(default_value = "false")]
76 pub ban_allow_speak: bool,
77 #[sea_orm(default_value = "false")]
78 pub ban_allow_push_repo: bool,
79 #[sea_orm(default_value = "false")]
80 pub ban_allow_comment_any: bool,
8154 pub invited_by: Option<i64>,
8255 pub joined_at: OffsetDateTime,
8356 pub created_at: OffsetDateTime,
8457 pub updated_at: Option<OffsetDateTime>,
85 #[sea_orm(default_value = 0)]
8658 pub message_notifications: MessageNotifications,
87 #[sea_orm(default_value = "false")]
8859 pub muted: bool,
8960 pub mute_until: Option<OffsetDateTime>,
90 #[sea_orm(default_value = "false")]
9161 pub mobile_push: bool,
92 #[sea_orm(default_value = "false")]
9362 pub suppress_everyone: bool,
94 #[sea_orm(default_value = "false")]
9563 pub suppress_roles: bool,
96 #[sea_orm(default_value = "false")]
9764 pub hide_muted_channels: bool,
98 #[sea_orm(belongs_to, from = "space_id", to = "id")]
99 pub space: BelongsTo<super::spaces::Entity>,
100 #[sea_orm(belongs_to, relation_enum = "Member", from = "user_id", to = "uid")]
101 pub user: BelongsTo<super::super::users::user::Entity>,
102 #[sea_orm(belongs_to, relation_enum = "Inviter", from = "invited_by", to = "uid")]
103 pub inviter: BelongsTo<Option<super::super::users::user::Entity>>,
65 pub banned_at: Option<OffsetDateTime>,
66 pub ban_reason: Option<String>,
67 pub ban_expires_at: Option<OffsetDateTime>,
68 pub ban_allow_speak: bool,
69 pub ban_allow_push_repo: bool,
70 pub ban_allow_comment_any: bool,
10471}
105
106impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/space/space_warn.rs View lib/models/src/space/space_warn.rs
@@ -14,8 +14,6 @@
1414 // 指向聊天域的 channel,实体尚未迁入 models crate,暂保持裸列
1515 pub channel_id: Option<Uuid>,
1616 pub created_at: OffsetDateTime,
17 #[sea_orm(belongs_to, from = "space_id", to = "id")]
18 pub space: BelongsTo<super::spaces::Entity>,
1917 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
2018 pub user: BelongsTo<Option<super::super::users::user::Entity>>,
2119}
Modified lib/models/src/space/spaces.rs View lib/models/src/space/spaces.rs
@@ -1,49 +1,50 @@
1use sea_orm::entity::prelude::*;
21use serde::{Deserialize, Serialize};
32use time::OffsetDateTime;
43use uuid::Uuid;
54
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
5/// A space view assembled from KenOAuth's `OrganizationInfo` plus the local
6/// `space_extras` row. It deliberately has no SeaORM entity: KenOAuth is the
7/// only authority for the organization itself, membership and permissions.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)]
209pub enum AllowJoinLevel {
2110 #[default]
22 #[sea_orm(num_value = 0)]
2311 None,
24 #[sea_orm(num_value = 1)]
2512 NeedMailActive,
26 #[sea_orm(num_value = 2)]
2713 NeedMailActiveAndLastOneMid,
28 #[sea_orm(num_value = 3)]
2914 NeedMailActiveAndLastOneHours,
30 #[sea_orm(num_value = 4)]
3115 NeedMailActiveAndPhoneActive,
3216}
3317
34#[sea_orm::model]
35#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
36#[sea_orm(table_name = "spaces")]
18impl AllowJoinLevel {
19 pub fn as_i16(self) -> i16 {
20 match self {
21 Self::None => 0,
22 Self::NeedMailActive => 1,
23 Self::NeedMailActiveAndLastOneMid => 2,
24 Self::NeedMailActiveAndLastOneHours => 3,
25 Self::NeedMailActiveAndPhoneActive => 4,
26 }
27 }
28
29 pub fn from_i16(value: i16) -> Self {
30 match value {
31 1 => Self::NeedMailActive,
32 2 => Self::NeedMailActiveAndLastOneMid,
33 3 => Self::NeedMailActiveAndLastOneHours,
34 4 => Self::NeedMailActiveAndPhoneActive,
35 _ => Self::None,
36 }
37 }
38}
39
40#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
3741pub struct Model {
38 #[sea_orm(primary_key, auto_increment = false)]
3942 pub id: Uuid,
40 #[sea_orm(unique)]
4143 pub lower_name: String,
4244 pub name: String,
4345 pub description: Option<String>,
4446 pub owner_id: i64,
4547 pub avatar_url: Option<String>,
46 #[sea_orm(default_value = "false")]
4748 pub is_private: bool,
4849 pub is_active: bool,
4950 pub allow_join: bool,
@@ -63,18 +64,10 @@
6364 pub allow_join_level: AllowJoinLevel,
6465 pub member_must_enable_2fa: bool,
6566 pub welcome_object: Option<Uuid>,
66 #[sea_orm(default_value = "invite_only")]
6767 pub join_policy: String,
68 #[sea_orm(default_value = true)]
6968 pub require_email_verified: bool,
70 #[sea_orm(default_value = false)]
7169 pub require_mfa: bool,
72 #[sea_orm(default_value = 1)]
7370 pub version: i32,
7471 pub created_at: OffsetDateTime,
7572 pub updated_at: OffsetDateTime,
76 #[sea_orm(belongs_to, from = "owner_id", to = "uid")]
77 pub owner: BelongsTo<super::super::users::user::Entity>,
7873}
79
80impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/schema_extras.sql View lib/models/schema_extras.sql
@@ -262,3 +262,56 @@
262262 ON event_outbox (ordering_key, occurred_at, id) WHERE published_at IS NULL;
263263CREATE INDEX IF NOT EXISTS idx_event_outbox_published
264264 ON event_outbox (published_at) WHERE published_at IS NOT NULL;
265
266-- ---------------------------------------------------------------------------
267-- 2026-10-30: KenOAuth becomes the only authority for organizations,
268-- membership and permissions. The old local mirrors are retired:
269-- * presentation-only fields move to space_extras
270-- * invited_at rows move to space_member_extras
271-- * `spaces` and `space_members` are dropped, together with every FK that
272-- pointed at `spaces` (local rows keep space_id as a plain UUID).
273-- Idempotent: the block is skipped once `spaces` no longer exists.
274-- ---------------------------------------------------------------------------
275DO $$
276DECLARE
277 fk record;
278BEGIN
279 IF to_regclass('spaces') IS NOT NULL THEN
280 IF to_regclass('space_extras') IS NOT NULL THEN
281 INSERT INTO space_extras (
282 space_id, avatar_url, invite_sort_url_hash,
283 banner_url, banner_width, banner_height,
284 invite_banner_url, invite_banner_width, invite_banner_height,
285 embed_splash_url, embed_splash_width, embed_splash_height,
286 welcome_object, created_at, updated_at
287 )
288 SELECT
289 id, avatar_url, invite_sort_url_hash,
290 banner_url, banner_width, banner_height,
291 invite_banner_url, invite_banner_width, invite_banner_height,
292 embed_splash_url, embed_splash_width, embed_splash_height,
293 welcome_object, created_at, updated_at
294 FROM spaces
295 ON CONFLICT (space_id) DO NOTHING;
296 END IF;
297
298 IF to_regclass('space_member_extras') IS NOT NULL
299 AND to_regclass('space_members') IS NOT NULL THEN
300 INSERT INTO space_member_extras (space_id, user_id, invited_at, created_at, updated_at)
301 SELECT space_id, user_id, invited_at, created_at, COALESCE(updated_at, created_at)
302 FROM space_members
303 ON CONFLICT (space_id, user_id) DO NOTHING;
304 END IF;
305
306 FOR fk IN
307 SELECT conrelid::regclass AS table_name, conname
308 FROM pg_constraint
309 WHERE contype = 'f' AND confrelid = 'spaces'::regclass
310 LOOP
311 EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', fk.table_name, fk.conname);
312 END LOOP;
313
314 DROP TABLE IF EXISTS space_members CASCADE;
315 DROP TABLE IF EXISTS spaces CASCADE;
316 END IF;
317END $$;
Modified lib/pulls/src/services/changes.rs View lib/pulls/src/services/changes.rs
@@ -146,7 +146,6 @@
146146 if moved {
147147 write_event(
148148 self.db_write(),
149 self.state.events_enabled,
150149 &updated,
151150 actor,
152151 EVENT_HEAD_UPDATED,
Modified lib/pulls/src/services/events.rs View lib/pulls/src/services/events.rs
@@ -49,7 +49,6 @@
4949
5050pub async fn write_event(
5151 db: &impl ConnectionTrait,
52 enabled: bool,
5352 pull: &pull_requests::Model,
5453 actor: Option<i64>,
5554 event_type: &str,
@@ -87,15 +86,7 @@
8786 if let Some(request_id) = export::events::request_id() {
8887 labels.insert("request_id", request_id);
8988 }
90 export::events::enqueue(
91 db,
92 enabled,
93 kind,
94 labels,
95 payload,
96 format!("pull:{}", pull.id),
97 )
98 .await
89 export::events::enqueue(db, kind, labels, payload, format!("pull:{}", pull.id)).await
9990}
10091
10192fn event_kind(event_type: &str) -> Option<&'static str> {
@@ -134,7 +125,6 @@
134125 ) -> Result<(), PullError> {
135126 write_event(
136127 self.db_write(),
137 self.state.events_enabled,
138128 pull,
139129 actor,
140130 event_type,
Modified lib/pulls/src/services/pulls.rs View lib/pulls/src/services/pulls.rs
@@ -403,7 +403,6 @@
403403 let pull = created.insert(&txn).await?;
404404 write_event(
405405 &txn,
406 self.state.events_enabled,
407406 &pull,
408407 Some(caller),
409408 EVENT_CREATED,
Modified lib/pulls/src/services/relations.rs View lib/pulls/src/services/relations.rs
@@ -160,7 +160,7 @@
160160 let show = match repo {
161161 None => false,
162162 Some(repo) if !repo.is_private => true,
163 Some(repo) => git::share::access::space_for_dir(self.db_read(), repo.dir_id)
163 Some(repo) => git::share::access::space_for_dir(&self.state, repo.dir_id)
164164 .await?
165165 .is_some_and(|space| space.id == space_id),
166166 };
Modified lib/pulls/src/services/reviewers.rs View lib/pulls/src/services/reviewers.rs
@@ -130,7 +130,6 @@
130130 }
131131 write_event(
132132 self.db_write(),
133 self.state.events_enabled,
134133 pull,
135134 Some(caller),
136135 EVENT_REVIEW_REQUESTED,
@@ -164,7 +163,6 @@
164163 .await?;
165164 write_event(
166165 self.db_write(),
167 self.state.events_enabled,
168166 pull,
169167 Some(caller),
170168 EVENT_REVIEW_REQUEST_CANCELLED,
Modified lib/pulls/src/services/reviews.rs View lib/pulls/src/services/reviews.rs
@@ -138,7 +138,6 @@
138138 if state != ReviewState::Pending {
139139 write_event(
140140 &txn,
141 self.state.events_enabled,
142141 pull,
143142 Some(caller),
144143 EVENT_REVIEW_SUBMITTED,
Modified lib/queue/src/conn.rs View lib/queue/src/conn.rs
@@ -27,6 +27,7 @@
2727impl RocketMqConn {
2828 pub async fn init(config: &RocketMqConfig) -> QueueResult<Self> {
2929 config.is_valid().map_err(QueueError::config)?;
30 probe_nameservers(config).await?;
3031
3132 let mut producer = DefaultMQProducer::builder()
3233 .producer_group(config.producer_group.clone())
@@ -71,3 +72,26 @@
7172 producer.shutdown().await;
7273 }
7374}
75
76/// The RocketMQ client starts lazily and keeps retrying an unreachable
77/// nameserver in the background. KenApi now treats RocketMQ as mandatory, so a
78/// dead nameserver must fail startup instead of silently running without an
79/// event channel.
80async fn probe_nameservers(config: &RocketMqConfig) -> QueueResult<()> {
81 let timeout = std::time::Duration::from_secs(3);
82 for server in config.name_servers() {
83 match tokio::time::timeout(timeout, tokio::net::TcpStream::connect(server)).await {
84 Ok(Ok(_)) => return Ok(()),
85 Ok(Err(error)) => {
86 tracing::warn!(%server, %error, "RocketMQ nameserver is not reachable");
87 }
88 Err(_) => {
89 tracing::warn!(%server, "RocketMQ nameserver probe timed out");
90 }
91 }
92 }
93 Err(QueueError::connect(format!(
94 "no RocketMQ nameserver is reachable ({})",
95 config.name_servers().join(",")
96 )))
97}
Modified lib/queue/Cargo.toml View lib/queue/Cargo.toml
@@ -11,7 +11,7 @@
1111bytes = { workspace = true }
1212uuid = { workspace = true }
1313time = { workspace = true }
14tokio = { workspace = true, features = ["time", "sync", "rt"] }
14tokio = { workspace = true, features = ["time", "sync", "rt", "net"] }
1515tokio-util = { workspace = true }
1616tracing = { workspace = true }
1717rocketmq-client-rust = { workspace = true }
Modified lib/space/src/services/bans.rs View lib/space/src/services/bans.rs
@@ -69,7 +69,6 @@
6969 pub allow_comment_any: Option<bool>,
7070}
7171
72
7372fn proto_timestamp(value: OffsetDateTime) -> prost_types::Timestamp {
7473 prost_types::Timestamp {
7574 seconds: value.unix_timestamp(),
@@ -87,7 +86,7 @@
8786 space_name: &str,
8887 cmd: BanUserCmd,
8988 ) -> Result<BanDto, SpaceError> {
90 let space = lookup_space_by_name(self, space_name).await?;
89 let space = lookup_space_by_name(self, caller, space_name).await?;
9190 require_perm(self, &space, caller, MANAGE_BANS).await?;
9291
9392 user::Entity::find_by_id(cmd.user_id)
@@ -136,10 +135,6 @@
136135 .await?;
137136 }
138137
139 let member = space_members::Entity::find_by_id((space.id, cmd.user_id))
140 .one(self.db().read())
141 .await?;
142
143138 let now = OffsetDateTime::now_utc();
144139 let ban_row = space_bans::ActiveModel {
145140 space_id: Set(space.id),
@@ -161,25 +156,6 @@
161156 Box::pin(async move {
162157 let ban = ban_row.insert(txn).await?;
163158
164 if let Some(m) = member {
165 let was_active = m.status == MemberStatus::Active;
166 let mut am: space_members::ActiveModel = m.into();
167 am.status = Set(MemberStatus::Removed);
168 am.left_at = Set(Some(now));
169 am.updated_at = Set(Some(now));
170 am.update(txn).await?;
171 if was_active {
172 spaces::Entity::update_many()
173 .filter(spaces::Column::Id.eq(space.id))
174 .col_expr(
175 spaces::Column::NumsMembers,
176 sea_orm::sea_query::Expr::cust("GREATEST(nums_members - 1, 0)"),
177 )
178 .exec(txn)
179 .await?;
180 }
181 }
182
183159 space_member_groups::Entity::delete_many()
184160 .filter(space_member_groups::Column::SpaceId.eq(space.id))
185161 .filter(space_member_groups::Column::UserId.eq(cmd.user_id))
@@ -191,7 +167,8 @@
191167 })
192168 .await?;
193169
194 let user_map: HashMap<i64, UserDto> = self.load_user_briefs(&[inserted.user_id, caller]).await?;
170 let user_map: HashMap<i64, UserDto> =
171 self.load_user_briefs(&[inserted.user_id, caller]).await?;
195172 let user_brief = user_map.get(&inserted.user_id).cloned();
196173 let actor_brief = user_map.get(&caller).cloned();
197174
@@ -215,7 +192,7 @@
215192 space_name: &str,
216193 uid: i64,
217194 ) -> Result<BanDto, SpaceError> {
218 let space = lookup_space_by_name(self, space_name).await?;
195 let space = lookup_space_by_name(self, caller, space_name).await?;
219196 require_perm(self, &space, caller, MANAGE_BANS).await?;
220197
221198 let ban = space_bans::Entity::find_by_id((space.id, uid))
@@ -223,7 +200,8 @@
223200 .await?
224201 .ok_or(SpaceError::NotFound)?;
225202
226 let user_map: HashMap<i64, UserDto> = self.load_user_briefs(&[ban.user_id, ban.banned_by]).await?;
203 let user_map: HashMap<i64, UserDto> =
204 self.load_user_briefs(&[ban.user_id, ban.banned_by]).await?;
227205 let user_brief = user_map.get(&ban.user_id).cloned();
228206 let actor_brief = user_map.get(&ban.banned_by).cloned();
229207
@@ -249,7 +227,7 @@
249227 uid: i64,
250228 cmd: UpdateBanCmd,
251229 ) -> Result<BanDto, SpaceError> {
252 let space = lookup_space_by_name(self, space_name).await?;
230 let space = lookup_space_by_name(self, caller, space_name).await?;
253231 require_perm(self, &space, caller, MANAGE_BANS).await?;
254232
255233 let ban = space_bans::Entity::find_by_id((space.id, uid))
@@ -325,7 +303,7 @@
325303 space_name: &str,
326304 uid: i64,
327305 ) -> Result<(), SpaceError> {
328 let space = lookup_space_by_name(self, space_name).await?;
306 let space = lookup_space_by_name(self, caller, space_name).await?;
329307 require_perm(self, &space, caller, MANAGE_BANS).await?;
330308
331309 let client = self
@@ -352,7 +330,7 @@
352330 space_name: &str,
353331 q: PageQuery,
354332 ) -> Result<BanPage, SpaceError> {
355 let space = lookup_space_by_name(self, space_name).await?;
333 let space = lookup_space_by_name(self, caller, space_name).await?;
356334 require_perm(self, &space, caller, MANAGE_BANS).await?;
357335
358336 let max_pagination = self.config().space.max_pagination;
Modified lib/space/src/services/dir.rs View lib/space/src/services/dir.rs
@@ -15,6 +15,7 @@
1515use utoipa::{IntoParams, ToSchema};
1616use uuid::Uuid;
1717
18use crate::authority;
1819use crate::errors::SpaceError;
1920use crate::services::service::SpaceService;
2021use crate::services::spaces::UserDto;
@@ -179,7 +180,7 @@
179180 space_name: &str,
180181 cmd: CreateDirCmd,
181182 ) -> Result<DirNodeDto, SpaceError> {
182 let space = lookup_space_by_name(self, space_name).await?;
183 let space = lookup_space_by_name(self, caller, space_name).await?;
183184 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
184185
185186 let name = cmd.name.trim();
@@ -248,7 +249,7 @@
248249 space_name: &str,
249250 parent_id: Option<Uuid>,
250251 ) -> Result<Vec<DirNodeDto>, SpaceError> {
251 let space = lookup_space_by_name(self, space_name).await?;
252 let space = lookup_space_by_name(self, caller, space_name).await?;
252253 let perms = effective_permissions(&self.state, &space, caller).await?;
253254
254255 let can_manage = perms.has(MANAGE_CHANNELS);
@@ -411,7 +412,7 @@
411412 dir_id: Uuid,
412413 cmd: UpdateDirCmd,
413414 ) -> Result<DirNodeDto, SpaceError> {
414 let space = lookup_space_by_name(self, space_name).await?;
415 let space = lookup_space_by_name(self, caller, space_name).await?;
415416 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
416417
417418 let node: dirs::Model = self.dir_in_space(space.id, dir_id).await?;
@@ -439,7 +440,7 @@
439440 dir_id: Uuid,
440441 cmd: MoveDirCmd,
441442 ) -> Result<DirNodeDto, SpaceError> {
442 let space = lookup_space_by_name(self, space_name).await?;
443 let space = lookup_space_by_name(self, caller, space_name).await?;
443444 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
444445
445446 let node = self.dir_in_space(space.id, dir_id).await?;
@@ -514,7 +515,7 @@
514515 caller: i64,
515516 space_name: &str,
516517 ) -> Result<RepairDirResp, SpaceError> {
517 let space = lookup_space_by_name(self, space_name).await?;
518 let space = lookup_space_by_name(self, caller, space_name).await?;
518519 if space.owner_id != caller {
519520 return Err(SpaceError::Forbidden);
520521 }
@@ -604,7 +605,7 @@
604605 space_name: &str,
605606 dir_id: Uuid,
606607 ) -> Result<Vec<GroupOverwriteDto>, SpaceError> {
607 let space = lookup_space_by_name(self, space_name).await?;
608 let space = lookup_space_by_name(self, caller, space_name).await?;
608609 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
609610 self.dir_in_space(space.id, dir_id).await?;
610611
@@ -633,7 +634,7 @@
633634 group_id: Uuid,
634635 cmd: SetOverwriteCmd,
635636 ) -> Result<GroupOverwriteDto, SpaceError> {
636 let space = lookup_space_by_name(self, space_name).await?;
637 let space = lookup_space_by_name(self, caller, space_name).await?;
637638 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
638639 self.dir_in_space(space.id, dir_id).await?;
639640
@@ -689,7 +690,7 @@
689690 dir_id: Uuid,
690691 group_id: Uuid,
691692 ) -> Result<(), SpaceError> {
692 let space = lookup_space_by_name(self, space_name).await?;
693 let space = lookup_space_by_name(self, caller, space_name).await?;
693694 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
694695 self.dir_in_space(space.id, dir_id).await?;
695696
@@ -711,7 +712,7 @@
711712 space_name: &str,
712713 dir_id: Uuid,
713714 ) -> Result<Vec<UserOverwriteDto>, SpaceError> {
714 let space = lookup_space_by_name(self, space_name).await?;
715 let space = lookup_space_by_name(self, caller, space_name).await?;
715716 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
716717 self.dir_in_space(space.id, dir_id).await?;
717718
@@ -747,18 +748,18 @@
747748 user_id: i64,
748749 cmd: SetOverwriteCmd,
749750 ) -> Result<UserOverwriteDto, SpaceError> {
750 let space = lookup_space_by_name(self, space_name).await?;
751 let space = lookup_space_by_name(self, caller, space_name).await?;
751752 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
752753 self.dir_in_space(space.id, dir_id).await?;
753754
754755 if space.owner_id != user_id {
755 space_members::Entity::find_by_id((space.id, user_id))
756 .one(self.db().read())
757 .await?
758 .filter(|m| m.status == MemberStatus::Active)
759 .ok_or(SpaceError::BadRequest(
760 "target user is not an active member of this space".to_string(),
761 ))?;
756 authority::require_member(&self.state, space.id, user_id)
757 .await
758 .map_err(|_| {
759 SpaceError::BadRequest(
760 "target user is not an active member of this space".to_string(),
761 )
762 })?;
762763 }
763764
764765 if cmd.allow_mask < 0 || cmd.deny_mask < 0 {
@@ -808,7 +809,7 @@
808809 dir_id: Uuid,
809810 user_id: i64,
810811 ) -> Result<(), SpaceError> {
811 let space = lookup_space_by_name(self, space_name).await?;
812 let space = lookup_space_by_name(self, caller, space_name).await?;
812813 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
813814 self.dir_in_space(space.id, dir_id).await?;
814815
Modified lib/space/src/services/groups.rs View lib/space/src/services/groups.rs
@@ -13,6 +13,7 @@
1313use utoipa::ToSchema;
1414use uuid::Uuid;
1515
16use crate::authority;
1617use crate::errors::SpaceError;
1718use crate::services::service::SpaceService;
1819use crate::services::spaces::{PageQuery, UserDto};
@@ -106,7 +107,6 @@
106107 }
107108 Ok(())
108109}
109
110110
111111fn org_permissions(permissions: &[String]) -> Vec<String> {
112112 let mut out = Vec::new();
@@ -152,7 +152,7 @@
152152 space_name: &str,
153153 cmd: CreateGroupCmd,
154154 ) -> Result<GroupDto, SpaceError> {
155 let space = lookup_space_by_name(self, space_name).await?;
155 let space = lookup_space_by_name(self, caller, space_name).await?;
156156 require_perm(self, &space, caller, MANAGE_ROLES).await?;
157157
158158 let name = cmd.name.trim();
@@ -191,9 +191,8 @@
191191 org_permissions(&permissions),
192192 )
193193 .await?;
194 let group_id = Uuid::parse_str(&role.id).map_err(|_| {
195 SpaceError::Internal("kenoauth returned a non-uuid role".to_string())
196 })?;
194 let group_id = Uuid::parse_str(&role.id)
195 .map_err(|_| SpaceError::Internal("kenoauth returned a non-uuid role".to_string()))?;
197196
198197 let now = OffsetDateTime::now_utc();
199198 let group_row = space_groups::ActiveModel {
@@ -249,7 +248,7 @@
249248 space_name: &str,
250249 group_id: Uuid,
251250 ) -> Result<GroupDto, SpaceError> {
252 let space = lookup_space_by_name(self, space_name).await?;
251 let space = lookup_space_by_name(self, caller.unwrap_or(0), space_name).await?;
253252 if !space.is_active {
254253 return Err(SpaceError::NotFound);
255254 }
@@ -304,7 +303,7 @@
304303 group_id: Uuid,
305304 cmd: UpdateGroupCmd,
306305 ) -> Result<GroupDto, SpaceError> {
307 let space = lookup_space_by_name(self, space_name).await?;
306 let space = lookup_space_by_name(self, caller, space_name).await?;
308307 require_perm(self, &space, caller, MANAGE_ROLES).await?;
309308
310309 let group = space_groups::Entity::find_by_id(group_id)
@@ -441,7 +440,7 @@
441440 space_name: &str,
442441 group_id: Uuid,
443442 ) -> Result<(), SpaceError> {
444 let space = lookup_space_by_name(self, space_name).await?;
443 let space = lookup_space_by_name(self, caller, space_name).await?;
445444 require_perm(self, &space, caller, MANAGE_ROLES).await?;
446445
447446 let group = space_groups::Entity::find_by_id(group_id)
@@ -491,7 +490,7 @@
491490 space_name: &str,
492491 q: PageQuery,
493492 ) -> Result<GroupPage, SpaceError> {
494 let space = lookup_space_by_name(self, space_name).await?;
493 let space = lookup_space_by_name(self, caller, space_name).await?;
495494 require_perm(self, &space, caller, MANAGE_ROLES).await?;
496495
497496 let max_pagination = self.config().space.max_pagination;
@@ -569,7 +568,7 @@
569568 uid: i64,
570569 cmd: AssignGroupCmd,
571570 ) -> Result<(), SpaceError> {
572 let space = lookup_space_by_name(self, space_name).await?;
571 let space = lookup_space_by_name(self, caller, space_name).await?;
573572 require_perm(self, &space, caller, MANAGE_ROLES).await?;
574573
575574 space_groups::Entity::find_by_id(cmd.group_id)
@@ -578,10 +577,9 @@
578577 .await?
579578 .ok_or(SpaceError::NotFound)?;
580579
581 space_members::Entity::find_by_id((space.id, uid))
582 .one(self.db().read())
583 .await?
584 .ok_or(SpaceError::NotFound)?;
580 authority::require_member(&self.state, space.id, uid)
581 .await
582 .map_err(|_| SpaceError::NotFound)?;
585583
586584 let client = self
587585 .state
@@ -589,7 +587,12 @@
589587 .as_ref()
590588 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
591589 client
592 .assign_role(caller, &space.id.to_string(), uid, &cmd.group_id.to_string())
590 .assign_role(
591 caller,
592 &space.id.to_string(),
593 uid,
594 &cmd.group_id.to_string(),
595 )
593596 .await?;
594597
595598 let now = OffsetDateTime::now_utc();
@@ -611,7 +614,7 @@
611614 uid: i64,
612615 group_id: Uuid,
613616 ) -> Result<(), SpaceError> {
614 let space = lookup_space_by_name(self, space_name).await?;
617 let space = lookup_space_by_name(self, caller, space_name).await?;
615618 require_perm(self, &space, caller, MANAGE_ROLES).await?;
616619
617620 let client = self
@@ -639,7 +642,7 @@
639642 space_name: &str,
640643 group_id: Uuid,
641644 ) -> Result<(), SpaceError> {
642 let space = lookup_space_by_name(self, space_name).await?;
645 let space = lookup_space_by_name(self, caller, space_name).await?;
643646 effective_permissions(&self.state, &space, caller).await?;
644647
645648 if !self.config().space.allow_self_join_groups {
@@ -665,12 +668,7 @@
665668 .as_ref()
666669 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
667670 client
668 .assign_role(
669 caller,
670 &space.id.to_string(),
671 caller,
672 &group_id.to_string(),
673 )
671 .assign_role(caller, &space.id.to_string(), caller, &group_id.to_string())
674672 .await?;
675673
676674 let now = OffsetDateTime::now_utc();
@@ -691,7 +689,7 @@
691689 space_name: &str,
692690 group_id: Uuid,
693691 ) -> Result<(), SpaceError> {
694 let space = lookup_space_by_name(self, space_name).await?;
692 let space = lookup_space_by_name(self, caller, space_name).await?;
695693 effective_permissions(&self.state, &space, caller).await?;
696694
697695 let client = self
@@ -700,12 +698,7 @@
700698 .as_ref()
701699 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
702700 client
703 .unassign_role(
704 caller,
705 &space.id.to_string(),
706 caller,
707 &group_id.to_string(),
708 )
701 .unassign_role(caller, &space.id.to_string(), caller, &group_id.to_string())
709702 .await?;
710703
711704 let res = space_member_groups::Entity::delete_many()
Modified lib/space/src/services/images.rs View lib/space/src/services/images.rs
@@ -133,33 +133,71 @@
133133 .filter(|url| !url.is_empty())
134134}
135135
136fn write_space_image(
137 am: &mut spaces::ActiveModel,
136async fn write_space_image(
137 state: &export::AppState,
138 space_id: Uuid,
138139 kind: SpaceImageKind,
139140 url: Option<String>,
140141 width: Option<i32>,
141142 height: Option<i32>,
142) {
143 match kind {
144 SpaceImageKind::Avatar => {
145 am.avatar_url = Set(url);
146 }
147 SpaceImageKind::Banner => {
148 am.banner_url = Set(url);
149 am.banner_width = Set(width);
150 am.banner_height = Set(height);
151 }
152 SpaceImageKind::InviteBanner => {
153 am.invite_banner_url = Set(url);
154 am.invite_banner_width = Set(width);
155 am.invite_banner_height = Set(height);
156 }
157 SpaceImageKind::EmbedSplash => {
158 am.embed_splash_url = Set(url);
159 am.embed_splash_width = Set(width);
160 am.embed_splash_height = Set(height);
161 }
162 }
143) -> Result<(), SpaceError> {
144 let (avatar, banner, banner_w, banner_h, invite, invite_w, invite_h, embed, embed_w, embed_h) =
145 match kind {
146 SpaceImageKind::Avatar => (
147 Some(url),
148 None,
149 None,
150 None,
151 None,
152 None,
153 None,
154 None,
155 None,
156 None,
157 ),
158 SpaceImageKind::Banner => (
159 None,
160 Some(url),
161 Some(width),
162 Some(height),
163 None,
164 None,
165 None,
166 None,
167 None,
168 None,
169 ),
170 SpaceImageKind::InviteBanner => (
171 None,
172 None,
173 None,
174 None,
175 Some(url),
176 Some(width),
177 Some(height),
178 None,
179 None,
180 None,
181 ),
182 SpaceImageKind::EmbedSplash => (
183 None,
184 None,
185 None,
186 None,
187 None,
188 None,
189 None,
190 Some(url),
191 Some(width),
192 Some(height),
193 ),
194 };
195 crate::authority::upsert_extras(
196 state, space_id, avatar, None, banner, banner_w, banner_h, invite, invite_w, invite_h,
197 embed, embed_w, embed_h, None,
198 )
199 .await?;
200 Ok(())
163201}
164202
165203/// Everything the sidecar records about one written image. Grouped into a struct
@@ -351,7 +389,7 @@
351389 caller: Option<i64>,
352390 space_name: &str,
353391 ) -> Result<spaces::Model, SpaceError> {
354 let space = lookup_space_by_name(self, space_name).await?;
392 let space = lookup_space_by_name(self, caller.unwrap_or(0), space_name).await?;
355393 if !space.is_active {
356394 return Err(SpaceError::NotFound);
357395 }
@@ -379,7 +417,7 @@
379417 dims: SpaceImageUploadQuery,
380418 ) -> Result<SpaceImageDto, SpaceError> {
381419 let SpaceImageUploadQuery { width, height } = dims;
382 let space = lookup_space_by_name(self, space_name).await?;
420 let space = lookup_space_by_name(self, caller, space_name).await?;
383421 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
384422
385423 let fmt = AvatarFormat::from_content_type(content_type).ok_or_else(|| {
@@ -415,10 +453,15 @@
415453 .put_with_content_type(&object_key, bytes, fmt.content_type())
416454 .await?;
417455
418 let mut am: spaces::ActiveModel = space.into();
419 write_space_image(&mut am, kind, Some(stored_url.clone()), width, height);
420 am.updated_at = Set(now);
421 am.update(self.db().write()).await?;
456 write_space_image(
457 &self.state,
458 space_id,
459 kind,
460 Some(stored_url.clone()),
461 width,
462 height,
463 )
464 .await?;
422465
423466 if let Err(error) = export::redis::delete(
424467 &self.state.redis,
@@ -489,17 +532,13 @@
489532 space_name: &str,
490533 kind: SpaceImageKind,
491534 ) -> Result<(), SpaceError> {
492 let space = lookup_space_by_name(self, space_name).await?;
535 let space = lookup_space_by_name(self, caller, space_name).await?;
493536 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
494537
495538 let space_id = space.id;
496539 let old_url = stored_space_image_url(&space, kind);
497540
498 let now = OffsetDateTime::now_utc();
499 let mut am: spaces::ActiveModel = space.into();
500 write_space_image(&mut am, kind, None, None, None);
501 am.updated_at = Set(now);
502 am.update(self.db().write()).await?;
541 write_space_image(&self.state, space_id, kind, None, None, None).await?;
503542
504543 if let Err(error) = export::redis::delete(
505544 &self.state.redis,
Modified lib/space/src/services/invites.rs View lib/space/src/services/invites.rs
@@ -60,7 +60,6 @@
6060 pub code: String,
6161}
6262
63
6463fn proto_timestamp(value: OffsetDateTime) -> prost_types::Timestamp {
6564 prost_types::Timestamp {
6665 seconds: value.unix_timestamp(),
@@ -83,8 +82,6 @@
8382 }
8483 true
8584}
86
87
8885
8986impl SpaceService {
9087 #[tracing::instrument(name = "space.create_invite", skip_all, fields(caller = %caller, space = %space_name))]
@@ -94,7 +91,7 @@
9491 space_name: &str,
9592 cmd: CreateInviteCmd,
9693 ) -> Result<InviteDto, SpaceError> {
97 let space = lookup_space_by_name(self, space_name).await?;
94 let space = lookup_space_by_name(self, caller, space_name).await?;
9895 require_perm(self, &space, caller, MANAGE_INVITES).await?;
9996
10097 let email_norm = cmd
@@ -233,9 +230,8 @@
233230 email_norm.as_deref().unwrap_or_default(),
234231 )
235232 .await?;
236 let invite_id = Uuid::parse_str(&invite_info.id).map_err(|_| {
237 SpaceError::Internal("kenoauth returned a non-uuid invite".to_string())
238 })?;
233 let invite_id = Uuid::parse_str(&invite_info.id)
234 .map_err(|_| SpaceError::Internal("kenoauth returned a non-uuid invite".to_string()))?;
239235 let now = OffsetDateTime::now_utc();
240236 let final_email = email_norm.clone().unwrap_or_default();
241237
@@ -297,8 +293,7 @@
297293 }
298294 .insert(self.db().write())
299295 .await?;
300 export::notify::publish_notification(&self.state, invitee, row.to_event_value())
301 .await;
296 export::notify::publish_notification(&self.state, invitee, row.to_event_value()).await;
302297 }
303298
304299 let mut recipient_email: Option<String> = email_norm;
@@ -368,7 +363,7 @@
368363 space_name: &str,
369364 invite_id: Uuid,
370365 ) -> Result<InviteDto, SpaceError> {
371 let space = lookup_space_by_name(self, space_name).await?;
366 let space = lookup_space_by_name(self, caller, space_name).await?;
372367 require_perm(self, &space, caller, MANAGE_INVITES).await?;
373368
374369 let invite = space_invite::Entity::find_by_id(invite_id)
@@ -440,11 +435,7 @@
440435 .as_ref()
441436 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
442437 client
443 .revoke_invite(
444 caller,
445 &invite.space_id.to_string(),
446 &invite.id.to_string(),
447 )
438 .revoke_invite(caller, &invite.space_id.to_string(), &invite.id.to_string())
448439 .await?;
449440 space_invite::Entity::update_many()
450441 .col_expr(
@@ -460,11 +451,7 @@
460451 }
461452
462453 #[tracing::instrument(name = "space.accept_invite", skip_all, fields(caller = %caller))]
463 pub async fn accept_invite(
464 &self,
465 caller: i64,
466 cmd: AcceptInviteCmd,
467 ) -> Result<(), SpaceError> {
454 pub async fn accept_invite(&self, caller: i64, cmd: AcceptInviteCmd) -> Result<(), SpaceError> {
468455 if !self
469456 .state
470457 .redis
@@ -533,15 +520,12 @@
533520 ));
534521 }
535522
536 let space = spaces::Entity::find_by_id(invite.space_id)
537 .one(self.db().read())
538 .await?
539 .ok_or(SpaceError::NotFound)?;
523 let space = crate::authority::space_by_id(&self.state, caller, invite.space_id).await?;
540524 if !space.is_active {
541525 return Err(SpaceError::NotFound);
542526 }
543527
544 assert_not_banned(self.db().read(), space.id, caller).await?;
528 assert_not_banned(&self.state, space.id, caller).await?;
545529
546530 if space.member_must_enable_2fa {
547531 let caller_user = user::Entity::find_by_id(caller)
@@ -560,11 +544,9 @@
560544 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
561545 let redeemed = client.redeem_invite(caller, &cmd.code).await?;
562546
563 let existing_member = space_members::Entity::find_by_id((invite.space_id, caller))
564 .one(self.db().read())
565 .await?;
566 if let Some(m) = &existing_member
567 && m.status == MemberStatus::Active
547 if let Some(member) =
548 crate::authority::member(&self.state, caller, invite.space_id, caller).await?
549 && member.status == MemberStatus::Active
568550 {
569551 return Err(SpaceError::Conflict(
570552 "user is already a member of this space".to_string(),
@@ -572,31 +554,8 @@
572554 }
573555
574556 let now = OffsetDateTime::now_utc();
575 let member_am: Option<space_members::ActiveModel> = existing_member.map(|m| m.into());
576 let member_row = space_members::ActiveModel {
577 space_id: Set(invite.space_id),
578 user_id: Set(caller),
579 alias_name: Set(None),
580 alias_avatar_url: Set(None),
581 status: Set(MemberStatus::Active),
582 last_active_at: Set(None),
583 left_at: Set(None),
584 invited_at: Set(Some(invite.created_at)),
585 invited_by: Set(None),
586 joined_at: Set(now),
587 created_at: Set(now),
588 updated_at: Set(Some(now)),
589 message_notifications: Set(MessageNotifications::All),
590 muted: Set(false),
591 mute_until: Set(None),
592 mobile_push: Set(false),
593 suppress_everyone: Set(false),
594 suppress_roles: Set(false),
595 hide_muted_channels: Set(false),
596 ..Default::default()
597 };
598
599557 let invite_space_id = invite.space_id;
558 let invite_created_at = invite.created_at;
600559 let mut invite_am: space_invite::ActiveModel = invite.into();
601560 invite_am.accepted_at = Set(Some(now));
602561
@@ -604,16 +563,6 @@
604563 .primary()
605564 .transaction::<_, (), SpaceError>(|txn| {
606565 Box::pin(async move {
607 if let Some(am) = member_am {
608 let mut am = am;
609 am.status = Set(MemberStatus::Active);
610 am.left_at = Set(None);
611 am.updated_at = Set(Some(now));
612 am.update(txn).await?;
613 } else {
614 member_row.insert(txn).await?;
615 }
616
617566 for gid in group_ids {
618567 let mg = space_member_groups::ActiveModel {
619568 space_id: Set(space.id),
@@ -623,12 +572,6 @@
623572 };
624573 mg.insert(txn).await?;
625574 }
626
627 spaces::Entity::update_many()
628 .filter(spaces::Column::Id.eq(space.id))
629 .col_expr(spaces::Column::NumsMembers, Expr::cust("nums_members + 1"))
630 .exec(txn)
631 .await?;
632575
633576 invite_am.update(txn).await?;
634577
@@ -637,9 +580,14 @@
637580 })
638581 .await?;
639582
640 if let Some(member) = redeemed.member.as_ref() {
641 crate::sync::upsert_member(self.db().write(), invite_space_id, member).await?;
642 }
583 let _ = redeemed;
584 crate::authority::upsert_member_extras(
585 &self.state,
586 invite_space_id,
587 caller,
588 Some(invite_created_at),
589 )
590 .await?;
643591 Ok(())
644592 }
645593
@@ -649,7 +597,7 @@
649597 space_name: &str,
650598 q: PageQuery,
651599 ) -> Result<InvitePage, SpaceError> {
652 let space = lookup_space_by_name(self, space_name).await?;
600 let space = lookup_space_by_name(self, caller, space_name).await?;
653601 require_perm(self, &space, caller, MANAGE_INVITES).await?;
654602
655603 let max_pagination = self.config().space.max_pagination;
Modified lib/space/src/services/join_requests.rs View lib/space/src/services/join_requests.rs
@@ -19,7 +19,6 @@
1919use uuid::Uuid;
2020
2121use crate::errors::{SpaceError, is_unique_violation};
22use crate::services::members::{MemberActivationDefaults, activate_member_row};
2322use crate::services::service::SpaceService;
2423use crate::services::spaces::{UserDto, resolve_space_image_url};
2524use crate::share::authz::{MANAGE_MEMBERS, assert_not_banned, require_perm};
@@ -200,16 +199,21 @@
200199 .map(|mg| mg.user_id)
201200 .collect();
202201 if !candidates.is_empty() {
203 let active: Vec<i64> = space_members::Entity::find()
204 .filter(space_members::Column::SpaceId.eq(space.id))
205 .filter(space_members::Column::UserId.is_in(candidates.clone()))
206 .filter(space_members::Column::Status.eq(MemberStatus::Active))
207 .all(self.db().read())
208 .await?
209 .into_iter()
210 .map(|m| m.user_id)
211 .collect();
212 uids.extend(active);
202 let candidate_set: HashSet<i64> = candidates.into_iter().collect();
203 let active = crate::authority::list_members(
204 &self.state,
205 space.owner_id,
206 space.id,
207 false,
208 false,
209 )
210 .await?;
211 uids.extend(
212 active
213 .into_iter()
214 .filter(|member| candidate_set.contains(&member.user_id))
215 .map(|member| member.user_id),
216 );
213217 }
214218 }
215219 Ok(uids.into_iter().collect())
@@ -234,8 +238,7 @@
234238 }
235239 .insert(self.db().write())
236240 .await?;
237 export::notify::publish_notification(&self.state, recipient, row.to_event_value())
238 .await;
241 export::notify::publish_notification(&self.state, recipient, row.to_event_value()).await;
239242 Ok(())
240243 }
241244
@@ -255,36 +258,26 @@
255258 .unwrap_or(0)
256259 .clamp(0, export::pagination::MAX_OFFSET);
257260
258 let mut select = spaces::Entity::find()
259 .filter(spaces::Column::IsActive.eq(true))
260 .filter(spaces::Column::IsPrivate.eq(false));
261 if let Some(raw) = query
262 .q
263 .as_deref()
264 .map(str::trim)
265 .filter(|value| !value.is_empty())
266 {
267 let needle = raw.to_ascii_lowercase();
268 select = select.filter(
269 Condition::any()
270 .add(spaces::Column::LowerName.contains(needle))
271 .add(spaces::Column::Name.contains(raw)),
272 );
261 let mut rows = crate::authority::list_public_spaces(
262 &self.state,
263 query.q.as_deref().unwrap_or_default(),
264 )
265 .await?;
266 if query.sort.as_deref() == Some("newest") {
267 rows.sort_by(|a, b| b.created_at.cmp(&a.created_at));
268 } else {
269 rows.sort_by(|a, b| {
270 b.nums_members
271 .cmp(&a.nums_members)
272 .then_with(|| b.created_at.cmp(&a.created_at))
273 });
273274 }
274 select = if query.sort.as_deref() == Some("newest") {
275 select.order_by_desc(spaces::Column::CreatedAt)
276 } else {
277 select
278 .order_by_desc(spaces::Column::NumsMembers)
279 .order_by_desc(spaces::Column::CreatedAt)
280 };
281
282 let total = select.clone().count(self.db().read()).await? as i64;
283 let rows = select
284 .limit(limit)
285 .offset(offset)
286 .all(self.db().read())
287 .await?;
275 let total = rows.len() as i64;
276 let rows: Vec<spaces::Model> = rows
277 .into_iter()
278 .skip(offset as usize)
279 .take(limit as usize)
280 .collect();
288281
289282 let mut memberships: HashSet<Uuid> = HashSet::new();
290283 let mut latest_requests: HashMap<Uuid, space_join_request::Model> = HashMap::new();
@@ -292,14 +285,16 @@
292285 && !rows.is_empty()
293286 {
294287 let space_ids: Vec<Uuid> = rows.iter().map(|s| s.id).collect();
295 memberships = space_members::Entity::find()
296 .filter(space_members::Column::UserId.eq(uid))
297 .filter(space_members::Column::SpaceId.is_in(space_ids.clone()))
298 .filter(space_members::Column::Status.eq(MemberStatus::Active))
299 .all(self.db().read())
288 let joined: HashSet<Uuid> = crate::authority::list_user_spaces(&self.state, uid, uid)
300289 .await?
301290 .into_iter()
302 .map(|m| m.space_id)
291 .filter(|space| space.is_active)
292 .map(|space| space.id)
293 .collect();
294 memberships = space_ids
295 .iter()
296 .copied()
297 .filter(|id| joined.contains(id))
303298 .collect();
304299 let requests = space_join_request::Entity::find()
305300 .filter(space_join_request::Column::UserId.eq(uid))
@@ -380,7 +375,7 @@
380375 "join request quota exceeded, retry tomorrow".to_string(),
381376 ));
382377 }
383 let space = lookup_space_by_name(self, space_name).await?;
378 let space = lookup_space_by_name(self, caller, space_name).await?;
384379 if space.is_private || !space.allow_join {
385380 return Err(SpaceError::JoinNotAllowed(
386381 "this space does not accept join requests".to_string(),
@@ -392,23 +387,19 @@
392387 .await?
393388 .ok_or(SpaceError::NotFound)?;
394389
395 let existing = space_members::Entity::find_by_id((space.id, caller))
396 .one(self.db().read())
397 .await?;
398 if existing.is_some_and(|member| member.status == MemberStatus::Active) {
399 return Err(SpaceError::Conflict(
400 "user is already a member of this space".to_string(),
401 ));
402 }
403
404 if let Some(ban) = models::space::space_bans::Entity::find_by_id((space.id, caller))
405 .one(self.db().read())
406 .await?
407 && (ban.expires_at.is_none() || ban.expires_at > Some(OffsetDateTime::now_utc()))
390 if let Some(member) =
391 crate::authority::member(&self.state, caller, space.id, caller).await?
408392 {
409 return Err(SpaceError::JoinNotAllowed(
410 "you are banned from this space".to_string(),
411 ));
393 if member.status == MemberStatus::Active {
394 return Err(SpaceError::Conflict(
395 "user is already a member of this space".to_string(),
396 ));
397 }
398 if member.status == MemberStatus::Banned {
399 return Err(SpaceError::JoinNotAllowed(
400 "you are banned from this space".to_string(),
401 ));
402 }
412403 }
413404
414405 let pending = space_join_request::Entity::find()
@@ -523,7 +514,7 @@
523514 space_name: &str,
524515 request_id: Uuid,
525516 ) -> Result<(), SpaceError> {
526 let space = lookup_space_by_name(self, space_name).await?;
517 let space = lookup_space_by_name(self, caller, space_name).await?;
527518 let request = space_join_request::Entity::find_by_id(request_id)
528519 .filter(space_join_request::Column::SpaceId.eq(space.id))
529520 .one(self.db().read())
@@ -575,7 +566,7 @@
575566 space_name: &str,
576567 query: ListJoinRequestsQuery,
577568 ) -> Result<JoinRequestPage, SpaceError> {
578 let space = lookup_space_by_name(self, space_name).await?;
569 let space = lookup_space_by_name(self, caller, space_name).await?;
579570 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
580571
581572 let max_pagination = self.config().space.max_pagination;
@@ -625,7 +616,7 @@
625616 request_id: Uuid,
626617 approve: bool,
627618 ) -> Result<JoinRequestDto, SpaceError> {
628 let space = lookup_space_by_name(self, space_name).await?;
619 let space = lookup_space_by_name(self, caller, space_name).await?;
629620 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
630621
631622 let request = space_join_request::Entity::find_by_id(request_id)
@@ -640,7 +631,7 @@
640631 }
641632
642633 if approve {
643 assert_not_banned(self.db().read(), space.id, request.user_id).await?;
634 assert_not_banned(&self.state, space.id, request.user_id).await?;
644635 let applicant = user::Entity::find_by_id(request.user_id)
645636 .one(self.db().read())
646637 .await?
@@ -701,25 +692,12 @@
701692 "join request is not pending".to_string(),
702693 ));
703694 }
704 if approve {
705 activate_member_row(
706 txn,
707 space_id,
708 applicant_id,
709 now,
710 MemberActivationDefaults::default(),
711 )
712 .await?;
713 }
714695 Ok(())
715696 })
716697 })
717698 .await?;
718699
719700 if approve {
720 if let Some(member) = decided_member.as_ref() {
721 crate::sync::upsert_member(self.db().write(), space_id, member).await?;
722 }
723701 self.grant_public_room_memberships(space_id, applicant_id)
724702 .await;
725703 }
@@ -745,7 +723,8 @@
745723 .one(self.db().read())
746724 .await?
747725 .ok_or(SpaceError::NotFound)?;
748 let users: HashMap<i64, UserDto> = self.load_user_briefs(&[updated.user_id, caller]).await?;
726 let users: HashMap<i64, UserDto> =
727 self.load_user_briefs(&[updated.user_id, caller]).await?;
749728 Ok(join_request_dto(updated, &users))
750729 }
751730
Modified lib/space/src/services/join_rules.rs View lib/space/src/services/join_rules.rs
@@ -80,7 +80,7 @@
8080/// Idempotent: membership rows are unique per (space, user, group) and races
8181/// resolve to a no-op via the unique-violation path.
8282pub async fn apply_space_join_rules(
83 db: &impl ConnectionTrait,
83 state: &export::AppState,
8484 space_id: Uuid,
8585 user_id: i64,
8686 trigger: SpaceJoinTrigger,
@@ -98,23 +98,21 @@
9898 .add(space_join_rule::Column::Matcher.eq(""))
9999 .add(space_join_rule::Column::Matcher.eq(matcher)),
100100 )
101 .all(db)
101 .all(state.database.read())
102102 .await?;
103103 if rules.is_empty() {
104104 return Ok(());
105105 }
106106
107 let Some(member) = space_members::Entity::find_by_id((space_id, user_id))
108 .one(db)
109 .await?
110 else {
111 return Ok(());
112 };
113 if member.status != MemberStatus::Active {
107 if let Some(member) = crate::authority::member(state, user_id, space_id, user_id).await?
108 && member.status == MemberStatus::Active
109 {
110 // continue below
111 } else {
114112 return Ok(());
115113 }
116114 if let Some(ban) = space_bans::Entity::find_by_id((space_id, user_id))
117 .one(db)
115 .one(state.database.read())
118116 .await?
119117 && (ban.expires_at.is_none() || ban.expires_at > Some(now))
120118 {
@@ -124,14 +122,14 @@
124122 for rule in &rules {
125123 let group_exists = space_groups::Entity::find_by_id(rule.group_id)
126124 .filter(space_groups::Column::SpaceId.eq(space_id))
127 .one(db)
125 .one(state.database.read())
128126 .await?
129127 .is_some();
130128 if !group_exists {
131129 continue;
132130 }
133131 let existing = space_member_groups::Entity::find_by_id((space_id, user_id, rule.group_id))
134 .one(db)
132 .one(state.database.read())
135133 .await?;
136134 if existing.is_some() {
137135 continue;
@@ -162,7 +160,7 @@
162160 group_id: Set(rule.group_id),
163161 created_at: Set(now),
164162 };
165 match row.insert(db).await {
163 match row.insert(state.database.write()).await {
166164 Ok(_) => {}
167165 Err(err) if is_unique_violation(&err) => {}
168166 Err(err) => return Err(SpaceError::from(err)),
@@ -170,7 +168,6 @@
170168 }
171169 Ok(())
172170}
173
174171
175172fn trigger_name(value: SpaceJoinTrigger) -> &'static str {
176173 match value {
@@ -187,7 +184,7 @@
187184 space_name: &str,
188185 cmd: CreateJoinRuleCmd,
189186 ) -> Result<JoinRuleDto, SpaceError> {
190 let space = lookup_space_by_name(self, space_name).await?;
187 let space = lookup_space_by_name(self, caller, space_name).await?;
191188 require_perm(self, &space, caller, MANAGE_ROLES).await?;
192189 let matcher = normalize_matcher(&cmd.matcher)?;
193190
@@ -237,7 +234,7 @@
237234 rule_id: Uuid,
238235 cmd: UpdateJoinRuleCmd,
239236 ) -> Result<JoinRuleDto, SpaceError> {
240 let space = lookup_space_by_name(self, space_name).await?;
237 let space = lookup_space_by_name(self, caller, space_name).await?;
241238 require_perm(self, &space, caller, MANAGE_ROLES).await?;
242239 let Some(row) = space_join_rule::Entity::find_by_id(rule_id)
243240 .filter(space_join_rule::Column::SpaceId.eq(space.id))
@@ -288,7 +285,7 @@
288285 space_name: &str,
289286 rule_id: Uuid,
290287 ) -> Result<(), SpaceError> {
291 let space = lookup_space_by_name(self, space_name).await?;
288 let space = lookup_space_by_name(self, caller, space_name).await?;
292289 require_perm(self, &space, caller, MANAGE_ROLES).await?;
293290 let client = self
294291 .state
@@ -315,7 +312,7 @@
315312 caller: i64,
316313 space_name: &str,
317314 ) -> Result<Vec<JoinRuleDto>, SpaceError> {
318 let space = lookup_space_by_name(self, space_name).await?;
315 let space = lookup_space_by_name(self, caller, space_name).await?;
319316 effective_permissions(&self.state, &space, caller).await?;
320317 let rows = space_join_rule::Entity::find()
321318 .filter(space_join_rule::Column::SpaceId.eq(space.id))
Modified lib/space/src/services/members.rs View lib/space/src/services/members.rs
@@ -14,6 +14,7 @@
1414use utoipa::ToSchema;
1515use uuid::Uuid;
1616
17use crate::authority;
1718use crate::errors::SpaceError;
1819use crate::services::service::SpaceService;
1920use crate::services::spaces::{PageQuery, UserDto};
@@ -96,7 +97,6 @@
9697
9798pub type MemberPage = export::pagination::Page<MemberDto>;
9899
99
100100fn proto_timestamp(value: OffsetDateTime) -> prost_types::Timestamp {
101101 prost_types::Timestamp {
102102 seconds: value.unix_timestamp(),
@@ -115,78 +115,30 @@
115115/// Presentation/notification values a member row is created with when the
116116/// caller does not sit in the admin "add member" flow (join-request approval,
117117/// invite acceptance).
118#[derive(Debug, Clone, Default)]
119pub struct MemberActivationDefaults {
120 pub alias_name: Option<String>,
121 pub alias_avatar_url: Option<String>,
122 pub message_notifications: Option<MessageNotifications>,
123 pub muted: Option<bool>,
124 pub mute_until: Option<OffsetDateTime>,
125 pub mobile_push: Option<bool>,
126 pub suppress_everyone: Option<bool>,
127 pub suppress_roles: Option<bool>,
128 pub hide_muted_channels: Option<bool>,
129}
130
131/// Revive a left/removed member row or insert a fresh one, bumping
132/// `nums_members` exactly once. Returns `false` when the row was already
133/// active, so a racing caller can settle for the existing membership instead
134/// of double-counting. Callers own the transaction and every permission, ban
135/// and 2FA check.
136pub async fn activate_member_row<C: ConnectionTrait>(
137 txn: &C,
138 space_id: Uuid,
139 user_id: i64,
140 now: OffsetDateTime,
141 defaults: MemberActivationDefaults,
142) -> Result<bool, SpaceError> {
143 let existing = space_members::Entity::find_by_id((space_id, user_id))
144 .one(txn)
145 .await?;
146 match existing {
147 Some(member) if member.status == MemberStatus::Active => return Ok(false),
148 Some(member) => {
149 let mut am: space_members::ActiveModel = member.into();
150 am.status = Set(MemberStatus::Active);
151 am.left_at = Set(None);
152 am.updated_at = Set(Some(now));
153 am.update(txn).await?;
154 }
155 None => {
156 let member_row = space_members::ActiveModel {
157 space_id: Set(space_id),
158 user_id: Set(user_id),
159 alias_name: Set(defaults.alias_name),
160 alias_avatar_url: Set(defaults.alias_avatar_url),
161 status: Set(MemberStatus::Active),
162 last_active_at: Set(None),
163 left_at: Set(None),
164 invited_at: Set(None),
165 invited_by: Set(None),
166 joined_at: Set(now),
167 created_at: Set(now),
168 updated_at: Set(Some(now)),
169 message_notifications: Set(defaults
170 .message_notifications
171 .unwrap_or(MessageNotifications::All)),
172 muted: Set(defaults.muted.unwrap_or(false)),
173 mute_until: Set(defaults.mute_until),
174 mobile_push: Set(defaults.mobile_push.unwrap_or(false)),
175 suppress_everyone: Set(defaults.suppress_everyone.unwrap_or(false)),
176 suppress_roles: Set(defaults.suppress_roles.unwrap_or(false)),
177 hide_muted_channels: Set(defaults.hide_muted_channels.unwrap_or(false)),
178 ..Default::default()
179 };
180 member_row.insert(txn).await?;
181 }
118fn member_dto(member: space_members::Model, users: &HashMap<i64, UserDto>) -> MemberDto {
119 let user = users.get(&member.user_id).cloned();
120 let invited_by = member.invited_by.and_then(|uid| users.get(&uid).cloned());
121 MemberDto {
122 space_id: member.space_id,
123 user,
124 alias_name: member.alias_name,
125 alias_avatar_url: member.alias_avatar_url,
126 status: member.status,
127 last_active_at: member.last_active_at,
128 left_at: member.left_at,
129 invited_at: member.invited_at,
130 invited_by,
131 joined_at: member.joined_at,
132 created_at: member.created_at,
133 updated_at: member.updated_at,
134 message_notifications: member.message_notifications,
135 muted: member.muted,
136 mute_until: member.mute_until,
137 mobile_push: member.mobile_push,
138 suppress_everyone: member.suppress_everyone,
139 suppress_roles: member.suppress_roles,
140 hide_muted_channels: member.hide_muted_channels,
182141 }
183
184 spaces::Entity::update_many()
185 .filter(spaces::Column::Id.eq(space_id))
186 .col_expr(spaces::Column::NumsMembers, Expr::cust("nums_members + 1"))
187 .exec(txn)
188 .await?;
189 Ok(true)
190142}
191143
192144impl SpaceService {
@@ -228,7 +180,7 @@
228180 space_name: &str,
229181 cmd: AddMemberCmd,
230182 ) -> Result<MemberDto, SpaceError> {
231 let space = lookup_space_by_name(self, space_name).await?;
183 let space = lookup_space_by_name(self, caller, space_name).await?;
232184 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
233185
234186 let target_user = user::Entity::find_by_id(cmd.user_id)
@@ -236,7 +188,7 @@
236188 .await?
237189 .ok_or(SpaceError::NotFound)?;
238190
239 assert_not_banned(self.db().read(), space.id, cmd.user_id).await?;
191 assert_not_banned(&self.state, space.id, cmd.user_id).await?;
240192
241193 if space.member_must_enable_2fa && !target_user.totp_enabled {
242194 return Err(SpaceError::Forbidden);
@@ -248,12 +200,8 @@
248200 return Err(SpaceError::BadRequest("alias name is too long".to_string()));
249201 }
250202
251 let client = self
252 .state
253 .kenauth
254 .as_ref()
255 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
256 let (member, created) = client
203 let client = authority::client(&self.state)?;
204 let (_, created) = client
257205 .add_member(caller, &space.id.to_string(), cmd.user_id, 0, Vec::new())
258206 .await?;
259207 if !created {
@@ -262,68 +210,42 @@
262210 ));
263211 }
264212
265 let now = OffsetDateTime::now_utc();
266 let mut projected = crate::sync::upsert_member(self.db().write(), space.id, &member).await?;
267
268 let mut local: space_members::ActiveModel = projected.clone().into();
269 if let Some(value) = cmd.alias_name {
270 local.alias_name = Set(Some(value));
271 }
272 if let Some(value) = cmd.alias_avatar_url {
273 local.alias_avatar_url = Set(Some(value));
274 }
275 if let Some(value) = cmd.message_notifications {
276 local.message_notifications = Set(value);
277 }
278 if let Some(value) = cmd.muted {
279 local.muted = Set(value);
280 }
281 if let Some(value) = cmd.mute_until {
282 local.mute_until = Set(Some(value));
283 }
284 if let Some(value) = cmd.mobile_push {
285 local.mobile_push = Set(value);
286 }
287 if let Some(value) = cmd.suppress_everyone {
288 local.suppress_everyone = Set(value);
289 }
290 if let Some(value) = cmd.suppress_roles {
291 local.suppress_roles = Set(value);
292 }
293 if let Some(value) = cmd.hide_muted_channels {
294 local.hide_muted_channels = Set(value);
213 let has_prefs = cmd.alias_name.is_some()
214 || cmd.alias_avatar_url.is_some()
215 || cmd.message_notifications.is_some()
216 || cmd.muted.is_some()
217 || cmd.mute_until.is_some()
218 || cmd.mobile_push.is_some()
219 || cmd.suppress_everyone.is_some()
220 || cmd.suppress_roles.is_some()
221 || cmd.hide_muted_channels.is_some();
222 if has_prefs {
223 client
224 .update_member(
225 caller,
226 &space.id.to_string(),
227 cmd.user_id,
228 cmd.alias_name.map(Some),
229 cmd.alias_avatar_url.map(Some),
230 cmd.message_notifications.map(notifications_name),
231 cmd.muted,
232 cmd.mute_until.map(|value| Some(proto_timestamp(value))),
233 cmd.mobile_push,
234 cmd.suppress_everyone,
235 cmd.suppress_roles,
236 cmd.hide_muted_channels,
237 )
238 .await?;
295239 }
296 local.updated_at = Set(Some(now));
297 projected = local.update(self.db().write()).await?;
298 crate::sync::bump_members(self.db().write(), space.id, 1).await?;
299240
300241 self.grant_public_room_memberships(space.id, cmd.user_id)
301242 .await;
302243
244 let member = authority::member(&self.state, caller, space.id, cmd.user_id)
245 .await?
246 .ok_or(SpaceError::NotFound)?;
303247 let user_map: HashMap<i64, UserDto> = self.load_user_briefs(&[cmd.user_id]).await?;
304 let user_brief = user_map.get(&cmd.user_id).cloned();
305
306 Ok(MemberDto {
307 space_id: projected.space_id,
308 user: user_brief,
309 alias_name: projected.alias_name,
310 alias_avatar_url: projected.alias_avatar_url,
311 status: projected.status,
312 last_active_at: projected.last_active_at,
313 left_at: projected.left_at,
314 invited_at: projected.invited_at,
315 invited_by: None,
316 joined_at: projected.joined_at,
317 created_at: projected.created_at,
318 updated_at: projected.updated_at,
319 message_notifications: projected.message_notifications,
320 muted: projected.muted,
321 mute_until: projected.mute_until,
322 mobile_push: projected.mobile_push,
323 suppress_everyone: projected.suppress_everyone,
324 suppress_roles: projected.suppress_roles,
325 hide_muted_channels: projected.hide_muted_channels,
326 })
248 Ok(member_dto(member, &user_map))
327249 }
328250
329251 pub async fn get_member(
@@ -332,13 +254,12 @@
332254 space_name: &str,
333255 uid: i64,
334256 ) -> Result<MemberDto, SpaceError> {
335 let space = lookup_space_by_name(self, space_name).await?;
257 let space = lookup_space_by_name(self, caller, space_name).await?;
336258 if caller != uid {
337259 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
338260 }
339261
340 let member = space_members::Entity::find_by_id((space.id, uid))
341 .one(self.db().read())
262 let member = authority::member(&self.state, caller, space.id, uid)
342263 .await?
343264 .ok_or(SpaceError::NotFound)?;
344265
@@ -347,30 +268,7 @@
347268 uids.push(inv_by);
348269 }
349270 let user_map: HashMap<i64, UserDto> = self.load_user_briefs(&uids).await?;
350 let user_brief = user_map.get(&member.user_id).cloned();
351 let inviter_brief = member.invited_by.and_then(|ib| user_map.get(&ib).cloned());
352
353 Ok(MemberDto {
354 space_id: member.space_id,
355 user: user_brief,
356 alias_name: member.alias_name,
357 alias_avatar_url: member.alias_avatar_url,
358 status: member.status,
359 last_active_at: member.last_active_at,
360 left_at: member.left_at,
361 invited_at: member.invited_at,
362 invited_by: inviter_brief,
363 joined_at: member.joined_at,
364 created_at: member.created_at,
365 updated_at: member.updated_at,
366 message_notifications: member.message_notifications,
367 muted: member.muted,
368 mute_until: member.mute_until,
369 mobile_push: member.mobile_push,
370 suppress_everyone: member.suppress_everyone,
371 suppress_roles: member.suppress_roles,
372 hide_muted_channels: member.hide_muted_channels,
373 })
271 Ok(member_dto(member, &user_map))
374272 }
375273
376274 #[tracing::instrument(name = "space.update_member", skip_all, fields(caller = %caller, space = %space_name, uid = %uid))]
@@ -381,16 +279,11 @@
381279 uid: i64,
382280 cmd: UpdateMemberCmd,
383281 ) -> Result<MemberDto, SpaceError> {
384 let space = lookup_space_by_name(self, space_name).await?;
282 let space = lookup_space_by_name(self, caller, space_name).await?;
385283 if caller != uid {
386284 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
387285 }
388286
389 let member = space_members::Entity::find_by_id((space.id, uid))
390 .one(self.db().read())
391 .await?
392 .ok_or(SpaceError::NotFound)?;
393
394287 if let Some(alias) = &cmd.alias_name
395288 && let Some(value) = alias
396289 && value.chars().count() > self.config().space.max_alias_name_len
@@ -410,11 +303,9 @@
410303 uid,
411304 cmd.alias_name.clone(),
412305 cmd.alias_avatar_url.clone(),
413 cmd.message_notifications
414 .map(notifications_name),
306 cmd.message_notifications.map(notifications_name),
415307 cmd.muted,
416 cmd.mute_until
417 .map(|value| value.map(proto_timestamp)),
308 cmd.mute_until.map(|value| value.map(proto_timestamp)),
418309 cmd.mobile_push,
419310 cmd.suppress_everyone,
420311 cmd.suppress_roles,
@@ -422,127 +313,39 @@
422313 )
423314 .await?;
424315
425 let mut am: space_members::ActiveModel = member.into();
426
427 if let Some(alias) = cmd.alias_name {
428 if let Some(a) = &alias
429 && a.chars().count() > self.config().space.max_alias_name_len
430 {
431 return Err(SpaceError::BadRequest("alias name is too long".to_string()));
432 }
433 am.alias_name = Set(alias);
434 }
435 if let Some(url) = cmd.alias_avatar_url {
436 am.alias_avatar_url = Set(url);
437 }
438 if let Some(mn) = cmd.message_notifications {
439 am.message_notifications = Set(mn);
440 }
441 if let Some(muted) = cmd.muted {
442 am.muted = Set(muted);
443 }
444 if let Some(mute_until) = cmd.mute_until {
445 am.mute_until = Set(mute_until);
446 }
447 if let Some(mp) = cmd.mobile_push {
448 am.mobile_push = Set(mp);
449 }
450 if let Some(se) = cmd.suppress_everyone {
451 am.suppress_everyone = Set(se);
452 }
453 if let Some(sr) = cmd.suppress_roles {
454 am.suppress_roles = Set(sr);
455 }
456 if let Some(hmc) = cmd.hide_muted_channels {
457 am.hide_muted_channels = Set(hmc);
458 }
459
460 let now = OffsetDateTime::now_utc();
461 am.updated_at = Set(Some(now));
462
463 let updated = am.update(self.db().write()).await?;
316 let updated = authority::member(&self.state, caller, space.id, uid)
317 .await?
318 .ok_or(SpaceError::NotFound)?;
464319
465320 let mut uids = vec![updated.user_id];
466321 if let Some(inv_by) = updated.invited_by {
467322 uids.push(inv_by);
468323 }
469324 let user_map: HashMap<i64, UserDto> = self.load_user_briefs(&uids).await?;
470 let user_brief = user_map.get(&updated.user_id).cloned();
471 let inviter_brief = updated.invited_by.and_then(|ib| user_map.get(&ib).cloned());
472
473 Ok(MemberDto {
474 space_id: updated.space_id,
475 user: user_brief,
476 alias_name: updated.alias_name,
477 alias_avatar_url: updated.alias_avatar_url,
478 status: updated.status,
479 last_active_at: updated.last_active_at,
480 left_at: updated.left_at,
481 invited_at: updated.invited_at,
482 invited_by: inviter_brief,
483 joined_at: updated.joined_at,
484 created_at: updated.created_at,
485 updated_at: updated.updated_at,
486 message_notifications: updated.message_notifications,
487 muted: updated.muted,
488 mute_until: updated.mute_until,
489 mobile_push: updated.mobile_push,
490 suppress_everyone: updated.suppress_everyone,
491 suppress_roles: updated.suppress_roles,
492 hide_muted_channels: updated.hide_muted_channels,
493 })
325 Ok(member_dto(updated, &user_map))
494326 }
495327
496328 #[tracing::instrument(name = "space.leave_space", skip_all, fields(caller = %caller, space = %space_name))]
497329 pub async fn leave_space(&self, caller: i64, space_name: &str) -> Result<(), SpaceError> {
498 let space = lookup_space_by_name(self, space_name).await?;
330 let space = lookup_space_by_name(self, caller, space_name).await?;
499331 if space.owner_id == caller {
500332 return Err(SpaceError::BadRequest(
501333 "space owner cannot leave; delete the space instead".to_string(),
502334 ));
503335 }
504 let member = space_members::Entity::find_by_id((space.id, caller))
505 .one(self.db().read())
506 .await?
507 .ok_or(SpaceError::NotFound)?;
508 if member.status != MemberStatus::Active {
336 let member = authority::member(&self.state, caller, space.id, caller).await?;
337 if !matches!(member, Some(ref member) if member.status == MemberStatus::Active) {
509338 return Ok(());
510339 }
511 let client = self
512 .state
513 .kenauth
514 .as_ref()
515 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
516 client
340 authority::client(&self.state)?
517341 .leave_organization(caller, &space.id.to_string())
518342 .await?;
519 let now = OffsetDateTime::now_utc();
520 let mut am: space_members::ActiveModel = member.into();
521 am.status = Set(MemberStatus::Left);
522 am.left_at = Set(Some(now));
523 am.updated_at = Set(Some(now));
524 self.db()
525 .primary()
526 .transaction::<_, (), SpaceError>(|txn| {
527 Box::pin(async move {
528 space_member_groups::Entity::delete_many()
529 .filter(space_member_groups::Column::SpaceId.eq(space.id))
530 .filter(space_member_groups::Column::UserId.eq(caller))
531 .exec(txn)
532 .await?;
533 am.update(txn).await?;
534 spaces::Entity::update_many()
535 .filter(spaces::Column::Id.eq(space.id))
536 .col_expr(
537 spaces::Column::NumsMembers,
538 sea_orm::sea_query::Expr::cust("GREATEST(nums_members - 1, 0)"),
539 )
540 .exec(txn)
541 .await?;
542 Ok(())
543 })
544 })
343 space_member_groups::Entity::delete_many()
344 .filter(space_member_groups::Column::SpaceId.eq(space.id))
345 .filter(space_member_groups::Column::UserId.eq(caller))
346 .exec(self.db().write())
545347 .await?;
348 authority::remove_member_extras(&self.state, space.id, caller).await?;
546349 Ok(())
547350 }
548351
@@ -553,7 +356,7 @@
553356 space_name: &str,
554357 uid: i64,
555358 ) -> Result<(), SpaceError> {
556 let space = lookup_space_by_name(self, space_name).await?;
359 let space = lookup_space_by_name(self, caller, space_name).await?;
557360 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
558361 if caller == uid {
559362 return Err(SpaceError::BadRequest(
@@ -561,53 +364,21 @@
561364 ));
562365 }
563366
564 let member = space_members::Entity::find_by_id((space.id, uid))
565 .one(self.db().read())
566 .await?
567 .ok_or(SpaceError::NotFound)?;
568
569 if member.status != MemberStatus::Active {
367 let member = authority::member(&self.state, caller, space.id, uid).await?;
368 if !matches!(member, Some(ref member) if member.status == MemberStatus::Active) {
570369 return Ok(());
571370 }
572371
573 let client = self
574 .state
575 .kenauth
576 .as_ref()
577 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
578 client
372 authority::client(&self.state)?
579373 .remove_member(caller, &space.id.to_string(), uid, "")
580374 .await?;
581375
582 let now = OffsetDateTime::now_utc();
583 let mut am: space_members::ActiveModel = member.into();
584 am.status = Set(MemberStatus::Removed);
585 am.left_at = Set(Some(now));
586 am.updated_at = Set(Some(now));
587
588 self.db()
589 .primary()
590 .transaction::<_, (), SpaceError>(|txn| {
591 Box::pin(async move {
592 space_member_groups::Entity::delete_many()
593 .filter(space_member_groups::Column::SpaceId.eq(space.id))
594 .filter(space_member_groups::Column::UserId.eq(uid))
595 .exec(txn)
596 .await?;
597
598 am.update(txn).await?;
599
600 spaces::Entity::update_many()
601 .filter(spaces::Column::Id.eq(space.id))
602 .col_expr(spaces::Column::NumsMembers, Expr::cust("nums_members - 1"))
603 .exec(txn)
604 .await?;
605
606 Ok(())
607 })
608 })
376 space_member_groups::Entity::delete_many()
377 .filter(space_member_groups::Column::SpaceId.eq(space.id))
378 .filter(space_member_groups::Column::UserId.eq(uid))
379 .exec(self.db().write())
609380 .await?;
610
381 authority::remove_member_extras(&self.state, space.id, uid).await?;
611382 Ok(())
612383 }
613384
@@ -617,7 +388,7 @@
617388 space_name: &str,
618389 q: PageQuery,
619390 ) -> Result<MemberPage, SpaceError> {
620 let space = lookup_space_by_name(self, space_name).await?;
391 let space = lookup_space_by_name(self, caller, space_name).await?;
621392 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
622393
623394 let max_pagination = self.config().space.max_pagination;
@@ -630,17 +401,10 @@
630401 .offset
631402 .unwrap_or(0)
632403 .clamp(0, export::pagination::MAX_OFFSET);
633
634 let select = space_members::Entity::find()
635 .filter(space_members::Column::SpaceId.eq(space.id))
636 .order_by_asc(space_members::Column::JoinedAt);
637404
638 let total = select.clone().count(self.db().read()).await? as i64;
639 let rows = select
640 .limit(limit)
641 .offset(offset)
642 .all(self.db().read())
643 .await?;
405 let mut rows = authority::list_members(&self.state, caller, space.id, true, true).await?;
406 rows.sort_by_key(|member| member.joined_at);
407 let total = rows.len() as i64;
644408
645409 let mut uids: Vec<i64> = Vec::new();
646410 for m in &rows {
@@ -653,31 +417,9 @@
653417
654418 let items = rows
655419 .into_iter()
656 .map(|m| {
657 let user_brief = user_map.get(&m.user_id).cloned();
658 let inviter_brief = m.invited_by.and_then(|ib| user_map.get(&ib).cloned());
659 MemberDto {
660 space_id: m.space_id,
661 user: user_brief,
662 alias_name: m.alias_name,
663 alias_avatar_url: m.alias_avatar_url,
664 status: m.status,
665 last_active_at: m.last_active_at,
666 left_at: m.left_at,
667 invited_at: m.invited_at,
668 invited_by: inviter_brief,
669 joined_at: m.joined_at,
670 created_at: m.created_at,
671 updated_at: m.updated_at,
672 message_notifications: m.message_notifications,
673 muted: m.muted,
674 mute_until: m.mute_until,
675 mobile_push: m.mobile_push,
676 suppress_everyone: m.suppress_everyone,
677 suppress_roles: m.suppress_roles,
678 hide_muted_channels: m.hide_muted_channels,
679 }
680 })
420 .skip(offset as usize)
421 .take(limit as usize)
422 .map(|member| member_dto(member, &user_map))
681423 .collect();
682424
683425 Ok(MemberPage {
Modified lib/space/src/services/rooms.rs View lib/space/src/services/rooms.rs
@@ -11,6 +11,7 @@
1111use utoipa::ToSchema;
1212use uuid::Uuid;
1313
14use crate::authority;
1415use crate::errors::SpaceError;
1516use crate::services::service::SpaceService;
1617use crate::share::authz::{MANAGE_CHANNELS, require_perm};
@@ -105,7 +106,7 @@
105106 "room creation quota exceeded, retry tomorrow".to_string(),
106107 ));
107108 }
108 let space = lookup_space_by_name(self, space_name).await?;
109 let space = lookup_space_by_name(self, caller, space_name).await?;
109110 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
110111 if !space.is_active {
111112 return Err(SpaceError::NotFound);
@@ -189,14 +190,11 @@
189190 };
190191
191192 let member_uids: Vec<i64> = if !cmd.is_private {
192 space_members::Entity::find()
193 .filter(space_members::Column::SpaceId.eq(space.id))
194 .filter(space_members::Column::Status.eq(MemberStatus::Active))
195 .select_only()
196 .column(space_members::Column::UserId)
197 .into_tuple::<i64>()
198 .all(self.db().read())
193 authority::list_members(&self.state, caller, space.id, false, false)
199194 .await?
195 .into_iter()
196 .map(|member| member.user_id)
197 .collect()
200198 } else {
201199 vec![]
202200 };
@@ -232,7 +230,6 @@
232230
233231 export::events::enqueue_chat_users(
234232 self.db().write(),
235 self.state.events_enabled,
236233 "chat.membership.create",
237234 &member_uids
238235 .iter()
@@ -261,10 +258,7 @@
261258 let Some(space_id) = room.space_id else {
262259 return Err(SpaceError::NotFound);
263260 };
264 let space = spaces::Entity::find_by_id(space_id)
265 .one(self.db().read())
266 .await?
267 .ok_or(SpaceError::NotFound)?;
261 let space = authority::space_by_id(&self.state, caller, space_id).await?;
268262 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
269263 if !space.is_active {
270264 return Err(SpaceError::NotFound);
@@ -278,7 +272,6 @@
278272
279273 export::events::enqueue_room(
280274 self.db().write(),
281 self.state.events_enabled,
282275 "chat.room.update",
283276 room_id,
284277 serde_json::json!({ "room_id": room_id, "deleted": true }),
Modified lib/space/src/services/spaces.rs View lib/space/src/services/spaces.rs
@@ -2,15 +2,13 @@
22
33use models::space::spaces::{self, AllowJoinLevel};
44use models::users::user;
5use sea_orm::{
6 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
7 Set,
8};
5use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
96use serde::{Deserialize, Serialize};
107use time::OffsetDateTime;
118use utoipa::{IntoParams, ToSchema};
129use uuid::Uuid;
1310
11use crate::authority;
1412use crate::errors::SpaceError;
1513use crate::services::service::SpaceService;
1614use crate::share::authz::effective_permissions;
@@ -125,6 +123,29 @@
125123
126124pub use export::pagination::PageQuery;
127125
126fn space_dto(space: spaces::Model, owners: &HashMap<i64, UserDto>) -> SpaceDto {
127 let owner = owners.get(&space.owner_id).cloned();
128 SpaceDto {
129 id: space.id,
130 lower_name: space.lower_name.clone(),
131 name: space.name,
132 description: space.description,
133 owner,
134 avatar_url: resolve_space_image_url(&space.lower_name, "avatar", space.avatar_url),
135 is_private: space.is_private,
136 is_active: space.is_active,
137 allow_join: space.allow_join,
138 allow_invite: space.allow_invite,
139 members_count: space.nums_members,
140 welcome_message: space.welcome_message,
141 allow_join_level: space.allow_join_level,
142 member_must_enable_2fa: space.member_must_enable_2fa,
143 welcome_object: space.welcome_object,
144 created_at: space.created_at,
145 updated_at: space.updated_at,
146 }
147}
148
128149#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
129150pub struct SpaceNamePath {
130151 pub space_name: String,
@@ -201,21 +222,7 @@
201222 )));
202223 }
203224
204 let existing = spaces::Entity::find()
205 .filter(spaces::Column::LowerName.eq(&lower))
206 .one(self.db().read())
207 .await?;
208 if existing.is_some() {
209 return Err(SpaceError::Conflict(format!(
210 "space name {lower} is already taken"
211 )));
212 }
213
214 let client = self
215 .state
216 .kenauth
217 .as_ref()
218 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
225 let client = authority::client(&self.state)?;
219226 let allow_join_level = cmd.allow_join_level.unwrap_or(AllowJoinLevel::None);
220227 let policy = if cmd.is_private {
221228 4
@@ -238,6 +245,8 @@
238245 Some(false),
239246 cmd.welcome_message.clone(),
240247 Some(cmd.member_must_enable_2fa),
248 Some(cmd.allow_invite),
249 Some(i32::from(allow_join_level.as_i16())),
241250 )
242251 .await?;
243252 let organization = created
@@ -246,42 +255,25 @@
246255 let space_id = Uuid::parse_str(&organization.id).map_err(|_| {
247256 SpaceError::Internal("kenoauth returned a non-uuid organization".to_string())
248257 })?;
249 let mut inserted_space = crate::sync::upsert_space(self.db().write(), &organization).await?;
250 let mut local: spaces::ActiveModel = inserted_space.clone().into();
251 local.allow_invite = Set(cmd.allow_invite);
252 local.allow_join_level = Set(allow_join_level);
253 local.welcome_object = Set(cmd.welcome_object);
254 inserted_space = local.update(self.db().write()).await?;
255 if let Some(member) = created.member.as_ref() {
256 crate::sync::upsert_member(self.db().write(), space_id, member).await?;
257 }
258
259 let owner_map: HashMap<i64, UserDto> = self.load_user_briefs(&[caller]).await?;
260 let owner = owner_map.get(&caller).cloned();
261
262 Ok(SpaceDto {
263 id: inserted_space.id,
264 lower_name: inserted_space.lower_name.clone(),
265 name: inserted_space.name,
266 description: inserted_space.description,
267 owner,
268 avatar_url: resolve_space_image_url(
269 &inserted_space.lower_name,
270 "avatar",
271 inserted_space.avatar_url,
272 ),
273 is_private: inserted_space.is_private,
274 is_active: inserted_space.is_active,
275 allow_join: inserted_space.allow_join,
276 allow_invite: inserted_space.allow_invite,
277 members_count: inserted_space.nums_members,
278 welcome_message: inserted_space.welcome_message,
279 allow_join_level: inserted_space.allow_join_level,
280 member_must_enable_2fa: inserted_space.member_must_enable_2fa,
281 welcome_object: inserted_space.welcome_object,
282 created_at: inserted_space.created_at,
283 updated_at: inserted_space.updated_at,
284 })
258 authority::upsert_extras(
259 &self.state,
260 space_id,
261 None,
262 Some(cmd.welcome_object),
263 None,
264 None,
265 None,
266 None,
267 None,
268 None,
269 None,
270 None,
271 None,
272 None,
273 )
274 .await?;
275 let space = authority::space_by_id(&self.state, caller, space_id).await?;
276 Ok(space_dto(space, &self.load_user_briefs(&[caller]).await?))
285277 }
286278
287279 pub async fn get_space(
@@ -289,7 +281,8 @@
289281 caller: Option<i64>,
290282 space_name: &str,
291283 ) -> Result<SpaceDto, SpaceError> {
292 let space = lookup_space_by_name(self, space_name).await?;
284 let actor = caller.unwrap_or(0);
285 let space = lookup_space_by_name(self, actor, space_name).await?;
293286 if !space.is_active {
294287 return Err(SpaceError::NotFound);
295288 }
@@ -305,28 +298,10 @@
305298 }
306299 }
307300
308 let owner_map: HashMap<i64, UserDto> = self.load_user_briefs(&[space.owner_id]).await?;
309 let owner = owner_map.get(&space.owner_id).cloned();
310
311 Ok(SpaceDto {
312 id: space.id,
313 lower_name: space.lower_name.clone(),
314 name: space.name,
315 description: space.description,
316 owner,
317 avatar_url: resolve_space_image_url(&space.lower_name, "avatar", space.avatar_url),
318 is_private: space.is_private,
319 is_active: space.is_active,
320 allow_join: space.allow_join,
321 allow_invite: space.allow_invite,
322 members_count: space.nums_members,
323 welcome_message: space.welcome_message,
324 allow_join_level: space.allow_join_level,
325 member_must_enable_2fa: space.member_must_enable_2fa,
326 welcome_object: space.welcome_object,
327 created_at: space.created_at,
328 updated_at: space.updated_at,
329 })
301 Ok(space_dto(
302 space.clone(),
303 &self.load_user_briefs(&[space.owner_id]).await?,
304 ))
330305 }
331306
332307 #[tracing::instrument(name = "space.update_space", skip_all, fields(caller = %caller, space = %space_name))]
@@ -336,7 +311,7 @@
336311 space_name: &str,
337312 cmd: UpdateSpaceCmd,
338313 ) -> Result<SpaceDto, SpaceError> {
339 let space = lookup_space_by_name(self, space_name).await?;
314 let space = lookup_space_by_name(self, caller, space_name).await?;
340315 if space.owner_id != caller {
341316 return Err(SpaceError::Forbidden);
342317 }
@@ -350,28 +325,6 @@
350325 return Err(SpaceError::BadRequest(
351326 "space name is empty or too long".to_string(),
352327 ));
353 }
354 let lower = trimmed.to_lowercase();
355 if !is_valid_lower_name(&lower) {
356 return Err(SpaceError::BadRequest(
357 "invalid space name: must contain only lowercase letters, digits, and hyphens"
358 .to_string(),
359 ));
360 }
361 if is_reserved_lower_name(&lower) {
362 return Err(SpaceError::Conflict(format!(
363 "space name {lower} is reserved"
364 )));
365 }
366 let existing = spaces::Entity::find()
367 .filter(spaces::Column::LowerName.eq(&lower))
368 .filter(spaces::Column::Id.ne(space.id))
369 .one(self.db().read())
370 .await?;
371 if existing.is_some() {
372 return Err(SpaceError::Conflict(format!(
373 "space name {lower} is already taken"
374 )));
375328 }
376329 name = Some(trimmed.to_string());
377330 }
@@ -383,7 +336,10 @@
383336 "space description is too long".to_string(),
384337 ));
385338 }
386 if let Some(welcome) = cmd.welcome_message.as_ref().and_then(|value| value.as_ref())
339 if let Some(welcome) = cmd
340 .welcome_message
341 .as_ref()
342 .and_then(|value| value.as_ref())
387343 && welcome.chars().count() > self.config().space.max_space_desc_len
388344 {
389345 return Err(SpaceError::BadRequest(
@@ -403,91 +359,66 @@
403359 let allow_join_level = cmd.allow_join_level.unwrap_or(space.allow_join_level);
404360 let require_email_verified = !matches!(allow_join_level, AllowJoinLevel::None);
405361
406 let client = self
407 .state
408 .kenauth
409 .as_ref()
410 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
411 let updated = client
362 authority::client(&self.state)?
412363 .update_organization(
413364 caller,
414365 &space.id.to_string(),
415366 name,
416 cmd.description.clone().map(|value| value.unwrap_or_default()),
367 cmd.description
368 .clone()
369 .map(|value| value.unwrap_or_default()),
417370 None,
418371 0,
419372 Some(policy),
420373 Some(next_allow_join),
421374 Some(require_email_verified),
422375 Some(false),
423 cmd.welcome_message.clone().map(|value| value.unwrap_or_default()),
376 cmd.welcome_message
377 .clone()
378 .map(|value| value.unwrap_or_default()),
424379 cmd.member_must_enable_2fa,
380 cmd.allow_invite,
381 Some(i32::from(allow_join_level.as_i16())),
425382 )
426383 .await?;
427 let mut projected = crate::sync::upsert_space(self.db().write(), &updated).await?;
428384
429 let mut local: spaces::ActiveModel = projected.clone().into();
430 if let Some(allow_invite) = cmd.allow_invite {
431 local.allow_invite = Set(allow_invite);
432 }
433 if let Some(level) = cmd.allow_join_level {
434 local.allow_join_level = Set(level);
385 if cmd.welcome_object.is_some() {
386 authority::upsert_extras(
387 &self.state,
388 space.id,
389 None,
390 cmd.welcome_object,
391 None,
392 None,
393 None,
394 None,
395 None,
396 None,
397 None,
398 None,
399 None,
400 None,
401 )
402 .await?;
435403 }
436 if let Some(welcome_object) = cmd.welcome_object {
437 local.welcome_object = Set(welcome_object);
438 }
439 local.updated_at = Set(OffsetDateTime::now_utc());
440 projected = local.update(self.db().write()).await?;
441
442 let owner_map: HashMap<i64, UserDto> = self.load_user_briefs(&[projected.owner_id]).await?;
443 let owner = owner_map.get(&projected.owner_id).cloned();
444
445 Ok(SpaceDto {
446 id: projected.id,
447 lower_name: projected.lower_name.clone(),
448 name: projected.name,
449 description: projected.description,
450 owner,
451 avatar_url: resolve_space_image_url(
452 &projected.lower_name,
453 "avatar",
454 projected.avatar_url,
455 ),
456 is_private: projected.is_private,
457 is_active: projected.is_active,
458 allow_join: projected.allow_join,
459 allow_invite: projected.allow_invite,
460 members_count: projected.nums_members,
461 welcome_message: projected.welcome_message,
462 allow_join_level: projected.allow_join_level,
463 member_must_enable_2fa: projected.member_must_enable_2fa,
464 welcome_object: projected.welcome_object,
465 created_at: projected.created_at,
466 updated_at: projected.updated_at,
467 })
404 let space = authority::space_by_id(&self.state, caller, space.id).await?;
405 Ok(space_dto(
406 space.clone(),
407 &self.load_user_briefs(&[space.owner_id]).await?,
408 ))
468409 }
469410
470411 #[tracing::instrument(name = "space.delete_space", skip_all, fields(caller = %caller, space = %space_name))]
471412 pub async fn delete_space(&self, caller: i64, space_name: &str) -> Result<(), SpaceError> {
472 let space = lookup_space_by_name(self, space_name).await?;
413 let space = lookup_space_by_name(self, caller, space_name).await?;
473414 if space.owner_id != caller {
474415 return Err(SpaceError::Forbidden);
475416 }
476417
477 let client = self
478 .state
479 .kenauth
480 .as_ref()
481 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
482 client
418 authority::client(&self.state)?
483419 .delete_organization(caller, &space.id.to_string(), true, "space deleted")
484420 .await?;
485
486 let now = OffsetDateTime::now_utc();
487 let mut am: spaces::ActiveModel = space.into();
488 am.is_active = Set(false);
489 am.updated_at = Set(now);
490 am.update(self.db().write()).await?;
421 authority::remove_extras(&self.state, space.id).await?;
491422 Ok(())
492423 }
493424
@@ -503,46 +434,19 @@
503434 .unwrap_or(0)
504435 .clamp(0, export::pagination::MAX_OFFSET);
505436
506 let select = spaces::Entity::find()
507 .filter(spaces::Column::IsActive.eq(true))
508 .filter(spaces::Column::OwnerId.eq(caller))
509 .order_by_desc(spaces::Column::CreatedAt);
510
511 let total = select.clone().count(self.db().read()).await? as i64;
512 let rows = select
513 .limit(limit)
514 .offset(offset)
515 .all(self.db().read())
516 .await?;
517
518 let owner_ids: Vec<i64> = rows.iter().map(|s| s.owner_id).collect();
437 let all: Vec<spaces::Model> = authority::list_user_spaces(&self.state, caller, caller)
438 .await?
439 .into_iter()
440 .filter(|space| space.is_active && space.owner_id == caller)
441 .collect();
442 let total = all.len() as i64;
443 let owner_ids: Vec<i64> = all.iter().map(|s| s.owner_id).collect();
519444 let owner_map: HashMap<i64, UserDto> = self.load_user_briefs(&owner_ids).await?;
520
521 let items = rows
445 let items = all
522446 .into_iter()
523 .map(|s| {
524 let avatar_url = resolve_space_image_url(&s.lower_name, "avatar", s.avatar_url);
525 let owner = owner_map.get(&s.owner_id).cloned();
526 SpaceDto {
527 id: s.id,
528 lower_name: s.lower_name,
529 name: s.name,
530 description: s.description,
531 owner,
532 avatar_url,
533 is_private: s.is_private,
534 is_active: s.is_active,
535 allow_join: s.allow_join,
536 allow_invite: s.allow_invite,
537 members_count: s.nums_members,
538 welcome_message: s.welcome_message,
539 allow_join_level: s.allow_join_level,
540 member_must_enable_2fa: s.member_must_enable_2fa,
541 welcome_object: s.welcome_object,
542 created_at: s.created_at,
543 updated_at: s.updated_at,
544 }
545 })
447 .skip(offset as usize)
448 .take(limit as usize)
449 .map(|space| space_dto(space, &owner_map))
546450 .collect();
547451
548452 Ok(SpacePage {
Modified lib/space/src/share/authz.rs View lib/space/src/share/authz.rs
@@ -1,11 +1,9 @@
11use std::collections::HashSet;
22
33use export::AppState;
4use models::space::{space_bans, spaces};
5use sea_orm::{ConnectionTrait, EntityTrait};
6use time::OffsetDateTime;
7use uuid::Uuid;
4use models::space::spaces;
85
6use crate::authority;
97use crate::errors::SpaceError;
108use crate::services::service::SpaceService;
119
@@ -31,8 +29,6 @@
3129 MANAGE_OVERWRITES,
3230];
3331
34const PERMISSION_CACHE_TTL_SECS: u64 = 300;
35
3632#[derive(Debug, Clone, Default)]
3733pub struct Permissions {
3834 granted: HashSet<String>,
@@ -64,82 +60,36 @@
6460}
6561
6662pub async fn assert_not_banned(
67 db: &impl ConnectionTrait,
68 space_id: Uuid,
63 state: &AppState,
64 space_id: uuid::Uuid,
6965 user_id: i64,
7066) -> Result<(), SpaceError> {
71 let now = OffsetDateTime::now_utc();
72 if let Some(ban) = space_bans::Entity::find_by_id((space_id, user_id))
73 .one(db)
74 .await?
75 && (ban.expires_at.is_none() || ban.expires_at > Some(now))
67 if let Ok(Some(member)) = authority::member(state, user_id, space_id, user_id).await
68 && member.status == models::space::space_members::MemberStatus::Banned
7669 {
7770 return Err(SpaceError::Forbidden);
7871 }
7972 Ok(())
8073}
8174
82fn cache_key(space: &spaces::Model, user_id: i64) -> String {
83 format!(
84 "space:perm:{}:{}:{}",
85 space.id, user_id, space.version
86 )
87}
88
89fn decode(raw: &str) -> Vec<String> {
90 raw.split(',')
91 .map(str::trim)
92 .filter(|value| !value.is_empty())
93 .map(String::from)
94 .collect()
95}
96
75/// KenOAuth is the only source of truth for organization permission decisions.
76/// There is deliberately no local owner shortcut and no version-keyed cache:
77/// a stale local row must never be able to grant access.
9778pub async fn effective_permissions(
9879 state: &AppState,
9980 space: &spaces::Model,
10081 user_id: i64,
10182) -> Result<Permissions, SpaceError> {
102 if space.owner_id == user_id {
103 return Ok(Permissions::owner());
104 }
105
106 let key = cache_key(space, user_id);
107 if let Ok(Some(raw)) = export::redis::get_string(&state.redis, &key).await {
108 let granted = decode(&raw);
109 if granted.is_empty() {
110 return Err(SpaceError::Forbidden);
111 }
112 return Ok(Permissions::from_vec(granted));
113 }
114
115 let client = state
116 .kenauth
117 .as_ref()
118 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_string()))?;
119 let permissions: Vec<String> = ALL_PERMISSIONS
83 let names: Vec<String> = ALL_PERMISSIONS
12084 .iter()
12185 .map(|action| format!("space.{action}"))
12286 .collect();
123 let response = client
124 .check_permissions(user_id, &space.id.to_string(), user_id, permissions)
125 .await?;
126 let mut granted = Vec::new();
127 for (index, action) in ALL_PERMISSIONS.iter().enumerate() {
128 if response.allowed.get(index).copied().unwrap_or(false) {
129 granted.push((*action).to_string());
130 }
131 }
132 let encoded = granted.join(",");
133 if let Err(error) = export::redis::set_string(
134 &state.redis,
135 &key,
136 &encoded,
137 PERMISSION_CACHE_TTL_SECS,
138 )
139 .await
140 {
141 tracing::warn!(%error, "space permission cache write failed");
142 }
87 let allowed = authority::permissions(state, space.id, user_id, &names).await?;
88 let granted: Vec<String> = ALL_PERMISSIONS
89 .iter()
90 .zip(allowed)
91 .filter_map(|(action, allowed)| allowed.then(|| (*action).to_string()))
92 .collect();
14393 if granted.is_empty() {
14494 return Err(SpaceError::Forbidden);
14595 }
@@ -157,4 +107,4 @@
157107 return Err(SpaceError::Forbidden);
158108 }
159109 Ok(())
160}
No newline at end of file
110}
Modified lib/space/src/share/lookup.rs View lib/space/src/share/lookup.rs
@@ -1,18 +1,34 @@
11use models::space::spaces;
2use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
2use uuid::Uuid;
33
4use crate::authority;
45use crate::errors::SpaceError;
56use crate::services::service::SpaceService;
67
78pub async fn lookup_space_by_name(
89 service: &SpaceService,
10 caller: i64,
911 space_name: &str,
1012) -> Result<spaces::Model, SpaceError> {
11 let lower_name = space_name.to_lowercase();
12 spaces::Entity::find()
13 .filter(spaces::Column::LowerName.eq(&lower_name))
14 .one(service.db().read())
15 .await?
16 .filter(|s| s.is_active)
17 .ok_or(SpaceError::NotFound)
13 let lower_name = space_name.trim().to_lowercase();
14 if lower_name.is_empty() {
15 return Err(SpaceError::NotFound);
16 }
17 let space = authority::space_by_slug(&service.state, caller, &lower_name).await?;
18 if !space.is_active {
19 return Err(SpaceError::NotFound);
20 }
21 Ok(space)
22}
23
24pub async fn lookup_space_by_id(
25 service: &SpaceService,
26 caller: i64,
27 space_id: Uuid,
28) -> Result<spaces::Model, SpaceError> {
29 let space = authority::space_by_id(&service.state, caller, space_id).await?;
30 if !space.is_active {
31 return Err(SpaceError::NotFound);
32 }
33 Ok(space)
1834}
Added lib/space/src/authority.rs View lib/space/src/authority.rs
@@ -0,0 +1,530 @@
1use export::AppState;
2use kenauth::proto;
3use models::space::space_extras::{self, Entity as SpaceExtras};
4use models::space::space_member_extras::{self, Entity as MemberExtras};
5use models::space::space_members::{self, MemberStatus, MessageNotifications};
6use models::space::spaces::{self, AllowJoinLevel};
7use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter, Set};
8use time::OffsetDateTime;
9use uuid::Uuid;
10
11use crate::errors::SpaceError;
12
13pub const RPC_KEY: &str = "kenoauth is not configured";
14
15pub fn client(state: &AppState) -> Result<&kenauth::KenAuthClient, SpaceError> {
16 state
17 .kenauth
18 .as_deref()
19 .ok_or_else(|| SpaceError::Internal(RPC_KEY.to_string()))
20}
21
22fn timestamp(value: &Option<prost_types::Timestamp>) -> Option<OffsetDateTime> {
23 let value = value.as_ref()?;
24 OffsetDateTime::from_unix_timestamp(value.seconds)
25 .ok()?
26 .replace_nanosecond(value.nanos.clamp(0, 999_999_999) as u32)
27 .ok()
28}
29
30fn join_policy(value: i32) -> &'static str {
31 match proto::v1::OrganizationJoinPolicy::try_from(value)
32 .unwrap_or(proto::v1::OrganizationJoinPolicy::InviteOnly)
33 {
34 proto::v1::OrganizationJoinPolicy::Open => "open",
35 proto::v1::OrganizationJoinPolicy::Approval => "approval",
36 proto::v1::OrganizationJoinPolicy::Private => "private",
37 _ => "invite_only",
38 }
39}
40
41fn status_of_member(member: &proto::v1::OrganizationMemberInfo) -> MemberStatus {
42 if member.banned_at.is_some() {
43 MemberStatus::Banned
44 } else if member.left_at.is_some() {
45 MemberStatus::Left
46 } else {
47 MemberStatus::Active
48 }
49}
50
51fn non_empty(value: &str) -> Option<String> {
52 let value = value.trim();
53 (!value.is_empty()).then(|| value.to_string())
54}
55
56pub fn space_from_info(
57 info: &proto::v1::OrganizationInfo,
58 extras: Option<&space_extras::Model>,
59) -> spaces::Model {
60 let id = Uuid::parse_str(info.id.trim()).unwrap_or_default();
61 let now = OffsetDateTime::now_utc();
62 let policy = join_policy(info.join_policy);
63 let status = proto::v1::OrganizationStatus::try_from(info.status)
64 .unwrap_or(proto::v1::OrganizationStatus::Active);
65 spaces::Model {
66 id,
67 lower_name: info.slug.clone(),
68 name: info.name.clone(),
69 description: non_empty(&info.description),
70 owner_id: info.owner_user_id,
71 avatar_url: extras
72 .and_then(|row| row.avatar_url.clone())
73 .or_else(|| non_empty(&info.avatar_url)),
74 is_private: policy == "private",
75 is_active: status == proto::v1::OrganizationStatus::Active,
76 allow_join: info.allow_join,
77 allow_invite: info.allow_invite,
78 invite_sort_url_hash: extras.and_then(|row| row.invite_sort_url_hash.clone()),
79 nums_members: info.member_count as i64,
80 banner_url: extras.and_then(|row| row.banner_url.clone()),
81 banner_width: extras.and_then(|row| row.banner_width),
82 banner_height: extras.and_then(|row| row.banner_height),
83 invite_banner_url: extras.and_then(|row| row.invite_banner_url.clone()),
84 invite_banner_width: extras.and_then(|row| row.invite_banner_width),
85 invite_banner_height: extras.and_then(|row| row.invite_banner_height),
86 embed_splash_url: extras.and_then(|row| row.embed_splash_url.clone()),
87 embed_splash_width: extras.and_then(|row| row.embed_splash_width),
88 embed_splash_height: extras.and_then(|row| row.embed_splash_height),
89 welcome_message: non_empty(&info.welcome_message),
90 allow_join_level: AllowJoinLevel::from_i16(info.allow_join_level as i16),
91 member_must_enable_2fa: info.member_must_enable_2fa,
92 welcome_object: extras.and_then(|row| row.welcome_object),
93 join_policy: policy.to_string(),
94 require_email_verified: info.require_email_verified,
95 require_mfa: info.require_mfa,
96 version: info.version,
97 created_at: timestamp(&info.created_at).unwrap_or(now),
98 updated_at: timestamp(&info.updated_at).unwrap_or(now),
99 }
100}
101
102pub fn member_from_info(
103 info: &proto::v1::OrganizationMemberInfo,
104 extras: Option<&space_member_extras::Model>,
105) -> space_members::Model {
106 let now = OffsetDateTime::now_utc();
107 let joined_at = timestamp(&info.joined_at).unwrap_or(now);
108 space_members::Model {
109 space_id: Uuid::parse_str(info.organization_id.trim()).unwrap_or_default(),
110 user_id: info.user_id,
111 alias_name: non_empty(&info.alias_name),
112 alias_avatar_url: non_empty(&info.alias_avatar_url),
113 status: status_of_member(info),
114 last_active_at: timestamp(&info.last_active_at),
115 left_at: timestamp(&info.left_at),
116 invited_at: extras.and_then(|row| row.invited_at),
117 invited_by: (info.invited_by > 0).then_some(info.invited_by),
118 joined_at,
119 created_at: extras.map(|row| row.created_at).unwrap_or(joined_at),
120 updated_at: extras.map(|row| row.updated_at),
121 message_notifications: MessageNotifications::from_str(&info.message_notifications),
122 muted: info.muted,
123 mute_until: timestamp(&info.mute_until),
124 mobile_push: info.mobile_push,
125 suppress_everyone: info.suppress_everyone,
126 suppress_roles: info.suppress_roles,
127 hide_muted_channels: info.hide_muted_channels,
128 banned_at: timestamp(&info.banned_at),
129 ban_reason: non_empty(&info.ban_reason),
130 ban_expires_at: timestamp(&info.ban_expires_at),
131 ban_allow_speak: info.ban_allow_speak,
132 ban_allow_push_repo: info.ban_allow_push_repo,
133 ban_allow_comment_any: info.ban_allow_comment_any,
134 }
135}
136
137pub async fn extras(
138 state: &AppState,
139 space_id: Uuid,
140) -> Result<Option<space_extras::Model>, SpaceError> {
141 Ok(SpaceExtras::find_by_id(space_id)
142 .one(state.database.read())
143 .await?)
144}
145
146pub async fn member_extras(
147 state: &AppState,
148 space_id: Uuid,
149 user_id: i64,
150) -> Result<Option<space_member_extras::Model>, SpaceError> {
151 Ok(MemberExtras::find_by_id((space_id, user_id))
152 .one(state.database.read())
153 .await?)
154}
155
156pub async fn space_by_id(
157 state: &AppState,
158 actor: i64,
159 space_id: Uuid,
160) -> Result<spaces::Model, SpaceError> {
161 let response = client(state)?
162 .get_organization(actor, &space_id.to_string(), "")
163 .await?;
164 let info = response.organization.ok_or(SpaceError::NotFound)?;
165 let extras = extras(state, space_id).await?;
166 Ok(space_from_info(&info, extras.as_ref()))
167}
168
169pub async fn space_by_slug(
170 state: &AppState,
171 actor: i64,
172 slug: &str,
173) -> Result<spaces::Model, SpaceError> {
174 let response = client(state)?.get_organization(actor, "", slug).await?;
175 let info = response.organization.ok_or(SpaceError::NotFound)?;
176 let id = Uuid::parse_str(info.id.trim()).unwrap_or_default();
177 let extras = extras(state, id).await?;
178 Ok(space_from_info(&info, extras.as_ref()))
179}
180
181pub async fn member(
182 state: &AppState,
183 actor: i64,
184 space_id: Uuid,
185 user_id: i64,
186) -> Result<Option<space_members::Model>, SpaceError> {
187 let client = client(state)?;
188 let info = match client
189 .get_member(actor, &space_id.to_string(), user_id)
190 .await
191 {
192 Ok(Some(member)) => member,
193 Ok(None) => return Ok(None),
194 Err(kenauth::KenAuthError::Rejected(message))
195 if message.to_lowercase().contains("forbidden")
196 || message.to_lowercase().contains("permission") =>
197 {
198 return Ok(None);
199 }
200 Err(error) => return Err(error.into()),
201 };
202 let extras = member_extras(state, space_id, user_id).await?;
203 Ok(Some(member_from_info(&info, extras.as_ref())))
204}
205
206pub async fn require_member(
207 state: &AppState,
208 space_id: Uuid,
209 user_id: i64,
210) -> Result<space_members::Model, SpaceError> {
211 match member(state, user_id, space_id, user_id).await? {
212 Some(member) if member.status == MemberStatus::Active => Ok(member),
213 _ => Err(SpaceError::Forbidden),
214 }
215}
216
217pub async fn list_members(
218 state: &AppState,
219 actor: i64,
220 space_id: Uuid,
221 include_inactive: bool,
222 include_banned: bool,
223) -> Result<Vec<space_members::Model>, SpaceError> {
224 let client = client(state)?;
225 let mut members = Vec::new();
226 let mut token = String::new();
227 loop {
228 let response = client
229 .list_members(
230 actor,
231 &space_id.to_string(),
232 include_inactive,
233 include_banned,
234 proto::common::v1::PageRequest {
235 page_size: 100,
236 page_token: token.clone(),
237 },
238 )
239 .await?;
240 members.extend(response.members);
241 let next = response
242 .page
243 .map(|page| page.next_page_token)
244 .unwrap_or_default();
245 if next.is_empty() {
246 break;
247 }
248 token = next;
249 }
250
251 let user_ids: Vec<i64> = members.iter().map(|member| member.user_id).collect();
252 let extras = if user_ids.is_empty() {
253 Vec::new()
254 } else {
255 MemberExtras::find()
256 .filter(space_member_extras::Column::SpaceId.eq(space_id))
257 .filter(space_member_extras::Column::UserId.is_in(user_ids))
258 .all(state.database.read())
259 .await?
260 };
261 let mut extras: std::collections::HashMap<i64, space_member_extras::Model> =
262 extras.into_iter().map(|row| (row.user_id, row)).collect();
263 Ok(members
264 .iter()
265 .map(|info| member_from_info(info, extras.remove(&info.user_id).as_ref()))
266 .collect())
267}
268
269pub async fn list_user_spaces(
270 state: &AppState,
271 actor: i64,
272 user_id: i64,
273) -> Result<Vec<spaces::Model>, SpaceError> {
274 let client = client(state)?;
275 let mut organizations = Vec::new();
276 let mut token = String::new();
277 loop {
278 let response = client
279 .list_organizations(
280 actor,
281 user_id,
282 false,
283 proto::common::v1::PageRequest {
284 page_size: 100,
285 page_token: token.clone(),
286 },
287 )
288 .await?;
289 organizations.extend(response.organizations);
290 let next = response
291 .page
292 .map(|page| page.next_page_token)
293 .unwrap_or_default();
294 if next.is_empty() {
295 break;
296 }
297 token = next;
298 }
299
300 let ids: Vec<Uuid> = organizations
301 .iter()
302 .filter_map(|info| Uuid::parse_str(info.id.trim()).ok())
303 .collect();
304 let extras = if ids.is_empty() {
305 Vec::new()
306 } else {
307 SpaceExtras::find()
308 .filter(space_extras::Column::SpaceId.is_in(ids))
309 .all(state.database.read())
310 .await?
311 };
312 let mut extras: std::collections::HashMap<Uuid, space_extras::Model> =
313 extras.into_iter().map(|row| (row.space_id, row)).collect();
314 Ok(organizations
315 .iter()
316 .map(|info| {
317 let id = Uuid::parse_str(info.id.trim()).unwrap_or_default();
318 space_from_info(info, extras.remove(&id).as_ref())
319 })
320 .collect())
321}
322
323/// Public directory (ACTIVE, non-PRIVATE). Authority is KenOAuth; the local
324/// extras row only contributes presentation fields.
325pub async fn list_public_spaces(
326 state: &AppState,
327 query: &str,
328) -> Result<Vec<spaces::Model>, SpaceError> {
329 let client = client(state)?;
330 let mut organizations = Vec::new();
331 let mut token = String::new();
332 loop {
333 let response = client
334 .list_public_organizations(
335 query,
336 proto::common::v1::PageRequest {
337 page_size: 100,
338 page_token: token.clone(),
339 },
340 )
341 .await?;
342 organizations.extend(response.organizations);
343 let next = response
344 .page
345 .map(|page| page.next_page_token)
346 .unwrap_or_default();
347 if next.is_empty() || organizations.len() >= 1_000 {
348 break;
349 }
350 token = next;
351 }
352
353 let ids: Vec<Uuid> = organizations
354 .iter()
355 .filter_map(|info| Uuid::parse_str(info.id.trim()).ok())
356 .collect();
357 let extras = if ids.is_empty() {
358 Vec::new()
359 } else {
360 SpaceExtras::find()
361 .filter(space_extras::Column::SpaceId.is_in(ids))
362 .all(state.database.read())
363 .await?
364 };
365 let mut extras: std::collections::HashMap<Uuid, space_extras::Model> =
366 extras.into_iter().map(|row| (row.space_id, row)).collect();
367 Ok(organizations
368 .iter()
369 .map(|info| {
370 let id = Uuid::parse_str(info.id.trim()).unwrap_or_default();
371 space_from_info(info, extras.remove(&id).as_ref())
372 })
373 .collect())
374}
375
376pub async fn permissions(
377 state: &AppState,
378 space_id: Uuid,
379 user_id: i64,
380 actions: &[String],
381) -> Result<Vec<bool>, SpaceError> {
382 let client = client(state)?;
383 let response = client
384 .check_permissions(user_id, &space_id.to_string(), user_id, actions.to_vec())
385 .await?;
386 Ok(response.allowed)
387}
388
389pub async fn upsert_extras(
390 state: &AppState,
391 space_id: Uuid,
392 avatar_url: Option<Option<String>>,
393 welcome_object: Option<Option<Uuid>>,
394 banner_url: Option<Option<String>>,
395 banner_width: Option<Option<i32>>,
396 banner_height: Option<Option<i32>>,
397 invite_banner_url: Option<Option<String>>,
398 invite_banner_width: Option<Option<i32>>,
399 invite_banner_height: Option<Option<i32>>,
400 embed_splash_url: Option<Option<String>>,
401 embed_splash_width: Option<Option<i32>>,
402 embed_splash_height: Option<Option<i32>>,
403 invite_sort_url_hash: Option<Option<String>>,
404) -> Result<space_extras::Model, SpaceError> {
405 let now = OffsetDateTime::now_utc();
406 let existing = extras(state, space_id).await?;
407 let row = match existing {
408 Some(row) => {
409 let mut active: space_extras::ActiveModel = row.into();
410 if let Some(value) = avatar_url {
411 active.avatar_url = Set(value);
412 }
413 if let Some(value) = welcome_object {
414 active.welcome_object = Set(value);
415 }
416 if let Some(value) = banner_url {
417 active.banner_url = Set(value);
418 }
419 if let Some(value) = banner_width {
420 active.banner_width = Set(value);
421 }
422 if let Some(value) = banner_height {
423 active.banner_height = Set(value);
424 }
425 if let Some(value) = invite_banner_url {
426 active.invite_banner_url = Set(value);
427 }
428 if let Some(value) = invite_banner_width {
429 active.invite_banner_width = Set(value);
430 }
431 if let Some(value) = invite_banner_height {
432 active.invite_banner_height = Set(value);
433 }
434 if let Some(value) = embed_splash_url {
435 active.embed_splash_url = Set(value);
436 }
437 if let Some(value) = embed_splash_width {
438 active.embed_splash_width = Set(value);
439 }
440 if let Some(value) = embed_splash_height {
441 active.embed_splash_height = Set(value);
442 }
443 if let Some(value) = invite_sort_url_hash {
444 active.invite_sort_url_hash = Set(value);
445 }
446 active.updated_at = Set(now);
447 active.update(state.database.write()).await?
448 }
449 None => {
450 space_extras::ActiveModel {
451 space_id: Set(space_id),
452 avatar_url: Set(avatar_url.unwrap_or(None)),
453 invite_sort_url_hash: Set(invite_sort_url_hash.unwrap_or(None)),
454 banner_url: Set(banner_url.unwrap_or(None)),
455 banner_width: Set(banner_width.unwrap_or(None)),
456 banner_height: Set(banner_height.unwrap_or(None)),
457 invite_banner_url: Set(invite_banner_url.unwrap_or(None)),
458 invite_banner_width: Set(invite_banner_width.unwrap_or(None)),
459 invite_banner_height: Set(invite_banner_height.unwrap_or(None)),
460 embed_splash_url: Set(embed_splash_url.unwrap_or(None)),
461 embed_splash_width: Set(embed_splash_width.unwrap_or(None)),
462 embed_splash_height: Set(embed_splash_height.unwrap_or(None)),
463 welcome_object: Set(welcome_object.unwrap_or(None)),
464 created_at: Set(now),
465 updated_at: Set(now),
466 }
467 .insert(state.database.write())
468 .await?
469 }
470 };
471 Ok(row)
472}
473
474pub async fn upsert_member_extras(
475 state: &AppState,
476 space_id: Uuid,
477 user_id: i64,
478 invited_at: Option<OffsetDateTime>,
479) -> Result<(), SpaceError> {
480 let now = OffsetDateTime::now_utc();
481 let existing = member_extras(state, space_id, user_id).await?;
482 match existing {
483 Some(row) => {
484 let mut active: space_member_extras::ActiveModel = row.into();
485 if invited_at.is_some() {
486 active.invited_at = Set(invited_at);
487 }
488 active.updated_at = Set(now);
489 active.update(state.database.write()).await?;
490 }
491 None => {
492 space_member_extras::ActiveModel {
493 space_id: Set(space_id),
494 user_id: Set(user_id),
495 invited_at: Set(invited_at),
496 created_at: Set(now),
497 updated_at: Set(now),
498 }
499 .insert(state.database.write())
500 .await?;
501 }
502 }
503 Ok(())
504}
505
506pub async fn remove_extras(state: &AppState, space_id: Uuid) -> Result<(), SpaceError> {
507 SpaceExtras::delete_by_id(space_id)
508 .exec(state.database.write())
509 .await?;
510 MemberExtras::delete_many()
511 .filter(space_member_extras::Column::SpaceId.eq(space_id))
512 .exec(state.database.write())
513 .await?;
514 Ok(())
515}
516
517pub async fn remove_member_extras(
518 state: &AppState,
519 space_id: Uuid,
520 user_id: i64,
521) -> Result<(), SpaceError> {
522 MemberExtras::delete_by_id((space_id, user_id))
523 .exec(state.database.write())
524 .await?;
525 Ok(())
526}
527
528pub fn db_error(error: DbErr) -> SpaceError {
529 SpaceError::from(error)
530}
Modified lib/space/src/lib.rs View lib/space/src/lib.rs
@@ -1,7 +1,7 @@
1pub mod authority;
12pub mod errors;
23pub mod handlers;
34pub mod openapi;
45pub mod routes;
56pub mod services;
67pub mod share;
7pub mod sync;
Deleted lib/space/src/sync.rs
@@ -1,232 +0,0 @@
1use models::space::space_members::{self, MemberStatus, MessageNotifications};
2use models::space::spaces;
3use sea_orm::sea_query::{Expr, ExprTrait};
4use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set};
5use time::OffsetDateTime;
6use uuid::Uuid;
7
8use kenauth::proto;
9
10fn timestamp(value: &Option<prost_types::Timestamp>) -> Option<OffsetDateTime> {
11 let value = value.as_ref()?;
12 OffsetDateTime::from_unix_timestamp(value.seconds)
13 .ok()?
14 .replace_nanosecond(value.nanos.clamp(0, 999_999_999) as u32)
15 .ok()
16}
17
18fn space_status(value: i32) -> proto::v1::OrganizationStatus {
19 proto::v1::OrganizationStatus::try_from(value).unwrap_or(proto::v1::OrganizationStatus::Active)
20}
21
22fn join_policy(value: i32) -> &'static str {
23 match proto::v1::OrganizationJoinPolicy::try_from(value)
24 .unwrap_or(proto::v1::OrganizationJoinPolicy::InviteOnly)
25 {
26 proto::v1::OrganizationJoinPolicy::Open => "open",
27 proto::v1::OrganizationJoinPolicy::Approval => "approval",
28 proto::v1::OrganizationJoinPolicy::Private => "private",
29 _ => "invite_only",
30 }
31}
32
33fn notifications(value: &str) -> MessageNotifications {
34 match value.trim() {
35 "mentions" => MessageNotifications::Mentions,
36 "nothing" => MessageNotifications::Nothing,
37 _ => MessageNotifications::All,
38 }
39}
40
41fn non_empty(value: &str) -> Option<String> {
42 let value = value.trim();
43 if value.is_empty() {
44 None
45 } else {
46 Some(value.to_string())
47 }
48}
49
50pub async fn upsert_space<C: ConnectionTrait>(
51 db: &C,
52 info: &proto::v1::OrganizationInfo,
53) -> Result<spaces::Model, sea_orm::DbErr> {
54 let id = Uuid::parse_str(info.id.trim())
55 .map_err(|_| sea_orm::DbErr::Custom("organization id is not a uuid".into()))?;
56 let now = OffsetDateTime::now_utc();
57 let policy = join_policy(info.join_policy);
58 let active = space_status(info.status) == proto::v1::OrganizationStatus::Active;
59 let existing = spaces::Entity::find_by_id(id).one(db).await?;
60 let row = match existing {
61 Some(row) => {
62 let mut active_model: spaces::ActiveModel = row.into();
63 active_model.lower_name = Set(info.slug.clone());
64 active_model.name = Set(info.name.clone());
65 active_model.description = Set(non_empty(&info.description));
66 active_model.avatar_url = Set(non_empty(&info.avatar_url));
67 active_model.owner_id = Set(info.owner_user_id);
68 active_model.is_private = Set(policy == "private");
69 active_model.is_active = Set(active);
70 active_model.allow_join = Set(info.allow_join);
71 active_model.nums_members = Set(info.member_count as i64);
72 active_model.welcome_message = Set(non_empty(&info.welcome_message));
73 active_model.member_must_enable_2fa = Set(info.member_must_enable_2fa);
74 active_model.join_policy = Set(policy.to_string());
75 active_model.require_email_verified = Set(info.require_email_verified);
76 active_model.require_mfa = Set(info.require_mfa);
77 active_model.version = Set(info.version);
78 active_model.updated_at = Set(now);
79 active_model.update(db).await?
80 }
81 None => {
82 spaces::ActiveModel {
83 id: Set(id),
84 lower_name: Set(info.slug.clone()),
85 name: Set(info.name.clone()),
86 description: Set(non_empty(&info.description)),
87 owner_id: Set(info.owner_user_id),
88 avatar_url: Set(non_empty(&info.avatar_url)),
89 is_private: Set(policy == "private"),
90 is_active: Set(active),
91 allow_join: Set(info.allow_join),
92 allow_invite: Set(true),
93 invite_sort_url_hash: Set(None),
94 nums_members: Set(info.member_count as i64),
95 banner_url: Set(None),
96 banner_width: Set(None),
97 banner_height: Set(None),
98 invite_banner_url: Set(None),
99 invite_banner_width: Set(None),
100 invite_banner_height: Set(None),
101 embed_splash_url: Set(None),
102 embed_splash_width: Set(None),
103 embed_splash_height: Set(None),
104 welcome_message: Set(non_empty(&info.welcome_message)),
105 allow_join_level: Set(models::space::spaces::AllowJoinLevel::None),
106 member_must_enable_2fa: Set(info.member_must_enable_2fa),
107 welcome_object: Set(None),
108 join_policy: Set(policy.to_string()),
109 require_email_verified: Set(info.require_email_verified),
110 require_mfa: Set(info.require_mfa),
111 version: Set(info.version),
112 created_at: Set(timestamp(&info.created_at).unwrap_or(now)),
113 updated_at: Set(timestamp(&info.updated_at).unwrap_or(now)),
114 }
115 .insert(db)
116 .await?
117 }
118 };
119 Ok(row)
120}
121
122pub async fn remove_space<C: ConnectionTrait>(
123 db: &C,
124 organization_id: Uuid,
125) -> Result<(), sea_orm::DbErr> {
126 spaces::Entity::delete_by_id(organization_id).exec(db).await?;
127 Ok(())
128}
129
130pub async fn upsert_member<C: ConnectionTrait>(
131 db: &C,
132 organization_id: Uuid,
133 member: &proto::v1::OrganizationMemberInfo,
134) -> Result<space_members::Model, sea_orm::DbErr> {
135 let now = OffsetDateTime::now_utc();
136 let status = if member.banned_at.is_some() {
137 MemberStatus::Banned
138 } else if member.left_at.is_some() {
139 MemberStatus::Left
140 } else {
141 MemberStatus::Active
142 };
143 let invited_by = (member.invited_by > 0).then_some(member.invited_by);
144 let existing = space_members::Entity::find_by_id((organization_id, member.user_id))
145 .one(db)
146 .await?;
147 let row = match existing {
148 Some(row) => {
149 let mut active: space_members::ActiveModel = row.into();
150 active.status = Set(status);
151 active.alias_name = Set(non_empty(&member.alias_name));
152 active.alias_avatar_url = Set(non_empty(&member.alias_avatar_url));
153 active.message_notifications = Set(notifications(&member.message_notifications));
154 active.muted = Set(member.muted);
155 active.mute_until = Set(timestamp(&member.mute_until));
156 active.mobile_push = Set(member.mobile_push);
157 active.suppress_everyone = Set(member.suppress_everyone);
158 active.suppress_roles = Set(member.suppress_roles);
159 active.hide_muted_channels = Set(member.hide_muted_channels);
160 active.banned_at = Set(timestamp(&member.banned_at));
161 active.ban_reason = Set(non_empty(&member.ban_reason));
162 active.ban_expires_at = Set(timestamp(&member.ban_expires_at));
163 active.ban_allow_speak = Set(member.ban_allow_speak);
164 active.ban_allow_push_repo = Set(member.ban_allow_push_repo);
165 active.ban_allow_comment_any = Set(member.ban_allow_comment_any);
166 active.left_at = Set(timestamp(&member.left_at));
167 active.invited_by = Set(invited_by);
168 active.last_active_at = Set(timestamp(&member.last_active_at));
169 active.updated_at = Set(Some(now));
170 active.update(db).await?
171 }
172 None => {
173 space_members::ActiveModel {
174 space_id: Set(organization_id),
175 user_id: Set(member.user_id),
176 alias_name: Set(non_empty(&member.alias_name)),
177 alias_avatar_url: Set(non_empty(&member.alias_avatar_url)),
178 status: Set(status),
179 last_active_at: Set(timestamp(&member.last_active_at)),
180 left_at: Set(timestamp(&member.left_at)),
181 invited_at: Set(None),
182 invited_by: Set(invited_by),
183 joined_at: Set(timestamp(&member.joined_at).unwrap_or(now)),
184 created_at: Set(now),
185 updated_at: Set(Some(now)),
186 message_notifications: Set(notifications(&member.message_notifications)),
187 muted: Set(member.muted),
188 mute_until: Set(timestamp(&member.mute_until)),
189 mobile_push: Set(member.mobile_push),
190 suppress_everyone: Set(member.suppress_everyone),
191 suppress_roles: Set(member.suppress_roles),
192 hide_muted_channels: Set(member.hide_muted_channels),
193 banned_at: Set(timestamp(&member.banned_at)),
194 ban_reason: Set(non_empty(&member.ban_reason)),
195 ban_expires_at: Set(timestamp(&member.ban_expires_at)),
196 ban_allow_speak: Set(member.ban_allow_speak),
197 ban_allow_push_repo: Set(member.ban_allow_push_repo),
198 ban_allow_comment_any: Set(member.ban_allow_comment_any),
199 }
200 .insert(db)
201 .await?
202 }
203 };
204 Ok(row)
205}
206
207pub async fn remove_member<C: ConnectionTrait>(
208 db: &C,
209 organization_id: Uuid,
210 user_id: i64,
211) -> Result<(), sea_orm::DbErr> {
212 space_members::Entity::delete_by_id((organization_id, user_id))
213 .exec(db)
214 .await?;
215 Ok(())
216}
217
218pub async fn bump_members<C: ConnectionTrait>(
219 db: &C,
220 organization_id: Uuid,
221 delta: i32,
222) -> Result<(), sea_orm::DbErr> {
223 spaces::Entity::update_many()
224 .col_expr(
225 spaces::Column::NumsMembers,
226 Expr::col(spaces::Column::NumsMembers).add(delta),
227 )
228 .filter(spaces::Column::Id.eq(organization_id))
229 .exec(db)
230 .await?;
231 Ok(())
232}
No newline at end of file
Modified lib/user/src/services/profile.rs View lib/user/src/services/profile.rs
@@ -120,51 +120,19 @@
120120 pub async fn my_spaces(&self, uid: i64, q: PageQuery) -> Result<Vec<MySpaceDto>, UserError> {
121121 let limit = q.limit();
122122 let offset = q.offset();
123 let members = space_members::Entity::find()
124 .filter(space_members::Column::UserId.eq(uid))
125 .filter(space_members::Column::Status.eq(space_members::MemberStatus::Active))
126 .all(self.db().read())
127 .await?;
128 let space_ids: Vec<Uuid> = members.into_iter().map(|m| m.space_id).collect();
129 if space_ids.is_empty() {
130 return Ok(Vec::new());
131 }
132
133 let space_rows = spaces::Entity::find()
134 .filter(spaces::Column::Id.is_in(space_ids))
135 .order_by_asc(spaces::Column::LowerName)
136 .limit(limit)
137 .offset(offset)
138 .all(self.db().read())
139 .await?;
123 let mut space_rows = space::authority::list_user_spaces(&self.state, uid, uid).await?;
124 space_rows.retain(|space| space.is_active);
125 space_rows.sort_by(|a, b| a.lower_name.cmp(&b.lower_name));
140126
141 let mut out = Vec::with_capacity(space_rows.len());
142 for space in space_rows {
143 let perms = if space.owner_id == uid {
144 OWNER_PERMISSIONS.iter().map(|s| s.to_string()).collect()
145 } else {
146 let member_groups = space_member_groups::Entity::find()
147 .filter(space_member_groups::Column::SpaceId.eq(space.id))
148 .filter(space_member_groups::Column::UserId.eq(uid))
149 .all(self.db().read())
150 .await?;
151 let group_ids: Vec<Uuid> =
152 member_groups.into_iter().map(|mg| mg.group_id).collect();
153 let mut granted = if group_ids.is_empty() {
154 Vec::new()
155 } else {
156 space_group_permissions::Entity::find()
157 .filter(space_group_permissions::Column::GroupId.is_in(group_ids))
158 .all(self.db().read())
159 .await?
160 .into_iter()
161 .map(|p| p.permission)
162 .collect()
163 };
164 granted.sort();
165 granted.dedup();
166 granted
167 };
127 let mut out = Vec::new();
128 for space in space_rows
129 .into_iter()
130 .skip(offset as usize)
131 .take(limit as usize)
132 {
133 let perms = space::share::authz::effective_permissions(&self.state, &space, uid)
134 .await?
135 .into_vec();
168136
169137 let avatar_url = space::services::spaces::resolve_space_image_url(
170138 &space.lower_name,
@@ -434,21 +402,17 @@
434402 viewer: i64,
435403 target: i64,
436404) -> Result<bool, UserError> {
437 let viewer_rows = space_members::Entity::find()
438 .filter(space_members::Column::UserId.eq(viewer))
439 .filter(space_members::Column::Status.eq(space_members::MemberStatus::Active))
440 .all(service.db().read())
441 .await?;
442 let space_ids: Vec<Uuid> = viewer_rows.into_iter().map(|m| m.space_id).collect();
443 if space_ids.is_empty() {
405 let viewer_spaces = space::authority::list_user_spaces(&service.state, viewer, viewer).await?;
406 if viewer_spaces.is_empty() {
444407 return Ok(false);
445408 }
446
447 let shared = space_members::Entity::find()
448 .filter(space_members::Column::SpaceId.is_in(space_ids))
449 .filter(space_members::Column::UserId.eq(target))
450 .filter(space_members::Column::Status.eq(space_members::MemberStatus::Active))
451 .one(service.db().read())
452 .await?;
453 Ok(shared.is_some())
409 let target_spaces = space::authority::list_user_spaces(&service.state, target, target).await?;
410 let target_ids: std::collections::HashSet<Uuid> = target_spaces
411 .into_iter()
412 .filter(|space| space.is_active)
413 .map(|space| space.id)
414 .collect();
415 Ok(viewer_spaces
416 .iter()
417 .any(|space| space.is_active && target_ids.contains(&space.id)))
454418}
Modified lib/user/src/errors.rs View lib/user/src/errors.rs
@@ -25,6 +25,19 @@
2525
2626impl std::error::Error for UserError {}
2727
28impl From<space::errors::SpaceError> for UserError {
29 fn from(error: space::errors::SpaceError) -> Self {
30 match error {
31 space::errors::SpaceError::BadRequest(message) => Self::BadRequest(message),
32 space::errors::SpaceError::Unauthorized => Self::Unauthorized,
33 space::errors::SpaceError::Forbidden => Self::Forbidden,
34 space::errors::SpaceError::NotFound => Self::NotFound,
35 space::errors::SpaceError::Conflict(message) => Self::Conflict(message),
36 other => Self::Internal(other.to_string()),
37 }
38 }
39}
40
2841export::db_error_conversions!(UserError);
2942export::storage_error_conversion!(UserError);
3043export::redis_error_conversion!(UserError);
Modified lib/webapi/src/cleanup.rs View lib/webapi/src/cleanup.rs
@@ -13,6 +13,8 @@
1313use axum::routing::{delete, get, post};
1414use axum::{Json, Router};
1515use export::rest::{AppResponseData, NoData};
16use jwt::Session;
17use jwt::share::session::require_uid;
1618use models::dir::{dir_overwrite_group, dir_overwrite_users, dirs};
1719use models::issue::issues as boards;
1820use models::repo::repos;
@@ -22,8 +24,6 @@
2224 TransactionTrait,
2325};
2426use serde::{Deserialize, Serialize};
25use jwt::Session;
26use jwt::share::session::require_uid;
2727use utoipa::{OpenApi, ToSchema};
2828use uuid::Uuid;
2929
@@ -80,11 +80,7 @@
8080}
8181
8282pub fn router(space: SpaceService, issues: IssueService, git: GitService) -> Router {
83 let state = CleanupState {
84 space,
85 issues,
86 git,
87 };
83 let state = CleanupState { space, issues, git };
8884 Router::new()
8985 .route("/spaces/{space_name}/cleanup", get(list_cleanup_objects))
9086 .route(
@@ -151,7 +147,7 @@
151147 space_name: &str,
152148 uid: i64,
153149) -> Result<models::space::spaces::Model, ApiError> {
154 let space = lookup_space_by_name(&state.space, space_name)
150 let space = lookup_space_by_name(&state.space, uid, space_name)
155151 .await
156152 .map_err(map_space)?;
157153 if space.owner_id != uid {
Modified docker-compose.dev.yml View docker-compose.dev.yml
@@ -1,6 +1,8 @@
11# 本地开发依赖服务(macOS / Linux)。
22# 与 config/local.toml 的默认端口一一对应:
33# PostgreSQL 5432 / Redis 6379 / S3(RustFS) 9000
4# RocketMQ 是 KenApi 的启动必需项,由宿主机提供(默认 127.0.0.1:6876);
5# 本 compose 不启动 MQ,启动 KenApi 前请先确认 nameserver 可达。
46# 用法:
57# just infra 启动
68# just infra-down 停止
@@ -62,21 +64,6 @@
6264 timeout: 3s
6365 retries: 20
6466
65 # NATS:KenOAuth 的事件与缓存失效通道(本地开发必需)
66 nats:
67 image: nats:2-alpine
68 container_name: gitdata-dev-nats
69 restart: unless-stopped
70 command: ["-js", "-m", "8222"]
71 ports:
72 - "127.0.0.1:4222:4222"
73 - "127.0.0.1:8222:8222"
74 healthcheck:
75 test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8222/healthz >/dev/null || exit 1"]
76 interval: 5s
77 timeout: 3s
78 retries: 20
79
8067 # KenOAuth 认证中心。image 由外部构建;配置从 config/oauth.yaml 只读挂载。
8168 # `just infra` 默认不启动,需要时:docker compose -f docker-compose.dev.yml --profile oauth up -d oauth
8269 oauth:
@@ -92,8 +79,6 @@
9279 condition: service_healthy
9380 redis:
9481 condition: service_healthy
95 nats:
96 condition: service_started
9782
9883 # 本地 S3 bucket 初始化(建出 config/local.toml 里声明的 bucket)
9984 minio-init: