Skip to content
Clone

feat: add new modules and restructure existing ones for improved organization

ZhenYi committed
parent 927a8b2 commit 1480255 Browse files

278 files changed

+4084 −6636

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

File tree 278
Modified app/src/main.rs View app/src/main.rs
@@ -68,20 +68,15 @@
6868 .unwrap_or_default();
6969 let mail =
7070 auth::email::MailPublisher::new(queue::Publisher::new(conn.clone()), email_pool.mail_topic);
71 let mut state = export::AppState::with_identity(
71 let state = export::AppState::with_identity(
7272 app_config,
7373 database,
74 None,
7574 Some(mail),
7675 Some(auth_events.clone()),
7776 )
7877 .await
7978 .context("building AppState")?;
80 let auth_service = auth::AuthService::new(state.clone());
81 state.kenauth = Some(std::sync::Arc::new(auth_service));
82 if let Some(client) = state.kenauth.clone() {
83 export::identity_client::install(client);
84 }
79 let accounts = auth::Accounts::new(state.database.connection().clone(), state.cache.clone());
8580 info!("dependencies ready");
8681
8782 spawn_queue_depth_reporter(
@@ -92,8 +87,10 @@
9287 ],
9388 );
9489
95 tokio::spawn(chat::worker::run_background_tasks(state.clone()));
96 tokio::spawn(deletion::run_worker(state.clone()));
90 tokio::spawn(chat::worker::run_background_tasks(
91 webapi::build_chat_service(&state, accounts.clone()),
92 ));
93 tokio::spawn(deletion::run_worker(state.clone(), accounts.clone()));
9794
9895 let relay = events::outbox::OutboxRelay::start(
9996 Some(auth_events),
@@ -122,6 +119,7 @@
122119 let state = app_state.clone();
123120 let app = App::new()
124121 .app_data(web::Data::new(state.clone()))
122 .app_data(web::Data::new(accounts.clone()))
125123 .app_data(
126124 web::JsonConfig::default()
127125 .limit(webapi::MAX_REQUEST_BODY_BYTES)
@@ -136,8 +134,8 @@
136134 .wrap(webapi::build_cors(&state.config.web))
137135 .wrap(from_fn(export::middleware::trace_request))
138136 .wrap(oltp::actix::HttpTelemetry::new("gitdata"))
139 .wrap(from_fn(auth::share::session::revalidate::revalidate))
140 .wrap(from_fn(auth::share::session::csrf::csrf))
137 .wrap(from_fn(webapi::middleware::revalidate::revalidate))
138 .wrap(from_fn(webapi::middleware::csrf::csrf))
141139 .service(web::resource("/api/docs").route(web::get().to(|| async {
142140 actix_web::HttpResponse::Found()
143141 .insert_header((actix_web::http::header::LOCATION, "/api/docs/"))
@@ -145,7 +143,7 @@
145143 })))
146144 .service(SwaggerUi::new("/api/docs/{_:.*}").url("/api/docs/openapi.json", doc.clone()))
147145 .service(webapi::scope(state))
148 .default_service(web::to(auth::assets::serve))
146 .default_service(web::to(webapi::assets::serve))
149147 })
150148 .disable_signals()
151149 .bind(addr)
Deleted lib/auth/src/api/authority.rs

Diff too large to display.

Deleted lib/auth/src/api/context.rs

Diff too large to display.

Deleted lib/auth/src/api/identity.rs

Diff too large to display.

Deleted lib/auth/src/handlers/account/mod.rs

Diff too large to display.

Deleted lib/auth/src/handlers/identity/me.rs

Diff too large to display.

Deleted lib/auth/src/handlers/identity/mod.rs

Diff too large to display.

Renamed lib/auth/src/handlers/account/accept_agreement.rs → lib/auth/src/handlers/accept_agreement.rs View lib/auth/src/handlers/accept_agreement.rs

No content changes.

Renamed lib/auth/src/handlers/account/agreement_page.rs → lib/auth/src/handlers/agreement_page.rs View lib/auth/src/handlers/agreement_page.rs
@@ -2,7 +2,7 @@
22use i18n::catalog;
33
44use crate::errors::ApiError;
5use crate::{AuthService, pages};
5use crate::{AuthService, templates};
66
77pub async fn agreement_page(
88 State(service): State<AuthService>,
@@ -20,10 +20,10 @@
2020 let doc = service.list_agreements().into_iter().find(|d| d.key == key);
2121 match doc {
2222 Some(doc) => {
23 let html_content = pages::render_markdown(&doc.content_md);
24 Ok(pages::page_agreement(&doc, &html_content, locale))
23 let html_content = templates::render_markdown(&doc.content_md);
24 Ok(templates::page_agreement(&doc, &html_content, locale))
2525 }
26 None => Ok(pages::page_message(
26 None => Ok(templates::page_message(
2727 false,
2828 catalog::agreement_not_found_title(locale),
2929 catalog::agreement_not_found_body(locale),
Added lib/auth/src/handlers/challenge.rs View lib/auth/src/handlers/challenge.rs

Diff too large to display.

Renamed lib/auth/src/handlers/account/create_report.rs → lib/auth/src/handlers/create_report.rs View lib/auth/src/handlers/create_report.rs

No content changes.

Renamed lib/auth/src/handlers/account/delete_notification.rs → lib/auth/src/handlers/delete_notification.rs View lib/auth/src/handlers/delete_notification.rs

No content changes.

Renamed lib/auth/src/handlers/account/devices.rs → lib/auth/src/handlers/devices.rs View lib/auth/src/handlers/devices.rs
@@ -1,5 +1,3 @@
1use export::AuthApi;
2
31use crate::protos::auth::common::v1::PageRequest;
42use ::session::Session;
53use ::session::share::session;
@@ -14,7 +12,7 @@
1412
1513use crate::AuthService;
1614use crate::errors::ApiError;
17use crate::share::rpc;
15use crate::share::account_helpers;
1816
1917const SESSION_PAGE_SIZE: i32 = 100;
2018
@@ -73,6 +71,7 @@
7371) -> Result<AppResponseData<Vec<DeviceDto>>, ApiError> {
7472 let uid = session::require_uid(&session)?;
7573 let listed = service
74 .accounts
7675 .list_user_sessions(
7776 uid,
7877 false,
@@ -89,13 +88,13 @@
8988 for info in listed.sessions {
9089 let Some(created_at) = info
9190 .created_at
92 .and_then(|ts| rpc::datetime(ts.seconds, ts.nanos))
91 .and_then(|ts| account_helpers::datetime(ts.seconds, ts.nanos))
9392 else {
9493 continue;
9594 };
9695 let last_seen_at = info
9796 .last_seen_at
98 .and_then(|ts| rpc::datetime(ts.seconds, ts.nanos))
97 .and_then(|ts| account_helpers::datetime(ts.seconds, ts.nanos))
9998 .unwrap_or(created_at);
10099 let id = {
101100 let user_agent = &info.user_agent;
Renamed lib/auth/src/handlers/account/get_preferences.rs → lib/auth/src/handlers/get_preferences.rs View lib/auth/src/handlers/get_preferences.rs

No content changes.

Renamed lib/auth/src/handlers/account/get_user.rs → lib/auth/src/handlers/get_user.rs View lib/auth/src/handlers/get_user.rs

No content changes.

Added lib/auth/src/handlers/identity_me.rs View lib/auth/src/handlers/identity_me.rs

Diff too large to display.

Renamed lib/auth/src/handlers/account/list_agreements.rs → lib/auth/src/handlers/list_agreements.rs View lib/auth/src/handlers/list_agreements.rs

No content changes.

Renamed lib/auth/src/handlers/account/list_notifications.rs → lib/auth/src/handlers/list_notifications.rs View lib/auth/src/handlers/list_notifications.rs

No content changes.

Added lib/auth/src/handlers/login.rs View lib/auth/src/handlers/login.rs

Diff too large to display.

Added lib/auth/src/handlers/logout.rs View lib/auth/src/handlers/logout.rs

Diff too large to display.

Renamed lib/auth/src/handlers/account/mark_notifications_read.rs → lib/auth/src/handlers/mark_notifications_read.rs View lib/auth/src/handlers/mark_notifications_read.rs

No content changes.

Renamed lib/auth/src/handlers/account/me.rs → lib/auth/src/handlers/me.rs View lib/auth/src/handlers/me.rs

No content changes.

Modified lib/auth/src/handlers/mod.rs View lib/auth/src/handlers/mod.rs
@@ -1,2 +1,24 @@
1pub mod account;
2pub mod identity;
1pub mod accept_agreement;
2pub mod agreement_page;
3pub mod challenge;
4pub mod create_report;
5pub mod delete_notification;
6pub mod devices;
7pub mod get_preferences;
8pub mod get_user;
9pub mod identity_me;
10pub mod list_agreements;
11pub mod list_notifications;
12pub mod login;
13pub mod logout;
14pub mod mark_notifications_read;
15pub mod me;
16pub mod password;
17pub mod password_reset;
18pub mod register;
19pub mod sessions;
20pub mod two_factor;
21pub mod unread_notifications_count;
22pub mod update_preferences;
23pub mod update_profile;
24pub mod users;
Renamed lib/auth/src/handlers/account/password.rs → lib/auth/src/handlers/password.rs View lib/auth/src/handlers/password.rs
@@ -1,5 +1,3 @@
1use export::AuthApi;
2
31use ::session::Session;
42use ::session::share::session;
53use export::http::{HeaderMap, Json, State};
@@ -9,7 +7,7 @@
97
108use crate::AuthService;
119use crate::errors::ApiError;
12use crate::share::rpc;
10use crate::share::account_helpers;
1311
1412#[derive(Debug, Deserialize, ToSchema)]
1513pub struct ChangePasswordCmd {
@@ -40,10 +38,10 @@
4038 service
4139 .change_password(
4240 uid,
43 &rpc::digest(&cmd.old_password),
44 &rpc::digest(&cmd.new_password),
41 &account_helpers::digest(&cmd.old_password),
42 &account_helpers::digest(&cmd.new_password),
4543 keep.as_str(),
46 &rpc::client_ip(&headers),
44 &account_helpers::client_ip(&headers),
4745 )
4846 .await
4947 .map_err(|error| ApiError::bad_request(error.to_string()))?;
Added lib/auth/src/handlers/password_reset.rs View lib/auth/src/handlers/password_reset.rs

Diff too large to display.

Added lib/auth/src/handlers/register.rs View lib/auth/src/handlers/register.rs

Diff too large to display.

Renamed lib/auth/src/handlers/account/sessions.rs → lib/auth/src/handlers/sessions.rs View lib/auth/src/handlers/sessions.rs
@@ -1,5 +1,3 @@
1use export::AuthApi;
2
31use crate::protos::auth::common::v1::{PageRequest, SessionInfo};
42use crate::protos::auth::v1::ListUserSessionsResponse;
53use ::session::Session;
@@ -14,7 +12,7 @@
1412
1513use crate::AuthService;
1614use crate::errors::ApiError;
17use crate::share::rpc;
15use crate::share::account_helpers;
1816
1917const SESSION_PAGE_SIZE: i32 = 100;
2018
@@ -76,15 +74,15 @@
7674fn session_dto(info: SessionInfo, current: Option<&str>) -> Result<SessionDto, ApiError> {
7775 let created_at = info
7876 .created_at
79 .and_then(|ts| rpc::datetime(ts.seconds, ts.nanos))
77 .and_then(|ts| account_helpers::datetime(ts.seconds, ts.nanos))
8078 .ok_or_else(|| ApiError::internal("auth service returned a session without created_at"))?;
8179 let last_seen_at = info
8280 .last_seen_at
83 .and_then(|ts| rpc::datetime(ts.seconds, ts.nanos))
81 .and_then(|ts| account_helpers::datetime(ts.seconds, ts.nanos))
8482 .unwrap_or(created_at);
8583 let expires_at = info
8684 .expires_at
87 .and_then(|ts| rpc::datetime(ts.seconds, ts.nanos))
85 .and_then(|ts| account_helpers::datetime(ts.seconds, ts.nanos))
8886 .unwrap_or(created_at);
8987 Ok(SessionDto {
9088 id: Uuid::parse_str(&info.session_id)
@@ -97,7 +95,7 @@
9795 is_revoked: info.revoked_at.is_some(),
9896 revoked_at: info
9997 .revoked_at
100 .and_then(|ts| rpc::datetime(ts.seconds, ts.nanos)),
98 .and_then(|ts| account_helpers::datetime(ts.seconds, ts.nanos)),
10199 last_seen_at,
102100 created_at,
103101 expires_at,
@@ -108,7 +106,7 @@
108106 if value.trim().is_empty() {
109107 None
110108 } else {
111 Some(rpc::digest(value))
109 Some(account_helpers::digest(value))
112110 }
113111}
114112
@@ -130,6 +128,7 @@
130128) -> Result<AppResponseData<Page<SessionDto>>, ApiError> {
131129 let uid = session::require_uid(&session)?;
132130 let listed = service
131 .accounts
133132 .list_user_sessions(
134133 uid,
135134 query.include_revoked.unwrap_or(false),
@@ -163,6 +162,7 @@
163162 let uid = session::require_uid(&session)?;
164163 let keep = session.session_id().unwrap_or_default();
165164 let revoked = service
165 .accounts
166166 .revoke_user_sessions(uid, keep.as_str(), "user_logout", false)
167167 .await
168168 .map_err(|error| ApiError::internal(error.to_string()))?;
@@ -190,6 +190,7 @@
190190) -> Result<AppResponseData<NoData>, ApiError> {
191191 let uid = session::require_uid(&session)?;
192192 let revoked = service
193 .accounts
193194 .revoke_session(uid, &session_id.to_string(), "user_revoked")
194195 .await
195196 .map_err(|error| ApiError::internal(error.to_string()))?;
Renamed lib/auth/src/handlers/account/two_factor.rs → lib/auth/src/handlers/two_factor.rs View lib/auth/src/handlers/two_factor.rs
@@ -1,5 +1,3 @@
1use export::AuthApi;
2
31use crate::protos::auth::common::v1::MfaStatus;
42use ::session::Session;
53use ::session::share::session;
@@ -10,7 +8,7 @@
108
119use crate::AuthService;
1210use crate::errors::ApiError;
13use crate::share::rpc;
11use crate::share::account_helpers;
1412
1513#[derive(Debug, Serialize, ToSchema)]
1614pub struct TotpStatusResp {
@@ -90,7 +88,7 @@
9088) -> Result<AppResponseData<TotpSetupResp>, ApiError> {
9189 let uid = session::require_uid(&session)?;
9290 let configured = service
93 .configure_totp(uid, "TOTP", &rpc::client_ip(&headers))
91 .configure_totp(uid, "TOTP", &account_helpers::client_ip(&headers))
9492 .await
9593 .map_err(|error| ApiError::bad_request(error.to_string()))?;
9694 Ok(AppResponseData::ok(TotpSetupResp {
@@ -122,8 +120,8 @@
122120 .enable_totp(
123121 uid,
124122 cmd.code.trim(),
125 &rpc::digest(&cmd.password),
126 &rpc::client_ip(&headers),
123 &account_helpers::digest(&cmd.password),
124 &account_helpers::client_ip(&headers),
127125 )
128126 .await
129127 .map_err(|error| ApiError::bad_request(error.to_string()))?;
@@ -155,7 +153,7 @@
155153) -> Result<AppResponseData<NoData>, ApiError> {
156154 let uid = session::require_uid(&session)?;
157155 service
158 .disable_totp(uid, cmd.code.trim(), &rpc::client_ip(&headers))
156 .disable_totp(uid, cmd.code.trim(), &account_helpers::client_ip(&headers))
159157 .await
160158 .map_err(|error| ApiError::bad_request(error.to_string()))?;
161159 Ok(AppResponseData::empty())
@@ -207,7 +205,11 @@
207205) -> Result<AppResponseData<RecoveryCodesResp>, ApiError> {
208206 let uid = session::require_uid(&session)?;
209207 let regenerated = service
210 .regenerate_recovery_codes(uid, &rpc::digest(&cmd.password), &rpc::client_ip(&headers))
208 .regenerate_recovery_codes(
209 uid,
210 &account_helpers::digest(&cmd.password),
211 &account_helpers::client_ip(&headers),
212 )
211213 .await
212214 .map_err(|error| ApiError::bad_request(error.to_string()))?;
213215 let total = regenerated.recovery_codes.len() as u32;
Renamed lib/auth/src/handlers/account/unread_notifications_count.rs → lib/auth/src/handlers/unread_notifications_count.rs View lib/auth/src/handlers/unread_notifications_count.rs

No content changes.

Renamed lib/auth/src/handlers/account/update_preferences.rs → lib/auth/src/handlers/update_preferences.rs View lib/auth/src/handlers/update_preferences.rs

No content changes.

Renamed lib/auth/src/handlers/account/update_profile.rs → lib/auth/src/handlers/update_profile.rs View lib/auth/src/handlers/update_profile.rs

No content changes.

Renamed lib/auth/src/handlers/account/users.rs → lib/auth/src/handlers/users.rs View lib/auth/src/handlers/users.rs

No content changes.

Renamed lib/auth/src/share/pow/hash.rs → lib/auth/src/pow/hash.rs View lib/auth/src/pow/hash.rs
@@ -1,7 +1,7 @@
11use sha2::{Digest, Sha256};
22
33use crate::errors::{AuthError, AuthResult};
4use crate::handlers::identity::challenge::{ChallengeResponse, PowProofRequest};
4use crate::handlers::challenge::{ChallengeResponse, PowProofRequest};
55use crate::share::crypto;
66
77pub fn pow_hash(challenge: &ChallengeResponse, nonce: u64, payload: &[u8]) -> [u8; 32] {
Renamed lib/auth/src/share/pow/mod.rs → lib/auth/src/pow/mod.rs View lib/auth/src/pow/mod.rs

No content changes.

Renamed lib/auth/src/share/pow/store.rs → lib/auth/src/pow/store.rs View lib/auth/src/pow/store.rs
@@ -5,7 +5,7 @@
55use crate::session::Session;
66
77use crate::errors::{AuthError, AuthResult};
8use crate::handlers::identity::challenge::{ChallengeResponse, PowProofRequest, SESSION_CHALLENGE};
8use crate::handlers::challenge::{ChallengeResponse, PowProofRequest, SESSION_CHALLENGE};
99use crate::share::crypto::{now_secs, random_alnum};
1010
1111pub const USED_KEY_PREFIX: &str = "auth:pow:used:";
@@ -34,7 +34,7 @@
3434 .get::<ChallengeResponse>(SESSION_CHALLENGE)?
3535 .ok_or(AuthError::Expired)?;
3636
37 crate::share::pow::hash::verify_pow(&challenge, proof, expected_payload)?;
37 crate::pow::hash::verify_pow(&challenge, proof, expected_payload)?;
3838
3939 let remaining = challenge.expires_at.saturating_sub(now_secs()).max(1);
4040 let used_key = used_key(&challenge.context);
Deleted lib/auth/src/services/ssh_key/mod.rs

Diff too large to display.

Added lib/auth/src/services/account_access.rs View lib/auth/src/services/account_access.rs

Diff too large to display.

Added lib/auth/src/services/account_identity.rs View lib/auth/src/services/account_identity.rs

Diff too large to display.

Added lib/auth/src/services/account_operations.rs View lib/auth/src/services/account_operations.rs

Diff too large to display.

Modified lib/auth/src/services/authority.rs View lib/auth/src/services/authority.rs
@@ -1,11 +1,11 @@
11use crate::models::users::{user_sessions, users};
22use crate::session::Session;
33use chrono::{Duration, Utc};
4use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set};
4use sea_orm::{ActiveModelTrait, Set};
55use uuid::Uuid;
66
7use crate::share::{crypto, user as store};
8use crate::{AuthError, AuthResult, AuthService};
7use crate::share::crypto;
8use crate::{AuthResult, AuthService};
99
1010/// 会话凭据摘要的算法标识,落在 `user_sessions.hash_key_id`。
1111pub const SESSION_HASH_KEY_ID: &str = "logical-session-v1";
@@ -49,150 +49,5 @@
4949 session.set_security_version(user.security_version)?;
5050 session.renew();
5151 Ok(())
52 }
53
54 #[tracing::instrument(skip_all, fields(user_id = user_id, session_id = %session_id, request_id = %request_id.unwrap_or_default()))]
55 pub async fn check_session(
56 &self,
57 user_id: i64,
58 version: i32,
59 session_id: &str,
60 request_id: Option<&str>,
61 ) -> AuthResult<(users::Model, bool)> {
62 // security_version 判定一律读主库:副本延迟会让刚吊销的旧版本短暂放行。
63 let user = users::Entity::find_by_id(user_id)
64 .one(self.db().write())
65 .await?
66 .ok_or(AuthError::Unauthenticated)?;
67 if !{
68 let user = &user;
69 user.id > 0
70 && user.deleted_at.is_none()
71 && user.allow_use
72 && (!user.forbid_login
73 || user
74 .endtime_forbid_login_at
75 .is_none_or(|until| until > Utc::now()))
76 } {
77 return Err(AuthError::Unauthenticated);
78 }
79 if version <= 0 || version != user.security_version {
80 return Err(AuthError::Unauthenticated);
81 }
82 let id = Uuid::parse_str(session_id).map_err(|_| AuthError::Unauthenticated)?;
83 let row = user_sessions::Entity::find_by_id(id)
84 .one(self.db().write())
85 .await?
86 .ok_or(AuthError::Unauthenticated)?;
87 // `token_hash` 是会话凭据的摘要:只有拿着明文会话 ID 的一方能算出来。
88 // 不校验它,单有数据库读权限的人就能直接拿行里的 ID 复会话。
89 if !crate::share::crypto::constant_time_eq(
90 row.token_hash.as_bytes(),
91 session_token_hash(id).as_bytes(),
92 ) {
93 return Err(AuthError::Unauthenticated);
94 }
95 if row.user_id != user_id
96 || row.security_version != version
97 || row.revoked_at.is_some()
98 || row.expires_at <= Utc::now()
99 {
100 return Err(AuthError::Unauthenticated);
101 }
102 Ok((user, row.mfa_verified_at.is_some()))
103 }
104
105 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
106 pub async fn require_session(
107 &self,
108 session: &Session,
109 request_id: Option<&str>,
110 ) -> AuthResult<i64> {
111 let user_id = session.user_id()?.ok_or(AuthError::Unauthenticated)?;
112 let version = session
113 .security_version()?
114 .ok_or(AuthError::Unauthenticated)?;
115 let id = session
116 .get::<String>("auth_session_id")?
117 .ok_or(AuthError::Unauthenticated)?;
118 let _ = self
119 .check_session(user_id, version, &id, request_id)
120 .await?;
121 Ok(user_id)
122 }
123
124 pub async fn user(&self, user_id: i64) -> AuthResult<users::Model> {
125 users::Entity::find_by_id(user_id)
126 .one(self.db().read())
127 .await?
128 .ok_or(AuthError::UserNotFound)
129 }
130
131 pub async fn users(&self, user_ids: &[i64]) -> AuthResult<Vec<users::Model>> {
132 let mut unique: Vec<i64> = Vec::with_capacity(user_ids.len());
133 for user_id in user_ids.iter().copied().filter(|user_id| *user_id > 0) {
134 if !unique.contains(&user_id) {
135 unique.push(user_id);
136 }
137 }
138 if unique.is_empty() {
139 return Ok(Vec::new());
140 }
141 Ok(users::Entity::find()
142 .filter(users::Column::Id.is_in(unique))
143 .all(self.db().read())
144 .await?)
145 }
146
147 pub async fn update(&self, user_id: i64, patch: serde_json::Value) -> AuthResult<users::Model> {
148 let user = store::load_active_user(self.db(), user_id).await?;
149 let version = patch
150 .get("version")
151 .and_then(|value| value.as_i64())
152 .ok_or(AuthError::MalformedRequest)?;
153 if i64::from(user.version) != version {
154 return Err(AuthError::VersionMismatch {
155 current: user.version,
156 });
157 }
158 let mut active = user.clone().into_active_model();
159 if let Some(name) = patch.get("username").and_then(|value| value.as_str()) {
160 let name = crate::share::validate::validate_username(name)
161 .map_err(|_| AuthError::InvalidUsername)?;
162 active.lowername = Set(name.to_lowercase());
163 active.username = Set(name);
164 }
165 if let Some(value) = patch.get("displayname") {
166 active.displayname = Set(match value.as_str() {
167 Some(value) => Some(
168 crate::share::validate::validate_display_name(value)
169 .map_err(|_| AuthError::InvalidDisplayname)?,
170 ),
171 None if value.is_null() => None,
172 _ => return Err(AuthError::MalformedRequest),
173 });
174 }
175 if let Some(value) = patch.get("public_show_email") {
176 active.public_show_email = Set(value.as_bool().ok_or(AuthError::MalformedRequest)?);
177 }
178 active.version = Set(user
179 .version
180 .checked_add(1)
181 .ok_or(AuthError::VersionMismatch {
182 current: user.version,
183 })?);
184 active.updated_at = Set(Utc::now());
185 let updated = users::Entity::update_many()
186 .set(active)
187 .filter(users::Column::Id.eq(user_id))
188 .filter(users::Column::Version.eq(user.version))
189 .exec_with_returning(self.db().write())
190 .await?
191 .pop()
192 .ok_or(AuthError::VersionMismatch {
193 current: user.version,
194 })?;
195 crate::services::me::invalidate_me_cache(self.cache(), user_id).await;
196 Ok(updated)
19752 }
19853}
Added lib/auth/src/services/authority_store.rs View lib/auth/src/services/authority_store.rs

Diff too large to display.

Added lib/auth/src/services/identity.rs View lib/auth/src/services/identity.rs

Diff too large to display.

Modified lib/auth/src/services/lifecycle.rs View lib/auth/src/services/lifecycle.rs
@@ -4,12 +4,12 @@
44 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set, TransactionTrait,
55};
66
7use crate::AuthService;
7use crate::Accounts;
88use crate::errors::{AuthError, AuthResult};
99use crate::models::space::{SpaceStatus, space_members, spaces};
1010use crate::models::users::{user_mfa, user_mfa_backup_codes, user_sessions, user_ssh_keys, users};
1111
12impl AuthService {
12impl Accounts {
1313 /// Soft-delete the account and revoke every credential. The row (and its
1414 /// numeric id) stays so historical references remain resolvable; a later
1515 /// `purge_account` anonymizes the PII.
@@ -17,7 +17,7 @@
1717 /// An account that still owns a non-archived organization is refused: the
1818 /// owner must transfer or delete the organization first, otherwise the
1919 /// organization would be left without an owner.
20 pub async fn delete_account(
20 pub async fn request_deletion(
2121 &self,
2222 user_id: i64,
2323 reason: &str,
@@ -54,7 +54,7 @@
5454 Ok(())
5555 }
5656
57 pub async fn cancel_account_deletion(&self, user_id: i64, request_id: &str) -> AuthResult<()> {
57 pub async fn cancel_deletion(&self, user_id: i64, request_id: &str) -> AuthResult<()> {
5858 let txn = self.db().write().begin().await?;
5959 let user = users::Entity::find_by_id(user_id)
6060 .one(&txn)
@@ -75,7 +75,7 @@
7575
7676 /// Anonymize PII while keeping the numeric id (and thus every historical
7777 /// author reference) intact. Called after the grace period.
78 pub async fn purge_account(&self, user_id: i64, request_id: &str) -> AuthResult<()> {
78 pub async fn purge_deleted_account(&self, user_id: i64, request_id: &str) -> AuthResult<()> {
7979 let txn = self.db().write().begin().await?;
8080 let user = users::Entity::find_by_id(user_id)
8181 .one(&txn)
@@ -118,7 +118,7 @@
118118 active.update(&txn).await?;
119119 txn.commit().await?;
120120 if let Err(error) =
121 crate::api::store::drop_user_session_states(self.cache(), user_id, None).await
121 crate::share::auth_store::drop_user_session_states(self.cache(), user_id, None).await
122122 {
123123 tracing::warn!(%error, %user_id, "failed to drop purged account sessions");
124124 }
Added lib/auth/src/services/local.rs View lib/auth/src/services/local.rs

Diff too large to display.

Renamed lib/auth/src/handlers/identity/login.rs → lib/auth/src/services/login.rs View lib/auth/src/services/login.rs
@@ -1,18 +1,16 @@
11use crate::AuthService;
2use crate::envelope::{Envelope, NoData};
32use crate::errors::{AuthError, AuthResult};
43use crate::events::{Labels, label};
5use crate::handlers::identity::challenge::PowProofRequest;
4use crate::handlers::challenge::PowProofRequest;
65use crate::models::users::user_events::UserEventType;
76use crate::models::users::user_mfa::{self, MfaStatus, MfaType};
87use crate::models::users::users;
9use crate::session::{RequestId, Session};
8use crate::session::Session;
109use crate::share::codes::{consume_email_code, store_email_code};
1110use crate::share::crypto::{now_secs, random_alnum, random_numeric_code};
1211use crate::share::password::{self, PasswordCheck};
1312use crate::share::rate::{attempts_load, attempts_reset, attempts_track, rate_limit_once};
1413use crate::share::validate;
15use actix_web::web;
1614use sea_orm::sea_query::{Expr, ExprTrait, Func};
1715use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter};
1816use serde::{Deserialize, Serialize};
@@ -599,118 +597,4 @@
599597 )
600598 .await
601599 }
602}
603
604#[utoipa::path(
605 post,
606 path = "/api/v1/auth/login",
607 tag = "auth",
608 operation_id = "auth_login",
609 summary = "口令登录",
610 description = "用口令登录。`identity` 可以是用户名、邮箱(大小写不敏感)或用户编号,`password_hash` 是客户端先算好的口令摘要,工作量证明的载荷必须是 `identity`。账号启用了多因子时返回 `mfa_required: true` 与 `challenge`,此时会话尚未绑定,需再调 `/api/v1/auth/login/verify`。",
611 request_body = UserLoginRequest,
612 responses(
613 (status = 200, description = "登录成功;`mfa_required` 为真时还需二次验证", body = Envelope<UserLoginResponse>, example = json!({"code":200,"message":"success","data":{"user_id":1024,"username":"ada","displayname":"Ada","mfa_required":false,"challenge":null}})),
614 (status = 400, description = "工作量证明过期、载荷不匹配或请求不合法(`expired`、`invalid_proof`、`invalid_payload`、`malformed_request`)", body = Envelope<NoData>, example = json!({"code": "expired", "message": "challenge expired"})),
615 (status = 401, description = "身份或口令不正确(`invalid_credentials`)", body = Envelope<NoData>, example = json!({"code": "invalid_credentials", "message": "invalid credentials"})),
616 (status = 403, description = "账号被禁止登录,原因见 `message`(`login_forbidden`)", body = Envelope<NoData>, example = json!({"code": "login_forbidden", "message": "account is banned"})),
617 (status = 429, description = "失败次数超出配额(`rate_limited`)", body = Envelope<NoData>, example = json!({"code": "rate_limited", "message": "too many attempts"})),
618 (status = 500, description = "服务端故障(`internal`、`database`、`cache`、`session`、`serialization`)", body = Envelope<NoData>, example = json!({"code": "internal", "message": "internal server error"}))
619 )
620)]
621pub async fn login(
622 svc: web::Data<crate::AuthService>,
623 session: Session,
624 body: web::Json<UserLoginRequest>,
625 request_id: RequestId,
626) -> Result<Envelope<UserLoginResponse>, AuthError> {
627 let response = svc.login(session, body.into_inner(), request_id.0).await?;
628 Ok(Envelope::ok(response))
629}
630
631#[utoipa::path(
632 post,
633 path = "/api/v1/auth/login/code",
634 tag = "auth",
635 operation_id = "auth_login_code",
636 summary = "发送邮箱登录验证码",
637 description = "向账号邮箱发送 6 位登录验证码,`expires_in_secs` 为有效期。工作量证明的载荷必须是 `email`。同一账号的发码间隔受 `email_code_rate_limit_secs` 限制。",
638 request_body = LoginCodeRequest,
639 responses(
640 (status = 200, description = "验证码已发送", body = Envelope<LoginCodeResponse>, example = json!({"code":200,"message":"success","data":{"expires_in_secs":600}})),
641 (status = 400, description = "邮箱格式不合法或工作量证明过期、载荷不匹配(`invalid_email`、`expired`、`invalid_proof`、`invalid_payload`、`malformed_request`)", body = Envelope<NoData>, example = json!({"code": "invalid_email", "message": "invalid email address"})),
642 (status = 401, description = "邮箱未注册或账号不可用(`invalid_credentials`)", body = Envelope<NoData>, example = json!({"code": "invalid_credentials", "message": "invalid credentials"})),
643 (status = 403, description = "账号被禁止登录,原因见 `message`(`login_forbidden`)", body = Envelope<NoData>, example = json!({"code": "login_forbidden", "message": "account is banned"})),
644 (status = 429, description = "发码过于频繁(`rate_limited`)", body = Envelope<NoData>, example = json!({"code": "rate_limited", "message": "too many requests"})),
645 (status = 500, description = "服务端故障,含邮件发送失败(`internal`、`database`、`cache`、`session`、`email`、`serialization`)", body = Envelope<NoData>, example = json!({"code": "internal", "message": "internal server error"}))
646 )
647)]
648pub async fn login_code(
649 svc: web::Data<crate::AuthService>,
650 session: Session,
651 body: web::Json<LoginCodeRequest>,
652 request_id: RequestId,
653) -> Result<Envelope<LoginCodeResponse>, AuthError> {
654 let response = svc
655 .login_code(session, body.into_inner(), request_id.0)
656 .await?;
657 Ok(Envelope::ok(response))
658}
659
660#[utoipa::path(
661 post,
662 path = "/api/v1/auth/login/code/verify",
663 tag = "auth",
664 operation_id = "auth_login_code_verify",
665 summary = "邮箱验证码换会话",
666 description = "用邮箱登录验证码换会话。工作量证明的载荷必须是 `email`。验证码一旦核对通过即作废,连续输错 `max_verify_attempts` 次后需重新申请。账号启用了多因子时返回 `mfa_required: true` 与 `challenge`。",
667 request_body = LoginCodeVerifyRequest,
668 responses(
669 (status = 200, description = "登录成功;`mfa_required` 为真时还需二次验证", body = Envelope<UserLoginResponse>, example = json!({"code":200,"message":"success","data":{"user_id":1024,"username":"ada","displayname":"Ada","mfa_required":false,"challenge":null}})),
670 (status = 400, description = "验证码不正确或已过期,或邮箱格式、工作量证明不合法(`invalid_email_code`、`invalid_email`、`expired`、`invalid_proof`、`invalid_payload`、`malformed_request`)", body = Envelope<NoData>, example = json!({"code": "invalid_email_code", "message": "invalid email code"})),
671 (status = 401, description = "邮箱未注册或账号不可用(`invalid_credentials`)", body = Envelope<NoData>, example = json!({"code": "invalid_credentials", "message": "invalid credentials"})),
672 (status = 403, description = "账号被禁止登录,原因见 `message`(`login_forbidden`)", body = Envelope<NoData>, example = json!({"code": "login_forbidden", "message": "account is banned"})),
673 (status = 429, description = "验证码尝试次数超限或请求过频(`email_code_attempts_exceeded`、`rate_limited`)", body = Envelope<NoData>, example = json!({"code": "email_code_attempts_exceeded", "message": "too many attempts"})),
674 (status = 500, description = "服务端故障(`internal`、`database`、`cache`、`session`、`serialization`)", body = Envelope<NoData>, example = json!({"code": "internal", "message": "internal server error"}))
675 )
676)]
677pub async fn login_code_verify(
678 svc: web::Data<crate::AuthService>,
679 session: Session,
680 body: web::Json<LoginCodeVerifyRequest>,
681 request_id: RequestId,
682) -> Result<Envelope<UserLoginResponse>, AuthError> {
683 let response = svc
684 .login_code_verify(session, body.into_inner(), request_id.0)
685 .await?;
686 Ok(Envelope::ok(response))
687}
688
689#[utoipa::path(
690 post,
691 path = "/api/v1/auth/login/verify",
692 tag = "auth",
693 operation_id = "auth_login_verify",
694 summary = "二次验证换会话",
695 description = "用 6 位 TOTP 动态码或一枚未使用的恢复码完成二次验证并绑定会话。`challenge` 必须与 `/api/v1/auth/login` 或 `/api/v1/auth/login/code/verify` 返回的一致,挑战有效期 300 秒,同一动态码时间步不能重复使用,恢复码用掉即作废。",
696 request_body = LoginVerifyRequest,
697 responses(
698 (status = 200, description = "登录成功,会话已绑定", body = Envelope<UserLoginResponse>, example = json!({"code":200,"message":"success","data":{"user_id":1024,"username":"ada","displayname":"Ada","mfa_required":false,"challenge":null}})),
699 (status = 400, description = "挑战过期或对不上、动态码错误、动态码时间步已用过、恢复码无效(`expired`、`malformed_request`、`totp_code_invalid`、`totp_step_reused`、`recovery_code_invalid`)", body = Envelope<NoData>, example = json!({"code": "totp_code_invalid", "message": "invalid totp code"})),
700 (status = 401, description = "会话里没有待验证挑战、账号不可用或多因子记录已被撤销(`unauthenticated`、`invalid_credentials`、`mfa_required`)", body = Envelope<NoData>, example = json!({"code": "mfa_required", "message": "multi-factor authentication required"})),
701 (status = 403, description = "账号被禁止登录,原因见 `message`(`login_forbidden`)", body = Envelope<NoData>, example = json!({"code": "login_forbidden", "message": "account is banned"})),
702 (status = 429, description = "尝试次数超限或恢复码已用尽(`rate_limited`、`recovery_codes_exhausted`)", body = Envelope<NoData>, example = json!({"code": "rate_limited", "message": "too many attempts"})),
703 (status = 500, description = "服务端故障(`internal`、`database`、`cache`、`session`、`serialization`)", body = Envelope<NoData>, example = json!({"code": "internal", "message": "internal server error"}))
704 )
705)]
706pub async fn login_verify(
707 svc: web::Data<crate::AuthService>,
708 session: Session,
709 body: web::Json<LoginVerifyRequest>,
710 request_id: RequestId,
711) -> Result<Envelope<UserLoginResponse>, AuthError> {
712 let response = svc
713 .login_verify(session, body.into_inner(), request_id.0)
714 .await?;
715 Ok(Envelope::ok(response))
716600}
Renamed lib/auth/src/handlers/identity/logout.rs → lib/auth/src/services/logout.rs View lib/auth/src/services/logout.rs
@@ -1,10 +1,8 @@
11use crate::AuthService;
2use crate::envelope::{Envelope, NoData};
3use crate::errors::{AuthError, AuthResult};
2use crate::errors::AuthResult;
43use crate::events::{Labels, label};
54use crate::models::users::user_events::UserEventType;
6use crate::session::{RequestId, Session};
7use actix_web::web;
5use crate::session::Session;
86use sea_orm::Set;
97use serde::{Deserialize, Serialize};
108use utoipa::ToSchema;
@@ -89,25 +87,4 @@
8987 .await;
9088 Ok(response)
9189 }
92}
93
94#[utoipa::path(
95 post,
96 path = "/api/v1/auth/session/logout",
97 tag = "auth",
98 operation_id = "auth_logout",
99 summary = "登出",
100 description = "销毁当前会话,并清掉该用户的 `/api/v1/auth/me` 缓存。匿名调用同样成功,此时 `user_id` 为 `null`。",
101 responses(
102 (status = 200, description = "会话已销毁", body = Envelope<LogoutResponse>, example = json!({"code":200,"message":"success","data":{"user_id":1024}})),
103 (status = 500, description = "服务端故障(`internal`、`database`、`cache`、`session`、`serialization`)", body = Envelope<NoData>, example = json!({"code": "internal", "message": "internal server error"}))
104 )
105)]
106pub async fn logout(
107 svc: web::Data<crate::AuthService>,
108 session: Session,
109 request_id: RequestId,
110) -> Result<Envelope<LogoutResponse>, AuthError> {
111 let response = svc.logout(session, request_id.0).await?;
112 Ok(Envelope::ok(response))
11390}
Modified lib/auth/src/services/me.rs View lib/auth/src/services/me.rs
@@ -67,6 +67,7 @@
6767 #[tracing::instrument(skip_all, fields(request_id = %_request_id, client_ip = ?session.client_ip().ok().flatten()))]
6868 pub async fn me(&self, session: Session, _request_id: String) -> AuthResult<MeResponse> {
6969 let user_id = self
70 .accounts
7071 .require_session(&session, Some(_request_id.as_str()))
7172 .await?;
7273 self.me_for_user(user_id).await
Modified lib/auth/src/services/mod.rs View lib/auth/src/services/mod.rs
@@ -1,12 +1,183 @@
11pub mod agreements;
22pub mod authority;
3pub mod authority_store;
4pub mod identity;
35pub mod lifecycle;
6pub mod local;
7pub mod login;
8pub mod logout;
49pub mod me;
510pub mod notifications;
11pub mod password;
12pub mod pow;
613pub mod preferences;
714pub mod profile;
15pub mod register;
816pub mod reports;
9pub mod service;
10pub mod ssh_key;
17pub mod sessions;
1118pub mod suspensions;
19pub mod two_factor;
1220pub mod users;
21
22use config::auth::AuthConfig;
23use config::pow::PowConfig;
24use serde::Serialize;
25
26use crate::cache::Cache;
27use crate::db::DbConn;
28use crate::email::MailPublisher;
29use crate::errors::{AuthError, AuthResult};
30use crate::events::{Labels, label};
31
32#[derive(Clone)]
33pub struct AuthService {
34 pub(crate) accounts: crate::Accounts,
35 config: AuthSettings,
36 mail: Option<MailPublisher>,
37}
38
39impl AuthService {
40 pub fn new(
41 accounts: crate::Accounts,
42 config: AuthSettings,
43 mail: Option<MailPublisher>,
44 ) -> Self {
45 Self {
46 accounts,
47 config,
48 mail,
49 }
50 }
51
52 pub fn config(&self) -> &AuthSettings {
53 &self.config
54 }
55
56 pub fn db(&self) -> &DbConn {
57 self.accounts.db()
58 }
59
60 pub fn cache(&self) -> &Cache {
61 self.accounts.cache()
62 }
63
64 pub fn mail(&self) -> Option<&MailPublisher> {
65 self.mail.as_ref()
66 }
67
68 pub fn auth(&self) -> &AuthConfig {
69 &self.config.auth
70 }
71
72 pub fn pow(&self) -> &PowConfig {
73 &self.config.pow
74 }
75
76 pub fn session_max_age_secs(&self) -> u64 {
77 self.config.session.max_age.max(1) as u64
78 }
79
80 pub async fn mail_locale(
81 &self,
82 uid: Option<i64>,
83 accept_language: Option<&str>,
84 ) -> i18n::UiLocale {
85 let fallback = i18n::UiLocale::parse(self.config().email.default_locale())
86 .unwrap_or(i18n::UiLocale::En);
87 match uid {
88 Some(uid) => i18n::resolve_user_locale(self.db().read(), uid, fallback).await,
89 None => i18n::negotiate_accept_language(accept_language, fallback),
90 }
91 }
92
93 pub async fn emit<T>(&self, kind: &str, payload: T, request_id: Option<&str>)
94 where
95 T: Serialize + Clone,
96 {
97 self.emit_labeled(kind, Labels::default(), payload, request_id)
98 .await;
99 }
100
101 pub async fn emit_labeled<T>(
102 &self,
103 kind: &str,
104 labels: Labels,
105 payload: T,
106 request_id: Option<&str>,
107 ) where
108 T: Serialize + Clone,
109 {
110 let enqueued = events::emit(
111 self.db().write(),
112 kind,
113 labels.clone().with_opt(label::REQUEST_ID, request_id),
114 &payload,
115 )
116 .await;
117
118 if let Err(error) = enqueued {
119 tracing::error!(kind, %error, "failed to enqueue the event in the outbox");
120 }
121 }
122
123 pub async fn emit_tx<T>(
124 &self,
125 conn: &impl sea_orm::ConnectionTrait,
126 kind: &str,
127 labels: Labels,
128 payload: T,
129 request_id: Option<&str>,
130 ) -> AuthResult<()>
131 where
132 T: Serialize,
133 {
134 events::emit(
135 conn,
136 kind,
137 labels.with_opt(label::REQUEST_ID, request_id),
138 payload,
139 )
140 .await?;
141 Ok(())
142 }
143
144 pub async fn publish_mail(
145 &self,
146 to: &str,
147 subject: &str,
148 text: &str,
149 html: &str,
150 request_id: Option<&str>,
151 ) -> AuthResult<()> {
152 let Some(mail) = self.mail() else {
153 return Err(AuthError::Unavailable(
154 "mail publisher is not configured".to_owned(),
155 ));
156 };
157 mail.send_mail(to, subject, text, html, request_id)
158 .await
159 .map_err(|error| AuthError::Internal(error.to_string()))
160 }
161}
162
163#[derive(Clone)]
164pub struct AuthSettings {
165 pub auth: config::auth::AuthConfig,
166 pub pow: config::pow::PowConfig,
167 pub session: config::session::SessionConfig,
168 pub email: config::email::EmailConfig,
169}
170impl AuthSettings {
171 pub fn from_config(config: &config::AppConfig) -> Self {
172 Self {
173 auth: config.auth.clone(),
174 pow: config.pow.clone(),
175 session: config.session.clone(),
176 email: config.email.clone(),
177 }
178 }
179}
180
181pub mod account_access;
182pub mod account_identity;
183pub mod account_operations;
Renamed lib/auth/src/handlers/identity/password_reset.rs → lib/auth/src/services/password.rs View lib/auth/src/services/password.rs
@@ -1,17 +1,15 @@
11use crate::AuthService;
2use crate::envelope::{Envelope, NoData};
32use crate::errors::{AuthError, AuthResult};
43use crate::events::{Labels, label};
5use crate::handlers::identity::challenge::PowProofRequest;
4use crate::handlers::challenge::PowProofRequest;
65use crate::models::users::user_events::UserEventType;
76use crate::models::users::user_verification_tokens::{self, VerificationPurpose};
87use crate::models::users::users;
9use crate::session::{RequestId, Session};
8use crate::session::Session;
109use crate::share::codes::{consume_email_code, store_email_code};
1110use crate::share::crypto::{random_b64url, sha256_hex};
1211use crate::share::password::{hash as hash_password, validate_new_password};
1312use crate::share::{rate, validate};
14use actix_web::web;
1513use chrono::{DateTime, Duration, Utc};
1614use sea_orm::entity::Set;
1715use sea_orm::sea_query::{Expr, ExprTrait, Func};
@@ -304,115 +302,4 @@
304302 .await?;
305303 Ok(())
306304 }
307}
308
309#[utoipa::path(
310 post,
311 path = "/api/v1/auth/password/reset",
312 tag = "auth",
313 operation_id = "auth_password_reset_request",
314 summary = "申请密码重置",
315 description = "校验工作量证明(payload = 邮箱的 UTF-8 字节)后,作废该用户其余未消费的重置令牌,签发新令牌(长度 `reset_token_bytes`,有效期 `reset_token_ttl_secs`),库内只存 `sha256_hex(token)` 且 `hash_key_id = \"sha256-v1\"`,同时把摘要写入缓存键 `auth:code:reset:{email}`,发信限流键 `auth:rate:reset:{email}`。",
316 request_body = PasswordResetRequest,
317 responses(
318 (
319 status = 200,
320 description = "申请已受理;是否真的发信不对外披露",
321 body = Envelope<PasswordResetResponse>,
322 example = json!({"code":200,"message":"success","data":{"expires_in_secs":1800}})
323 ),
324 (
325 status = 400,
326 description = "`invalid_email` / `expired` / `invalid_payload`(payload 与邮箱不一致)/ `invalid_proof`",
327 body = Envelope<NoData>,
328 example = json!({"code": "invalid_email", "message": "email is invalid"})
329 ),
330 (
331 status = 429,
332 description = "`rate_limited`(同一邮箱申请过于频繁)",
333 body = Envelope<NoData>,
334 example = json!({"code": "rate_limited", "message": "too many requests, retry after 60s"})
335 ),
336 (
337 status = 500,
338 description = "`email`(投递失败)/ `database` / `cache` / `session` / `serialization` / `internal`",
339 body = Envelope<NoData>,
340 example = json!({"code": "internal", "message": "internal server error"})
341 )
342 )
343)]
344pub async fn password_reset_request(
345 svc: web::Data<crate::AuthService>,
346 session: Session,
347 body: web::Json<PasswordResetRequest>,
348 request_id: RequestId,
349) -> Result<Envelope<PasswordResetResponse>, AuthError> {
350 let user_agent = session.user_agent()?;
351 let ip_addr = session.client_ip()?;
352 let response = svc
353 .request_password_reset(
354 session,
355 body.into_inner(),
356 user_agent,
357 ip_addr,
358 request_id.0,
359 )
360 .await?;
361 Ok(Envelope::ok(response))
362}
363
364/// 并与 `security_version` 绑定;成功后作废该用户其余未消费的重置令牌并递增 `security_version`。
365#[utoipa::path(
366 post,
367 path = "/api/v1/auth/password/reset/confirm",
368 tag = "auth",
369 operation_id = "auth_password_reset",
370 summary = "确认密码重置",
371 description = "校验工作量证明(payload = `token` 的 UTF-8 字节)与缓存键 `auth:code:reset:{email}`(尝试上限 `max_verify_attempts`,错误计入 `invalid_email_code`)后,条件消费数据库令牌行(`consumed_at` 单次占用),Argon2 重哈希新口令,更新 `password_hash`/`password_updated_at`/`password_must_change`/`security_version + 1`,作废其余令牌并记 `PasswordChange` 审计事件。",
372 request_body = PasswordResetConfirmRequest,
373 responses(
374 (
375 status = 200,
376 description = "口令已改写;旧口令与其余重置令牌失效",
377 body = Envelope<PasswordResetConfirmResponse>,
378 example = json!({"code":200,"message":"success","data":{"user_id":1234567}})
379 ),
380 (
381 status = 400,
382 description = "`invalid_reset_token`(令牌不存在/已消费/已作废/已过期/账号不可用或凭据代数不匹配)/ `invalid_email_code`(缓存内摘要不匹配或已过期)/ `invalid_password` / `expired` / `invalid_payload` / `invalid_proof`",
383 body = Envelope<NoData>,
384 example = json!({"code": "invalid_reset_token", "message": "password reset token is invalid"})
385 ),
386 (
387 status = 429,
388 description = "`email_code_attempts_exceeded`(令牌尝试次数用尽)",
389 body = Envelope<NoData>,
390 example = json!({"code": "email_code_attempts_exceeded", "message": "too many invalid email verification attempts"})
391 ),
392 (
393 status = 500,
394 description = "`password_hash`(口令重哈希失败)/ `database` / `cache` / `session` / `serialization` / `internal`",
395 body = Envelope<NoData>,
396 example = json!({"code": "internal", "message": "internal server error"})
397 )
398 )
399)]
400pub async fn password_reset(
401 svc: web::Data<crate::AuthService>,
402 session: Session,
403 body: web::Json<PasswordResetConfirmRequest>,
404 request_id: RequestId,
405) -> Result<Envelope<PasswordResetConfirmResponse>, AuthError> {
406 let user_agent = session.user_agent()?;
407 let ip_addr = session.client_ip()?;
408 let response = svc
409 .reset_password(
410 session,
411 body.into_inner(),
412 user_agent,
413 ip_addr,
414 request_id.0,
415 )
416 .await?;
417 Ok(Envelope::ok(response))
418305}
Renamed lib/auth/src/handlers/identity/challenge.rs → lib/auth/src/services/pow.rs View lib/auth/src/services/pow.rs
@@ -1,9 +1,7 @@
11use crate::AuthService;
2use crate::envelope::{Envelope, NoData};
3use crate::errors::{AuthError, AuthResult};
2use crate::errors::AuthResult;
43use crate::events::{Labels, label};
5use crate::session::{RequestId, Session};
6use actix_web::web;
4use crate::session::Session;
75use serde::{Deserialize, Serialize};
86use utoipa::ToSchema;
97
@@ -30,7 +28,7 @@
3028 session: Session,
3129 request_id: String,
3230 ) -> AuthResult<ChallengeResponse> {
33 let response = crate::share::pow::store::issue(&session, self.pow()).await?;
31 let response = crate::pow::store::issue(&session, self.pow()).await?;
3432 self.emit_labeled(
3533 label::AUTH_CHALLENGE_CREATE,
3634 Labels::new()
@@ -51,7 +49,7 @@
5149 expected_payload: &[u8],
5250 request_id: String,
5351 ) -> AuthResult<()> {
54 crate::share::pow::store::verify(&session, self.cache(), proof, expected_payload).await?;
52 crate::pow::store::verify(&session, self.cache(), proof, expected_payload).await?;
5553 self.emit_labeled(
5654 label::AUTH_CHALLENGE_USED,
5755 Labels::new()
@@ -65,35 +63,4 @@
6563 .await;
6664 Ok(())
6765 }
68}
69
70#[utoipa::path(
71 post,
72 path = "/api/v1/auth/challenge",
73 tag = "auth",
74 operation_id = "auth_challenge",
75 summary = "签发工作量证明挑战",
76 description = "把挑战写进当前会话并返回。客户端需要为 `context` 找出一枚满足 `difficulty`(前导零比特数)的 nonce;`pow.v1` 算法与前端求解器一致。",
77 responses(
78 (
79 status = 200,
80 description = "挑战已签发并写入会话",
81 body = Envelope<ChallengeResponse>,
82 example = json!({"code":200,"message":"success","data":{"context":"3xJk9Qp2mV7sLbT1rZa8nHc4wDf6YgU0","difficulty":16,"issued_at":1767139200,"expires_at":1767139500}})
83 ),
84 (
85 status = 500,
86 description = "`internal`(挑战无法写入会话)/ `cache` / `session` / `serialization`",
87 body = Envelope<NoData>,
88 example = json!({"code": "internal", "message": "internal server error"})
89 )
90 )
91)]
92pub async fn challenge(
93 svc: web::Data<crate::AuthService>,
94 session: Session,
95 request_id: RequestId,
96) -> Result<Envelope<ChallengeResponse>, AuthError> {
97 let challenge = svc.challenge(session, request_id.0).await?;
98 Ok(Envelope::ok(challenge))
9966}
Modified lib/auth/src/services/profile.rs View lib/auth/src/services/profile.rs
@@ -69,14 +69,12 @@
6969 &self,
7070 uid: i64,
7171 ) -> Result<(UserMeDto, Option<ProfileDto>), AuthBusinessError> {
72 export::users::ensure_local_user(&self.state, uid).await?;
72 export::users::ensure_local_user(self.db(), uid).await?;
7373 let db = self.db();
7474 let Some(user) = user::Entity::find_by_id(uid).one(db.read()).await? else {
7575 return Err(AuthBusinessError::NotFound);
7676 };
77 let client = self.state.kenauth.as_ref().ok_or_else(|| {
78 AuthBusinessError::Internal("auth service is not configured".to_string())
79 })?;
77 let client = &self.accounts;
8078 let authority = client.get_authority_user(uid).await.map_err(map_kenauth)?;
8179 let public = client.get_user_public(uid).await.map_err(map_kenauth)?;
8280 let status = client.get_user_status(uid).await.map_err(map_kenauth)?;
@@ -145,7 +143,7 @@
145143 uid: i64,
146144 cmd: UpdateProfileCmd,
147145 ) -> Result<(), AuthBusinessError> {
148 export::users::ensure_local_user(&self.state, uid).await?;
146 export::users::ensure_local_user(self.db(), uid).await?;
149147 let db = self.db();
150148 let Some(user) = user::Entity::find_by_id(uid).one(db.write()).await? else {
151149 return Err(AuthBusinessError::NotFound);
@@ -163,9 +161,7 @@
163161 "display_name must be at most 64 characters".to_string(),
164162 ));
165163 }
166 let client = self.state.kenauth.as_ref().ok_or_else(|| {
167 AuthBusinessError::Internal("auth service is not configured".to_string())
168 })?;
164 let client = &self.accounts;
169165 let current = client.get_authority_user(uid).await.map_err(map_kenauth)?;
170166 client
171167 .update_authority_user(
@@ -196,7 +192,7 @@
196192 }
197193 user_active.updated_at = Set(now);
198194
199 TransactionTrait::transaction(self.state.database.primary(), |txn| {
195 TransactionTrait::transaction(self.db().write(), |txn| {
200196 Box::pin(async move {
201197 user_active.update(txn).await?;
202198
@@ -283,9 +279,7 @@
283279 let db = self.db();
284280 let local = user::Entity::find_by_id(uid).one(db.read()).await?;
285281
286 let client = self.state.kenauth.as_ref().ok_or_else(|| {
287 AuthBusinessError::Internal("auth service is not configured".to_string())
288 })?;
282 let client = &self.accounts;
289283 let current = client.get_authority_user(uid).await.map_err(map_kenauth)?;
290284 let updated = if current.username != username {
291285 client
Renamed lib/auth/src/handlers/identity/register.rs → lib/auth/src/services/register.rs View lib/auth/src/services/register.rs
@@ -1,16 +1,14 @@
11use crate::AuthService;
2use crate::envelope::{Envelope, NoData};
32use crate::errors::{AuthError, AuthResult};
43use crate::events::{Labels, label};
5use crate::handlers::identity::challenge::PowProofRequest;
4use crate::handlers::challenge::PowProofRequest;
65use crate::models::users::user_events::UserEventType;
76use crate::models::users::users;
8use crate::session::{RequestId, Session};
7use crate::session::Session;
98use crate::share::codes::{consume_email_code, store_email_code};
109use crate::share::password::{hash as hash_password, validate_new_password};
1110use crate::share::validate::{validate_display_name, validate_email, validate_username};
1211use crate::share::{crypto, rate};
13use actix_web::web;
1412use chrono::Utc;
1513use rand::RngExt;
1614use sea_orm::entity::Set;
@@ -291,124 +289,4 @@
291289 }
292290 Ok(())
293291 }
294}
295
296#[utoipa::path(
297 post,
298 path = "/api/v1/auth/register/code",
299 tag = "auth",
300 operation_id = "auth_register_code",
301 summary = "发送注册验证码",
302 description = "校验工作量证明(payload = 邮箱的 UTF-8 字节)与邮箱格式后,把 6 位验证码写入缓存并寄出。缓存键 `auth:code:register:{email}`,有效期由 `verify_code_ttl_secs` 决定,发码限流键 `auth:rate:register:{email}`。",
303 request_body = RegisterCodeRequest,
304 responses(
305 (
306 status = 200,
307 description = "验证码已写入缓存并寄出",
308 body = Envelope<RegisterCodeResponse>,
309 example = json!({"code":200,"message":"success","data":{"expires_in_secs":600}})
310 ),
311 (
312 status = 400,
313 description = "`invalid_email`(邮箱格式)/ `expired`(挑战过期)/ `invalid_payload`(payload 与邮箱不一致)/ `invalid_proof`(未满足难度)",
314 body = Envelope<NoData>,
315 example = json!({"code": "invalid_email", "message": "email is invalid"})
316 ),
317 (
318 status = 409,
319 description = "`email_taken`(邮箱已注册)",
320 body = Envelope<NoData>,
321 example = json!({"code": "email_taken", "message": "email is already taken"})
322 ),
323 (
324 status = 429,
325 description = "`rate_limited`(同一邮箱发码过于频繁)",
326 body = Envelope<NoData>,
327 example = json!({"code": "rate_limited", "message": "too many requests, retry after 60s"})
328 ),
329 (
330 status = 500,
331 description = "`email`(投递失败)/ `cache` / `session` / `serialization` / `internal`",
332 body = Envelope<NoData>,
333 example = json!({"code": "internal", "message": "internal server error"})
334 )
335 )
336)]
337pub async fn register_code(
338 svc: web::Data<crate::AuthService>,
339 session: Session,
340 body: web::Json<RegisterCodeRequest>,
341 request_id: RequestId,
342) -> Result<Envelope<RegisterCodeResponse>, AuthError> {
343 let response = svc
344 .send_register_code(session, body.into_inner(), request_id.0)
345 .await?;
346 Ok(Envelope::ok(response))
347}
348
349#[utoipa::path(
350 post,
351 path = "/api/v1/auth/register",
352 tag = "auth",
353 operation_id = "auth_register",
354 summary = "提交注册",
355 description = "校验工作量证明(payload = 用户名的 UTF-8 字节)、用户名/展示名/出生日期/邮箱、邮箱验证码(键 `auth:code:register:{email}`,尝试上限 `max_verify_attempts`)后,Argon2 重哈希口令并创建用户与初始档案。成功后写会话(user_id、security_version、renew)并记审计事件。",
356 request_body = UserRegisterRequest,
357 responses(
358 (
359 status = 200,
360 description = "注册成功且已登录",
361 body = Envelope<UserRegisterResponse>,
362 example = json!({"code":200,"message":"success","data":{"user_id":1234567,"username":"alice","displayname":"Alice"}})
363 ),
364 (
365 status = 400,
366 description = "`invalid_username` / `invalid_displayname` / `invalid_email` / `invalid_birth_date` / `invalid_password` / `invalid_email_code`(验证码错误或已过期)/ `expired` / `invalid_payload` / `invalid_proof`",
367 body = Envelope<NoData>,
368 example = json!({"code": "invalid_email_code", "message": "email verification code is invalid"})
369 ),
370 (
371 status = 403,
372 description = "`under_minimum_age`(未满最小年龄)",
373 body = Envelope<NoData>,
374 example = json!({"code": "under_minimum_age", "message": "you must be at least 13 years old"})
375 ),
376 (
377 status = 409,
378 description = "`username_taken` / `email_taken`",
379 body = Envelope<NoData>,
380 example = json!({"code": "username_taken", "message": "username is already taken"})
381 ),
382 (
383 status = 429,
384 description = "`email_code_attempts_exceeded`(验证码尝试次数用尽)",
385 body = Envelope<NoData>,
386 example = json!({"code": "email_code_attempts_exceeded", "message": "too many invalid email verification attempts"})
387 ),
388 (
389 status = 500,
390 description = "`password_hash`(口令重哈希失败)/ `database` / `cache` / `session` / `serialization` / `internal`",
391 body = Envelope<NoData>,
392 example = json!({"code": "internal", "message": "internal server error"})
393 )
394 )
395)]
396pub async fn register(
397 svc: web::Data<crate::AuthService>,
398 session: Session,
399 body: web::Json<UserRegisterRequest>,
400 request_id: RequestId,
401) -> Result<Envelope<UserRegisterResponse>, AuthError> {
402 let user_agent = session.user_agent()?;
403 let ip_addr = session.client_ip()?;
404 let response = svc
405 .register(
406 session,
407 body.into_inner(),
408 user_agent,
409 ip_addr,
410 request_id.0,
411 )
412 .await?;
413 Ok(Envelope::ok(response))
414292}
Deleted lib/auth/src/services/service.rs

Diff too large to display.

Renamed lib/auth/src/api/session.rs → lib/auth/src/services/sessions.rs View lib/auth/src/services/sessions.rs
@@ -1,101 +1,25 @@
11use crate::models::users::user_events::UserEventType;
22use crate::models::users::{user_sessions, users};
3use crate::protos::auth::v1::session_service_server::{SessionService, SessionServiceServer};
43use crate::protos::auth::v1::*;
4use export::KenAuthError;
55use sea_orm::sea_query::{Expr, ExprTrait};
66use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
7use tonic::{Request, Response, Status};
87use uuid::Uuid;
98
10use crate::AuthService;
11use crate::api::{context, convert, error, store};
9use crate::Accounts;
1210use crate::errors::AuthError;
11use crate::share::{auth_convert as convert, auth_error as error, auth_store as store};
1312
1413pub const MAX_LIST_PAGE: u64 = 100;
1514pub const SESSION_LIST_MAX: usize = 200;
1615
17#[derive(Clone)]
18pub struct SessionApi {
19 svc: AuthService,
20}
21
22impl SessionApi {
23 pub fn new(svc: AuthService) -> Self {
24 Self { svc }
25 }
26
27 pub fn server(
28 self,
29 ) -> tonic::service::interceptor::InterceptedService<
30 SessionServiceServer<Self>,
31 context::Interceptor,
32 > {
33 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
34 SessionServiceServer::with_interceptor(self, gate)
35 }
36}
37
38#[tonic::async_trait]
39impl SessionService for SessionApi {
40 async fn list_user_sessions(
41 &self,
42 request: Request<ListUserSessionsRequest>,
43 ) -> Result<Response<ListUserSessionsResponse>, Status> {
44 let meta = context::meta(&request).await?;
45 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.SessionService");
46 let result = self
47 .list_user_sessions_inner(meta.request_id(), request.into_inner())
48 .await;
49 timer.finish("ListUserSessions", &result);
50 result
51 }
52
53 async fn revoke_session(
54 &self,
55 request: Request<RevokeSessionRequest>,
56 ) -> Result<Response<RevokeSessionResponse>, Status> {
57 let meta = context::meta(&request).await?;
58 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.SessionService");
59 let result = self
60 .revoke_session_inner(meta.request_id(), request.into_inner())
61 .await;
62 timer.finish("RevokeSession", &result);
63 result
64 }
65
66 async fn revoke_user_sessions(
67 &self,
68 request: Request<RevokeUserSessionsRequest>,
69 ) -> Result<Response<RevokeUserSessionsResponse>, Status> {
70 let meta = context::meta(&request).await?;
71 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.SessionService");
72 let result = self
73 .revoke_user_sessions_inner(meta.request_id(), request.into_inner())
74 .await;
75 timer.finish("RevokeUserSessions", &result);
76 result
77 }
78
79 async fn bump_security_version(
80 &self,
81 request: Request<BumpSecurityVersionRequest>,
82 ) -> Result<Response<BumpSecurityVersionResponse>, Status> {
83 let meta = context::meta(&request).await?;
84 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.SessionService");
85 let result = self
86 .bump_security_version_inner(meta.request_id(), request.into_inner())
87 .await;
88 timer.finish("BumpSecurityVersion", &result);
89 result
90 }
91}
92impl SessionApi {
16impl Accounts {
9317 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
94 async fn list_user_sessions_inner(
18 pub(crate) async fn list_user_sessions_inner(
9519 &self,
9620 request_id: Option<&str>,
9721 request: ListUserSessionsRequest,
98 ) -> Result<Response<ListUserSessionsResponse>, Status> {
22 ) -> Result<ListUserSessionsResponse, KenAuthError> {
9923 let page = request.page.unwrap_or_default();
10024 let offset = match store::parse_page_token(&page.page_token) {
10125 Some(offset) => offset,
@@ -129,7 +53,7 @@
12953 .order_by_desc(user_sessions::Column::LastSeenAt)
13054 .limit(limit)
13155 .offset(offset)
132 .all(self.svc.db().read())
56 .all(self.db().read())
13357 .await
13458 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
13559
@@ -139,39 +63,39 @@
13963 String::new()
14064 };
14165 let sessions = rows.iter().map(convert::session_info).collect();
142 Ok(Response::new(ListUserSessionsResponse {
66 Ok(ListUserSessionsResponse {
14367 sessions,
14468 page: Some(crate::protos::auth::common::v1::PageResponse { next_page_token }),
145 }))
69 })
14670 }
14771 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
148 async fn revoke_session_inner(
72 pub(crate) async fn revoke_session_inner(
14973 &self,
15074 request_id: Option<&str>,
15175 request: RevokeSessionRequest,
152 ) -> Result<Response<RevokeSessionResponse>, Status> {
76 ) -> Result<RevokeSessionResponse, KenAuthError> {
15377 let session_id = Uuid::parse_str(request.session_id.trim())
15478 .map_err(|_| error::invalid_argument("session_id is not a valid uuid", request_id))?;
155 let user = store::load_user(self.svc.db(), request.user_id)
79 let user = store::load_user(self.db(), request.user_id)
15680 .await
15781 .map_err(|err| error::status(&err, request_id))?;
15882 let security_version = user.security_version;
15983 let row = user_sessions::Entity::find_by_id(session_id)
16084 .filter(user_sessions::Column::UserId.eq(request.user_id))
161 .one(self.svc.db().write())
85 .one(self.db().write())
16286 .await
16387 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
16488 let Some(row) = row else {
165 return Ok(Response::new(RevokeSessionResponse {
89 return Ok(RevokeSessionResponse {
16690 revoked: false,
16791 security_version,
168 }));
92 });
16993 };
17094 if row.revoked_at.is_some() {
171 return Ok(Response::new(RevokeSessionResponse {
95 return Ok(RevokeSessionResponse {
17296 revoked: true,
17397 security_version,
174 }));
98 });
17599 }
176100
177101 let revoked_at = store::now();
@@ -185,24 +109,23 @@
185109 .col_expr(user_sessions::Column::UpdatedAt, Expr::value(revoked_at))
186110 .filter(user_sessions::Column::Id.eq(session_id))
187111 .filter(user_sessions::Column::RevokedAt.is_null())
188 .exec(self.svc.db().write())
112 .exec(self.db().write())
189113 .await
190114 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
191115 if updated.rows_affected == 0 {
192 return Ok(Response::new(RevokeSessionResponse {
116 return Ok(RevokeSessionResponse {
193117 revoked: true,
194118 security_version,
195 }));
119 });
196120 }
197121
198 if let Err(err) =
199 store::drop_session_by_id(self.svc.cache(), request.user_id, session_id).await
122 if let Err(err) = store::drop_session_by_id(self.cache(), request.user_id, session_id).await
200123 {
201124 tracing::warn!(%err, %session_id, "failed to drop the revoked session state");
202125 }
203126
204127 if let Err(err) = store::audit(
205 self.svc.db(),
128 self.db(),
206129 request.user_id,
207130 None,
208131 UserEventType::Logout,
@@ -220,17 +143,17 @@
220143 tracing::warn!(%err, %session_id, "failed to record session revocation");
221144 }
222145
223 Ok(Response::new(RevokeSessionResponse {
146 Ok(RevokeSessionResponse {
224147 revoked: true,
225148 security_version,
226 }))
149 })
227150 }
228151 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
229 async fn revoke_user_sessions_inner(
152 pub(crate) async fn revoke_user_sessions_inner(
230153 &self,
231154 request_id: Option<&str>,
232155 request: RevokeUserSessionsRequest,
233 ) -> Result<Response<RevokeUserSessionsResponse>, Status> {
156 ) -> Result<RevokeUserSessionsResponse, KenAuthError> {
234157 let except = match request.except_session_id.trim() {
235158 "" => None,
236159 value => Some(Uuid::parse_str(value).map_err(|_| {
@@ -252,17 +175,17 @@
252175 update = update.filter(user_sessions::Column::Id.ne(except));
253176 }
254177 let affected = update
255 .exec(self.svc.db().write())
178 .exec(self.db().write())
256179 .await
257180 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
258181
259182 if let Err(err) =
260 store::drop_user_session_states(self.svc.cache(), request.user_id, except).await
183 store::drop_user_session_states(self.cache(), request.user_id, except).await
261184 {
262185 tracing::warn!(%err, user_id = request.user_id, "failed to drop revoked sessions");
263186 }
264187
265 let user = store::load_user(self.svc.db(), request.user_id)
188 let user = store::load_user(self.db(), request.user_id)
266189 .await
267190 .map_err(|err| error::status(&err, request_id))?;
268191 let mut security_version = user.security_version;
@@ -274,85 +197,19 @@
274197 )
275198 .col_expr(users::Column::UpdatedAt, Expr::value(now))
276199 .filter(users::Column::Id.eq(request.user_id))
277 .exec(self.svc.db().write())
200 .exec(self.db().write())
278201 .await
279202 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
280 crate::services::me::invalidate_me_cache(self.svc.cache(), request.user_id).await;
281 security_version = store::load_user(self.svc.db(), request.user_id)
203 crate::services::me::invalidate_me_cache(self.cache(), request.user_id).await;
204 security_version = store::load_user(self.db(), request.user_id)
282205 .await
283206 .map_err(|err| error::status(&err, request_id))?
284207 .security_version;
285208 }
286209
287 Ok(Response::new(RevokeUserSessionsResponse {
210 Ok(RevokeUserSessionsResponse {
288211 revoked_count: affected.rows_affected as u32,
289212 security_version,
290 }))
291 }
292 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
293 async fn bump_security_version_inner(
294 &self,
295 request_id: Option<&str>,
296 request: BumpSecurityVersionRequest,
297 ) -> Result<Response<BumpSecurityVersionResponse>, Status> {
298 store::load_user(self.svc.db(), request.user_id)
299 .await
300 .map_err(|err| error::status(&err, request_id))?;
301 let now = store::now();
302
303 users::Entity::update_many()
304 .col_expr(
305 users::Column::SecurityVersion,
306 Expr::col(users::Column::SecurityVersion).add(1),
307 )
308 .col_expr(users::Column::UpdatedAt, Expr::value(now))
309 .filter(users::Column::Id.eq(request.user_id))
310 .exec(self.svc.db().write())
311 .await
312 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
313 let security_version = store::load_user(self.svc.db(), request.user_id)
314 .await
315 .map_err(|err| error::status(&err, request_id))?
316 .security_version;
317
318 let mut revoked_sessions = 0u32;
319 if request.revoke_sessions {
320 let revoked = user_sessions::Entity::update_many()
321 .col_expr(user_sessions::Column::RevokedAt, Expr::value(now))
322 .col_expr(
323 user_sessions::Column::Version,
324 Expr::col(user_sessions::Column::Version).add(1),
325 )
326 .col_expr(user_sessions::Column::UpdatedAt, Expr::value(now))
327 .filter(user_sessions::Column::UserId.eq(request.user_id))
328 .filter(user_sessions::Column::RevokedAt.is_null())
329 .exec(self.svc.db().write())
330 .await
331 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
332 revoked_sessions = revoked.rows_affected as u32;
333 }
334
335 crate::services::me::invalidate_me_cache(self.svc.cache(), request.user_id).await;
336 store::audit(
337 self.svc.db(),
338 request.user_id,
339 None,
340 UserEventType::Logout,
341 Some(serde_json::json!({
342 "reason": request.reason,
343 "revoke_sessions": request.revoke_sessions,
344 })),
345 request_id,
346 None,
347 None,
348 )
349 .await
350 .map_err(|err| error::status(&err, request_id))?;
351
352 Ok(Response::new(BumpSecurityVersionResponse {
353 security_version,
354 revoked_sessions,
355 bumped_at: Some(convert::now_timestamp()),
356 }))
213 })
357214 }
358215}
Renamed lib/auth/src/api/mfa.rs → lib/auth/src/services/two_factor.rs View lib/auth/src/services/two_factor.rs
@@ -1,42 +1,23 @@
11use crate::models::users::user_events::UserEventType;
22use crate::models::users::user_mfa::{self, MfaStatus, MfaType};
33use crate::models::users::user_mfa_backup_codes;
4use crate::protos::auth::common::v1::{Code, MfaStatus as ProtoMfaStatus, MfaType as ProtoMfaType};
5use crate::protos::auth::v1::mfa_service_server::{MfaService, MfaServiceServer};
4use crate::protos::auth::common::v1::{MfaStatus as ProtoMfaStatus, MfaType as ProtoMfaType};
65use crate::protos::auth::v1::*;
76use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
87use serde_json::json;
9use tonic::{Request, Response, Status};
108
119use crate::AuthService;
12use crate::api::{context, convert, error, store};
1310use crate::errors::{AuthError, AuthResult};
11use crate::share::{auth_convert as convert, auth_store as store};
1412
15#[derive(Clone)]
16pub struct MfaApi {
17 svc: AuthService,
18}
19
20impl MfaApi {
21 pub fn new(svc: AuthService) -> Self {
22 Self { svc }
23 }
24
25 pub fn server(
26 self,
27 ) -> tonic::service::interceptor::InterceptedService<MfaServiceServer<Self>, context::Interceptor>
28 {
29 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
30 MfaServiceServer::with_interceptor(self, gate)
31 }
32
13impl AuthService {
3314 async fn remaining_codes(&self, user_id: i64) -> AuthResult<u32> {
3415 let now = chrono::Utc::now();
3516 let rows = user_mfa_backup_codes::Entity::find()
3617 .filter(user_mfa_backup_codes::Column::UserId.eq(user_id))
3718 .filter(user_mfa_backup_codes::Column::ConsumedAt.is_null())
3819 .filter(user_mfa_backup_codes::Column::RevokedAt.is_null())
39 .all(self.svc.db().read())
20 .all(self.db().read())
4021 .await?;
4122 Ok(rows
4223 .iter()
@@ -45,10 +26,13 @@
4526 }
4627
4728 #[tracing::instrument(skip_all)]
48 async fn status_impl(&self, request: GetMfaStatusRequest) -> AuthResult<GetMfaStatusResponse> {
29 pub(crate) async fn status_impl(
30 &self,
31 request: GetMfaStatusRequest,
32 ) -> AuthResult<GetMfaStatusResponse> {
4933 let rows = user_mfa::Entity::find()
5034 .filter(user_mfa::Column::UserId.eq(request.user_id))
51 .all(self.svc.db().read())
35 .all(self.db().read())
5236 .await?;
5337
5438 let mut login_requires_mfa = false;
@@ -106,7 +90,7 @@
10690 let rows = user_mfa_backup_codes::Entity::find()
10791 .filter(user_mfa_backup_codes::Column::UserId.eq(user_id))
10892 .filter(user_mfa_backup_codes::Column::RevokedAt.is_null())
109 .all(self.svc.db().read())
93 .all(self.db().read())
11094 .await?;
11195 let used = rows.iter().filter(|row| row.consumed_at.is_some()).count();
11296 Ok((rows.len() as u32, used as u32))
@@ -121,7 +105,7 @@
121105 )
122106 .filter(user_mfa_backup_codes::Column::UserId.eq(user_id))
123107 .filter(user_mfa_backup_codes::Column::RevokedAt.is_null())
124 .exec(self.svc.db().write())
108 .exec(self.db().write())
125109 .await?;
126110 Ok(result.rows_affected)
127111 }
@@ -163,7 +147,7 @@
163147 revoked_at: Set(None),
164148 version: Set(1),
165149 }
166 .insert(self.svc.db().write())
150 .insert(self.db().write())
167151 .await?;
168152 codes.push(code);
169153 }
@@ -171,7 +155,7 @@
171155 }
172156
173157 async fn verify_password_hash(&self, user_id: i64, client_digest: &str) -> AuthResult<()> {
174 let user = store::load_active_user(self.svc.db(), user_id).await?;
158 let user = store::load_active_user(self.db(), user_id).await?;
175159 let stored = user.password_hash.as_str();
176160 if stored.is_empty() {
177161 return Err(AuthError::InvalidPassword);
@@ -194,14 +178,14 @@
194178 }
195179
196180 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
197 async fn configure_totp_impl(
181 pub(crate) async fn configure_totp_impl(
198182 &self,
199183 request: ConfigureTotpRequest,
200184 request_id: Option<&str>,
201185 ) -> AuthResult<ConfigureTotpResponse> {
202 let db = self.svc.db();
186 let db = self.db();
203187 let user = store::load_active_user(db, request.user_id).await?;
204 let key = self.svc.auth().totp_encryption_key.as_deref();
188 let key = self.auth().totp_encryption_key.as_deref();
205189
206190 let rows = user_mfa::Entity::find()
207191 .filter(user_mfa::Column::UserId.eq(user.id))
@@ -270,12 +254,12 @@
270254 }
271255
272256 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
273 async fn enable_totp_impl(
257 pub(crate) async fn enable_totp_impl(
274258 &self,
275259 request: EnableTotpRequest,
276260 request_id: Option<&str>,
277261 ) -> AuthResult<EnableTotpResponse> {
278 let db = self.svc.db();
262 let db = self.db();
279263 let user = store::load_active_user(db, request.user_id).await?;
280264 self.verify_password_hash(user.id, &request.password_hash)
281265 .await?;
@@ -293,7 +277,7 @@
293277 .find(|row| row.mfa_type == MfaType::Totp && row.mfa_status == MfaStatus::Pending)
294278 .ok_or(AuthError::TotpNotConfigured)?;
295279
296 let key = self.svc.auth().totp_encryption_key.as_deref();
280 let key = self.auth().totp_encryption_key.as_deref();
297281 let ciphertext = pending
298282 .secret_ciphertext
299283 .as_deref()
@@ -339,12 +323,12 @@
339323 }
340324
341325 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
342 async fn disable_totp_impl(
326 pub(crate) async fn disable_totp_impl(
343327 &self,
344328 request: DisableTotpRequest,
345329 request_id: Option<&str>,
346330 ) -> AuthResult<DisableTotpResponse> {
347 let db = self.svc.db();
331 let db = self.db();
348332 let user = store::load_active_user(db, request.user_id).await?;
349333
350334 let rows = user_mfa::Entity::find()
@@ -357,7 +341,7 @@
357341 };
358342
359343 let code = request.code.trim();
360 let key = self.svc.auth().totp_encryption_key.as_deref();
344 let key = self.auth().totp_encryption_key.as_deref();
361345 let consumed = crate::share::mfa::consume_totp(db, &factor, key, code, None)
362346 .await
363347 .is_ok();
@@ -399,12 +383,12 @@
399383 }
400384
401385 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
402 async fn regenerate_recovery_codes_impl(
386 pub(crate) async fn regenerate_recovery_codes_impl(
403387 &self,
404388 request: RegenerateRecoveryCodesRequest,
405389 request_id: Option<&str>,
406390 ) -> AuthResult<RegenerateRecoveryCodesResponse> {
407 let db = self.svc.db();
391 let db = self.db();
408392 let user = store::load_active_user(db, request.user_id).await?;
409393 self.verify_password_hash(user.id, &request.password_hash)
410394 .await?;
@@ -438,152 +422,6 @@
438422 recovery_codes: codes,
439423 })
440424 }
441
442 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
443 async fn verify_totp_impl(
444 &self,
445 request: VerifyTotpRequest,
446 request_id: Option<&str>,
447 ) -> AuthResult<VerifyTotpResponse> {
448 let db = self.svc.db();
449
450 let user = match store::load_active_user(db, request.user_id).await {
451 Ok(user) => user,
452 Err(
453 AuthError::AccountUnavailable { .. }
454 | AuthError::LoginForbidden { .. }
455 | AuthError::UserNotFound,
456 ) => {
457 return Ok(VerifyTotpResponse {
458 valid: false,
459 reason: Code::UserDisabled as i32,
460 matched_step: 0,
461 attempt_count: 0,
462 });
463 }
464 // 数据库/缓存故障不能伪装成「用户被禁用」,否则上游会把可用性故障当业务结果。
465 Err(error) => return Err(error),
466 };
467 let factor = user_mfa::Entity::find()
468 .filter(user_mfa::Column::UserId.eq(user.id))
469 .filter(user_mfa::Column::MfaType.eq(MfaType::Totp))
470 .filter(user_mfa::Column::MfaStatus.eq(MfaStatus::Enabled))
471 .filter(user_mfa::Column::RevokedAt.is_null())
472 .one(db.read())
473 .await?;
474 let Some(factor) = factor else {
475 return Ok(VerifyTotpResponse {
476 valid: false,
477 reason: Code::MfaNotConfigured as i32,
478 matched_step: 0,
479 attempt_count: 0,
480 });
481 };
482 match crate::share::mfa::consume_totp(
483 db,
484 &factor,
485 self.svc.auth().totp_encryption_key.as_deref(),
486 &request.code,
487 request_id,
488 )
489 .await
490 {
491 Ok(step) => {
492 if let Err(error) = store::audit(
493 db,
494 user.id,
495 Some(user.id),
496 UserEventType::Login,
497 Some(json!({ "action": "totp_verified" })),
498 request_id,
499 non_empty(&request.client_ip),
500 None,
501 )
502 .await
503 {
504 // TOTP 步长已推进,审计失败不能让客户端重试(重试只会得到 TotpStepReused)。
505 tracing::warn!(%error, user_id = user.id, "failed to record totp verification");
506 }
507
508 Ok(VerifyTotpResponse {
509 valid: true,
510 reason: Code::Unspecified as i32,
511 matched_step: step as i64,
512 attempt_count: 0,
513 })
514 }
515 Err(AuthError::TotpStepReused) => Ok(VerifyTotpResponse {
516 valid: false,
517 reason: Code::TotpStepReused as i32,
518 matched_step: 0,
519 attempt_count: 0,
520 }),
521 Err(AuthError::TotpCodeInvalid) => Ok(VerifyTotpResponse {
522 valid: false,
523 reason: Code::InvalidCredentials as i32,
524 matched_step: 0,
525 attempt_count: 0,
526 }),
527 // 其余错误(密钥缺失、解密失败、数据库故障)必须上抛,不能归为「凭据错误」。
528 Err(error) => Err(error),
529 }
530 }
531
532 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
533 async fn redeem_recovery_code_impl(
534 &self,
535 request: RedeemRecoveryCodeRequest,
536 request_id: Option<&str>,
537 ) -> AuthResult<RedeemRecoveryCodeResponse> {
538 let db = self.svc.db();
539
540 let user = match store::load_active_user(db, request.user_id).await {
541 Ok(user) => user,
542 Err(
543 AuthError::AccountUnavailable { .. }
544 | AuthError::LoginForbidden { .. }
545 | AuthError::UserNotFound,
546 ) => {
547 return Ok(RedeemRecoveryCodeResponse {
548 valid: false,
549 reason: Code::UserDisabled as i32,
550 remaining_codes: 0,
551 });
552 }
553 Err(error) => return Err(error),
554 };
555
556 let remaining_before = self.remaining_codes(user.id).await?;
557 match crate::share::mfa::consume_recovery(db, user.id, &request.code, request_id).await {
558 Ok(()) => {}
559 Err(AuthError::RecoveryCodeInvalid) => {
560 return Ok(RedeemRecoveryCodeResponse {
561 valid: false,
562 reason: Code::InvalidCredentials as i32,
563 remaining_codes: remaining_before,
564 });
565 }
566 Err(error) => return Err(error),
567 }
568
569 store::audit(
570 db,
571 user.id,
572 Some(user.id),
573 UserEventType::Login,
574 Some(json!({ "action": "recovery_code_redeemed" })),
575 request_id,
576 non_empty(&request.client_ip),
577 None,
578 )
579 .await?;
580
581 Ok(RedeemRecoveryCodeResponse {
582 valid: true,
583 reason: Code::Unspecified as i32,
584 remaining_codes: remaining_before.saturating_sub(1),
585 })
586 }
587425}
588426
589427fn non_empty(value: &str) -> Option<String> {
@@ -598,125 +436,3 @@
598436const RECOVERY_CODE_COUNT: usize = 10;
599437const RECOVERY_CODE_TTL_DAYS: i64 = 365;
600438const RECOVERY_CODE_HASH_KEY_ID: &str = "v1";
601
602#[tonic::async_trait]
603impl MfaService for MfaApi {
604 async fn get_mfa_status(
605 &self,
606 request: Request<GetMfaStatusRequest>,
607 ) -> Result<Response<GetMfaStatusResponse>, Status> {
608 let meta = context::meta(&request).await?;
609 let request = request.into_inner();
610 let request_id = meta.request_id().map(str::to_owned);
611 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.MfaService");
612 let result = self
613 .status_impl(request)
614 .await
615 .map(Response::new)
616 .map_err(|failure| error::status(&failure, request_id.as_deref()));
617 timer.finish("GetMfaStatus", &result);
618 result
619 }
620
621 async fn verify_totp(
622 &self,
623 request: Request<VerifyTotpRequest>,
624 ) -> Result<Response<VerifyTotpResponse>, Status> {
625 let meta = context::meta(&request).await?;
626 let request = request.into_inner();
627 let request_id = meta.request_id().map(str::to_owned);
628 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.MfaService");
629 let result = self
630 .verify_totp_impl(request, request_id.as_deref())
631 .await
632 .map(Response::new)
633 .map_err(|failure| error::status(&failure, request_id.as_deref()));
634 timer.finish("VerifyTotp", &result);
635 result
636 }
637
638 async fn redeem_recovery_code(
639 &self,
640 request: Request<RedeemRecoveryCodeRequest>,
641 ) -> Result<Response<RedeemRecoveryCodeResponse>, Status> {
642 let meta = context::meta(&request).await?;
643 let request = request.into_inner();
644 let request_id = meta.request_id().map(str::to_owned);
645 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.MfaService");
646 let result = self
647 .redeem_recovery_code_impl(request, request_id.as_deref())
648 .await
649 .map(Response::new)
650 .map_err(|failure| error::status(&failure, request_id.as_deref()));
651 timer.finish("RedeemRecoveryCode", &result);
652 result
653 }
654
655 async fn configure_totp(
656 &self,
657 request: Request<ConfigureTotpRequest>,
658 ) -> Result<Response<ConfigureTotpResponse>, Status> {
659 let meta = context::meta(&request).await?;
660 let request = request.into_inner();
661 let request_id = meta.request_id().map(str::to_owned);
662 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.MfaService");
663 let result = self
664 .configure_totp_impl(request, request_id.as_deref())
665 .await
666 .map(Response::new)
667 .map_err(|failure| error::status(&failure, request_id.as_deref()));
668 timer.finish("ConfigureTotp", &result);
669 result
670 }
671
672 async fn enable_totp(
673 &self,
674 request: Request<EnableTotpRequest>,
675 ) -> Result<Response<EnableTotpResponse>, Status> {
676 let meta = context::meta(&request).await?;
677 let request = request.into_inner();
678 let request_id = meta.request_id().map(str::to_owned);
679 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.MfaService");
680 let result = self
681 .enable_totp_impl(request, request_id.as_deref())
682 .await
683 .map(Response::new)
684 .map_err(|failure| error::status(&failure, request_id.as_deref()));
685 timer.finish("EnableTotp", &result);
686 result
687 }
688
689 async fn disable_totp(
690 &self,
691 request: Request<DisableTotpRequest>,
692 ) -> Result<Response<DisableTotpResponse>, Status> {
693 let meta = context::meta(&request).await?;
694 let request = request.into_inner();
695 let request_id = meta.request_id().map(str::to_owned);
696 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.MfaService");
697 let result = self
698 .disable_totp_impl(request, request_id.as_deref())
699 .await
700 .map(Response::new)
701 .map_err(|failure| error::status(&failure, request_id.as_deref()));
702 timer.finish("DisableTotp", &result);
703 result
704 }
705
706 async fn regenerate_recovery_codes(
707 &self,
708 request: Request<RegenerateRecoveryCodesRequest>,
709 ) -> Result<Response<RegenerateRecoveryCodesResponse>, Status> {
710 let meta = context::meta(&request).await?;
711 let request = request.into_inner();
712 let request_id = meta.request_id().map(str::to_owned);
713 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.MfaService");
714 let result = self
715 .regenerate_recovery_codes_impl(request, request_id.as_deref())
716 .await
717 .map(Response::new)
718 .map_err(|failure| error::status(&failure, request_id.as_deref()));
719 timer.finish("RegenerateRecoveryCodes", &result);
720 result
721 }
722}
Modified lib/auth/src/services/users.rs View lib/auth/src/services/users.rs
@@ -49,9 +49,7 @@
4949 caller: i64,
5050 uid: i64,
5151 ) -> Result<UserPublic, AuthBusinessError> {
52 let client = self.state.kenauth.as_ref().ok_or_else(|| {
53 AuthBusinessError::Internal("auth service is not configured".to_string())
54 })?;
52 let client = &self.accounts;
5553 let public = client.get_user_public(uid).await.map_err(map_kenauth)?;
5654 let authority = client.get_authority_user(uid).await.map_err(map_kenauth)?;
5755 let local = user::Entity::find_by_id(uid).one(self.db().read()).await?;
@@ -75,9 +73,7 @@
7573 unique.sort_unstable();
7674 unique.dedup();
7775
78 let client = self.state.kenauth.as_ref().ok_or_else(|| {
79 AuthBusinessError::Internal("auth service is not configured".to_string())
80 })?;
76 let client = &self.accounts;
8177 let publics = client
8278 .batch_get_user_public(unique.clone())
8379 .await
@@ -118,9 +114,7 @@
118114 if name.is_empty() {
119115 return Err(AuthBusinessError::NotFound);
120116 }
121 let client = self.state.kenauth.as_ref().ok_or_else(|| {
122 AuthBusinessError::Internal("auth service is not configured".to_string())
123 })?;
117 let client = &self.accounts;
124118 let public = client
125119 .get_user_public_by_ref(auth_public::user_ref::Key::Username(name.to_string()))
126120 .await
Renamed lib/auth/src/share/rpc.rs → lib/auth/src/share/account_helpers.rs View lib/auth/src/share/account_helpers.rs
@@ -1,16 +1,6 @@
1use export::AppState;
21use export::http::HeaderMap;
32use sha2::{Digest, Sha256};
43use time::OffsetDateTime;
5
6use crate::errors::ApiError;
7
8pub fn client(state: &AppState) -> Result<&dyn export::AuthApi, ApiError> {
9 state
10 .kenauth
11 .as_deref()
12 .ok_or_else(|| ApiError::internal("auth service client is not configured"))
13}
144
155pub fn client_ip(headers: &HeaderMap) -> String {
166 headers
Renamed lib/auth/src/api/convert.rs → lib/auth/src/share/auth_convert.rs View lib/auth/src/share/auth_convert.rs
@@ -1,9 +1,7 @@
1use crate::models::users::{user_sessions, user_ssh_keys};
2use crate::protos::auth::common::v1::{SessionInfo, SshKeyInfo};
1use crate::models::users::user_sessions;
2use crate::protos::auth::common::v1::SessionInfo;
33use chrono::{DateTime, NaiveDate, TimeZone, Utc};
44use prost_types::Timestamp;
5
6use crate::api::store;
75
86pub const DEFAULT_PAGE_SIZE: i32 = 20;
97pub const MAX_PAGE_SIZE: i32 = 100;
@@ -52,22 +50,6 @@
5250 mfa_verified_at: datetime_opt(row.mfa_verified_at),
5351 revoked_at: datetime_opt(row.revoked_at),
5452 revoke_reason: String::new(),
55 }
56}
57
58pub fn ssh_key_info(row: &user_ssh_keys::Model) -> SshKeyInfo {
59 SshKeyInfo {
60 key_id: row.id.to_string(),
61 user_id: row.user_id,
62 name: row.name.clone(),
63 description: row.description.clone().unwrap_or_default(),
64 algorithm: store::ssh_algorithm_code(row.algorithm.clone()),
65 public_key: row.public_key.clone(),
66 fingerprint: row.fingerprint.clone(),
67 created_at: datetime_opt(Some(row.created_at)),
68 expires_at: datetime_opt(row.expires_at),
69 last_used_at: datetime_opt(row.last_used_at),
70 revoked_at: datetime_opt(row.revoked_at),
7153 }
7254}
7355
Renamed lib/auth/src/api/error.rs → lib/auth/src/share/auth_error.rs View lib/auth/src/share/auth_error.rs
@@ -1,11 +1,6 @@
1use crate::protos::auth::common::v1::{Code, ErrorDetail};
2use crate::protos::google::rpc::Status as RpcStatus;
3use prost::Message;
4use prost_types::Any;
5use tonic::Status;
6
71use crate::errors::AuthError;
8
2use crate::protos::auth::common::v1::Code;
3use export::KenAuthError;
94pub fn code_of(error: &AuthError) -> Code {
105 match error {
116 AuthError::Expired => Code::ExpiredChallenge,
@@ -89,9 +84,9 @@
8984 }
9085}
9186
92pub fn tonic_code(code: Code) -> tonic::Code {
87fn category(code: Code) -> u8 {
9388 match code {
94 Code::Unspecified => tonic::Code::Unknown,
89 Code::Unspecified => 0,
9590 Code::InvalidArgument
9691 | Code::MalformedRequest
9792 | Code::ExpiredChallenge
@@ -107,20 +102,20 @@
107102 | Code::InvalidScope
108103 | Code::InvalidExpiry
109104 | Code::InvalidName
110 | Code::InvalidPublicKey => tonic::Code::InvalidArgument,
105 | Code::InvalidPublicKey => 1,
111106 Code::Unauthenticated
112107 | Code::InvalidCredentials
113108 | Code::InvalidAccessToken
114109 | Code::TokenExpired
115110 | Code::TokenRevoked
116111 | Code::SecurityVersionStale
117 | Code::MfaRequired => tonic::Code::Unauthenticated,
112 | Code::MfaRequired => 2,
118113 Code::Forbidden
119114 | Code::LoginForbidden
120115 | Code::UserDisabled
121116 | Code::UnderMinimumAge
122117 | Code::ScopeInsufficient
123 | Code::AgreementNotAccepted => tonic::Code::PermissionDenied,
118 | Code::AgreementNotAccepted => 1,
124119 Code::MfaNotConfigured
125120 | Code::TooManyAccessKeys
126121 | Code::TotpAlreadyEnabled
@@ -131,7 +126,7 @@
131126 | Code::OrganizationRoleSystem
132127 | Code::OrganizationInviteExhausted
133128 | Code::OrganizationInviteExpired
134 | Code::OrganizationOwnerRequired => tonic::Code::FailedPrecondition,
129 | Code::OrganizationOwnerRequired => 1,
135130 Code::Conflict
136131 | Code::UsernameTaken
137132 | Code::EmailTaken
@@ -141,7 +136,7 @@
141136 | Code::OrganizationRoleKeyTaken
142137 | Code::OrganizationHasMembers
143138 | Code::VersionMismatch
144 | Code::EmailUnchanged => tonic::Code::AlreadyExists,
139 | Code::EmailUnchanged => 1,
145140 Code::NotFound
146141 | Code::UserNotFound
147142 | Code::SessionNotFound
@@ -152,94 +147,48 @@
152147 | Code::OrganizationMemberNotFound
153148 | Code::OrganizationRoleNotFound
154149 | Code::OrganizationInviteNotFound
155 | Code::TokenNotFound => tonic::Code::NotFound,
156 Code::OrganizationInactive | Code::OrganizationMemberBanned => {
157 tonic::Code::PermissionDenied
158 }
159 Code::InvalidPermission => tonic::Code::InvalidArgument,
150 | Code::TokenNotFound => 3,
151 Code::OrganizationInactive | Code::OrganizationMemberBanned => 1,
152 Code::InvalidPermission => 1,
160153 Code::RateLimited
161154 | Code::EmailCodeAttemptsExceeded
162155 | Code::RecoveryCodesExhausted
163 | Code::PayloadTooLarge => tonic::Code::ResourceExhausted,
156 | Code::PayloadTooLarge => 1,
164157 Code::Internal
165158 | Code::DatabaseError
166159 | Code::CacheError
167160 | Code::SerializationError
168 | Code::EmailError => tonic::Code::Internal,
169 Code::Unavailable => tonic::Code::Unavailable,
170 }
171}
172
173pub fn detail(error: &AuthError, request_id: Option<&str>) -> ErrorDetail {
174 let retry_after_ms = match error {
175 AuthError::RateLimited { retry_after_secs } => (*retry_after_secs).min(3600) as u32 * 1000,
176 _ => 0,
177 };
178 let current_security_version = match error {
179 AuthError::SecurityVersionStale { current } => *current,
180 _ => 0,
181 };
182 ErrorDetail {
183 code: code_of(error) as i32,
184 message: error.message(),
185 retry_after_ms,
186 current_security_version,
187 request_id: request_id.unwrap_or_default().to_owned(),
188 offending_fields: Vec::new(),
161 | Code::EmailError => 0,
162 Code::Unavailable => 4,
189163 }
190164}
191165
192/// 业务错误详情的 type_url(放进 `google.rpc.Status.details`)。
193pub const DETAIL_TYPE_URL: &str = "type.googleapis.com/auth.common.v1.ErrorDetail";
194
195/// 组装 `grpc-status-details-bin`。
196///
197/// 按 gRPC 规范,这个 trailer 必须是 `google.rpc.Status`:外层 `code` 与 `grpc-status`
198/// 一致,业务错误码放在 `details` 的 `Any` 里。直接把自定义 message 塞进去会被严格客户端
199/// (grpcurl、Go 的 status.FromError)解析成错误的 code。
200fn with_details(code: tonic::Code, message: String, detail: ErrorDetail) -> Status {
201 let outer = RpcStatus {
202 code: code as i32,
203 message: message.clone(),
204 details: vec![Any {
205 type_url: DETAIL_TYPE_URL.to_owned(),
206 value: detail.encode_to_vec(),
207 }],
208 };
209 Status::with_details(code, message, outer.encode_to_vec().into())
166pub fn status(error: &AuthError, request_id: Option<&str>) -> KenAuthError {
167 status_of(code_of(error), error.message(), request_id)
210168}
211
212pub fn status(error: &AuthError, request_id: Option<&str>) -> Status {
213 let code = code_of(error);
214 let detail = detail(error, request_id);
215 with_details(tonic_code(code), error.message(), detail)
169pub fn status_of(
170 code: Code,
171 message: impl Into<String>,
172 _request_id: Option<&str>,
173) -> KenAuthError {
174 let message = message.into();
175 match category(code) {
176 1 => KenAuthError::Rejected(message),
177 2 => KenAuthError::Unauthenticated,
178 3 => KenAuthError::NotFound,
179 4 => KenAuthError::Unavailable(message),
180 _ => KenAuthError::Internal(message),
181 }
216182}
217
218pub fn status_of(code: Code, message: impl Into<String>, request_id: Option<&str>) -> Status {
219 let detail = ErrorDetail {
220 code: code as i32,
221 message: message.into(),
222 retry_after_ms: 0,
223 current_security_version: 0,
224 request_id: request_id.unwrap_or_default().to_owned(),
225 offending_fields: Vec::new(),
226 };
227 let message = detail.message.clone();
228 with_details(tonic_code(code), message, detail)
183pub fn invalid_argument(message: impl Into<String>, request_id: Option<&str>) -> KenAuthError {
184 status_of(Code::InvalidArgument, message, request_id)
229185}
230
231pub fn unauthenticated(message: impl Into<String>, request_id: Option<&str>) -> Status {
186pub fn unauthenticated(message: impl Into<String>, request_id: Option<&str>) -> KenAuthError {
232187 status_of(Code::Unauthenticated, message, request_id)
233188}
234
235pub fn invalid_argument(message: impl Into<String>, request_id: Option<&str>) -> Status {
236 status_of(Code::InvalidArgument, message, request_id)
237}
238
239pub fn forbidden(message: impl Into<String>, request_id: Option<&str>) -> Status {
189pub fn forbidden(message: impl Into<String>, request_id: Option<&str>) -> KenAuthError {
240190 status_of(Code::Forbidden, message, request_id)
241191}
242
243pub fn unavailable(message: impl Into<String>, request_id: Option<&str>) -> Status {
192pub fn unavailable(message: impl Into<String>, request_id: Option<&str>) -> KenAuthError {
244193 status_of(Code::Unavailable, message, request_id)
245194}
Renamed lib/auth/src/api/store.rs → lib/auth/src/share/auth_store.rs View lib/auth/src/share/auth_store.rs
@@ -1,15 +1,13 @@
11use crate::models::users::users;
22use crate::protos::auth::common::v1::{
3 AuthMethod, MfaStatus, MfaType, Principal, SshKeyAlgorithm, UserPublic, UserRef, UserStatus,
4 user_ref,
3 AuthMethod, MfaStatus, MfaType, Principal, UserPublic, UserRef, UserStatus, user_ref,
54};
65use chrono::{DateTime, Utc};
76use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
87use uuid::Uuid;
98
10use crate::api::convert;
119use crate::errors::{AuthError, AuthResult};
12use crate::share::user as user_store;
10use crate::share::{auth_convert as convert, user as user_store};
1311
1412pub const ALLOWED_APP_SCOPES: [&str; 12] = [
1513 "user_name",
@@ -174,15 +172,6 @@
174172
175173pub fn mfa_status_code(value: MfaStatus) -> i32 {
176174 value as i32
177}
178
179pub fn ssh_algorithm_code(value: crate::models::users::user_ssh_keys::PublicKeyType) -> i32 {
180 let algorithm = match value {
181 crate::models::users::user_ssh_keys::PublicKeyType::RSA => SshKeyAlgorithm::Rsa,
182 crate::models::users::user_ssh_keys::PublicKeyType::ED25519 => SshKeyAlgorithm::Ed25519,
183 crate::models::users::user_ssh_keys::PublicKeyType::ECDSA => SshKeyAlgorithm::Ecdsa,
184 };
185 algorithm as i32
186175}
187176
188177pub fn new_id() -> Uuid {
Modified lib/auth/src/share/mod.rs View lib/auth/src/share/mod.rs
@@ -1,4 +1,5 @@
11pub mod account;
2pub mod account_helpers;
23pub mod audit;
34pub mod codes;
45pub mod crypto;
@@ -6,13 +7,13 @@
67pub mod mfa;
78pub mod net;
89pub mod password;
9pub mod pow;
1010pub mod rate;
11pub mod rpc;
12pub mod session;
1311pub mod totp;
1412pub mod types;
1513pub mod user;
1614pub mod validate;
1715
1816pub use types::{AgreementDoc, ListQuery, UserPublic};
17pub mod auth_convert;
18pub mod auth_error;
19pub mod auth_store;
Modified lib/auth/src/share/user.rs View lib/auth/src/share/user.rs
@@ -1,5 +1,5 @@
11//! 用户加载与状态判定 helper:service 层与 gRPC 层共用的唯一实现点。
2//! gRPC 侧(`grpc::store`)re-export 这些函数,避免 service 反向依赖传输层。
2//! gRPC 侧(`gaccount_helpers::store`)re-export 这些函数,避免 service 反向依赖传输层。
33
44use crate::db::DbConn;
55use crate::errors::{AuthError, AuthResult};
Renamed lib/auth/src/pages/common.rs → lib/auth/src/templates/common.rs View lib/auth/src/templates/common.rs

No content changes.

Renamed lib/auth/src/pages/mod.rs → lib/auth/src/templates/mod.rs View lib/auth/src/templates/mod.rs

No content changes.

Renamed lib/auth/src/pages/page.rs → lib/auth/src/templates/page.rs View lib/auth/src/templates/page.rs

No content changes.

Added lib/auth/src/accounts.rs View lib/auth/src/accounts.rs
@@ -0,0 +1,18 @@
1use crate::cache::Cache;
2use crate::db::DbConn;
3#[derive(Clone)]
4pub struct Accounts {
5 db: DbConn,
6 cache: Cache,
7}
8impl Accounts {
9 pub fn new(db: DbConn, cache: Cache) -> Self {
10 Self { db, cache }
11 }
12 pub fn db(&self) -> &DbConn {
13 &self.db
14 }
15 pub fn cache(&self) -> &Cache {
16 &self.cache
17 }
18}
Deleted lib/auth/src/api.rs
@@ -1,509 +0,0 @@
1use async_trait::async_trait;
2use export::{AuthApi, KenAuthError};
3use tonic::Request;
4
5use crate::AuthService;
6use crate::api::context::RequestMeta;
7use crate::protos::auth as proto;
8use crate::protos::auth::internal::v1::authority_service_server::AuthorityService;
9use crate::protos::auth::v1::identity_service_server::IdentityService;
10use crate::protos::auth::v1::mfa_service_server::MfaService;
11use crate::protos::auth::v1::session_service_server::SessionService;
12use crate::protos::auth::v1::ssh_key_service_server::SshKeyService;
13
14pub mod authority;
15pub mod context;
16pub mod convert;
17pub mod error;
18pub mod identity;
19pub mod mfa;
20pub mod session;
21pub mod ssh_key;
22pub mod store;
23
24impl AuthService {
25 fn identity(&self) -> crate::api::identity::IdentityApi {
26 crate::api::identity::IdentityApi::new(self.clone())
27 }
28
29 fn authority(&self) -> crate::api::authority::AuthorityApi {
30 crate::api::authority::AuthorityApi::new(self.clone())
31 }
32
33 fn session(&self) -> crate::api::session::SessionApi {
34 crate::api::session::SessionApi::new(self.clone())
35 }
36
37 fn ssh_key(&self) -> crate::api::ssh_key::SshKeyApi {
38 crate::api::ssh_key::SshKeyApi::new(self.clone())
39 }
40
41 fn mfa(&self) -> crate::api::mfa::MfaApi {
42 crate::api::mfa::MfaApi::new(self.clone())
43 }
44
45 pub fn read_request<T>(&self, message: T) -> Request<T> {
46 let mut request = Request::new(message);
47 request.set_timeout(self.read_timeout);
48 request.extensions_mut().insert(RequestMeta::internal());
49 request
50 }
51
52 pub fn write_request<T>(&self, message: T) -> Request<T> {
53 let mut request = Request::new(message);
54 request.set_timeout(self.write_timeout);
55 request.extensions_mut().insert(RequestMeta::internal());
56 request
57 }
58}
59
60#[async_trait]
61impl AuthApi for AuthService {
62 async fn check_health(&self) -> Result<(), KenAuthError> {
63 self.cache()
64 .ping()
65 .await
66 .map_err(|error| KenAuthError::Unavailable(error.to_string()))
67 }
68
69 #[allow(clippy::too_many_arguments)]
70 async fn change_password(
71 &self,
72 user_id: i64,
73 old_password_hash: &str,
74 new_password_hash: &str,
75 keep_session_id: &str,
76 client_ip: &str,
77 ) -> Result<proto::v1::ChangePasswordResponse, KenAuthError> {
78 let client = self.identity();
79 client
80 .change_password(self.write_request(proto::v1::ChangePasswordRequest {
81 user_id,
82 old_password_hash: old_password_hash.to_string(),
83 new_password_hash: new_password_hash.to_string(),
84 keep_session_id: keep_session_id.to_string(),
85 client_ip: client_ip.to_string(),
86 }))
87 .await
88 .map_err(KenAuthError::from_status)
89 .map(|response| response.into_inner())
90 }
91
92 async fn get_mfa_status(
93 &self,
94 user_id: i64,
95 ) -> Result<proto::v1::GetMfaStatusResponse, KenAuthError> {
96 let client = self.mfa();
97 client
98 .get_mfa_status(self.read_request(proto::v1::GetMfaStatusRequest { user_id }))
99 .await
100 .map_err(KenAuthError::from_status)
101 .map(|response| response.into_inner())
102 }
103
104 async fn configure_totp(
105 &self,
106 user_id: i64,
107 name: &str,
108 client_ip: &str,
109 ) -> Result<proto::v1::ConfigureTotpResponse, KenAuthError> {
110 let client = self.mfa();
111 client
112 .configure_totp(self.write_request(proto::v1::ConfigureTotpRequest {
113 user_id,
114 name: name.to_string(),
115 client_ip: client_ip.to_string(),
116 }))
117 .await
118 .map_err(KenAuthError::from_status)
119 .map(|response| response.into_inner())
120 }
121
122 async fn enable_totp(
123 &self,
124 user_id: i64,
125 code: &str,
126 password_hash: &str,
127 client_ip: &str,
128 ) -> Result<proto::v1::EnableTotpResponse, KenAuthError> {
129 let client = self.mfa();
130 client
131 .enable_totp(self.write_request(proto::v1::EnableTotpRequest {
132 user_id,
133 code: code.to_string(),
134 password_hash: password_hash.to_string(),
135 client_ip: client_ip.to_string(),
136 }))
137 .await
138 .map_err(KenAuthError::from_status)
139 .map(|response| response.into_inner())
140 }
141
142 async fn disable_totp(
143 &self,
144 user_id: i64,
145 code: &str,
146 client_ip: &str,
147 ) -> Result<proto::v1::DisableTotpResponse, KenAuthError> {
148 let client = self.mfa();
149 client
150 .disable_totp(self.write_request(proto::v1::DisableTotpRequest {
151 user_id,
152 code: code.to_string(),
153 client_ip: client_ip.to_string(),
154 }))
155 .await
156 .map_err(KenAuthError::from_status)
157 .map(|response| response.into_inner())
158 }
159
160 async fn regenerate_recovery_codes(
161 &self,
162 user_id: i64,
163 password_hash: &str,
164 client_ip: &str,
165 ) -> Result<proto::v1::RegenerateRecoveryCodesResponse, KenAuthError> {
166 let client = self.mfa();
167 client
168 .regenerate_recovery_codes(self.write_request(
169 proto::v1::RegenerateRecoveryCodesRequest {
170 user_id,
171 password_hash: password_hash.to_string(),
172 client_ip: client_ip.to_string(),
173 },
174 ))
175 .await
176 .map_err(KenAuthError::from_status)
177 .map(|response| response.into_inner())
178 }
179
180 #[allow(clippy::too_many_arguments)]
181
182 async fn list_user_ssh_keys(
183 &self,
184 user_id: i64,
185 include_revoked: bool,
186 page: proto::common::v1::PageRequest,
187 ) -> Result<proto::v1::ListUserSshKeysResponse, KenAuthError> {
188 let client = self.ssh_key();
189 client
190 .list_user_ssh_keys(self.read_request(proto::v1::ListUserSshKeysRequest {
191 user_id,
192 include_revoked,
193 page: Some(page),
194 }))
195 .await
196 .map_err(KenAuthError::from_status)
197 .map(|response| response.into_inner())
198 }
199
200 async fn add_ssh_key(
201 &self,
202 user_id: i64,
203 name: &str,
204 public_key: &str,
205 description: &str,
206 client_ip: &str,
207 ) -> Result<proto::v1::AddSshKeyResponse, KenAuthError> {
208 let client = self.ssh_key();
209 client
210 .add_ssh_key(self.write_request(proto::v1::AddSshKeyRequest {
211 user_id,
212 name: name.to_string(),
213 public_key: public_key.to_string(),
214 description: description.to_string(),
215 client_ip: client_ip.to_string(),
216 }))
217 .await
218 .map_err(KenAuthError::from_status)
219 .map(|response| response.into_inner())
220 }
221
222 async fn revoke_ssh_key(
223 &self,
224 user_id: i64,
225 key_id: &str,
226 reason: &str,
227 bump_security_version: bool,
228 ) -> Result<proto::v1::RevokeSshKeyResponse, KenAuthError> {
229 let client = self.ssh_key();
230 client
231 .revoke_ssh_key(self.write_request(proto::v1::RevokeSshKeyRequest {
232 user_id,
233 key_id: key_id.to_string(),
234 reason: reason.to_string(),
235 bump_security_version,
236 }))
237 .await
238 .map_err(KenAuthError::from_status)
239 .map(|response| response.into_inner())
240 }
241
242 async fn list_user_sessions(
243 &self,
244 user_id: i64,
245 include_revoked: bool,
246 include_expired: bool,
247 page: proto::common::v1::PageRequest,
248 ) -> Result<proto::v1::ListUserSessionsResponse, KenAuthError> {
249 let client = self.session();
250 client
251 .list_user_sessions(self.read_request(proto::v1::ListUserSessionsRequest {
252 user_id,
253 include_revoked,
254 include_expired,
255 page: Some(page),
256 }))
257 .await
258 .map_err(KenAuthError::from_status)
259 .map(|response| response.into_inner())
260 }
261
262 async fn revoke_user_sessions(
263 &self,
264 user_id: i64,
265 except_session_id: &str,
266 reason: &str,
267 bump_security_version: bool,
268 ) -> Result<proto::v1::RevokeUserSessionsResponse, KenAuthError> {
269 let client = self.session();
270 client
271 .revoke_user_sessions(self.write_request(proto::v1::RevokeUserSessionsRequest {
272 user_id,
273 except_session_id: except_session_id.to_string(),
274 reason: reason.to_string(),
275 bump_security_version,
276 }))
277 .await
278 .map_err(KenAuthError::from_status)
279 .map(|response| response.into_inner())
280 }
281
282 async fn get_session(
283 &self,
284 user_id: i64,
285 version: i32,
286 session_id: &str,
287 ) -> Result<proto::internal::v1::AuthorityAccess, KenAuthError> {
288 let client = self.authority();
289 client
290 .get_session(self.read_request(proto::internal::v1::GetSessionRequest {
291 user_id,
292 version,
293 session_id: session_id.to_string(),
294 }))
295 .await
296 .map_err(KenAuthError::from_status)
297 .map(|response| response.into_inner().access.unwrap_or_default())
298 }
299
300 async fn delete_account(&self, user_id: i64, reason: &str) -> Result<(), KenAuthError> {
301 let client = self.authority();
302 client
303 .delete_account(
304 self.write_request(proto::internal::v1::DeleteAccountRequest {
305 user_id,
306 reason: reason.to_string(),
307 }),
308 )
309 .await
310 .map_err(KenAuthError::from_status)?;
311 Ok(())
312 }
313
314 async fn cancel_account_deletion(&self, user_id: i64) -> Result<(), KenAuthError> {
315 let client = self.authority();
316 client
317 .cancel_account_deletion(
318 self.write_request(proto::internal::v1::CancelAccountDeletionRequest { user_id }),
319 )
320 .await
321 .map_err(KenAuthError::from_status)?;
322 Ok(())
323 }
324
325 async fn purge_account(&self, user_id: i64) -> Result<(), KenAuthError> {
326 let client = self.authority();
327 client
328 .purge_account(self.write_request(proto::internal::v1::PurgeAccountRequest { user_id }))
329 .await
330 .map_err(KenAuthError::from_status)?;
331 Ok(())
332 }
333
334 async fn batch_get_authority_users(
335 &self,
336 user_ids: Vec<i64>,
337 ) -> Result<Vec<proto::internal::v1::AuthorityUser>, KenAuthError> {
338 let client = self.authority();
339 client
340 .batch_get_user(
341 self.read_request(proto::internal::v1::BatchGetUserRequest { user_ids }),
342 )
343 .await
344 .map_err(KenAuthError::from_status)
345 .map(|response| response.into_inner().users)
346 }
347
348 async fn get_user_status(
349 &self,
350 user_id: i64,
351 ) -> Result<proto::common::v1::UserStatus, KenAuthError> {
352 let client = self.identity();
353 let user = Some(proto::common::v1::UserRef {
354 key: Some(proto::common::v1::user_ref::Key::UserId(user_id)),
355 });
356 client
357 .get_user_status(self.read_request(proto::v1::GetUserStatusRequest { user }))
358 .await
359 .map_err(KenAuthError::from_status)
360 .map(|response| response.into_inner().status.unwrap_or_default())
361 }
362
363 async fn get_user_public_by_ref(
364 &self,
365 key: proto::common::v1::user_ref::Key,
366 ) -> Result<proto::common::v1::UserPublic, KenAuthError> {
367 let client = self.identity();
368 let user = Some(proto::common::v1::UserRef { key: Some(key) });
369 client
370 .get_user_public(self.read_request(proto::v1::GetUserPublicRequest { user }))
371 .await
372 .map_err(KenAuthError::from_status)?
373 .into_inner()
374 .user
375 .ok_or(KenAuthError::NotFound)
376 }
377
378 async fn get_authority_user(
379 &self,
380 user_id: i64,
381 ) -> Result<proto::internal::v1::AuthorityUser, KenAuthError> {
382 let client = self.authority();
383 client
384 .get_user(self.read_request(proto::internal::v1::GetUserRequest { user_id }))
385 .await
386 .map_err(KenAuthError::from_status)?
387 .into_inner()
388 .user
389 .ok_or(KenAuthError::NotFound)
390 }
391
392 #[allow(clippy::too_many_arguments)]
393 async fn update_authority_user(
394 &self,
395 user_id: i64,
396 username: Option<String>,
397 displayname: Option<String>,
398 clear_displayname: bool,
399 public_show_email: Option<bool>,
400 version: i32,
401 ) -> Result<proto::internal::v1::AuthorityUser, KenAuthError> {
402 let client = self.authority();
403 client
404 .update_user(self.write_request(proto::internal::v1::UpdateUserRequest {
405 user_id,
406 patch: Some(proto::internal::v1::AuthorityUserPatch {
407 version,
408 username,
409 displayname,
410 clear_displayname,
411 public_show_email,
412 }),
413 }))
414 .await
415 .map_err(KenAuthError::from_status)?
416 .into_inner()
417 .user
418 .ok_or(KenAuthError::NotFound)
419 }
420
421 async fn batch_get_user_public(
422 &self,
423 user_ids: Vec<i64>,
424 ) -> Result<Vec<proto::common::v1::UserPublic>, KenAuthError> {
425 let client = self.identity();
426 client
427 .batch_get_user_public(
428 self.read_request(proto::v1::BatchGetUserPublicRequest { user_ids }),
429 )
430 .await
431 .map_err(KenAuthError::from_status)
432 .map(|response| response.into_inner().users)
433 }
434
435 async fn get_user_public(
436 &self,
437 user_id: i64,
438 ) -> Result<proto::common::v1::UserPublic, KenAuthError> {
439 let client = self.identity();
440 let user = Some(proto::common::v1::UserRef {
441 key: Some(proto::common::v1::user_ref::Key::UserId(user_id)),
442 });
443 client
444 .get_user_public(self.read_request(proto::v1::GetUserPublicRequest { user }))
445 .await
446 .map_err(KenAuthError::from_status)?
447 .into_inner()
448 .user
449 .ok_or(KenAuthError::NotFound)
450 }
451
452 async fn revoke_session(
453 &self,
454 user_id: i64,
455 session_id: &str,
456 reason: &str,
457 ) -> Result<(), KenAuthError> {
458 let client = self.session();
459 client
460 .revoke_session(self.write_request(proto::v1::RevokeSessionRequest {
461 user_id,
462 session_id: session_id.to_string(),
463 reason: reason.to_string(),
464 }))
465 .await
466 .map_err(KenAuthError::from_status)?;
467 Ok(())
468 }
469
470 async fn lookup_ssh_key(
471 &self,
472 public_key: &str,
473 ) -> Result<Option<proto::common::v1::Principal>, KenAuthError> {
474 let client = self.ssh_key();
475 let response = client
476 .lookup_ssh_key(self.read_request(proto::v1::LookupSshKeyRequest {
477 fingerprint: String::new(),
478 public_key: public_key.to_string(),
479 require_active: true,
480 }))
481 .await
482 .map_err(KenAuthError::from_status)?
483 .into_inner();
484 if !response.found {
485 return Ok(None);
486 }
487 Ok(response.principal)
488 }
489
490 async fn ensure_bot_user(
491 &self,
492 username: &str,
493 displayname: &str,
494 system: bool,
495 ) -> Result<proto::common::v1::UserPublic, KenAuthError> {
496 let client = self.identity();
497 client
498 .ensure_bot_user(self.write_request(proto::v1::EnsureBotUserRequest {
499 username: username.to_string(),
500 displayname: displayname.to_string(),
501 system,
502 }))
503 .await
504 .map_err(KenAuthError::from_status)?
505 .into_inner()
506 .user
507 .ok_or(KenAuthError::NotFound)
508 }
509}
Modified lib/auth/src/errors.rs View lib/auth/src/errors.rs
@@ -1,10 +1,6 @@
11pub use export::KenAuthError;
22
33use actix_web::http::StatusCode;
4use actix_web::http::header::{HeaderValue, RETRY_AFTER};
5use actix_web::{HttpResponse, ResponseError};
6use serde::{Deserialize, Serialize};
7use utoipa::ToSchema;
84
95pub type AuthResult<T> = std::result::Result<T, AuthError>;
106
@@ -336,42 +332,8 @@
336332 self.to_string()
337333 }
338334 }
339
340 pub fn body(&self) -> ErrorBody {
341 ErrorBody {
342 code: self.code().to_string(),
343 message: self.message(),
344 }
345 }
346}
347
348impl ResponseError for AuthError {
349 fn status_code(&self) -> StatusCode {
350 AuthError::status_code(self)
351 }
352
353 fn error_response(&self) -> HttpResponse {
354 let body = self.body();
355 let mut response = crate::envelope::Envelope::<crate::envelope::NoData>::err(
356 self.status_code().as_u16() as i32,
357 body.message,
358 )
359 .with_error_code(body.code)
360 .into_http_response();
361 if let Self::RateLimited { retry_after_secs } = self
362 && let Ok(value) = HeaderValue::from_str(&retry_after_secs.to_string())
363 {
364 response.headers_mut().insert(RETRY_AFTER, value);
365 }
366 response
367 }
368335}
369336
370#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
371pub struct ErrorBody {
372 pub code: String,
373 pub message: String,
374}
375337use std::fmt;
376338
377339#[derive(Debug)]
@@ -414,23 +376,6 @@
414376}
415377
416378impl std::error::Error for AuthBusinessError {}
417
418impl From<deletion::DeletionError> for AuthBusinessError {
419 fn from(error: deletion::DeletionError) -> Self {
420 use deletion::DeletionError;
421 match error {
422 DeletionError::Database(error) => Self::from(error),
423 DeletionError::Conflict(message) => Self::Conflict(message),
424 DeletionError::NotFound(message) => {
425 tracing::debug!(%message, "deletion target not found");
426 Self::NotFound
427 }
428 DeletionError::KenAuth(message)
429 | DeletionError::KenShell(message)
430 | DeletionError::Config(message) => Self::Internal(message),
431 }
432 }
433}
434379
435380export::db_error_conversions!(AuthBusinessError);
436381export::redis_error_conversion!(AuthBusinessError);
@@ -447,6 +392,8 @@
447392 NotFound(404, "resource not found"),
448393 Conflict(409, "resource conflict"),
449394 RateLimited(429, "rate limit exceeded"),
395 PreconditionFailed(412, "precondition failed"),
396 Unavailable(503, "service unavailable"),
450397 Internal(500, "internal server error"),
451398 }
452399}
@@ -498,42 +445,17 @@
498445 }
499446}
500447
501impl From<space::errors::SpaceError> for AuthError {
502 fn from(error: space::errors::SpaceError) -> Self {
503 use space::errors::SpaceError as E;
504 match error {
505 E::BadRequest(message) => Self::BadRequest(message),
506 E::Conflict(message) => Self::Conflict(message),
507 E::Unauthorized => Self::Unauthenticated,
508 E::Forbidden
509 | E::JoinNotAllowed(_)
510 | E::JoinRequirementNotMet(_)
511 | E::JoinRequestCooldown(_)
512 | E::SpaceInactive
513 | E::SpaceMemberBanned => Self::Forbidden,
514 E::MalformedRequest => Self::MalformedRequest,
515 E::InvalidEmail => Self::InvalidEmail,
516 E::InvalidPermission { permission } => Self::InvalidPermission { permission },
517 E::NotFound | E::SpaceNotFound => Self::OrganizationNotFound,
518 E::SpaceMemberNotFound => Self::OrganizationMemberNotFound,
519 E::SpaceRoleNotFound => Self::OrganizationRoleNotFound,
520 E::SpaceInviteNotFound => Self::OrganizationInviteNotFound,
521 E::SpaceSlugTaken => Self::OrganizationSlugTaken,
522 E::SpaceMemberExists => Self::OrganizationMemberExists,
523 E::SpaceRoleKeyTaken => Self::OrganizationRoleKeyTaken,
524 E::SpaceRoleInUse => Self::OrganizationRoleInUse,
525 E::SpaceRoleSystem => Self::OrganizationRoleSystem,
526 E::SpaceInviteExhausted => Self::OrganizationInviteExhausted,
527 E::SpaceInviteExpired => Self::OrganizationInviteExpired,
528 E::SpaceOwnerRequired => Self::OrganizationOwnerRequired,
529 E::SpaceHasMembers => Self::OrganizationHasMembers,
530 E::VersionMismatch { current } => Self::VersionMismatch { current },
531 E::UserNotFound => Self::UserNotFound,
532 E::ScopeInsufficient { required } => Self::ScopeInsufficient { required },
533 E::RateLimited(_) => Self::RateLimited {
534 retry_after_secs: 0,
535 },
536 E::Internal(message) => Self::Internal(message),
448impl From<AuthError> for ApiError {
449 fn from(error: AuthError) -> Self {
450 let status = error.status_code().as_u16();
451 if error.is_internal() {
452 tracing::warn!(%error, "auth handler failed");
453 }
454 let mut response = export::rest::AppResponseData::err(i32::from(status), error.message())
455 .with_error_code(error.code());
456 if let AuthError::RateLimited { retry_after_secs } = &error {
457 response = response.with_retry_after(*retry_after_secs);
537458 }
459 Self::build(status, response)
538460 }
539461}
Modified lib/auth/src/lib.rs View lib/auth/src/lib.rs
@@ -1,22 +1,22 @@
1//! 认证业务:账号、会话、组织、SSH 公钥与登录页。
1//! 认证业务:账号、会话与登录页。
22
33#![recursion_limit = "512"]
44
5pub mod api;
6pub mod assets;
5mod accounts;
6pub use accounts::Accounts;
7
78pub mod errors;
89pub mod handlers;
910pub mod migrate;
1011pub mod openapi;
11pub mod openapi_account;
12pub mod pages;
12pub mod pow;
1313pub mod routes;
14pub mod routes_account;
1514pub mod services;
1615pub mod share;
16pub mod templates;
1717
1818pub use errors::{ApiError, AuthBusinessError, AuthError, AuthResult, KenAuthError};
19pub use services::service::AuthService;
19pub use services::AuthService;
2020
2121pub use cache;
2222pub use config;
@@ -30,3 +30,5 @@
3030pub use proto as protos;
3131pub use queue;
3232pub use session;
33
34pub mod users;
Modified lib/auth/src/openapi.rs View lib/auth/src/openapi.rs
@@ -4,26 +4,106 @@
44#[openapi(
55 info(
66 title = "auth",
7 description = "认证与账号服务:工作量证明、口令与验证码登录、二次验证、注册、口令重置与 SSH 公钥。",
7 description = "认证与账号服务:工作量证明、口令与验证码登录、二次验证、注册、口令重置。",
88 version = env!("CARGO_PKG_VERSION")
99 ),
1010 paths(
11 crate::handlers::identity::challenge::challenge,
12 crate::handlers::identity::login::login,
13 crate::handlers::identity::login::login_code,
14 crate::handlers::identity::login::login_code_verify,
15 crate::handlers::identity::login::login_verify,
16 crate::handlers::identity::logout::logout,
17 crate::handlers::identity::me::me,
18 crate::handlers::identity::register::register_code,
19 crate::handlers::identity::register::register,
20 crate::handlers::identity::password_reset::password_reset_request,
21 crate::handlers::identity::password_reset::password_reset,
22 crate::handlers::identity::ssh_key::ssh_key_list,
23 crate::handlers::identity::ssh_key::ssh_key_add,
24 crate::handlers::identity::ssh_key::ssh_key_delete,
11 crate::handlers::challenge::challenge,
12 crate::handlers::login::login,
13 crate::handlers::login::login_code,
14 crate::handlers::login::login_code_verify,
15 crate::handlers::login::login_verify,
16 crate::handlers::logout::logout,
17 crate::handlers::identity_me::me,
18 crate::handlers::register::register_code,
19 crate::handlers::register::register,
20 crate::handlers::password_reset::password_reset_request,
21 crate::handlers::password_reset::password_reset,
2522 ),
2623 components(schemas(
2724 ))
2825)]
2926pub struct ApiDoc;
27
28use export::rest::NoData;
29use models::users::user_notifications::NotificationType;
30
31use crate::handlers;
32use crate::handlers::accept_agreement::AcceptAgreementCmd;
33use crate::handlers::me::MeResp;
34use crate::handlers::unread_notifications_count::UnreadCountResp;
35use crate::services::agreements::AcceptAgreementResp;
36use crate::services::notifications::NotificationDto;
37use crate::services::preferences::{UpdatePreferencesCmd, UserPreferencesDto};
38use crate::services::profile::{ProfileDto, UpdateProfileCmd, UserMeDto};
39use crate::services::reports::{CreateReportCmd, ReportDto};
40use crate::share::account::types::{AgreementDoc, UserPublic};
41
42#[derive(OpenApi)]
43#[openapi(
44 info(title = "gitdata auth api", version = "1.0"),
45 paths(
46 handlers::accept_agreement::accept_agreement,
47 handlers::create_report::create_report,
48 handlers::delete_notification::delete_notification,
49 handlers::get_preferences::get_preferences,
50 handlers::get_user::get_user,
51 handlers::users::list_users_batch,
52 handlers::users::get_user_by_username,
53 handlers::users::update_username,
54 handlers::list_agreements::list_agreements,
55 handlers::list_notifications::list_notifications,
56 handlers::mark_notifications_read::mark_notifications_read,
57 handlers::me::me,
58 handlers::unread_notifications_count::unread_notifications_count,
59 handlers::update_preferences::update_preferences,
60 handlers::update_profile::update_profile,
61 handlers::password::change_password,
62 handlers::two_factor::totp_status,
63 handlers::two_factor::totp_setup,
64 handlers::two_factor::totp_enable,
65 handlers::two_factor::totp_disable,
66 handlers::two_factor::recovery_codes,
67 handlers::two_factor::regenerate_recovery_codes,
68 handlers::sessions::list_sessions,
69 handlers::sessions::revoke_all_sessions,
70 handlers::sessions::revoke_session,
71 handlers::devices::list_devices,
72 ),
73 components(schemas(
74 export::pagination::PageQuery,
75 export::pagination::Page<NotificationDto>,
76 handlers::mark_notifications_read::MarkNotificationsReadCmd,
77 handlers::mark_notifications_read::MarkedCountResp,
78 NoData,
79 UserPublic,
80 AgreementDoc,
81 UserMeDto,
82 ProfileDto,
83 UpdateProfileCmd,
84 UserPreferencesDto,
85 UpdatePreferencesCmd,
86 AcceptAgreementCmd,
87 AcceptAgreementResp,
88 MeResp,
89 NotificationDto,
90 CreateReportCmd,
91 ReportDto,
92 UnreadCountResp,
93 NotificationType,
94 handlers::password::ChangePasswordCmd,
95 handlers::two_factor::TotpStatusResp,
96 handlers::two_factor::TotpSetupResp,
97 handlers::two_factor::TotpEnableCmd,
98 handlers::two_factor::TotpCodeCmd,
99 handlers::two_factor::RegenerateRecoveryCodesCmd,
100 handlers::two_factor::RecoveryCodesResp,
101 models::users::access_scope::AccessKeyScope,
102 handlers::sessions::SessionDto,
103 handlers::sessions::SessionKind,
104 handlers::sessions::CountResp,
105 export::pagination::Page<handlers::sessions::SessionDto>,
106 handlers::devices::DeviceDto,
107 ))
108)]
109pub struct AuthApiDoc;
Deleted lib/auth/src/openapi_account.rs

Diff too large to display.

Modified lib/auth/src/routes.rs View lib/auth/src/routes.rs
@@ -1,61 +1,124 @@
1use actix_web::web;
1use export::http::Router;
2use export::http::routing::{delete, get, post, put};
23
3pub fn configure(cfg: &mut web::ServiceConfig) {
4 cfg.service(web::scope("/api/v1").configure(configure_api));
4use crate::AuthService;
5use crate::handlers::accept_agreement::accept_agreement;
6use crate::handlers::agreement_page::agreement_page;
7use crate::handlers::create_report::create_report;
8use crate::handlers::delete_notification::delete_notification;
9use crate::handlers::get_preferences::get_preferences;
10use crate::handlers::get_user::get_user;
11use crate::handlers::list_agreements::list_agreements;
12use crate::handlers::list_notifications::list_notifications;
13use crate::handlers::me::me;
14use crate::handlers::unread_notifications_count::unread_notifications_count;
15use crate::handlers::update_preferences::update_preferences;
16use crate::handlers::update_profile::update_profile;
17use crate::handlers::users;
18
19pub fn router(_service: AuthService) -> Router<AuthService> {
20 let public = Router::<AuthService>::new()
21 .route(
22 "/auth/challenge",
23 post(crate::handlers::challenge::challenge),
24 )
25 .route("/auth/login", post(crate::handlers::login::login))
26 .route("/auth/login/code", post(crate::handlers::login::login_code))
27 .route(
28 "/auth/login/code/verify",
29 post(crate::handlers::login::login_code_verify),
30 )
31 .route(
32 "/auth/login/verify",
33 post(crate::handlers::login::login_verify),
34 )
35 .route("/auth/logout", post(crate::handlers::logout::logout))
36 .route("/auth/me", get(crate::handlers::identity_me::me))
37 .route(
38 "/auth/register/code",
39 post(crate::handlers::register::register_code),
40 )
41 .route("/auth/register", post(crate::handlers::register::register))
42 .route(
43 "/auth/password/reset",
44 post(crate::handlers::password_reset::password_reset_request),
45 )
46 .route(
47 "/auth/password/reset/confirm",
48 post(crate::handlers::password_reset::password_reset),
49 )
50 .route("/auth/agreements", get(list_agreements));
51
52 let authed = Router::<AuthService>::new()
53 .route("/users", get(users::list_users_batch))
54 .route(
55 "/users/by-username/{username}",
56 get(users::get_user_by_username),
57 )
58 .route("/users/{user_id}", get(get_user))
59 .route("/me", get(me).patch(update_profile))
60 .route("/me/username", put(users::update_username))
61 .route(
62 "/me/preferences",
63 get(get_preferences).patch(update_preferences),
64 )
65 .route("/me/agreements/{key}", put(accept_agreement))
66 .route("/me/notifications", get(list_notifications))
67 .route(
68 "/me/notifications/unread-count",
69 get(unread_notifications_count),
70 )
71 .route(
72 "/me/notifications/{notification_id}",
73 delete(delete_notification),
74 )
75 .route(
76 "/me/notifications/read",
77 put(crate::handlers::mark_notifications_read::mark_notifications_read),
78 )
79 .route("/me/reports", post(create_report))
80 .route(
81 "/auth/password/change",
82 post(crate::handlers::password::change_password),
83 )
84 .route(
85 "/auth/2fa/status",
86 get(crate::handlers::two_factor::totp_status),
87 )
88 .route(
89 "/auth/2fa/setup",
90 post(crate::handlers::two_factor::totp_setup),
91 )
92 .route(
93 "/auth/2fa/enable",
94 post(crate::handlers::two_factor::totp_enable),
95 )
96 .route(
97 "/auth/2fa/disable",
98 post(crate::handlers::two_factor::totp_disable),
99 )
100 .route(
101 "/auth/2fa/recovery-codes",
102 get(crate::handlers::two_factor::recovery_codes)
103 .put(crate::handlers::two_factor::regenerate_recovery_codes),
104 )
105 .route(
106 "/me/sessions",
107 get(crate::handlers::sessions::list_sessions)
108 .delete(crate::handlers::sessions::revoke_all_sessions),
109 )
110 .route(
111 "/me/sessions/{session_id}",
112 delete(crate::handlers::sessions::revoke_session),
113 )
114 .route("/me/devices", get(crate::handlers::devices::list_devices))
115 .wrap(export::http::Guard::Session);
116
117 public.merge(authed)
5118}
6119
7pub fn configure_api(cfg: &mut web::ServiceConfig) {
8 cfg.route(
9 "/auth/challenge",
10 web::post().to(crate::handlers::identity::challenge::challenge),
11 );
12 cfg.route(
13 "/auth/login",
14 web::post().to(crate::handlers::identity::login::login),
15 );
16 cfg.route(
17 "/auth/login/code",
18 web::post().to(crate::handlers::identity::login::login_code),
19 );
20 cfg.route(
21 "/auth/login/code/verify",
22 web::post().to(crate::handlers::identity::login::login_code_verify),
23 );
24 cfg.route(
25 "/auth/login/verify",
26 web::post().to(crate::handlers::identity::login::login_verify),
27 );
28 cfg.route(
29 "/auth/logout",
30 web::post().to(crate::handlers::identity::logout::logout),
31 );
32 cfg.route("/auth/me", web::get().to(crate::handlers::identity::me::me));
33 cfg.route(
34 "/auth/register/code",
35 web::post().to(crate::handlers::identity::register::register_code),
36 );
37 cfg.route(
38 "/auth/register",
39 web::post().to(crate::handlers::identity::register::register),
40 );
41 cfg.route(
42 "/auth/password/reset",
43 web::post().to(crate::handlers::identity::password_reset::password_reset_request),
44 );
45 cfg.route(
46 "/auth/password/reset/confirm",
47 web::post().to(crate::handlers::identity::password_reset::password_reset),
48 );
49 cfg.route(
50 "/auth/ssh-keys",
51 web::get().to(crate::handlers::identity::ssh_key::ssh_key_list),
52 );
53 cfg.route(
54 "/auth/ssh-keys",
55 web::post().to(crate::handlers::identity::ssh_key::ssh_key_add),
56 );
57 cfg.route(
58 "/auth/ssh-keys/{key_id}",
59 web::delete().to(crate::handlers::identity::ssh_key::ssh_key_delete),
60 );
120pub fn pages_router(service: AuthService) -> Router {
121 Router::<()>::new()
122 .route("/agreements/{key}", get(agreement_page))
123 .with_state(service)
61124}
Deleted lib/auth/src/routes_account.rs

Diff too large to display.

Added lib/auth/src/users.rs View lib/auth/src/users.rs

Diff too large to display.

Modified lib/auth/Cargo.toml View lib/auth/Cargo.toml
@@ -3,13 +3,12 @@
33version.workspace = true
44edition.workspace = true
55publish = false
6description = "认证中心:账号、会话、令牌、组织与 SSH 公钥的唯一入口"
6description = "认证中心:账号、会话与认证业务"
77license = "MIT"
88
99[dependencies]
1010models = { workspace = true }
1111export = { workspace = true }
12deletion = { workspace = true }
1312i18n = { workspace = true }
1413config = { workspace = true }
1514crypt = { workspace = true }
@@ -23,7 +22,6 @@
2322redis = { workspace = true }
2423queue = { workspace = true }
2524session = { workspace = true }
26space = { workspace = true }
2725tokio-stream = { version = "0.1", features = ["net"] }
2826actix-web = "4.16.0"
2927aes-gcm = "0.11.1"
@@ -95,8 +93,6 @@
9593thiserror = "2.0.21"
9694tokio = { version = "1.53.3", features = ["full"] }
9795tokio-util = "0.7.20"
98tonic = { version = "0.14.6", features = ["tls-ring"] }
99tonic-prost = "0.14.6"
10096tower = "0.5.3"
10197tracing = "0.1.44"
10298tracing-opentelemetry = "0.33.0"
Modified lib/chat/src/handlers/connection.rs View lib/chat/src/handlers/connection.rs
@@ -17,6 +17,7 @@
1717 Json(cmd): Json<ConnectionCmd>,
1818) -> Result<AppResponseData<NoData>, ApiError> {
1919 service
20 .participants
2021 .connection_open(cmd.user_id, &cmd.session_id)
2122 .await?;
2223 Ok(AppResponseData::empty())
@@ -27,6 +28,7 @@
2728 Json(cmd): Json<ConnectionCmd>,
2829) -> Result<AppResponseData<NoData>, ApiError> {
2930 service
31 .participants
3032 .connection_touch(cmd.user_id, &cmd.session_id)
3133 .await?;
3234 Ok(AppResponseData::empty())
@@ -37,6 +39,7 @@
3739 Json(cmd): Json<ConnectionCmd>,
3840) -> Result<AppResponseData<NoData>, ApiError> {
3941 service
42 .participants
4043 .connection_close(cmd.user_id, &cmd.session_id)
4144 .await?;
4245 Ok(AppResponseData::empty())
Modified lib/chat/src/handlers/presence.rs View lib/chat/src/handlers/presence.rs
@@ -25,7 +25,7 @@
2525 Json(cmd): Json<SetPresenceCmd>,
2626) -> Result<AppResponseData<PresenceDto>, ApiError> {
2727 let uid = session::require_uid(&session)?;
28 let dto = service.set_presence(uid, cmd).await?;
28 let dto = service.participants.set_presence(uid, cmd).await?;
2929 Ok(AppResponseData::ok(dto))
3030}
3131
@@ -46,7 +46,7 @@
4646 Path(user_id): Path<i64>,
4747) -> Result<AppResponseData<PresenceDto>, ApiError> {
4848 let uid = session::require_uid(&session)?;
49 let dto = service.get_presence_for(uid, user_id).await?;
49 let dto = service.participants.get_presence_for(uid, user_id).await?;
5050 Ok(AppResponseData::ok(dto))
5151}
5252
@@ -68,6 +68,9 @@
6868 Path(space_name): Path<String>,
6969) -> Result<AppResponseData<Vec<PresenceDto>>, ApiError> {
7070 let uid = session::require_uid(&session)?;
71 let presences = service.list_space_presences(uid, &space_name).await?;
71 let presences = service
72 .participants
73 .list_space_presences(uid, &space_name)
74 .await?;
7275 Ok(AppResponseData::ok(presences))
7376}
Modified lib/chat/src/handlers/voice.rs View lib/chat/src/handlers/voice.rs
@@ -413,7 +413,7 @@
413413 headers: HeaderMap,
414414 body: String,
415415) -> Result<AppResponseData<NoData>, ApiError> {
416 let cfg = service.config().livekit.clone();
416 let cfg = service.settings.livekit.clone();
417417 if !cfg.enabled {
418418 return Ok(AppResponseData::empty());
419419 }
@@ -438,6 +438,7 @@
438438 match event.event.as_str() {
439439 "room_started" => {
440440 service
441 .audience
441442 .realtime
442443 .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
443444 .await;
@@ -447,6 +448,7 @@
447448 tracing::warn!(%error, %room_id, "voice room clear failed");
448449 }
449450 service
451 .audience
450452 .realtime
451453 .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
452454 .await;
@@ -481,6 +483,7 @@
481483 }
482484 }
483485 service
486 .audience
484487 .realtime
485488 .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
486489 .await;
@@ -494,10 +497,12 @@
494497 tracing::warn!(%error, %room_id, "voice member remove failed");
495498 }
496499 service
500 .audience
497501 .realtime
498502 .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
499503 .await;
500504 service
505 .audience
501506 .realtime
502507 .publish_room(&crate::share::realtime::RoomEvent::VoiceHands { room_id })
503508 .await;
@@ -531,6 +536,7 @@
531536 }
532537 }
533538 service
539 .audience
534540 .realtime
535541 .publish_room(&crate::share::realtime::RoomEvent::VoicePresence { room_id })
536542 .await;
Modified lib/chat/src/services/attachments.rs View lib/chat/src/services/attachments.rs
@@ -2,7 +2,6 @@
22use actix_web::web::Bytes;
33use futures::stream::BoxStream;
44use imaging::layout::{ImageVariant, PreviewState};
5use imaging::preset::PreviewPreset;
65use models::chat::attachments::{self, AttachmentStatus};
76use sea_orm::sea_query::Expr;
87use sea_orm::{
@@ -122,14 +121,14 @@
122121/// Whether the attachment is an inline image, i.e. something worth deriving a
123122/// preview from. Kept separate from [`is_inline_renderable`] so the preview
124123/// machinery never touches video/audio/PDF.
125fn is_inline_image(content_type: &str) -> bool {
124pub(crate) fn is_inline_image(content_type: &str) -> bool {
126125 let main = content_type.split(';').next().unwrap_or("").trim();
127126 main.to_ascii_lowercase().starts_with("image/")
128127}
129128
130129/// Content type for a streamed original, mirroring the safety rule applied to
131130/// response headers: an unsafe inline type is served as a plain download.
132fn attachment_stream_content_type(row: &attachments::Model) -> String {
131pub(crate) fn attachment_stream_content_type(row: &attachments::Model) -> String {
133132 if is_unsafe_inline(&row.content_type) {
134133 "application/octet-stream".to_string()
135134 } else {
@@ -153,7 +152,7 @@
153152 caller: i64,
154153 cmd: CreateAttachmentCmd,
155154 ) -> Result<UploadIntentDto, ChatError> {
156 let max_size = self.config().chat.upload.max_size;
155 let max_size = self.settings.chat.upload.max_size;
157156 if cmd.size <= 0 || cmd.size > max_size {
158157 return Err(ChatError::BadRequest(format!(
159158 "file size must be within 1..{max_size} bytes"
@@ -170,12 +169,12 @@
170169 let unattached = attachments::Entity::find()
171170 .filter(attachments::Column::UserId.eq(caller))
172171 .filter(attachments::Column::MessageId.is_null())
173 .count(self.db().read())
172 .count(self.database.read())
174173 .await?;
175 if unattached >= self.config().chat.max_pending_attachments_per_user as u64 {
174 if unattached >= self.settings.chat.max_pending_attachments_per_user as u64 {
176175 return Err(ChatError::RateLimited(format!(
177176 "too many unused attachments (max {}), finalize or reference them in a message first",
178 self.config().chat.max_pending_attachments_per_user
177 self.settings.chat.max_pending_attachments_per_user
179178 )));
180179 }
181180
@@ -183,7 +182,7 @@
183182 let id = Uuid::now_v7();
184183 let filename = sanitize_filename(&cmd.filename);
185184 let key = format!("{ATTACHMENT_PREFIX}/{caller}/{id}/{filename}");
186 let bucket = self.storage().bucket_name().to_string();
185 let bucket = self.media.client.as_ref().bucket_name().to_string();
187186
188187 let row = attachments::ActiveModel {
189188 id: Set(id),
@@ -204,16 +203,18 @@
204203 created_at: Set(now),
205204 updated_at: Set(now),
206205 }
207 .insert(self.db().write())
206 .insert(self.database.write())
208207 .await?;
209208
210 let ttl = self.config().chat.upload.presign_ttl_secs.min(604_800);
209 let ttl = self.settings.chat.upload.presign_ttl_secs.min(604_800);
211210 let mut put_headers = http::HeaderMap::new();
212211 if let Ok(value) = cmd.content_type.parse() {
213212 put_headers.insert(http::header::CONTENT_TYPE, value);
214213 }
215214 let upload_url = self
216 .storage()
215 .media
216 .client
217 .as_ref()
217218 .presign_put_with_headers(&key, ttl as u32, put_headers)
218219 .map_err(ChatError::from)?;
219220
@@ -232,7 +233,7 @@
232233 cmd: FinalizeAttachmentCmd,
233234 ) -> Result<AttachmentDto, ChatError> {
234235 let row = attachments::Entity::find_by_id(attachment_id)
235 .one(self.db().write())
236 .one(self.database.write())
236237 .await?
237238 .filter(|a| a.user_id == caller)
238239 .ok_or(ChatError::NotFound)?;
@@ -241,11 +242,11 @@
241242 "attachment already finalized".to_string(),
242243 ));
243244 }
244 let head = self.storage().head(&row.s3_key).await?;
245 let head = self.media.client.as_ref().head(&row.s3_key).await?;
245246 let Some((actual_size, actual_content_type)) = head else {
246247 return Err(ChatError::BadRequest("object not uploaded yet".to_string()));
247248 };
248 let max_size = self.config().chat.upload.max_size;
249 let max_size = self.settings.chat.upload.max_size;
249250 if actual_size == 0 || actual_size > max_size as u64 {
250251 return Err(ChatError::BadRequest(format!(
251252 "uploaded object size {actual_size} outside 1..{max_size} bytes"
@@ -270,7 +271,7 @@
270271 am.height = Set(cmd.height);
271272 am.duration_secs = Set(cmd.duration_secs);
272273 am.updated_at = Set(now);
273 let updated = am.update(self.db().write()).await?;
274 let updated = am.update(self.database.write()).await?;
274275
275276 // Build the list-view derivative while the bytes are still hot. A
276277 // failure here must never fail the upload: the attachment is already
@@ -288,17 +289,7 @@
288289 /// the outcome (including "skipped") in `variants` so a read never repeats
289290 /// the decision.
290291 async fn generate_attachment_preview(&self, row: &attachments::Model) -> Result<(), ChatError> {
291 let key_base = export::imaging::preview_key_base_of(&row.s3_key);
292 let preview = export::imaging::ensure_preview(
293 self.storage(),
294 &self.state.redis,
295 &key_base,
296 &row.s3_key,
297 &row.content_type,
298 PreviewPreset::Content,
299 None,
300 )
301 .await;
292 let preview = self.media.preview(row, None).await;
302293 let Some(state) = preview else {
303294 tracing::debug!(attachment_id = %row.id, "attachment preview unavailable; serving the original");
304295 return Ok(());
@@ -318,7 +309,7 @@
318309 attachments::Entity::update_many()
319310 .filter(attachments::Column::Id.eq(attachment_id))
320311 .col_expr(attachments::Column::Variants, Expr::value(variants))
321 .exec(self.db().write())
312 .exec(self.database.write())
322313 .await?;
323314 Ok(())
324315 }
@@ -328,7 +319,7 @@
328319 async fn delete_attachment_previews(&self, original_key: &str) {
329320 let key_base = export::imaging::preview_key_base_of(original_key);
330321 for candidate in imaging::layout::preview_candidate_keys(&key_base) {
331 if let Err(error) = self.storage().delete(&candidate).await {
322 if let Err(error) = self.media.client.as_ref().delete(&candidate).await {
332323 tracing::warn!(%error, key = %candidate, "failed to delete attachment preview");
333324 }
334325 }
@@ -337,57 +328,6 @@
337328 /// Locator of the representation to stream: the derivative when it exists,
338329 /// the original otherwise. Legacy rows (finalized before previews existed)
339330 /// are healed here on first demand.
340 async fn attachment_variant_locator(
341 &self,
342 row: &attachments::Model,
343 variant: ImageVariant,
344 ) -> (String, String) {
345 if variant == ImageVariant::Original || !is_inline_image(&row.content_type) {
346 return (row.s3_key.clone(), attachment_stream_content_type(row));
347 }
348 let key_base = export::imaging::preview_key_base_of(&row.s3_key);
349 let recorded = imaging::layout::preview_from_variants(&row.variants);
350 let state = export::imaging::ensure_preview(
351 self.storage(),
352 &self.state.redis,
353 &key_base,
354 &row.s3_key,
355 &row.content_type,
356 PreviewPreset::Content,
357 recorded.as_ref(),
358 )
359 .await;
360 match state {
361 Some(state @ PreviewState::Ready(_)) => {
362 if recorded.as_ref() != Some(&state)
363 && let Err(error) = self
364 .record_attachment_preview(row.id, row.variants.clone(), &state)
365 .await
366 {
367 tracing::warn!(%error, attachment_id = %row.id, "failed to record attachment preview");
368 }
369 (
370 {
371 let state = &state;
372 match state {
373 PreviewState::Ready(ready) => ready.key.clone(),
374 PreviewState::Skipped(_) => String::new(),
375 }
376 },
377 {
378 let state = &state;
379 match state {
380 PreviewState::Ready(ready) => ready.content_type.clone(),
381 PreviewState::Skipped(_) => "application/octet-stream".to_string(),
382 }
383 },
384 )
385 }
386 Some(PreviewState::Skipped(_)) | None => {
387 (row.s3_key.clone(), attachment_stream_content_type(row))
388 }
389 }
390 }
391331
392332 pub async fn cleanup_stale_attachments(&self) -> Result<u64, ChatError> {
393333 let now = OffsetDateTime::now_utc();
@@ -409,21 +349,21 @@
409349 ),
410350 )
411351 .limit(200)
412 .all(self.db().write())
352 .all(self.database.write())
413353 .await?;
414354 let count = stale.len() as u64;
415355 for row in stale {
416356 let shared = attachments::Entity::find()
417357 .filter(attachments::Column::S3Key.eq(row.s3_key.clone()))
418358 .filter(attachments::Column::Id.ne(row.id))
419 .count(self.db().read())
359 .count(self.database.read())
420360 .await
421361 .unwrap_or(1);
422362 let _ = attachments::Entity::delete_by_id(row.id)
423 .exec(self.db().write())
363 .exec(self.database.write())
424364 .await;
425365 if shared == 0 {
426 let _ = self.storage().delete(&row.s3_key).await;
366 let _ = self.media.client.as_ref().delete(&row.s3_key).await;
427367 self.delete_attachment_previews(&row.s3_key).await;
428368 }
429369 }
@@ -436,7 +376,7 @@
436376 attachment_id: Uuid,
437377 ) -> Result<AttachmentDto, ChatError> {
438378 let row = attachments::Entity::find_by_id(attachment_id)
439 .one(self.db().read())
379 .one(self.database.read())
440380 .await?
441381 .ok_or(ChatError::NotFound)?;
442382 self.check_attachment_access(caller, &row).await?;
@@ -461,15 +401,15 @@
461401 ChatError,
462402 > {
463403 let row = attachments::Entity::find_by_id(attachment_id)
464 .one(self.db().read())
404 .one(self.database.read())
465405 .await?
466406 .ok_or(ChatError::NotFound)?;
467407 self.check_attachment_access(caller, &row).await?;
468408 if row.status == AttachmentStatus::Pending {
469409 return Err(ChatError::NotFound);
470410 }
471 let (key, content_type) = self.attachment_variant_locator(&row, variant).await;
472 let stream = self.storage().get_stream(&key).await?;
411 let (key, content_type) = self.media.attachment_variant_locator(&row, variant).await;
412 let stream = self.media.client.as_ref().get_stream(&key).await?;
473413 let body = Box::pin(futures::StreamExt::map(stream, |item| {
474414 item.map(Bytes::from)
475415 .map_err(|e| std::io::Error::other(e.to_string()))
Added lib/chat/src/services/audience.rs View lib/chat/src/services/audience.rs

Diff too large to display.

Modified lib/chat/src/services/bookmarks.rs View lib/chat/src/services/bookmarks.rs
@@ -72,7 +72,7 @@
7272 .filter(room_bookmarks::Column::RoomId.eq(room_id))
7373 .order_by_asc(room_bookmarks::Column::Position)
7474 .order_by_asc(room_bookmarks::Column::CreatedAt)
75 .all(self.db().read())
75 .all(self.database.read())
7676 .await?;
7777 Ok(rows.into_iter().map(BookmarkDto::from).collect())
7878 }
@@ -85,7 +85,7 @@
8585 ) -> Result<BookmarkDto, ChatError> {
8686 let _ctx = lookup::require_room_readable(self, room_id, caller).await?;
8787 let title = cmd.title.trim().to_string();
88 if title.is_empty() || title.chars().count() > self.config().chat.max_bookmark_title_len {
88 if title.is_empty() || title.chars().count() > self.settings.chat.max_bookmark_title_len {
8989 return Err(ChatError::BadRequest("invalid bookmark title".to_string()));
9090 }
9191 if cmd.url.is_none() && cmd.emoji.is_none() {
@@ -99,7 +99,7 @@
9999 .select_only()
100100 .column_as(Expr::col(room_bookmarks::Column::Position).max(), "max_pos")
101101 .into_tuple::<Option<i64>>()
102 .one(self.db().read())
102 .one(self.database.read())
103103 .await?
104104 .flatten()
105105 .unwrap_or(-1);
@@ -116,7 +116,7 @@
116116 created_at: Set(now),
117117 updated_at: Set(now),
118118 }
119 .insert(self.db().write())
119 .insert(self.database.write())
120120 .await?;
121121 Ok(BookmarkDto::from(row))
122122 }
@@ -131,7 +131,7 @@
131131 let ctx = lookup::require_room_readable(self, room_id, caller).await?;
132132 let row = room_bookmarks::Entity::find_by_id(bookmark_id)
133133 .filter(room_bookmarks::Column::RoomId.eq(room_id))
134 .one(self.db().write())
134 .one(self.database.write())
135135 .await?
136136 .ok_or(ChatError::NotFound)?;
137137 if row.user_id != caller && !self.bookmark_manager(&ctx, caller).await? {
@@ -140,7 +140,7 @@
140140 let mut am: room_bookmarks::ActiveModel = row.into();
141141 if let Some(title) = cmd.title {
142142 let title = title.trim().to_string();
143 if title.is_empty() || title.chars().count() > self.config().chat.max_bookmark_title_len
143 if title.is_empty() || title.chars().count() > self.settings.chat.max_bookmark_title_len
144144 {
145145 return Err(ChatError::BadRequest("invalid bookmark title".to_string()));
146146 }
@@ -156,7 +156,7 @@
156156 am.position = Set(position);
157157 }
158158 am.updated_at = Set(OffsetDateTime::now_utc());
159 let updated = am.update(self.db().write()).await?;
159 let updated = am.update(self.database.write()).await?;
160160 Ok(BookmarkDto::from(updated))
161161 }
162162
@@ -169,14 +169,14 @@
169169 let ctx = lookup::require_room_readable(self, room_id, caller).await?;
170170 let row = room_bookmarks::Entity::find_by_id(bookmark_id)
171171 .filter(room_bookmarks::Column::RoomId.eq(room_id))
172 .one(self.db().write())
172 .one(self.database.write())
173173 .await?
174174 .ok_or(ChatError::NotFound)?;
175175 if row.user_id != caller && !self.bookmark_manager(&ctx, caller).await? {
176176 return Err(ChatError::Forbidden);
177177 }
178178 room_bookmarks::Entity::delete_by_id(row.id)
179 .exec(self.db().write())
179 .exec(self.database.write())
180180 .await?;
181181 Ok(())
182182 }
Modified lib/chat/src/services/dms.rs View lib/chat/src/services/dms.rs
@@ -1,10 +1,10 @@
1use export::users::UserDto;
12use models::chat::{read_states, room_seq};
23use models::room::room_members;
34use models::room::rooms::{self, RoomKind};
45use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
56use serde::{Deserialize, Serialize};
67use sha2::{Digest, Sha256};
7use space::services::dto::UserDto;
88use time::OffsetDateTime;
99use utoipa::ToSchema;
1010use uuid::Uuid;
@@ -64,32 +64,10 @@
6464 self.open_group_dm(caller, uniq).await
6565 }
6666
67 async fn check_dm_quota(&self, caller: i64) -> Result<(), ChatError> {
68 if !self
69 .state
70 .redis
71 .quota_allow(
72 &format!("quota:dm_open:{caller}"),
73 self.config().quota.dm_open_per_day,
74 86_400,
75 )
76 .await
77 {
78 return Err(ChatError::RateLimited(
79 "DM creation quota exceeded, retry tomorrow".to_string(),
80 ));
81 }
82 Ok(())
83 }
84
8567 async fn open_direct_dm(&self, caller: i64, peer_user_id: i64) -> Result<DmDto, ChatError> {
86 self.check_dm_quota(caller).await?;
87 let client = self
88 .state
89 .kenauth
90 .as_ref()
91 .ok_or_else(|| ChatError::Internal("kenoauth is not configured".to_string()))?;
92 let peer = export::users::get_public_user(&self.state, peer_user_id)
68 self.participants.allow_dm(caller).await?;
69 let client = &self.audience.accounts;
70 let peer = auth::users::get_public_user(&self.audience.accounts, peer_user_id)
9371 .await?
9472 .ok_or(ChatError::NotFound)?;
9573 let peer_status = client
@@ -109,7 +87,7 @@
10987 let existing = rooms::Entity::find()
11088 .filter(rooms::Column::LowerName.eq(&pair))
11189 .filter(rooms::Column::Kind.eq(RoomKind::DirectMessage))
112 .one(self.db().read())
90 .one(self.database.read())
11391 .await?;
11492
11593 let room = match existing {
@@ -138,7 +116,7 @@
138116 created_at: Set(now),
139117 updated_at: Set(now),
140118 }
141 .insert(self.db().write())
119 .insert(self.database.write())
142120 .await?
143121 }
144122 };
@@ -152,18 +130,14 @@
152130 }
153131
154132 async fn open_group_dm(&self, caller: i64, peers: Vec<i64>) -> Result<DmDto, ChatError> {
155 self.check_dm_quota(caller).await?;
133 self.participants.allow_dm(caller).await?;
156134 if peers.len() + 1 > MAX_GROUP_DM_MEMBERS {
157135 return Err(ChatError::BadRequest(format!(
158136 "group DM supports at most {MAX_GROUP_DM_MEMBERS} members"
159137 )));
160138 }
161 let client = self
162 .state
163 .kenauth
164 .as_ref()
165 .ok_or_else(|| ChatError::Internal("kenoauth is not configured".to_string()))?;
166 let publics = export::users::load_public_users(&self.state, &peers).await?;
139 let client = &self.audience.accounts;
140 let publics = auth::users::load_public_users(&self.audience.accounts, &peers).await?;
167141 if publics.len() != peers.len() {
168142 return Err(ChatError::NotFound);
169143 }
@@ -199,7 +173,7 @@
199173 let existing = rooms::Entity::find()
200174 .filter(rooms::Column::LowerName.eq(&key))
201175 .filter(rooms::Column::Kind.eq(RoomKind::GroupDm))
202 .one(self.db().read())
176 .one(self.database.read())
203177 .await?;
204178
205179 let room = match existing {
@@ -236,7 +210,7 @@
236210 created_at: Set(now),
237211 updated_at: Set(now),
238212 }
239 .insert(self.db().write())
213 .insert(self.database.write())
240214 .await?
241215 }
242216 };
@@ -254,7 +228,7 @@
254228 let mut am: rooms::ActiveModel = room.clone().into();
255229 am.is_active = Set(true);
256230 am.updated_at = Set(OffsetDateTime::now_utc());
257 am.update(self.db().write()).await?;
231 am.update(self.database.write()).await?;
258232 Ok(room)
259233 }
260234
@@ -267,14 +241,14 @@
267241 let now = OffsetDateTime::now_utc();
268242 for &uid in uids {
269243 let existing_member = room_members::Entity::find_by_id((room_id, uid))
270 .one(self.db().write())
244 .one(self.database.write())
271245 .await?;
272246 match existing_member {
273247 Some(row) if row.left_at.is_some() => {
274248 let mut am: room_members::ActiveModel = row.into();
275249 am.left_at = Set(None);
276250 am.updated_at = Set(Some(now));
277 am.update(self.db().write()).await?;
251 am.update(self.database.write()).await?;
278252 }
279253 Some(_) => {}
280254 None => {
@@ -292,7 +266,7 @@
292266 created_at: Set(now),
293267 updated_at: Set(Some(now)),
294268 }
295 .insert(self.db().write())
269 .insert(self.database.write())
296270 .await?;
297271 }
298272 }
@@ -304,7 +278,7 @@
304278 let memberships = room_members::Entity::find()
305279 .filter(room_members::Column::UserId.eq(caller))
306280 .filter(room_members::Column::LeftAt.is_null())
307 .all(self.db().read())
281 .all(self.database.read())
308282 .await?;
309283 let room_ids: Vec<Uuid> = memberships.into_iter().map(|m| m.room_id).collect();
310284 if room_ids.is_empty() {
@@ -314,7 +288,7 @@
314288 .filter(rooms::Column::Id.is_in(room_ids))
315289 .filter(rooms::Column::IsActive.eq(true))
316290 .filter(rooms::Column::SpaceId.is_null())
317 .all(self.db().read())
291 .all(self.database.read())
318292 .await?;
319293 let dm_room_ids: Vec<Uuid> = rooms.iter().map(|r| r.id).collect();
320294
@@ -322,10 +296,10 @@
322296 .filter(room_members::Column::RoomId.is_in(dm_room_ids.clone()))
323297 .filter(room_members::Column::UserId.ne(caller))
324298 .filter(room_members::Column::LeftAt.is_null())
325 .all(self.db().read())
299 .all(self.database.read())
326300 .await?;
327301 let peer_uids: Vec<i64> = peer_rows.iter().map(|m| m.user_id).collect();
328 let user_map = self.load_user_briefs(&peer_uids).await?;
302 let user_map = self.audience.load_user_briefs(&peer_uids).await?;
329303 let mut peers_by_room: std::collections::HashMap<Uuid, Vec<UserDto>> =
330304 std::collections::HashMap::new();
331305 for m in peer_rows {
@@ -336,14 +310,14 @@
336310
337311 let tips = room_seq::Entity::find()
338312 .filter(room_seq::Column::RoomId.is_in(dm_room_ids.clone()))
339 .all(self.db().read())
313 .all(self.database.read())
340314 .await?;
341315 let tip_map: std::collections::HashMap<Uuid, i64> =
342316 tips.into_iter().map(|s| (s.room_id, s.seq)).collect();
343317 let reads = read_states::Entity::find()
344318 .filter(read_states::Column::UserId.eq(caller))
345319 .filter(read_states::Column::RoomId.is_in(dm_room_ids))
346 .all(self.db().read())
320 .all(self.database.read())
347321 .await?;
348322 let read_map: std::collections::HashMap<Uuid, i64> = reads
349323 .into_iter()
@@ -388,12 +362,12 @@
388362 members: Vec<UserDto>,
389363 ) -> Result<DmDto, ChatError> {
390364 let tip = room_seq::Entity::find_by_id(room.id)
391 .one(self.db().read())
365 .one(self.database.read())
392366 .await?
393367 .map(|s| s.seq)
394368 .unwrap_or(0);
395369 let last_read = read_states::Entity::find_by_id((caller, room.id))
396 .one(self.db().read())
370 .one(self.database.read())
397371 .await?
398372 .map(|r| r.last_read_seq)
399373 .unwrap_or(0);
@@ -413,12 +387,12 @@
413387
414388 pub async fn close_dm(&self, caller: i64, room_id: Uuid) -> Result<(), ChatError> {
415389 let room = rooms::Entity::find_by_id(room_id)
416 .one(self.db().read())
390 .one(self.database.read())
417391 .await?
418392 .filter(|r| r.is_active && r.space_id.is_none())
419393 .ok_or(ChatError::NotFound)?;
420394 let member = room_members::Entity::find_by_id((room_id, caller))
421 .one(self.db().write())
395 .one(self.database.write())
422396 .await?
423397 .ok_or(ChatError::NotFound)?;
424398 if member.left_at.is_none() {
@@ -426,12 +400,12 @@
426400 let mut am: room_members::ActiveModel = member.into();
427401 am.left_at = Set(Some(now));
428402 am.updated_at = Set(Some(now));
429 am.update(self.db().write()).await?;
403 am.update(self.database.write()).await?;
430404 let event = RoomEvent::MemberLeft {
431405 room_id,
432406 user_id: caller,
433407 };
434 self.realtime.publish_room(&event).await;
408 self.audience.realtime.publish_room(&event).await;
435409 }
436410 let _ = room;
437411 Ok(())
Modified lib/chat/src/services/drafts.rs View lib/chat/src/services/drafts.rs
@@ -32,7 +32,7 @@
3232 cmd: UpsertDraftCmd,
3333 ) -> Result<DraftDto, ChatError> {
3434 let _ctx = lookup::require_room_readable(self, room_id, caller).await?;
35 if cmd.content.chars().count() > self.config().chat.max_message_len {
35 if cmd.content.chars().count() > self.settings.chat.max_message_len {
3636 return Err(ChatError::BadRequest("draft too long".to_string()));
3737 }
3838 let now = OffsetDateTime::now_utc();
@@ -41,7 +41,7 @@
4141 let candidates = user_drafts::Entity::find()
4242 .filter(user_drafts::Column::UserId.eq(caller))
4343 .filter(user_drafts::Column::RoomId.eq(room_id))
44 .all(self.db().write())
44 .all(self.database.write())
4545 .await?;
4646 for row in candidates {
4747 if row.thread_id == cmd.thread_id {
@@ -54,7 +54,7 @@
5454 Some(row) => {
5555 if cmd.content.is_empty() {
5656 user_drafts::Entity::delete_by_id(row.id)
57 .exec(self.db().write())
57 .exec(self.database.write())
5858 .await?;
5959 return Ok(DraftDto {
6060 room_id,
@@ -66,7 +66,7 @@
6666 let mut am: user_drafts::ActiveModel = row.into();
6767 am.content = Set(cmd.content.clone());
6868 am.updated_at = Set(now);
69 am.update(self.db().write()).await?;
69 am.update(self.database.write()).await?;
7070 }
7171 None => {
7272 if !cmd.content.is_empty() {
@@ -79,7 +79,7 @@
7979 created_at: Set(now),
8080 updated_at: Set(now),
8181 }
82 .insert(self.db().write())
82 .insert(self.database.write())
8383 .await?;
8484 }
8585 }
@@ -103,7 +103,7 @@
103103 let rows = user_drafts::Entity::find()
104104 .filter(user_drafts::Column::UserId.eq(caller))
105105 .filter(user_drafts::Column::RoomId.eq(room_id))
106 .all(self.db().read())
106 .all(self.database.read())
107107 .await?;
108108 Ok(rows
109109 .into_iter()
@@ -129,7 +129,7 @@
129129 if let Some(tid) = thread_id {
130130 q = q.filter(user_drafts::Column::ThreadId.eq(tid));
131131 }
132 let deleted = q.exec(self.db().write()).await?;
132 let deleted = q.exec(self.database.write()).await?;
133133 if deleted.rows_affected == 0 {
134134 return Err(ChatError::NotFound);
135135 }
@@ -139,7 +139,7 @@
139139 pub async fn list_my_drafts(&self, caller: i64) -> Result<Vec<DraftDto>, ChatError> {
140140 let rows = user_drafts::Entity::find()
141141 .filter(user_drafts::Column::UserId.eq(caller))
142 .all(self.db().read())
142 .all(self.database.read())
143143 .await?;
144144 Ok(rows
145145 .into_iter()
Modified lib/chat/src/services/emojis.rs View lib/chat/src/services/emojis.rs
@@ -1,7 +1,7 @@
11use models::chat::emojis;
2use models::space::spaces;
32use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
43use serde::{Deserialize, Serialize};
4use space::Space;
55use time::OffsetDateTime;
66use utoipa::ToSchema;
77use uuid::Uuid;
@@ -44,8 +44,8 @@
4444 let space = lookup_space_by_name(self, space_name).await?;
4545 require_space_member(self, &space, caller).await?;
4646 let e = emojis::Entity::find_by_id(emoji_id)
47 .filter(emojis::Column::SpaceId.eq(space.id))
48 .one(self.db().read())
47 .filter(emojis::Column::SpaceId.eq(space.id()))
48 .one(self.database.read())
4949 .await?
5050 .ok_or(ChatError::NotFound)?;
5151 Ok(EmojiDto {
@@ -66,9 +66,9 @@
6666 let space = lookup_space_by_name(self, space_name).await?;
6767 require_space_member(self, &space, caller).await?;
6868 let rows = emojis::Entity::find()
69 .filter(emojis::Column::SpaceId.eq(space.id))
69 .filter(emojis::Column::SpaceId.eq(space.id()))
7070 .order_by_asc(emojis::Column::LowerName)
71 .all(self.db().read())
71 .all(self.database.read())
7272 .await?;
7373 Ok(rows
7474 .into_iter()
@@ -96,7 +96,7 @@
9696 let name = cmd.name.trim().to_string();
9797 let lower = name.to_lowercase();
9898 if name.is_empty()
99 || name.chars().count() > self.config().chat.max_emoji_name_len
99 || name.chars().count() > self.settings.chat.max_emoji_name_len
100100 || !lower
101101 .chars()
102102 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
@@ -123,9 +123,9 @@
123123 }
124124
125125 let duplicate = emojis::Entity::find()
126 .filter(emojis::Column::SpaceId.eq(space.id))
126 .filter(emojis::Column::SpaceId.eq(space.id()))
127127 .filter(emojis::Column::LowerName.eq(&lower))
128 .one(self.db().read())
128 .one(self.database.read())
129129 .await?;
130130 if duplicate.is_some() {
131131 return Err(ChatError::Conflict("emoji name already exists".to_string()));
@@ -139,25 +139,27 @@
139139 "image/gif" => "gif",
140140 _ => "webp",
141141 };
142 let key = format!("{EMOJI_PREFIX}/{}/{id}.{ext}", space.id);
143 self.storage()
142 let key = format!("{EMOJI_PREFIX}/{}/{id}.{ext}", space.id());
143 self.media
144 .client
145 .as_ref()
144146 .put_with_content_type(&key, &bytes, &cmd.content_type)
145147 .await
146148 .map_err(ChatError::from)?;
147149
148150 let row = emojis::ActiveModel {
149151 id: Set(id),
150 space_id: Set(space.id),
152 space_id: Set(space.id()),
151153 name: Set(name),
152154 lower_name: Set(lower),
153155 user_id: Set(caller),
154 bucket: Set(self.storage().bucket_name().to_string()),
156 bucket: Set(self.media.client.as_ref().bucket_name().to_string()),
155157 s3_key: Set(key),
156158 animated: Set(cmd.animated),
157159 disabled: Set(false),
158160 created_at: Set(now),
159161 }
160 .insert(self.db().write())
162 .insert(self.database.write())
161163 .await?;
162164
163165 Ok(EmojiDto {
@@ -180,14 +182,14 @@
180182 require_space_member(self, &space, caller).await?;
181183 authz::require_space_manager(self, &space, caller).await?;
182184 let row = emojis::Entity::find_by_id(emoji_id)
183 .filter(emojis::Column::SpaceId.eq(space.id))
184 .one(self.db().write())
185 .filter(emojis::Column::SpaceId.eq(space.id()))
186 .one(self.database.write())
185187 .await?
186188 .ok_or(ChatError::NotFound)?;
187189 emojis::Entity::delete_by_id(row.id)
188 .exec(self.db().write())
190 .exec(self.database.write())
189191 .await?;
190 if let Err(error) = self.storage().delete(&row.s3_key).await {
192 if let Err(error) = self.media.client.as_ref().delete(&row.s3_key).await {
191193 tracing::warn!(%error, "failed to delete emoji object");
192194 }
193195 Ok(())
@@ -195,14 +197,14 @@
195197
196198 pub(crate) async fn emoji_image(
197199 &self,
198 space: &spaces::Model,
200 space: &Space,
199201 emoji_id: Uuid,
200202 ) -> Result<Vec<u8>, ChatError> {
201203 let row = emojis::Entity::find_by_id(emoji_id)
202 .filter(emojis::Column::SpaceId.eq(space.id))
203 .one(self.db().read())
204 .filter(emojis::Column::SpaceId.eq(space.id()))
205 .one(self.database.read())
204206 .await?
205207 .ok_or(ChatError::NotFound)?;
206 Ok(self.storage().get(&row.s3_key).await?)
208 Ok(self.media.client.as_ref().get(&row.s3_key).await?)
207209 }
208210}
Added lib/chat/src/services/media.rs View lib/chat/src/services/media.rs

Diff too large to display.

Modified lib/chat/src/services/messages.rs View lib/chat/src/services/messages.rs
@@ -1,3 +1,4 @@
1use export::users::UserDto;
12use models::chat::attachments::{self as attachment_rows};
23use models::chat::messages::{self, AuthorType, ChatMessageType};
34use models::chat::thread_members::{self, ThreadNotifyLevel};
@@ -11,7 +12,6 @@
1112};
1213use serde::{Deserialize, Serialize};
1314use serde_json::json;
14use space::services::dto::UserDto;
1515use time::OffsetDateTime;
1616use utoipa::{IntoParams, ToSchema};
1717use uuid::Uuid;
@@ -24,7 +24,7 @@
2424async fn delete_attachment_previews(service: &ChatService, original_key: &str) {
2525 let key_base = export::imaging::preview_key_base_of(original_key);
2626 for candidate in imaging::layout::preview_candidate_keys(&key_base) {
27 if let Err(error) = service.storage().delete(&candidate).await {
27 if let Err(error) = service.media.client.as_ref().delete(&candidate).await {
2828 tracing::warn!(%error, key = %candidate, "failed to delete attachment preview");
2929 }
3030 }
@@ -72,13 +72,13 @@
7272 Ok(NotifyDecision::Notify) => {}
7373 _ => continue,
7474 }
75 let locale = service.recipient_locale(uid).await;
75 let locale = service.audience.recipient_locale(uid).await;
7676 let title = match kind {
7777 DirectMessageKind::Direct => i18n::catalog::notification_direct_message_title(locale),
7878 DirectMessageKind::Group => i18n::catalog::notification_group_dm_title(locale),
7979 };
8080 export::push::enqueue(
81 &service.state.redis,
81 &service.participants.redis,
8282 &export::push::PushJob {
8383 user_id: uid,
8484 category: export::push::PushCategory::Dm,
@@ -220,12 +220,12 @@
220220 return Ok(Vec::new());
221221 }
222222 let author_ids: Vec<i64> = rows.iter().map(|m| m.author_id).collect();
223 let user_map = self.load_user_briefs(&author_ids).await?;
223 let user_map = self.audience.load_user_briefs(&author_ids).await?;
224224
225225 let message_ids: Vec<Uuid> = rows.iter().map(|m| m.id).collect();
226226 let attachment_rows = attachment_rows::Entity::find()
227227 .filter(attachment_rows::Column::MessageId.is_in(message_ids.clone()))
228 .all(self.db().read())
228 .all(self.database.read())
229229 .await?;
230230 let mut attachments_by_msg: std::collections::HashMap<Uuid, Vec<AttachmentDto>> =
231231 std::collections::HashMap::new();
@@ -238,7 +238,7 @@
238238
239239 let reaction_rows = reactions::Entity::find()
240240 .filter(reactions::Column::MessageId.is_in(message_ids.clone()))
241 .all(self.db().read())
241 .all(self.database.read())
242242 .await?;
243243 let mut reaction_agg: std::collections::HashMap<(Uuid, String), (i64, bool)> =
244244 std::collections::HashMap::new();
@@ -313,7 +313,7 @@
313313 .filter(messages::Column::RoomId.eq(ctx.room.id))
314314 .filter(messages::Column::AuthorId.eq(caller))
315315 .order_by_desc(messages::Column::Seq)
316 .one(self.db().read())
316 .one(self.database.read())
317317 .await?;
318318 if let Some(last) = last {
319319 let elapsed = OffsetDateTime::now_utc() - last.created_at;
@@ -352,17 +352,17 @@
352352 let ctx = require_room_writable(self, room_id, caller).await?;
353353 self.check_slow_mode(&ctx, caller).await?;
354354
355 if cmd.attachments.len() > self.config().chat.max_attachments_per_message {
355 if cmd.attachments.len() > self.settings.chat.max_attachments_per_message {
356356 return Err(ChatError::BadRequest(format!(
357357 "attachments must be at most {}",
358 self.config().chat.max_attachments_per_message
358 self.settings.chat.max_attachments_per_message
359359 )));
360360 }
361361
362362 let forwarded_from = match cmd.forwarded_from {
363363 Some(orig_id) => {
364364 let orig = messages::Entity::find_by_id(orig_id)
365 .one(self.db().read())
365 .one(self.database.read())
366366 .await?
367367 .filter(|m| m.deleted_at.is_none())
368368 .ok_or(ChatError::NotFound)?;
@@ -378,7 +378,7 @@
378378 "message content is empty".to_string(),
379379 ));
380380 }
381 if content_len > self.config().chat.max_message_len {
381 if content_len > self.settings.chat.max_message_len {
382382 return Err(ChatError::BadRequest("message too long".to_string()));
383383 }
384384
@@ -387,7 +387,7 @@
387387 .filter(messages::Column::RoomId.eq(room_id))
388388 .filter(messages::Column::AuthorId.eq(caller))
389389 .filter(messages::Column::Nonce.eq(nonce.as_str()))
390 .one(self.db().read())
390 .one(self.database.read())
391391 .await?;
392392 if let Some(existing) = existing {
393393 let items = self.build_message_dto(vec![existing], caller).await?;
@@ -398,7 +398,7 @@
398398 let mut thread: Option<threads::Model> = None;
399399 if let Some(thread_id) = cmd.thread_id {
400400 let t = threads::Entity::find_by_id(thread_id)
401 .one(self.db().read())
401 .one(self.database.read())
402402 .await?
403403 .filter(|t| t.room_id == room_id)
404404 .ok_or(ChatError::NotFound)?;
@@ -413,7 +413,7 @@
413413 // `reply` notification type exists precisely for it.
414414 let reply_parent_author = if let Some(reply_to) = cmd.reply_to_id {
415415 let parent = messages::Entity::find_by_id(reply_to)
416 .one(self.db().read())
416 .one(self.database.read())
417417 .await?
418418 .filter(|p| p.room_id == room_id && p.deleted_at.is_none())
419419 .ok_or(ChatError::NotFound)?;
@@ -427,9 +427,9 @@
427427 // Resolve every mentioned recipient's language up front: the
428428 // notification rows are written inside the transaction and each one has
429429 // to carry the recipient's own copy.
430 let fallback_locale = i18n::config_fallback(self.config().email.default_locale());
430 let fallback_locale = self.audience.default_locale;
431431 let mention_locales =
432 i18n::resolve_user_locales(self.db().read(), &mention_uids, fallback_locale).await;
432 i18n::resolve_user_locales(self.database.read(), &mention_uids, fallback_locale).await;
433433
434434 if !cmd.attachments.is_empty() {
435435 let owned = attachment_rows::Entity::find()
@@ -439,7 +439,7 @@
439439 .filter(
440440 attachment_rows::Column::Status.eq(attachment_rows::AttachmentStatus::Uploaded),
441441 )
442 .count(self.db().read())
442 .count(self.database.read())
443443 .await?;
444444 if owned != cmd.attachments.len() as u64 {
445445 return Err(ChatError::BadRequest(
@@ -496,7 +496,7 @@
496496 let pending_txn = pending.clone();
497497
498498 let txn_result = self
499 .db()
499 .database
500500 .primary()
501501 .transaction::<_, (), ChatError>(move |txn| {
502502 Box::pin(async move {
@@ -693,7 +693,7 @@
693693 .filter(messages::Column::RoomId.eq(room_id))
694694 .filter(messages::Column::AuthorId.eq(caller))
695695 .filter(messages::Column::Nonce.eq(nonce.as_str()))
696 .one(self.db().read())
696 .one(self.database.read())
697697 .await?;
698698 if let Some(existing) = existing {
699699 let items = self.build_message_dto(vec![existing], caller).await?;
@@ -701,10 +701,10 @@
701701 }
702702 }
703703 txn_result?;
704 pending.flush(&self.state).await;
704 pending.flush(&self.audience.notifications).await;
705705
706706 let row = messages::Entity::find_by_id(message_id)
707 .one(self.db().read())
707 .one(self.database.read())
708708 .await?
709709 .ok_or(ChatError::Internal("message vanished after insert".into()))?;
710710 let seq = row.seq;
@@ -720,7 +720,7 @@
720720 room_id,
721721 thread_id: t.id,
722722 };
723 self.realtime.publish_room(&event).await;
723 self.audience.realtime.publish_room(&event).await;
724724 }
725725 let _ = self
726726 .sender_mark_read(caller, room_id, message_id, seq)
@@ -760,7 +760,8 @@
760760 .filter(|uid| *uid != caller)
761761 .collect();
762762 for uid in recipients {
763 self.realtime
763 self.audience
764 .realtime
764765 .publish_user(
765766 uid,
766767 &json!({"kind": "mention", "room_id": room_id, "message_id": message_id}),
@@ -818,7 +819,7 @@
818819 };
819820 let email_svc = self.clone();
820821 tokio::spawn(async move {
821 let base_url = email_svc.config().web.web_base_url.clone();
822 let base_url = email_svc.settings.web.web_base_url.clone();
822823 for uid in email_recipients {
823824 if !mention_email_allowed(&email_svc, room_id, space_id, uid)
824825 .await
@@ -826,7 +827,7 @@
826827 {
827828 continue;
828829 }
829 let Ok(mut emails) = email_svc.primary_emails(&[uid]).await else {
830 let Ok(mut emails) = email_svc.audience.primary_emails(&[uid]).await else {
830831 continue;
831832 };
832833 let Some(addr) = emails.remove(&uid) else {
@@ -834,7 +835,7 @@
834835 };
835836 // One recipient per iteration, so the mail follows that
836837 // person's own language rather than the sender's.
837 let locale = email_svc.recipient_locale(uid).await;
838 let locale = email_svc.audience.recipient_locale(uid).await;
838839 let subject = i18n::catalog::mention_email_subject(locale, &room_name);
839840 let link = format!("{base_url}/chat/rooms/{room_id}");
840841 let text = format!(
@@ -845,7 +846,11 @@
845846 r#"<p>{subject}</p><p>{email_body}</p><p><a href="{link}">{}</a></p>"#,
846847 i18n::catalog::view_channel(locale)
847848 );
848 if let Err(err) = email_svc.send_email(&addr, &subject, &text, &html).await {
849 if let Err(err) = email_svc
850 .audience
851 .send_email(&addr, &subject, &text, &html)
852 .await
853 {
849854 tracing::warn!(error = %err, "failed to send mention email");
850855 }
851856 }
@@ -866,8 +871,8 @@
866871 let (limit, _) = export::pagination::page_bounds(
867872 q.limit,
868873 None,
869 self.config().chat.default_pagination,
870 self.config().chat.max_pagination,
874 self.settings.chat.default_pagination,
875 self.settings.chat.max_pagination,
871876 );
872877
873878 let mut select = messages::Entity::find()
@@ -896,7 +901,7 @@
896901 .filter(messages::Column::Seq.gt(after_seq))
897902 .order_by_asc(messages::Column::Seq)
898903 .limit(limit + 1)
899 .all(self.db().read())
904 .all(self.database.read())
900905 .await?;
901906 let has_more = rows.len() as u64 > limit;
902907 let rows: Vec<_> = rows.into_iter().take(limit as usize).collect();
@@ -906,7 +911,7 @@
906911 if let Some(before_seq) = q.before_seq {
907912 desc = desc.filter(messages::Column::Seq.lt(before_seq));
908913 }
909 let mut rows = desc.all(self.db().read()).await?;
914 let mut rows = desc.all(self.database.read()).await?;
910915 let has_more = rows.len() as u64 > limit;
911916 rows.truncate(limit as usize);
912917 rows.reverse();
@@ -934,7 +939,7 @@
934939 let _ctx = require_room_readable(self, room_id, caller).await?;
935940 let row = messages::Entity::find_by_id(message_id)
936941 .filter(messages::Column::RoomId.eq(room_id))
937 .one(self.db().read())
942 .one(self.database.read())
938943 .await?
939944 .filter(|m| m.deleted_at.is_none())
940945 .ok_or(ChatError::NotFound)?;
@@ -952,7 +957,7 @@
952957 let ctx = require_room_readable(self, room_id, caller).await?;
953958 let row = messages::Entity::find_by_id(message_id)
954959 .filter(messages::Column::RoomId.eq(room_id))
955 .one(self.db().read())
960 .one(self.database.read())
956961 .await?
957962 .filter(|m| m.deleted_at.is_none())
958963 .ok_or(ChatError::NotFound)?;
@@ -969,7 +974,7 @@
969974 if content.is_empty() {
970975 return Err(ChatError::BadRequest("content is empty".to_string()));
971976 }
972 if content.chars().count() > self.config().chat.max_message_len {
977 if content.chars().count() > self.settings.chat.max_message_len {
973978 return Err(ChatError::BadRequest("message too long".to_string()));
974979 }
975980 if content == row.content {
@@ -982,9 +987,9 @@
982987 let now = OffsetDateTime::now_utc();
983988
984989 let parsed = mention::parse_mentions(self, &ctx, &content, caller).await?;
985 let fallback_locale = i18n::config_fallback(self.config().email.default_locale());
990 let fallback_locale = self.audience.default_locale;
986991 let new_mention_locales =
987 i18n::resolve_user_locales(self.db().read(), &parsed.users, fallback_locale).await;
992 i18n::resolve_user_locales(self.database.read(), &parsed.users, fallback_locale).await;
988993 let mention_everyone = if parsed.everyone {
989994 match &ctx.space {
990995 Some(space) => authz::can_manage_space(self, space, caller).await?,
@@ -1004,14 +1009,14 @@
10041009 let mentions_json = json!(parsed.users);
10051010 let groups_json = json!(parsed.groups);
10061011 let new_mentions_txn = new_mentions.clone();
1007 let space_id = ctx.space.as_ref().map(|s| s.id);
1012 let space_id = ctx.space.as_ref().map(|s| s.id());
10081013 let content_for_notify = content.clone();
10091014
10101015 let this = self.clone();
10111016 let this_txn = self.clone();
10121017 let pending = export::notify::PendingNotifications::default();
10131018 let pending_txn = pending.clone();
1014 self.db()
1019 self.database
10151020 .primary()
10161021 .transaction::<_, (), ChatError>(move |txn| {
10171022 Box::pin(async move {
@@ -1082,13 +1087,13 @@
10821087 })
10831088 })
10841089 .await?;
1085 pending.flush(&self.state).await;
1090 pending.flush(&self.audience.notifications).await;
10861091
10871092 let row = messages::Entity::find_by_id(message_id)
1088 .one(self.db().read())
1093 .one(self.database.read())
10891094 .await?
10901095 .ok_or(ChatError::Internal("message vanished after update".into()))?;
1091 let seq = current_room_seq(self.db().read(), room_id).await;
1096 let seq = current_room_seq(self.database.read(), room_id).await;
10921097 let items = self.build_message_dto(vec![row], caller).await?;
10931098 let dto = items.into_iter().next().unwrap();
10941099 let dto_value = serde_json::to_value(&dto)?;
@@ -1097,7 +1102,7 @@
10971102 seq,
10981103 message: dto_value,
10991104 };
1100 this.realtime.publish_room(&event).await;
1105 this.audience.realtime.publish_room(&event).await;
11011106 crate::services::outbound::enqueue_outbound_event(
11021107 self,
11031108 room_id,
@@ -1107,7 +1112,8 @@
11071112 .await;
11081113
11091114 for &uid in &new_mentions {
1110 self.realtime
1115 self.audience
1116 .realtime
11111117 .publish_user(
11121118 uid,
11131119 &json!({"kind": "mention", "room_id": room_id, "message_id": message_id}),
@@ -1160,7 +1166,7 @@
11601166 .filter(messages::Column::Id.is_in(message_ids.clone()))
11611167 .filter(messages::Column::RoomId.eq(room_id))
11621168 .filter(messages::Column::DeletedAt.is_null())
1163 .all(self.db().read())
1169 .all(self.database.read())
11641170 .await?;
11651171 if rows.is_empty() {
11661172 return Ok(());
@@ -1176,7 +1182,7 @@
11761182 let first_id = row_ids[0];
11771183 let thread_roots: Vec<Uuid> = threads::Entity::find()
11781184 .filter(threads::Column::RootMessageId.is_in(row_ids.clone()))
1179 .all(self.db().read())
1185 .all(self.database.read())
11801186 .await?
11811187 .into_iter()
11821188 .map(|t| t.id)
@@ -1184,7 +1190,7 @@
11841190
11851191 let now = OffsetDateTime::now_utc();
11861192 let ids = row_ids.clone();
1187 self.db()
1193 self.database
11881194 .primary()
11891195 .transaction::<_, (), ChatError>(move |txn| {
11901196 Box::pin(async move {
@@ -1212,20 +1218,20 @@
12121218
12131219 let attached = attachment_rows::Entity::find()
12141220 .filter(attachment_rows::Column::MessageId.is_in(row_ids.clone()))
1215 .all(self.db().read())
1221 .all(self.database.read())
12161222 .await?;
12171223 for attachment in attached {
12181224 let shared = attachment_rows::Entity::find()
12191225 .filter(attachment_rows::Column::S3Key.eq(attachment.s3_key.clone()))
12201226 .filter(attachment_rows::Column::Id.ne(attachment.id))
1221 .count(self.db().read())
1227 .count(self.database.read())
12221228 .await
12231229 .unwrap_or(1);
12241230 let _ = attachment_rows::Entity::delete_by_id(attachment.id)
1225 .exec(self.db().write())
1231 .exec(self.database.write())
12261232 .await;
12271233 if shared == 0 {
1228 let _ = self.storage().delete(&attachment.s3_key).await;
1234 let _ = self.media.client.as_ref().delete(&attachment.s3_key).await;
12291235 // The derivative is an orphan once the last reference to the
12301236 // original is gone; leaving it behind would leak an object that
12311237 // nothing can ever address again.
@@ -1233,7 +1239,7 @@
12331239 }
12341240 }
12351241
1236 let seq = current_room_seq(self.db().read(), room_id).await;
1242 let seq = current_room_seq(self.database.read(), room_id).await;
12371243
12381244 let event = if single {
12391245 RoomEvent::MessageDeleted {
@@ -1248,7 +1254,7 @@
12481254 message_ids: row_ids.clone(),
12491255 }
12501256 };
1251 self.realtime.publish_room(&event).await;
1257 self.audience.realtime.publish_room(&event).await;
12521258 crate::services::outbound::enqueue_outbound_event(
12531259 self,
12541260 room_id,
@@ -1280,7 +1286,7 @@
12801286 }
12811287 let row = messages::Entity::find_by_id(message_id)
12821288 .filter(messages::Column::RoomId.eq(room_id))
1283 .one(self.db().read())
1289 .one(self.database.read())
12841290 .await?
12851291 .filter(|m| m.deleted_at.is_none())
12861292 .ok_or(ChatError::NotFound)?;
@@ -1291,7 +1297,7 @@
12911297 }
12921298
12931299 let now = OffsetDateTime::now_utc();
1294 self.db()
1300 self.database
12951301 .primary()
12961302 .transaction::<_, (), ChatError>(move |txn| {
12971303 Box::pin(async move {
@@ -1316,7 +1322,7 @@
13161322 })
13171323 .await?;
13181324
1319 let seq = current_room_seq(self.db().read(), room_id).await;
1325 let seq = current_room_seq(self.database.read(), room_id).await;
13201326
13211327 let event = RoomEvent::MessagePinned {
13221328 room_id,
@@ -1324,7 +1330,7 @@
13241330 message_id,
13251331 pinned: cmd.pinned,
13261332 };
1327 self.realtime.publish_room(&event).await;
1333 self.audience.realtime.publish_room(&event).await;
13281334
13291335 self.get_message(caller, room_id, message_id).await
13301336 }
@@ -1340,7 +1346,7 @@
13401346 .filter(messages::Column::Pinned.eq(true))
13411347 .filter(messages::Column::DeletedAt.is_null())
13421348 .order_by_desc(messages::Column::PinnedAt)
1343 .all(self.db().read())
1349 .all(self.database.read())
13441350 .await?;
13451351 self.build_message_dto(rows, caller).await
13461352 }
@@ -1355,14 +1361,14 @@
13551361 let _ctx = require_room_readable(self, room_id, caller).await?;
13561362 let original = messages::Entity::find_by_id(message_id)
13571363 .filter(messages::Column::RoomId.eq(room_id))
1358 .one(self.db().read())
1364 .one(self.database.read())
13591365 .await?
13601366 .filter(|m| m.deleted_at.is_none())
13611367 .ok_or(ChatError::NotFound)?;
13621368
13631369 let original_attachments = attachment_rows::Entity::find()
13641370 .filter(attachment_rows::Column::MessageId.eq(original.id))
1365 .all(self.db().read())
1371 .all(self.database.read())
13661372 .await?;
13671373 let send_cmd = SendMessageCmd {
13681374 content: cmd.comment.unwrap_or_default(),
@@ -1389,7 +1395,7 @@
13891395 let _ctx = require_room_readable(self, room_id, caller).await?;
13901396 let message = messages::Entity::find_by_id(message_id)
13911397 .filter(messages::Column::RoomId.eq(room_id))
1392 .one(self.db().read())
1398 .one(self.database.read())
13931399 .await?
13941400 .filter(|m| m.deleted_at.is_none())
13951401 .ok_or(ChatError::NotFound)?;
@@ -1397,10 +1403,10 @@
13971403 let rows = message_edits::Entity::find()
13981404 .filter(message_edits::Column::MessageId.eq(message_id))
13991405 .order_by_desc(message_edits::Column::CreatedAt)
1400 .all(self.db().read())
1406 .all(self.database.read())
14011407 .await?;
14021408 let editor_ids: Vec<i64> = rows.iter().map(|r| r.editor_id).collect();
1403 let user_map = self.load_user_briefs(&editor_ids).await?;
1409 let user_map = self.audience.load_user_briefs(&editor_ids).await?;
14041410 Ok(rows
14051411 .into_iter()
14061412 .map(|r| MessageEditDto {
@@ -1420,7 +1426,14 @@
14201426 message_id: Uuid,
14211427 seq: i64,
14221428 ) -> Result<(), ChatError> {
1423 advance_read_state(self.db().write(), caller, room_id, Some(message_id), seq).await?;
1429 advance_read_state(
1430 self.database.write(),
1431 caller,
1432 room_id,
1433 Some(message_id),
1434 seq,
1435 )
1436 .await?;
14241437 Ok(())
14251438 }
14261439}
Modified lib/chat/src/services/mod.rs View lib/chat/src/services/mod.rs
@@ -1,8 +1,10 @@
11pub mod attachments;
2pub mod audience;
23pub mod bookmarks;
34pub mod dms;
45pub mod drafts;
56pub mod emojis;
7pub mod media;
68pub mod messages;
79pub mod outbound;
810pub mod presence;
Modified lib/chat/src/services/outbound.rs View lib/chat/src/services/outbound.rs
@@ -129,7 +129,7 @@
129129 ) -> Result<OutboundWebhookSecretDto, ChatError> {
130130 require_room_manager(self, room_id, caller).await?;
131131 let name = cmd.name.trim();
132 if name.is_empty() || name.chars().count() > self.config().chat.max_webhook_name_len {
132 if name.is_empty() || name.chars().count() > self.settings.chat.max_webhook_name_len {
133133 return Err(ChatError::BadRequest("invalid webhook name".to_string()));
134134 }
135135 validate_outbound_url(cmd.url.trim())?;
@@ -147,7 +147,7 @@
147147 created_at: Set(now),
148148 updated_at: Set(now),
149149 }
150 .insert(self.db().write())
150 .insert(self.database.write())
151151 .await?;
152152 Ok(OutboundWebhookSecretDto {
153153 id: row.id,
@@ -167,7 +167,7 @@
167167 require_room_manager(self, room_id, caller).await?;
168168 let rows = outbound_webhooks::Entity::find()
169169 .filter(outbound_webhooks::Column::RoomId.eq(room_id))
170 .all(self.db().read())
170 .all(self.database.read())
171171 .await?;
172172 Ok(rows.into_iter().map(OutboundWebhookDto::from).collect())
173173 }
@@ -182,13 +182,13 @@
182182 require_room_manager(self, room_id, caller).await?;
183183 let webhook = outbound_webhooks::Entity::find_by_id(webhook_id)
184184 .filter(outbound_webhooks::Column::RoomId.eq(room_id))
185 .one(self.db().write())
185 .one(self.database.write())
186186 .await?
187187 .ok_or(ChatError::NotFound)?;
188188 let mut am: outbound_webhooks::ActiveModel = webhook.into();
189189 if let Some(name) = cmd.name {
190190 let name = name.trim();
191 if name.is_empty() || name.chars().count() > self.config().chat.max_webhook_name_len {
191 if name.is_empty() || name.chars().count() > self.settings.chat.max_webhook_name_len {
192192 return Err(ChatError::BadRequest("invalid webhook name".to_string()));
193193 }
194194 am.name = Set(name.to_string());
@@ -201,7 +201,7 @@
201201 am.disabled = Set(disabled);
202202 }
203203 am.updated_at = Set(OffsetDateTime::now_utc());
204 let updated = am.update(self.db().write()).await?;
204 let updated = am.update(self.database.write()).await?;
205205 Ok(OutboundWebhookDto::from(updated))
206206 }
207207
@@ -215,7 +215,7 @@
215215 let deleted = outbound_webhooks::Entity::delete_many()
216216 .filter(outbound_webhooks::Column::Id.eq(webhook_id))
217217 .filter(outbound_webhooks::Column::RoomId.eq(room_id))
218 .exec(self.db().write())
218 .exec(self.database.write())
219219 .await?;
220220 if deleted.rows_affected == 0 {
221221 return Err(ChatError::NotFound);
@@ -223,7 +223,7 @@
223223 webhook_deliveries::Entity::delete_many()
224224 .filter(webhook_deliveries::Column::WebhookId.eq(webhook_id))
225225 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Pending))
226 .exec(self.db().write())
226 .exec(self.database.write())
227227 .await?;
228228 Ok(())
229229 }
@@ -242,14 +242,14 @@
242242 webhook_deliveries::Column::NextRetryAt
243243 .lt(now - time::Duration::minutes(crate::worker::STUCK_CLAIM_MINUTES)),
244244 )
245 .exec(self.db().write())
245 .exec(self.database.write())
246246 .await?;
247247
248248 let due = webhook_deliveries::Entity::find()
249249 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Pending))
250250 .filter(webhook_deliveries::Column::NextRetryAt.lte(now))
251251 .limit(50)
252 .all(self.db().write())
252 .all(self.database.write())
253253 .await?;
254254 let count = due.len() as u64;
255255 for delivery in due {
@@ -260,14 +260,14 @@
260260 )
261261 .filter(webhook_deliveries::Column::Id.eq(delivery.id))
262262 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Pending))
263 .exec(self.db().write())
263 .exec(self.database.write())
264264 .await?;
265265 if claimed.rows_affected == 0 {
266266 continue;
267267 }
268268
269269 let Some(webhook) = outbound_webhooks::Entity::find_by_id(delivery.webhook_id)
270 .one(self.db().read())
270 .one(self.database.read())
271271 .await?
272272 else {
273273 self.finish_delivery(
@@ -290,7 +290,7 @@
290290 am.last_status = Set(Some(status_line.clone()));
291291 am.last_used_at = Set(Some(OffsetDateTime::now_utc()));
292292 am.updated_at = Set(OffsetDateTime::now_utc());
293 let _ = am.update(self.db().write()).await;
293 let _ = am.update(self.database.write()).await;
294294 (WebhookDeliveryStatus::Done, None)
295295 }
296296 Err(error) => {
@@ -305,7 +305,7 @@
305305 + time::Duration::seconds(60 * attempts as i64));
306306 am.last_error = Set(Some(error.clone()));
307307 am.updated_at = Set(OffsetDateTime::now_utc());
308 let _ = am.update(self.db().write()).await;
308 let _ = am.update(self.database.write()).await;
309309 continue;
310310 }
311311 }
@@ -331,7 +331,7 @@
331331 updated_at: Set(OffsetDateTime::now_utc()),
332332 ..Default::default()
333333 };
334 let _ = am.update(self.db().write()).await;
334 let _ = am.update(self.database.write()).await;
335335 }
336336
337337 async fn post_webhook_payload(
@@ -409,7 +409,7 @@
409409 let hooks = outbound_webhooks::Entity::find()
410410 .filter(outbound_webhooks::Column::RoomId.eq(room_id))
411411 .filter(outbound_webhooks::Column::Disabled.eq(false))
412 .all(service.db().read())
412 .all(service.database.read())
413413 .await
414414 .unwrap_or_default();
415415 if hooks.is_empty() {
@@ -430,7 +430,7 @@
430430 created_at: Set(now),
431431 updated_at: Set(now),
432432 };
433 if let Err(error) = row.insert(service.db().write()).await {
433 if let Err(error) = row.insert(service.database.write()).await {
434434 tracing::warn!(%error, "failed to enqueue outbound webhook delivery");
435435 }
436436 }
Modified lib/chat/src/services/presence.rs View lib/chat/src/services/presence.rs
@@ -11,13 +11,14 @@
1111use uuid::Uuid;
1212
1313use crate::errors::ChatError;
14use crate::services::service::ChatService;
15use crate::share::lookup::{lookup_space_by_name, require_space_member};
14use crate::services::audience::Audience;
15use export::{AppDatabase, AppRedis};
16use std::sync::Arc;
1617
1718#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1819pub struct PresenceDto {
1920 pub user_id: i64,
20 pub user: Option<space::services::dto::UserDto>,
21 pub user: Option<export::users::UserDto>,
2122 pub status: PresenceStatus,
2223 pub activities: serde_json::Value,
2324 pub last_seen_at: Option<OffsetDateTime>,
@@ -60,7 +61,7 @@
6061 dto.last_seen_at = None;
6162}
6263
63impl ChatService {
64impl Participants {
6465 pub async fn set_presence(
6566 &self,
6667 caller: i64,
@@ -73,9 +74,9 @@
7374 activities: activities.clone(),
7475 at: now.unix_timestamp(),
7576 };
76 let ttl = self.config().chat.presence_ttl_secs;
77 let ttl = self.settings.chat.presence_ttl_secs;
7778
78 let mut conn = self.state.redis.connection_clone();
79 let mut conn = self.redis.connection_clone();
7980 let key = presence_key(caller);
8081 let value = serde_json::to_string(&payload)?;
8182 let result: Result<(), redis::RedisError> = match &mut conn {
@@ -87,7 +88,7 @@
8788 }
8889
8990 let existing = presence_states::Entity::find_by_id(caller)
90 .one(self.db().write())
91 .one(self.database.write())
9192 .await?;
9293 match existing {
9394 Some(row) => {
@@ -96,7 +97,7 @@
9697 am.activities = Set(activities.clone());
9798 am.last_seen_at = Set(Some(now));
9899 am.updated_at = Set(now);
99 am.update(self.db().write()).await?;
100 am.update(self.database.write()).await?;
100101 }
101102 None => {
102103 presence_states::ActiveModel {
@@ -106,12 +107,13 @@
106107 last_seen_at: Set(Some(now)),
107108 updated_at: Set(now),
108109 }
109 .insert(self.db().write())
110 .insert(self.database.write())
110111 .await?;
111112 }
112113 }
113114
114 self.realtime
115 self.audience
116 .realtime
115117 .publish_user(
116118 caller,
117119 &serde_json::json!({"kind": "presence", "status": cmd.status}),
@@ -136,14 +138,15 @@
136138 /// 广播。多标签页/多实例共享同一份 Redis 状态:谁先连谁写,后来者续期。
137139 /// 生命周期 TTL 取两倍心跳,崩溃后由 TTL 兜底回离线。
138140 pub async fn mark_online(&self, uid: i64) {
139 let ttl = self.config().chat.online_ttl_secs();
141 let ttl = self.settings.chat.online_ttl_secs();
140142 let key = presence_key(uid);
141143 let Some(value) = encode_presence(PresenceStatus::Active, serde_json::json!([])) else {
142144 return;
143145 };
144 match export::redis::set_nx_ex(&self.state.redis, &key, &value, ttl).await {
146 match export::redis::set_nx_ex(&self.redis, &key, &value, ttl).await {
145147 Ok(true) => {
146 self.realtime
148 self.audience
149 .realtime
147150 .publish_user(
148151 uid,
149152 &serde_json::json!({"kind": "presence", "status": PresenceStatus::Active}),
@@ -151,8 +154,7 @@
151154 .await;
152155 }
153156 Ok(false) => {
154 if let Err(error) = export::redis::touch_expire(&self.state.redis, &key, ttl).await
155 {
157 if let Err(error) = export::redis::touch_expire(&self.redis, &key, ttl).await {
156158 tracing::warn!(%error, "presence renew failed");
157159 }
158160 }
@@ -164,18 +166,16 @@
164166 /// ——连接还活着就应当在线。补写走 `SET NX`,不会覆盖用户此刻手动设置
165167 /// 的状态。
166168 pub async fn touch_presence(&self, uid: i64) {
167 let ttl = self.config().chat.online_ttl_secs();
169 let ttl = self.settings.chat.online_ttl_secs();
168170 let key = presence_key(uid);
169 match export::redis::touch_expire(&self.state.redis, &key, ttl).await {
171 match export::redis::touch_expire(&self.redis, &key, ttl).await {
170172 Ok(true) => {}
171173 Ok(false) => {
172174 let Some(value) = encode_presence(PresenceStatus::Active, serde_json::json!([]))
173175 else {
174176 return;
175177 };
176 if let Err(error) =
177 export::redis::set_nx_ex(&self.state.redis, &key, &value, ttl).await
178 {
178 if let Err(error) = export::redis::set_nx_ex(&self.redis, &key, &value, ttl).await {
179179 tracing::warn!(%error, "presence re-mark failed");
180180 }
181181 }
@@ -191,12 +191,12 @@
191191 let now = OffsetDateTime::now_utc();
192192 let key = presence_key(uid);
193193 // 先读再删:DB 里留下的是用户最后处在的状态(Active/Away/Dnd/隐身)。
194 let last = export::redis::get_string(&self.state.redis, &key)
194 let last = export::redis::get_string(&self.redis, &key)
195195 .await
196196 .ok()
197197 .flatten()
198198 .and_then(|value| serde_json::from_str::<PresencePayload>(&value).ok());
199 if let Err(error) = export::redis::delete(&self.state.redis, &key).await {
199 if let Err(error) = export::redis::delete(&self.redis, &key).await {
200200 tracing::warn!(%error, "presence key delete failed");
201201 }
202202 let (status, activities) = match last {
@@ -204,7 +204,7 @@
204204 None => (PresenceStatus::Offline, serde_json::json!([])),
205205 };
206206 match presence_states::Entity::find_by_id(uid)
207 .one(self.db().write())
207 .one(self.database.write())
208208 .await
209209 {
210210 Ok(Some(row)) => {
@@ -213,7 +213,7 @@
213213 am.activities = Set(activities);
214214 am.last_seen_at = Set(Some(now));
215215 am.updated_at = Set(now);
216 if let Err(error) = am.update(self.db().write()).await {
216 if let Err(error) = am.update(self.database.write()).await {
217217 tracing::warn!(%error, "presence last_seen update failed");
218218 }
219219 }
@@ -225,13 +225,14 @@
225225 last_seen_at: Set(Some(now)),
226226 updated_at: Set(now),
227227 };
228 if let Err(error) = row.insert(self.db().write()).await {
228 if let Err(error) = row.insert(self.database.write()).await {
229229 tracing::warn!(%error, "presence last_seen insert failed");
230230 }
231231 }
232232 Err(error) => tracing::warn!(%error, "presence row lookup failed"),
233233 }
234 self.realtime
234 self.audience
235 .realtime
235236 .publish_user(
236237 uid,
237238 &serde_json::json!({"kind": "presence", "status": PresenceStatus::Offline}),
@@ -246,7 +247,7 @@
246247 }
247248 let rows = user_preferences::Entity::find()
248249 .filter(user_preferences::Column::UserId.is_in(uids.to_vec()))
249 .all(self.db().read())
250 .all(self.database.read())
250251 .await?;
251252 Ok(rows
252253 .into_iter()
@@ -256,13 +257,13 @@
256257
257258 pub async fn get_presence(&self, uid: i64) -> Result<PresenceDto, ChatError> {
258259 let mut dto = self.get_presence_raw(uid).await?;
259 let map = self.load_user_briefs(&[uid]).await?;
260 let map = self.audience.load_user_briefs(&[uid]).await?;
260261 dto.user = map.get(&uid).cloned();
261262 Ok(dto)
262263 }
263264
264265 async fn get_presence_raw(&self, uid: i64) -> Result<PresenceDto, ChatError> {
265 let mut conn = self.state.redis.connection_clone();
266 let mut conn = self.redis.connection_clone();
266267 let key = presence_key(uid);
267268 let value: Result<Option<String>, redis::RedisError> = match &mut conn {
268269 export::RedisConnection::Standalone(c) => c.get(&key).await,
@@ -284,7 +285,7 @@
284285 });
285286 }
286287 let row = presence_states::Entity::find_by_id(uid)
287 .one(self.db().read())
288 .one(self.database.read())
288289 .await?;
289290 match row {
290291 Some(row) => Ok(PresenceDto {
@@ -334,11 +335,11 @@
334335
335336 async fn shares_room_with(&self, caller: i64, target: i64) -> Result<bool, ChatError> {
336337 use models::room::room_members;
337 let mine = space::authority::list_user_spaces(&self.state, caller, caller).await?;
338 let theirs = space::authority::list_user_spaces(&self.state, target, target).await?;
338 let mine = self.access.user_spaces(caller).await?;
339 let theirs = self.access.user_spaces(target).await?;
339340 if mine
340341 .iter()
341 .any(|left| theirs.iter().any(|right| left.id == right.id))
342 .any(|left| theirs.iter().any(|right| left.id() == right.id()))
342343 {
343344 return Ok(true);
344345 }
@@ -350,7 +351,7 @@
350351 .select_only()
351352 .column(room_members::Column::RoomId)
352353 .into_tuple()
353 .all(self.db().read())
354 .all(self.database.read())
354355 .await?;
355356 if my_rooms.is_empty() {
356357 return Ok(false);
@@ -363,7 +364,7 @@
363364 .add(rooms::Column::OwnerId.eq(caller))
364365 .add(rooms::Column::OwnerId.eq(target)),
365366 )
366 .count(self.db().read())
367 .count(self.database.read())
367368 .await?;
368369 if shared > 0 {
369370 return Ok(true);
@@ -372,7 +373,7 @@
372373 .filter(room_members::Column::UserId.eq(target))
373374 .filter(room_members::Column::RoomId.is_in(my_rooms))
374375 .filter(room_members::Column::LeftAt.is_null())
375 .count(self.db().read())
376 .count(self.database.read())
376377 .await?;
377378 Ok(dm_peer > 0)
378379 }
@@ -381,7 +382,7 @@
381382 if uids.is_empty() {
382383 return Ok(Vec::new());
383384 }
384 let mut conn = self.state.redis.connection_clone();
385 let mut conn = self.redis.connection_clone();
385386 let keys: Vec<String> = uids.iter().map(|uid| presence_key(*uid)).collect();
386387 let values: Vec<Option<String>> = match &mut conn {
387388 export::RedisConnection::Standalone(c) => {
@@ -417,7 +418,7 @@
417418 } else {
418419 presence_states::Entity::find()
419420 .filter(presence_states::Column::UserId.is_in(missing))
420 .all(self.db().read())
421 .all(self.database.read())
421422 .await?
422423 };
423424 let mut result = Vec::with_capacity(uids.len());
@@ -446,7 +447,7 @@
446447 online: false,
447448 });
448449 }
449 let map = self.load_user_briefs(uids).await?;
450 let map = self.audience.load_user_briefs(uids).await?;
450451 for dto in result.iter_mut() {
451452 dto.user = map.get(&dto.user_id).cloned();
452453 }
@@ -458,13 +459,12 @@
458459 caller: i64,
459460 space_name: &str,
460461 ) -> Result<Vec<PresenceDto>, ChatError> {
461 let space = lookup_space_by_name(self, space_name).await?;
462 require_space_member(self, &space, caller).await?;
463 let members =
464 space::authority::list_members(&self.state, caller, space.id, false, false).await?;
462 let space = self.access.by_slug(space_name).await?;
463 space::require(&space, caller, space::Capability::Read).await?;
464 let members = self.access.members(space.id(), caller).await?;
465465 let mut uids: Vec<i64> = members.into_iter().map(|m| m.user_id).collect();
466 if !uids.contains(&space.owner_user_id) {
467 uids.push(space.owner_user_id);
466 if !uids.contains(&space.owner()) {
467 uids.push(space.owner());
468468 }
469469 let mut presences = self.get_presences(&uids).await?;
470470 // 隐私:关掉"展示在线状态"的成员对他人一律表现为离线,本人照常。
@@ -478,7 +478,7 @@
478478 }
479479}
480480
481impl ChatService {
481impl Participants {
482482 /// 聊天连接建立:置在线并把这条连接登记进在线占位集合。
483483 ///
484484 /// 占位集合与 `presence` 键是两套语义:`chat:ws:{uid}` 回答"有多少条
@@ -486,28 +486,18 @@
486486 /// 回答"用户此刻的状态是什么"。同一连接同时续期两者。
487487 pub async fn connection_open(&self, uid: i64, session: &str) -> Result<(), ChatError> {
488488 self.mark_online(uid).await;
489 let ttl = self.config().chat.online_ttl_secs();
490 export::redis::zset_touch(
491 &self.state.redis,
492 &export::push::online_key(uid),
493 session,
494 ttl,
495 )
496 .await?;
489 let ttl = self.settings.chat.online_ttl_secs();
490 export::redis::zset_touch(&self.redis, &export::push::online_key(uid), session, ttl)
491 .await?;
497492 Ok(())
498493 }
499494
500495 /// 心跳续期:两套状态一起续,避免"有连接但状态过期"的中间态。
501496 pub async fn connection_touch(&self, uid: i64, session: &str) -> Result<(), ChatError> {
502497 self.touch_presence(uid).await;
503 let ttl = self.config().chat.online_ttl_secs();
504 export::redis::zset_touch(
505 &self.state.redis,
506 &export::push::online_key(uid),
507 session,
508 ttl,
509 )
510 .await?;
498 let ttl = self.settings.chat.online_ttl_secs();
499 export::redis::zset_touch(&self.redis, &export::push::online_key(uid), session, ttl)
500 .await?;
511501 Ok(())
512502 }
513503
@@ -517,7 +507,7 @@
517507 /// `mark_offline` 放在 `zset_release` 返回值之后的原因。
518508 pub async fn connection_close(&self, uid: i64, session: &str) -> Result<(), ChatError> {
519509 let live =
520 export::redis::zset_release(&self.state.redis, &export::push::online_key(uid), session)
510 export::redis::zset_release(&self.redis, &export::push::online_key(uid), session)
521511 .await?;
522512 if live == 0 {
523513 self.mark_offline(uid).await;
@@ -525,3 +515,107 @@
525515 Ok(())
526516 }
527517}
518
519#[derive(Clone)]
520pub struct Participants {
521 database: Arc<AppDatabase>,
522 pub(crate) redis: AppRedis,
523 pub(crate) access: space::Access,
524 audience: Audience,
525 settings: ParticipantSettings,
526}
527impl Participants {
528 pub fn new(
529 database: Arc<AppDatabase>,
530 redis: AppRedis,
531 access: space::Access,
532 audience: Audience,
533 settings: ParticipantSettings,
534 ) -> Self {
535 Self {
536 database,
537 redis,
538 access,
539 audience,
540 settings,
541 }
542 }
543 pub async fn allow_dm(&self, user: i64) -> Result<(), ChatError> {
544 if self
545 .redis
546 .quota_allow(
547 &format!("quota:dm_open:{user}"),
548 self.settings.quota.dm_open_per_day,
549 86_400,
550 )
551 .await
552 {
553 Ok(())
554 } else {
555 Err(ChatError::RateLimited(
556 "DM creation quota exceeded, retry tomorrow".to_owned(),
557 ))
558 }
559 }
560 pub async fn allow_webhook_creation(&self, user: i64) -> Result<(), ChatError> {
561 if self
562 .redis
563 .quota_allow(
564 &format!("quota:webhook_create:{user}"),
565 self.settings.quota.webhook_create_per_day,
566 86_400,
567 )
568 .await
569 {
570 Ok(())
571 } else {
572 Err(ChatError::RateLimited(
573 "webhook creation quota exceeded, retry tomorrow".to_owned(),
574 ))
575 }
576 }
577 pub async fn allow_webhook_delivery(
578 &self,
579 hash: &str,
580 maximum: i64,
581 seconds: u64,
582 ) -> Result<(), ChatError> {
583 if self
584 .redis
585 .quota_allow(&format!("chat:webhook:{hash}"), maximum, seconds)
586 .await
587 {
588 Ok(())
589 } else {
590 Err(ChatError::RateLimited(
591 "webhook rate limit exceeded, retry in 60s".to_owned(),
592 ))
593 }
594 }
595 pub async fn allow_invite_acceptance(&self, user: i64) -> Result<(), ChatError> {
596 if self
597 .redis
598 .quota_allow(&format!("chat:invite_accept:{user}"), 10, 60)
599 .await
600 {
601 Ok(())
602 } else {
603 Err(ChatError::RateLimited(
604 "too many invite acceptance attempts, retry in 60s".to_owned(),
605 ))
606 }
607 }
608}
609#[derive(Clone)]
610pub struct ParticipantSettings {
611 pub chat: config::chat::ChatConfig,
612 pub quota: config::quota::QuotaConfig,
613}
614impl ParticipantSettings {
615 pub fn from_config(config: &config::AppConfig) -> Self {
616 Self {
617 chat: config.chat.clone(),
618 quota: config.quota.clone(),
619 }
620 }
621}
Modified lib/chat/src/services/reactions.rs View lib/chat/src/services/reactions.rs
@@ -20,7 +20,7 @@
2020#[derive(Debug, Clone, Serialize, ToSchema)]
2121pub struct ReactionUserDto {
2222 pub emoji: String,
23 pub user: space::services::dto::UserDto,
23 pub user: export::users::UserDto,
2424 pub created_at: OffsetDateTime,
2525}
2626
@@ -38,13 +38,13 @@
3838 cmd: ReactionCmd,
3939 ) -> Result<Vec<ReactionAggDto>, ChatError> {
4040 let emoji = cmd.emoji.trim().to_string();
41 if emoji.is_empty() || emoji.chars().count() > self.config().chat.max_reaction_emoji_len {
41 if emoji.is_empty() || emoji.chars().count() > self.settings.chat.max_reaction_emoji_len {
4242 return Err(ChatError::BadRequest("invalid emoji".to_string()));
4343 }
4444 let _ctx = lookup::require_room_readable(self, room_id, caller).await?;
4545 let message = messages::Entity::find_by_id(message_id)
4646 .filter(messages::Column::RoomId.eq(room_id))
47 .one(self.db().read())
47 .one(self.database.read())
4848 .await?
4949 .filter(|m| m.deleted_at.is_none())
5050 .ok_or(ChatError::NotFound)?;
@@ -57,7 +57,7 @@
5757 user_id: Set(caller),
5858 created_at: Set(now),
5959 })
60 .exec(self.db().write())
60 .exec(self.database.write())
6161 .await;
6262 match result {
6363 Ok(_) => {}
@@ -85,7 +85,7 @@
8585 .filter(reactions::Column::MessageId.eq(message_id))
8686 .filter(reactions::Column::Emoji.eq(&emoji))
8787 .filter(reactions::Column::UserId.eq(caller))
88 .exec(self.db().write())
88 .exec(self.database.write())
8989 .await?;
9090 self.publish_reactions(room_id, message_id, caller).await
9191 }
@@ -101,10 +101,10 @@
101101 .filter(reactions::Column::MessageId.eq(message_id))
102102 .order_by_asc(reactions::Column::Emoji)
103103 .order_by_asc(reactions::Column::CreatedAt)
104 .all(self.db().read())
104 .all(self.database.read())
105105 .await?;
106106 let uids: Vec<i64> = rows.iter().map(|r| r.user_id).collect();
107 let user_map = self.load_user_briefs(&uids).await?;
107 let user_map = self.audience.load_user_briefs(&uids).await?;
108108 Ok(rows
109109 .into_iter()
110110 .filter_map(|r| {
@@ -125,7 +125,7 @@
125125 ) -> Result<Vec<ReactionAggDto>, ChatError> {
126126 let rows = reactions::Entity::find()
127127 .filter(reactions::Column::MessageId.eq(message_id))
128 .all(self.db().read())
128 .all(self.database.read())
129129 .await?;
130130 let mut agg: std::collections::HashMap<String, (i64, bool)> =
131131 std::collections::HashMap::new();
@@ -155,7 +155,7 @@
155155 message_id,
156156 reactions: value,
157157 };
158 self.realtime.publish_room(&event).await;
158 self.audience.realtime.publish_room(&event).await;
159159 Ok(result)
160160 }
161161}
Modified lib/chat/src/services/read_state.rs View lib/chat/src/services/read_state.rs
@@ -54,14 +54,14 @@
5454 (Some(message_id), _) => {
5555 let msg = messages::Entity::find_by_id(message_id)
5656 .filter(messages::Column::RoomId.eq(room_id))
57 .one(self.db().read())
57 .one(self.database.read())
5858 .await?
5959 .ok_or(ChatError::NotFound)?;
6060 (Some(message_id), msg.seq)
6161 }
6262 (None, Some(seq)) => {
6363 let tip = room_seq::Entity::find_by_id(room_id)
64 .one(self.db().read())
64 .one(self.database.read())
6565 .await?
6666 .map(|s| s.seq)
6767 .unwrap_or(0);
@@ -69,12 +69,12 @@
6969 }
7070 (None, None) => {
7171 let tip = room_seq::Entity::find_by_id(room_id)
72 .one(self.db().read())
72 .one(self.database.read())
7373 .await?
7474 .map(|s| s.seq)
7575 .unwrap_or(0);
7676 let last_msg = rooms::Entity::find_by_id(room_id)
77 .one(self.db().read())
77 .one(self.database.read())
7878 .await?
7979 .and_then(|r| r.last_message_id);
8080 (last_msg, tip)
@@ -82,9 +82,11 @@
8282 };
8383
8484 let changed =
85 notify::advance_read_state(self.db().write(), caller, room_id, message_id, seq).await?;
85 notify::advance_read_state(self.database.write(), caller, room_id, message_id, seq)
86 .await?;
8687 if changed {
87 self.realtime
88 self.audience
89 .realtime
8890 .publish_user(
8991 caller,
9092 &serde_json::json!({
@@ -106,12 +108,12 @@
106108 ) -> Result<ReadStateDto, ChatError> {
107109 require_room_readable(self, room_id, caller).await?;
108110 let tip = room_seq::Entity::find_by_id(room_id)
109 .one(self.db().read())
111 .one(self.database.read())
110112 .await?
111113 .map(|s| s.seq)
112114 .unwrap_or(0);
113115 let state = read_states::Entity::find_by_id((caller, room_id))
114 .one(self.db().read())
116 .one(self.database.read())
115117 .await?;
116118 let last_viewed_at = state.as_ref().and_then(|s| s.last_viewed_at);
117119 let unread_mentions = match last_viewed_at {
@@ -120,14 +122,14 @@
120122 .filter(user_recent_mentions::Column::UserId.eq(caller))
121123 .filter(user_recent_mentions::Column::ChannelId.eq(room_id))
122124 .filter(user_recent_mentions::Column::CreatedAt.gt(at))
123 .count(self.db().read())
125 .count(self.database.read())
124126 .await? as i64
125127 }
126128 None => {
127129 user_recent_mentions::Entity::find()
128130 .filter(user_recent_mentions::Column::UserId.eq(caller))
129131 .filter(user_recent_mentions::Column::ChannelId.eq(room_id))
130 .count(self.db().read())
132 .count(self.database.read())
131133 .await? as i64
132134 }
133135 };
@@ -152,9 +154,9 @@
152154 require_space_member(self, &space, caller).await?;
153155
154156 let space_rooms = rooms::Entity::find()
155 .filter(rooms::Column::SpaceId.eq(space.id))
157 .filter(rooms::Column::SpaceId.eq(space.id()))
156158 .filter(rooms::Column::IsActive.eq(true))
157 .all(self.db().read())
159 .all(self.database.read())
158160 .await?;
159161 let mut items = Vec::new();
160162 for room in space_rooms {
@@ -176,14 +178,14 @@
176178 message_id: Option<Uuid>,
177179 ) -> Result<(), ChatError> {
178180 let thread = models::chat::threads::Entity::find_by_id(thread_id)
179 .one(self.db().read())
181 .one(self.database.read())
180182 .await?
181183 .ok_or(ChatError::NotFound)?;
182184 let _ctx = require_room_readable(self, thread.room_id, caller).await?;
183185 let (mid, seq) = match message_id {
184186 Some(message_id) => {
185187 let msg = messages::Entity::find_by_id(message_id)
186 .one(self.db().read())
188 .one(self.database.read())
187189 .await?
188190 .filter(|m| m.thread_id == Some(thread_id))
189191 .ok_or(ChatError::NotFound)?;
@@ -192,7 +194,7 @@
192194 None => match thread.last_reply_id {
193195 Some(last) => {
194196 let msg = messages::Entity::find_by_id(last)
195 .one(self.db().read())
197 .one(self.database.read())
196198 .await?
197199 .ok_or(ChatError::NotFound)?;
198200 (last, msg.seq)
Modified lib/chat/src/services/reminders.rs View lib/chat/src/services/reminders.rs
@@ -14,7 +14,6 @@
1414use crate::errors::ChatError;
1515use crate::services::service::ChatService;
1616use crate::share::lookup::{require_room_readable, space_slug_for};
17use crate::share::notify;
1817
1918#[derive(Debug, Clone, Serialize, ToSchema)]
2019pub struct ReminderDto {
@@ -73,7 +72,7 @@
7372 let _ctx = require_room_readable(self, room_id, caller).await?;
7473 let _message = messages::Entity::find_by_id(message_id)
7574 .filter(messages::Column::RoomId.eq(room_id))
76 .one(self.db().read())
75 .one(self.database.read())
7776 .await?
7877 .filter(|m| m.deleted_at.is_none())
7978 .ok_or(ChatError::NotFound)?;
@@ -92,7 +91,7 @@
9291 status: Set(ReminderStatus::Pending),
9392 created_at: Set(OffsetDateTime::now_utc()),
9493 }
95 .insert(self.db().write())
94 .insert(self.database.write())
9695 .await?;
9796 Ok(ReminderDto::from(row))
9897 }
@@ -102,7 +101,7 @@
102101 .filter(message_reminders::Column::UserId.eq(caller))
103102 .filter(message_reminders::Column::Status.eq(ReminderStatus::Pending))
104103 .order_by_asc(message_reminders::Column::RemindAt)
105 .all(self.db().read())
104 .all(self.database.read())
106105 .await?;
107106 let mut items: Vec<ReminderDto> =
108107 rows.iter().map(|r| ReminderDto::from(r.clone())).collect();
@@ -112,7 +111,7 @@
112111 use sea_orm::{ColumnTrait as _, EntityTrait as _, QueryFilter as _};
113112 let msgs = messages::Entity::find()
114113 .filter(messages::Column::Id.is_in(msg_ids))
115 .all(self.db().read())
114 .all(self.database.read())
116115 .await?;
117116 let previews: std::collections::HashMap<Uuid, String> = msgs
118117 .into_iter()
@@ -139,7 +138,7 @@
139138 ) -> Result<ReminderDto, ChatError> {
140139 let row = message_reminders::Entity::find_by_id(reminder_id)
141140 .filter(message_reminders::Column::UserId.eq(caller))
142 .one(self.db().write())
141 .one(self.database.write())
143142 .await?
144143 .ok_or(ChatError::NotFound)?;
145144 if row.status != ReminderStatus::Pending {
@@ -159,14 +158,14 @@
159158 if let Some(note) = cmd.note {
160159 am.note = Set(note.filter(|n| !n.trim().is_empty()));
161160 }
162 let updated = am.update(self.db().write()).await?;
161 let updated = am.update(self.database.write()).await?;
163162 Ok(ReminderDto::from(updated))
164163 }
165164
166165 pub async fn cancel_reminder(&self, caller: i64, reminder_id: Uuid) -> Result<(), ChatError> {
167166 let row = message_reminders::Entity::find_by_id(reminder_id)
168167 .filter(message_reminders::Column::UserId.eq(caller))
169 .one(self.db().write())
168 .one(self.database.write())
170169 .await?
171170 .ok_or(ChatError::NotFound)?;
172171 if row.status != ReminderStatus::Pending {
@@ -174,7 +173,7 @@
174173 }
175174 let mut am: message_reminders::ActiveModel = row.into();
176175 am.status = Set(ReminderStatus::Cancelled);
177 am.update(self.db().write()).await?;
176 am.update(self.database.write()).await?;
178177 Ok(())
179178 }
180179
@@ -193,17 +192,16 @@
193192 message_reminders::Column::RemindAt
194193 .lt(now - time::Duration::minutes(crate::worker::STUCK_CLAIM_MINUTES)),
195194 )
196 .exec(self.db().write())
195 .exec(self.database.write())
197196 .await?;
198197
199198 let due = message_reminders::Entity::find()
200199 .filter(message_reminders::Column::Status.eq(ReminderStatus::Pending))
201200 .filter(message_reminders::Column::RemindAt.lte(now))
202201 .limit(200)
203 .all(self.db().write())
202 .all(self.database.write())
204203 .await?;
205204 let count = due.len() as u64;
206 let pending = export::notify::PendingNotifications::default();
207205 for reminder in due {
208206 // Atomic claim so concurrent replicas notify at most once.
209207 let claimed = message_reminders::Entity::update_many()
@@ -213,14 +211,14 @@
213211 )
214212 .filter(message_reminders::Column::Id.eq(reminder.id))
215213 .filter(message_reminders::Column::Status.eq(ReminderStatus::Pending))
216 .exec(self.db().write())
214 .exec(self.database.write())
217215 .await?;
218216 if claimed.rows_affected == 0 {
219217 continue;
220218 }
221219
222220 let space_id = rooms::Entity::find_by_id(reminder.room_id)
223 .one(self.db().read())
221 .one(self.database.read())
224222 .await
225223 .ok()
226224 .flatten()
@@ -230,20 +228,21 @@
230228 "message_id": reminder.message_id,
231229 "space_name": space_slug_for(self, space_id).await,
232230 });
233 if let Err(error) = notify::notify_user(
234 self.db().write(),
235 &pending,
236 reminder.user_id,
237 NotificationType::System,
238 i18n::catalog::notification_reminder_title(
239 self.recipient_locale(reminder.user_id).await,
231 if let Err(error) = self
232 .audience
233 .notifications
234 .create(
235 reminder.user_id,
236 NotificationType::System,
237 i18n::catalog::notification_reminder_title(
238 self.audience.recipient_locale(reminder.user_id).await,
239 )
240 .to_string(),
241 None,
242 Some(data),
243 None,
240244 )
241 .to_string(),
242 None,
243 Some(data),
244 None,
245 )
246 .await
245 .await
247246 {
248247 tracing::warn!(%error, "failed to write reminder notification");
249248 message_reminders::Entity::update_many()
@@ -253,7 +252,7 @@
253252 )
254253 .filter(message_reminders::Column::Id.eq(reminder.id))
255254 .filter(message_reminders::Column::Status.eq(ReminderStatus::Delivering))
256 .exec(self.db().write())
255 .exec(self.database.write())
257256 .await?;
258257 continue;
259258 }
@@ -264,9 +263,10 @@
264263 )
265264 .filter(message_reminders::Column::Id.eq(reminder.id))
266265 .filter(message_reminders::Column::Status.eq(ReminderStatus::Delivering))
267 .exec(self.db().write())
266 .exec(self.database.write())
268267 .await?;
269 self.realtime
268 self.audience
269 .realtime
270270 .publish_user(
271271 reminder.user_id,
272272 &serde_json::json!({
@@ -276,7 +276,6 @@
276276 }),
277277 )
278278 .await;
279 pending.flush(&self.state).await;
280279 }
281280 Ok(count)
282281 }
Modified lib/chat/src/services/room_admin.rs View lib/chat/src/services/room_admin.rs
@@ -1,8 +1,8 @@
1use export::users::UserDto;
12use models::room::room_members::{self, RoomMemberRole};
23use models::room::{room_bans, rooms};
34use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
45use serde::{Deserialize, Serialize};
5use space::services::dto::UserDto;
66use time::OffsetDateTime;
77use utoipa::ToSchema;
88use uuid::Uuid;
@@ -49,7 +49,7 @@
4949 uid: i64,
5050) -> Result<Option<RoomMemberRole>, ChatError> {
5151 Ok(room_members::Entity::find_by_id((room_id, uid))
52 .one(service.db().read())
52 .one(service.database.read())
5353 .await?
5454 .filter(|m| m.left_at.is_none())
5555 .map(|m| m.role))
@@ -72,7 +72,7 @@
7272
7373async fn set_left_at(service: &ChatService, room_id: Uuid, uid: i64) -> Result<(), ChatError> {
7474 let Some(row) = room_members::Entity::find_by_id((room_id, uid))
75 .one(service.db().write())
75 .one(service.database.write())
7676 .await?
7777 else {
7878 return Ok(());
@@ -81,14 +81,17 @@
8181 let mut am: room_members::ActiveModel = row.into();
8282 am.left_at = Set(Some(OffsetDateTime::now_utc()));
8383 am.updated_at = Set(Some(OffsetDateTime::now_utc()));
84 am.update(service.db().write()).await?;
84 am.update(service.database.write()).await?;
8585 }
8686 Ok(())
8787}
8888
8989impl ChatService {
9090 async fn build_room_ban_dto(&self, row: room_bans::Model) -> Result<RoomBanDto, ChatError> {
91 let user_map = self.load_user_briefs(&[row.user_id, row.banned_by]).await?;
91 let user_map = self
92 .audience
93 .load_user_briefs(&[row.user_id, row.banned_by])
94 .await?;
9295 Ok(RoomBanDto {
9396 room_id: row.room_id,
9497 user: user_map.get(&row.user_id).cloned(),
@@ -107,12 +110,12 @@
107110 ) -> Result<RoomMemberDto, ChatError> {
108111 crate::share::lookup::require_room_readable(self, room_id, caller).await?;
109112 let Some(row) = room_members::Entity::find_by_id((room_id, uid))
110 .one(self.db().read())
113 .one(self.database.read())
111114 .await?
112115 else {
113116 return Err(ChatError::NotFound);
114117 };
115 let user_map = self.load_user_briefs(&[row.user_id]).await?;
118 let user_map = self.audience.load_user_briefs(&[row.user_id]).await?;
116119 let user = user_map
117120 .get(&row.user_id)
118121 .cloned()
@@ -146,7 +149,8 @@
146149 return Err(ChatError::Forbidden);
147150 }
148151 set_left_at(self, room_id, uid).await?;
149 self.realtime
152 self.audience
153 .realtime
150154 .publish_room(&RoomEvent::MemberLeft {
151155 room_id,
152156 user_id: uid,
@@ -172,7 +176,7 @@
172176 return Err(ChatError::Forbidden);
173177 }
174178 let existing = room_bans::Entity::find_by_id((room_id, cmd.user_id))
175 .one(self.db().read())
179 .one(self.database.read())
176180 .await?;
177181 if existing.is_some() {
178182 return Err(ChatError::Conflict("user already banned".to_string()));
@@ -187,10 +191,11 @@
187191 created_at: Set(now),
188192 updated_at: Set(Some(now)),
189193 }
190 .insert(self.db().write())
194 .insert(self.database.write())
191195 .await?;
192196 set_left_at(self, room_id, cmd.user_id).await?;
193 self.realtime
197 self.audience
198 .realtime
194199 .publish_room(&RoomEvent::MemberLeft {
195200 room_id,
196201 user_id: cmd.user_id,
@@ -207,7 +212,7 @@
207212 ) -> Result<(), ChatError> {
208213 require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?;
209214 let deleted = room_bans::Entity::delete_by_id((room_id, uid))
210 .exec(self.db().write())
215 .exec(self.database.write())
211216 .await?;
212217 if deleted.rows_affected == 0 {
213218 return Err(ChatError::NotFound);
@@ -224,7 +229,7 @@
224229 let rows = room_bans::Entity::find()
225230 .filter(room_bans::Column::RoomId.eq(room_id))
226231 .order_by_desc(room_bans::Column::CreatedAt)
227 .all(self.db().read())
232 .all(self.database.read())
228233 .await?;
229234 let mut out = Vec::with_capacity(rows.len());
230235 for row in rows {
@@ -249,7 +254,7 @@
249254 ));
250255 }
251256 let target = room_members::Entity::find_by_id((room_id, new_owner))
252 .one(self.db().read())
257 .one(self.database.read())
253258 .await?
254259 .filter(|m| m.left_at.is_none())
255260 .ok_or(ChatError::NotFound)?;
@@ -257,22 +262,22 @@
257262 let mut owner_am = rooms::ActiveModel::from(room);
258263 owner_am.owner_id = Set(new_owner);
259264 owner_am.updated_at = Set(now);
260 owner_am.update(self.db().write()).await?;
265 owner_am.update(self.database.write()).await?;
261266
262267 let mut new_owner_am: room_members::ActiveModel = target.into();
263268 new_owner_am.role = Set(RoomMemberRole::Owner);
264269 new_owner_am.updated_at = Set(Some(now));
265 new_owner_am.update(self.db().write()).await?;
270 new_owner_am.update(self.database.write()).await?;
266271
267272 if let Some(old) = room_members::Entity::find_by_id((room_id, caller))
268 .one(self.db().write())
273 .one(self.database.write())
269274 .await?
270275 && old.left_at.is_none()
271276 {
272277 let mut am: room_members::ActiveModel = old.into();
273278 am.role = Set(RoomMemberRole::Admin);
274279 am.updated_at = Set(Some(now));
275 am.update(self.db().write()).await?;
280 am.update(self.database.write()).await?;
276281 }
277282 Ok(())
278283 }
@@ -285,7 +290,7 @@
285290 cmd: UpdateRoomMemberCmd,
286291 ) -> Result<RoomMemberDto, ChatError> {
287292 let row = room_members::Entity::find_by_id((room_id, uid))
288 .one(self.db().write())
293 .one(self.database.write())
289294 .await?
290295 .ok_or(ChatError::NotFound)?;
291296 let is_self = caller == uid;
@@ -316,8 +321,8 @@
316321 am.role = Set(role);
317322 }
318323 am.updated_at = Set(Some(OffsetDateTime::now_utc()));
319 let updated = am.update(self.db().write()).await?;
320 let user_map = self.load_user_briefs(&[updated.user_id]).await?;
324 let updated = am.update(self.database.write()).await?;
325 let user_map = self.audience.load_user_briefs(&[updated.user_id]).await?;
321326 let user = user_map
322327 .get(&updated.user_id)
323328 .cloned()
Modified lib/chat/src/services/rooms.rs View lib/chat/src/services/rooms.rs
@@ -1,3 +1,4 @@
1use export::users::UserDto;
12use models::chat::messages::{self, ChatMessageType};
23use models::chat::{read_states, room_seq, room_user_settings};
34use models::dir::dirs::{self, DirKind};
@@ -12,8 +13,6 @@
1213 Set, TransactionTrait,
1314};
1415use serde::{Deserialize, Serialize};
15use space::services::dto::UserDto;
16use space::share::authz::{MANAGE_INVITES, effective_permissions};
1716use time::OffsetDateTime;
1817use utoipa::{IntoParams, ToSchema};
1918use uuid::Uuid;
@@ -25,7 +24,7 @@
2524 is_room_member, load_room_context, lookup_room, lookup_space_by_name, require_room_readable,
2625 require_space_member,
2726};
28use crate::share::notify::{next_room_seq, notify_user};
27use crate::share::notify::next_room_seq;
2928use crate::share::realtime::RoomEvent;
3029
3130#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -84,7 +83,7 @@
8483
8584#[derive(Debug, Clone, Serialize, ToSchema)]
8685pub struct RoomMemberDto {
87 pub user: space::services::dto::UserDto,
86 pub user: export::users::UserDto,
8887 pub role: RoomMemberRole,
8988 pub nick: Option<String>,
9089 pub joined_at: OffsetDateTime,
@@ -141,17 +140,17 @@
141140 let member = require_space_member(self, &space, caller).await?;
142141
143142 let rooms = rooms::Entity::find()
144 .filter(rooms::Column::SpaceId.eq(space.id))
143 .filter(rooms::Column::SpaceId.eq(space.id()))
145144 .filter(rooms::Column::IsActive.eq(true))
146145 .order_by_asc(rooms::Column::Position)
147 .all(self.db().read())
146 .all(self.database.read())
148147 .await?;
149148
150149 let room_ids: Vec<Uuid> = rooms.iter().map(|r| r.id).collect();
151150 let memberships = room_members::Entity::find()
152151 .filter(room_members::Column::RoomId.is_in(room_ids.clone()))
153152 .filter(room_members::Column::UserId.eq(caller))
154 .all(self.db().read())
153 .all(self.database.read())
155154 .await?;
156155 let member_of: std::collections::HashSet<Uuid> = memberships
157156 .into_iter()
@@ -162,7 +161,7 @@
162161 let settings = room_user_settings::Entity::find()
163162 .filter(room_user_settings::Column::UserId.eq(caller))
164163 .filter(room_user_settings::Column::RoomId.is_in(room_ids.clone()))
165 .all(self.db().read())
164 .all(self.database.read())
166165 .await?;
167166 let settings_map: std::collections::HashMap<Uuid, room_user_settings::Model> =
168167 settings.into_iter().map(|s| (s.room_id, s)).collect();
@@ -170,7 +169,7 @@
170169 let read = read_states::Entity::find()
171170 .filter(read_states::Column::UserId.eq(caller))
172171 .filter(read_states::Column::RoomId.is_in(room_ids.clone()))
173 .all(self.db().read())
172 .all(self.database.read())
174173 .await?;
175174 let read_map: std::collections::HashMap<Uuid, (i64, Option<OffsetDateTime>)> = read
176175 .into_iter()
@@ -179,7 +178,7 @@
179178
180179 let seqs = room_seq::Entity::find()
181180 .filter(room_seq::Column::RoomId.is_in(room_ids.clone()))
182 .all(self.db().read())
181 .all(self.database.read())
183182 .await?;
184183 let seq_map: std::collections::HashMap<Uuid, i64> =
185184 seqs.into_iter().map(|s| (s.room_id, s.seq)).collect();
@@ -191,7 +190,7 @@
191190 .column(models::users::user_recent_mentions::Column::ChannelId)
192191 .column(models::users::user_recent_mentions::Column::CreatedAt)
193192 .into_tuple::<(Uuid, OffsetDateTime)>()
194 .all(self.db().read())
193 .all(self.database.read())
195194 .await?;
196195 let mut mentions_by_room: std::collections::HashMap<Uuid, Vec<OffsetDateTime>> =
197196 std::collections::HashMap::new();
@@ -210,7 +209,7 @@
210209 .column_as(Expr::col(room_members::Column::UserId).count(), "cnt")
211210 .group_by(room_members::Column::RoomId)
212211 .into_tuple()
213 .all(self.db().read())
212 .all(self.database.read())
214213 .await?;
215214 let count_map: std::collections::HashMap<Uuid, i64> = member_counts.into_iter().collect();
216215
@@ -220,7 +219,7 @@
220219 .collect::<Vec<_>>()
221220 .into_iter()
222221 .collect();
223 let owner_map = self.load_user_briefs(&owner_ids).await?;
222 let owner_map = self.audience.load_user_briefs(&owner_ids).await?;
224223 let mut result = Vec::new();
225224 for room in rooms {
226225 if room.is_private && !member_of.contains(&room.id) && room.owner_id != caller {
@@ -272,20 +271,20 @@
272271 let ctx = require_room_readable(self, room_id, caller).await?;
273272 let room = ctx.room;
274273 let tip = room_seq::Entity::find_by_id(room.id)
275 .one(self.db().read())
274 .one(self.database.read())
276275 .await?
277276 .map(|s| s.seq)
278277 .unwrap_or(0);
279278 let last_read = read_states::Entity::find_by_id((caller, room.id))
280 .one(self.db().read())
279 .one(self.database.read())
281280 .await?
282281 .map(|r| r.last_read_seq)
283282 .unwrap_or(0);
284283 let setting = room_user_settings::Entity::find_by_id((caller, room.id))
285 .one(self.db().read())
284 .one(self.database.read())
286285 .await?;
287286 let member_count = self.room_member_count(room.id).await? as i64;
288 let owner_map = self.load_user_briefs(&[room.owner_id]).await?;
287 let owner_map = self.audience.load_user_briefs(&[room.owner_id]).await?;
289288 Ok(RoomBriefDto {
290289 id: room.id,
291290 owner_id: room.owner_id,
@@ -368,7 +367,7 @@
368367 am.lower_name = Set(lower);
369368 }
370369 if let Some(topic) = cmd.topic {
371 if topic.chars().count() > self.config().chat.max_topic_len {
370 if topic.chars().count() > self.settings.chat.max_topic_len {
372371 return Err(ChatError::BadRequest("topic too long".to_string()));
373372 }
374373 if topic != room.topic.clone().unwrap_or_default() {
@@ -398,11 +397,11 @@
398397 let space_id = ctx
399398 .space
400399 .as_ref()
401 .map(|space| space.id)
400 .map(|space| space.id())
402401 .ok_or(ChatError::Forbidden)?;
403402 let node = dirs::Entity::find_by_id(dir_id)
404403 .filter(dirs::Column::SpaceId.eq(space_id))
405 .one(self.db().read())
404 .one(self.database.read())
406405 .await?
407406 .ok_or(ChatError::NotFound)?;
408407 if node.kind != DirKind::Dir {
@@ -418,7 +417,7 @@
418417 }
419418 }
420419 am.updated_at = Set(now);
421 am.update(self.db().write()).await?;
420 am.update(self.database.write()).await?;
422421
423422 if let Some((_old, new_topic)) = topic_changed {
424423 let body = format!("set the channel topic to: {new_topic}");
@@ -428,7 +427,7 @@
428427 }
429428
430429 let event = RoomEvent::RoomUpdated { room_id };
431 self.realtime.publish_room(&event).await;
430 self.audience.realtime.publish_room(&event).await;
432431
433432 self.get_room(caller, room_id).await
434433 }
@@ -443,7 +442,7 @@
443442 let now = OffsetDateTime::now_utc();
444443 let message_id = Uuid::now_v7();
445444 let seq = self
446 .db()
445 .database
447446 .primary()
448447 .transaction::<_, i64, ChatError>(move |txn| {
449448 Box::pin(async move {
@@ -488,7 +487,7 @@
488487 })
489488 .await?;
490489 let row = messages::Entity::find_by_id(message_id)
491 .one(self.db().read())
490 .one(self.database.read())
492491 .await?;
493492 if let Some(row) = row {
494493 let items = self.build_message_dto(vec![row], author).await?;
@@ -515,14 +514,14 @@
515514
516515 let now = OffsetDateTime::now_utc();
517516 let existing = room_members::Entity::find_by_id((room_id, caller))
518 .one(self.db().write())
517 .one(self.database.write())
519518 .await?;
520519 match existing {
521520 Some(row) if row.left_at.is_some() => {
522521 let mut am: room_members::ActiveModel = row.into();
523522 am.left_at = Set(None);
524523 am.updated_at = Set(Some(now));
525 am.update(self.db().write()).await?;
524 am.update(self.database.write()).await?;
526525 }
527526 Some(_) => return Ok(()),
528527 None => {
@@ -536,7 +535,7 @@
536535 created_at: Set(now),
537536 updated_at: Set(Some(now)),
538537 }
539 .insert(self.db().write())
538 .insert(self.database.write())
540539 .await?;
541540 }
542541 }
@@ -544,7 +543,7 @@
544543 room_id,
545544 user_id: caller,
546545 };
547 self.realtime.publish_room(&event).await;
546 self.audience.realtime.publish_room(&event).await;
548547 Ok(())
549548 }
550549
@@ -560,7 +559,7 @@
560559 .filter(room_members::Column::UserId.ne(caller))
561560 .filter(room_members::Column::LeftAt.is_null())
562561 .order_by_asc(room_members::Column::JoinedAt)
563 .one(self.db().write())
562 .one(self.database.write())
564563 .await?;
565564 match successor {
566565 Some(next) => {
@@ -568,34 +567,34 @@
568567 let mut am: room_members::ActiveModel = next.into();
569568 am.role = Set(RoomMemberRole::Owner);
570569 am.updated_at = Set(Some(now));
571 am.update(self.db().write()).await?;
570 am.update(self.database.write()).await?;
572571 let mut room_am: rooms::ActiveModel = room.clone().into();
573572 room_am.owner_id = Set(next_uid);
574573 room_am.updated_at = Set(now);
575 room_am.update(self.db().write()).await?;
574 room_am.update(self.database.write()).await?;
576575 }
577576 None => {
578577 let mut room_am: rooms::ActiveModel = room.clone().into();
579578 room_am.is_active = Set(false);
580579 room_am.updated_at = Set(now);
581 room_am.update(self.db().write()).await?;
580 room_am.update(self.database.write()).await?;
582581 }
583582 }
584583 }
585584 let row = room_members::Entity::find_by_id((room_id, caller))
586 .one(self.db().write())
585 .one(self.database.write())
587586 .await?
588587 .ok_or(ChatError::NotFound)?;
589588 if row.left_at.is_none() {
590589 let mut am: room_members::ActiveModel = row.into();
591590 am.left_at = Set(Some(now));
592591 am.updated_at = Set(Some(now));
593 am.update(self.db().write()).await?;
592 am.update(self.database.write()).await?;
594593 let event = RoomEvent::MemberLeft {
595594 room_id,
596595 user_id: caller,
597596 };
598 self.realtime.publish_room(&event).await;
597 self.audience.realtime.publish_room(&event).await;
599598 }
600599 Ok(())
601600 }
@@ -606,10 +605,10 @@
606605 room_id: Uuid,
607606 cmd: AddMembersCmd,
608607 ) -> Result<(), ChatError> {
609 if cmd.user_ids.is_empty() || cmd.user_ids.len() > self.config().chat.max_members_per_add {
608 if cmd.user_ids.is_empty() || cmd.user_ids.len() > self.settings.chat.max_members_per_add {
610609 return Err(ChatError::BadRequest(format!(
611610 "user_ids must contain 1..={} entries",
612 self.config().chat.max_members_per_add
611 self.settings.chat.max_members_per_add
613612 )));
614613 }
615614 let ctx = require_room_readable(self, room_id, caller).await?;
@@ -656,14 +655,14 @@
656655 ) -> Result<(), ChatError> {
657656 let now = OffsetDateTime::now_utc();
658657 let existing = room_members::Entity::find_by_id((room_id, uid))
659 .one(self.db().write())
658 .one(self.database.write())
660659 .await?;
661660 match existing {
662661 Some(row) if row.left_at.is_some() => {
663662 let mut am: room_members::ActiveModel = row.into();
664663 am.left_at = Set(None);
665664 am.updated_at = Set(Some(now));
666 am.update(self.db().write()).await?;
665 am.update(self.database.write()).await?;
667666 }
668667 Some(_) => return Ok(()),
669668 None => {
@@ -677,7 +676,7 @@
677676 created_at: Set(now),
678677 updated_at: Set(Some(now)),
679678 }
680 .insert(self.db().write())
679 .insert(self.database.write())
681680 .await?;
682681 }
683682 }
@@ -685,8 +684,9 @@
685684 room_id,
686685 user_id: uid,
687686 };
688 self.realtime.publish_room(&event).await;
689 self.realtime
687 self.audience.realtime.publish_room(&event).await;
688 self.audience
689 .realtime
690690 .publish_user(
691691 uid,
692692 &serde_json::json!({"kind": "room_added", "room_id": room_id}),
@@ -705,8 +705,8 @@
705705 let (limit, offset) = export::pagination::page_bounds(
706706 q.limit,
707707 q.offset,
708 self.config().chat.default_pagination,
709 self.config().chat.max_pagination,
708 self.settings.chat.default_pagination,
709 self.settings.chat.max_pagination,
710710 );
711711 let rows = room_members::Entity::find()
712712 .filter(room_members::Column::RoomId.eq(room_id))
@@ -714,10 +714,10 @@
714714 .order_by_asc(room_members::Column::JoinedAt)
715715 .limit(limit)
716716 .offset(offset)
717 .all(self.db().read())
717 .all(self.database.read())
718718 .await?;
719719 let uids: Vec<i64> = rows.iter().map(|r| r.user_id).collect();
720 let user_map = self.load_user_briefs(&uids).await?;
720 let user_map = self.audience.load_user_briefs(&uids).await?;
721721 Ok(rows
722722 .into_iter()
723723 .filter_map(|r| {
@@ -742,9 +742,7 @@
742742 return Err(ChatError::BadRequest("cannot invite to a DM".to_string()));
743743 };
744744 if !authz::can_manage_space(self, space, caller).await?
745 && !effective_permissions(&self.state, space, caller)
746 .await?
747 .has(MANAGE_INVITES)
745 && !space.can(caller, space::Capability::ManageInvites).await?
748746 {
749747 return Err(ChatError::Forbidden);
750748 }
@@ -783,7 +781,7 @@
783781 accepted_at: Set(None),
784782 revoked_at: Set(None),
785783 }
786 .insert(self.db().write())
784 .insert(self.database.write())
787785 .await?;
788786 if let Some(invitee) = cmd.invitee_id {
789787 // The inbox renders real accept / reject controls, so the row has to
@@ -794,30 +792,28 @@
794792 "room_id": room_id,
795793 "invite_id": id,
796794 "accept_code": accept_code,
797 "space_name": space.slug,
795 "space_name": space.slug(),
798796 });
799 let pending = export::notify::PendingNotifications::default();
800 notify_user(
801 self.db().write(),
802 &pending,
803 invitee,
804 NotificationType::Invite,
805 i18n::catalog::notification_channel_invite_title(
806 self.recipient_locale(invitee).await,
797 self.audience
798 .notifications
799 .create(
800 invitee,
801 NotificationType::Invite,
802 i18n::catalog::notification_channel_invite_title(
803 self.audience.recipient_locale(invitee).await,
804 )
805 .to_string(),
806 None,
807 Some(data),
808 Some(caller),
807809 )
808 .to_string(),
809 None,
810 Some(data),
811 Some(caller),
812 )
813 .await?;
814 pending.flush(&self.state).await;
810 .await?;
815811 }
816812 let mut uids = vec![row.inviter_id];
817813 if let Some(invitee) = row.invitee_user_id {
818814 uids.push(invitee);
819815 }
820 let user_map = self.load_user_briefs(&uids).await?;
816 let user_map = self.audience.load_user_briefs(&uids).await?;
821817 Ok(RoomInviteDto {
822818 id: row.id,
823819 room_id: row.room_id,
@@ -841,7 +837,7 @@
841837 ) -> Result<(), ChatError> {
842838 let invite = room_invites::Entity::find()
843839 .filter(room_invites::Column::AcceptCode.eq(accept_code))
844 .one(self.db().read())
840 .one(self.database.read())
845841 .await?
846842 .ok_or(ChatError::NotFound)?;
847843 if invite.invitee_user_id != Some(caller) {
@@ -861,7 +857,7 @@
861857 .filter(room_invites::Column::Id.eq(invite.id))
862858 .filter(room_invites::Column::AcceptedAt.is_null())
863859 .filter(room_invites::Column::RevokedAt.is_null())
864 .exec(self.db().write())
860 .exec(self.database.write())
865861 .await?;
866862 Ok(())
867863 }
@@ -875,7 +871,7 @@
875871 let rows = room_invites::Entity::find()
876872 .filter(room_invites::Column::RoomId.eq(room_id))
877873 .order_by_desc(room_invites::Column::CreatedAt)
878 .all(self.db().read())
874 .all(self.database.read())
879875 .await?;
880876 let mut items: Vec<RoomInviteListItemDto> = rows
881877 .into_iter()
@@ -905,7 +901,7 @@
905901 uids.sort();
906902 uids.dedup();
907903 if !uids.is_empty() {
908 let user_map = self.load_user_briefs(&uids).await?;
904 let user_map = self.audience.load_user_briefs(&uids).await?;
909905 for item in items.iter_mut() {
910906 item.inviter = user_map.get(&item.inviter_id).cloned();
911907 item.invitee = item.invitee_id.and_then(|i| user_map.get(&i).cloned());
@@ -923,7 +919,7 @@
923919 let ctx = require_room_readable(self, room_id, caller).await?;
924920 let row = room_invites::Entity::find_by_id(invite_id)
925921 .filter(room_invites::Column::RoomId.eq(room_id))
926 .one(self.db().write())
922 .one(self.database.write())
927923 .await?
928924 .ok_or(ChatError::NotFound)?;
929925 if row.accepted_at.is_some() {
@@ -942,7 +938,7 @@
942938 if row.revoked_at.is_none() {
943939 let mut am: room_invites::ActiveModel = row.into();
944940 am.revoked_at = Set(Some(OffsetDateTime::now_utc()));
945 am.update(self.db().write()).await?;
941 am.update(self.database.write()).await?;
946942 }
947943 Ok(())
948944 }
@@ -952,19 +948,10 @@
952948 caller: i64,
953949 accept_code: &str,
954950 ) -> Result<RoomBriefDto, ChatError> {
955 if !self
956 .state
957 .redis
958 .quota_allow(&format!("chat:invite_accept:{caller}"), 10, 60)
959 .await
960 {
961 return Err(ChatError::RateLimited(
962 "too many invite acceptance attempts, retry in 60s".to_string(),
963 ));
964 }
951 self.participants.allow_invite_acceptance(caller).await?;
965952 let invite = room_invites::Entity::find()
966953 .filter(room_invites::Column::AcceptCode.eq(accept_code))
967 .one(self.db().write())
954 .one(self.database.write())
968955 .await?
969956 .ok_or(ChatError::NotFound)?;
970957 if invite.accepted_at.is_some() || invite.revoked_at.is_some() {
@@ -983,7 +970,7 @@
983970 if let Some(space_id) = room.space_id
984971 && require_space_member(
985972 self,
986 &space::authority::space_by_id(&self.state, 0, space_id).await?,
973 &self.participants.access.by_id(space_id).await?,
987974 caller,
988975 )
989976 .await
@@ -997,7 +984,7 @@
997984 .col_expr(room_invites::Column::AcceptedAt, Expr::value(Some(now)))
998985 .filter(room_invites::Column::Id.eq(invite.id))
999986 .filter(room_invites::Column::AcceptedAt.is_null())
1000 .exec(self.db().write())
987 .exec(self.database.write())
1001988 .await?;
1002989 if claimed.rows_affected == 0 {
1003990 return Err(ChatError::NotFound);
Modified lib/chat/src/services/service.rs View lib/chat/src/services/service.rs
@@ -1,82 +1,47 @@
1use std::collections::HashMap;
21use std::sync::Arc;
32
43use config::AppConfig;
5use export::AppState;
6use export::database::Database;
7use space::services::dto::UserDto;
8use storage::BlobStore;
9
10use crate::errors::ChatError;
11use crate::share::realtime::Realtime;
4use export::database::AppDatabase;
125
136#[derive(Clone)]
147pub struct ChatService {
15 pub state: AppState,
16 pub realtime: Realtime,
8 pub(crate) database: Arc<AppDatabase>,
9 pub(crate) media: crate::services::media::Media,
10 pub(crate) audience: crate::services::audience::Audience,
11 pub(crate) settings: ChatSettings,
12 pub(crate) participants: crate::services::presence::Participants,
1713}
1814
1915impl ChatService {
20 pub fn new(state: AppState) -> Self {
21 let realtime = Realtime::new(state.clone());
22 Self { state, realtime }
23 }
24
25 pub fn config(&self) -> &AppConfig {
26 &self.state.config
27 }
28
29 pub fn db(&self) -> &Arc<dyn Database> {
30 &self.state.database
31 }
32
33 pub fn storage(&self) -> &dyn BlobStore {
34 self.state.storage.as_ref()
35 }
36
37 pub async fn load_user_briefs(&self, uids: &[i64]) -> Result<HashMap<i64, UserDto>, ChatError> {
38 Ok(export::users::load_user_briefs(&self.state, uids).await?)
39 }
40
41 /// Language for one recipient's notification/e-mail text.
42 ///
43 /// Account preference only: a fan-out event has no single ambient request
44 /// language, and writing one language for every recipient is the bug this
45 /// replaces.
46 pub(crate) async fn recipient_locale(&self, uid: i64) -> i18n::UiLocale {
47 let fallback = i18n::config_fallback(self.config().email.default_locale());
48 i18n::resolve_user_locale(self.db().read(), uid, fallback).await
49 }
50
51 pub(crate) async fn primary_emails(
52 &self,
53 uids: &[i64],
54 ) -> Result<HashMap<i64, String>, ChatError> {
55 if uids.is_empty() {
56 return Ok(HashMap::new());
16 pub fn new(
17 database: Arc<AppDatabase>,
18 media: crate::services::media::Media,
19 audience: crate::services::audience::Audience,
20 settings: ChatSettings,
21 participants: crate::services::presence::Participants,
22 ) -> Self {
23 Self {
24 database,
25 media,
26 audience,
27 settings,
28 participants,
5729 }
58 let users = export::users::load_public_users(&self.state, uids).await?;
59 Ok(users
60 .into_iter()
61 .filter(|(_, user)| user.email_verified && !user.email.trim().is_empty())
62 .map(|(uid, user)| (uid, user.email.to_lowercase()))
63 .collect())
6430 }
31}
6532
66 pub(crate) async fn send_email(
67 &self,
68 to: &str,
69 subject: &str,
70 text: &str,
71 html: &str,
72 ) -> Result<(), ChatError> {
73 let Some(client) = self.state.mail.as_ref() else {
74 tracing::warn!(to, "email rpc disabled; skipping chat mail");
75 return Ok(());
76 };
77 client
78 .send_mail(to, subject, text, html, None)
79 .await
80 .map_err(|error| ChatError::Internal(format!("email send failed: {error}")))
33#[derive(Clone)]
34pub struct ChatSettings {
35 pub chat: config::chat::ChatConfig,
36 pub web: config::web::WebConfig,
37 pub livekit: config::livekit::LiveKitConfig,
38}
39impl ChatSettings {
40 pub fn from_config(config: &AppConfig) -> Self {
41 Self {
42 chat: config.chat.clone(),
43 web: config.web.clone(),
44 livekit: config.livekit.clone(),
45 }
8146 }
8247}
Modified lib/chat/src/services/settings.rs View lib/chat/src/services/settings.rs
@@ -54,7 +54,7 @@
5454 ) -> Result<RoomSettingDto, ChatError> {
5555 let _ctx = lookup::require_room_readable(self, room_id, caller).await?;
5656 let row = room_user_settings::Entity::find_by_id((caller, room_id))
57 .one(self.db().read())
57 .one(self.database.read())
5858 .await?;
5959 match row {
6060 Some(row) => Ok(RoomSettingDto::from(row)),
@@ -78,7 +78,7 @@
7878 let _ctx = lookup::require_room_readable(self, room_id, caller).await?;
7979 let now = OffsetDateTime::now_utc();
8080 let existing = room_user_settings::Entity::find_by_id((caller, room_id))
81 .one(self.db().write())
81 .one(self.database.write())
8282 .await?;
8383
8484 let mut am: room_user_settings::ActiveModel = match existing {
@@ -118,10 +118,10 @@
118118 am.hidden = Set(hidden);
119119 }
120120 am.updated_at = Set(now);
121 am.save(self.db().write()).await?;
121 am.save(self.database.write()).await?;
122122
123123 let row = room_user_settings::Entity::find_by_id((caller, room_id))
124 .one(self.db().read())
124 .one(self.database.read())
125125 .await?
126126 .ok_or(ChatError::Internal("setting vanished".into()))?;
127127 Ok(RoomSettingDto::from(row))
Modified lib/chat/src/services/threads.rs View lib/chat/src/services/threads.rs
@@ -1,3 +1,4 @@
1use export::users::UserDto;
12use models::chat::messages::{self, FLAG_HAS_THREAD};
23use models::chat::thread_members::{self, ThreadNotifyLevel};
34use models::chat::threads;
@@ -8,7 +9,6 @@
89 QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait,
910};
1011use serde::{Deserialize, Serialize};
11use space::services::dto::UserDto;
1212use time::OffsetDateTime;
1313use utoipa::{IntoParams, ToSchema};
1414use uuid::Uuid;
@@ -122,7 +122,7 @@
122122 rows: Vec<threads::Model>,
123123 ) -> Result<Vec<ThreadDto>, ChatError> {
124124 let owner_ids: Vec<i64> = rows.iter().map(|t| t.owner_id).collect();
125 let user_map = self.load_user_briefs(&owner_ids).await?;
125 let user_map = self.audience.load_user_briefs(&owner_ids).await?;
126126 Ok(rows
127127 .into_iter()
128128 .map(|t| ThreadDto {
@@ -155,14 +155,14 @@
155155
156156 let root = messages::Entity::find_by_id(message_id)
157157 .filter(messages::Column::RoomId.eq(room_id))
158 .one(self.db().read())
158 .one(self.database.read())
159159 .await?
160160 .filter(|m| m.deleted_at.is_none())
161161 .ok_or(ChatError::NotFound)?;
162162
163163 let existing = threads::Entity::find()
164164 .filter(threads::Column::RootMessageId.eq(message_id))
165 .one(self.db().read())
165 .one(self.database.read())
166166 .await?;
167167 if let Some(existing) = existing {
168168 let items = self.build_thread_dto(vec![existing]).await?;
@@ -178,12 +178,12 @@
178178 .filter(|n| !n.is_empty());
179179 if name
180180 .as_ref()
181 .is_some_and(|n| n.chars().count() > self.config().chat.max_thread_name_len)
181 .is_some_and(|n| n.chars().count() > self.settings.chat.max_thread_name_len)
182182 {
183183 return Err(ChatError::BadRequest("thread name too long".to_string()));
184184 }
185185
186 self.db()
186 self.database
187187 .primary()
188188 .transaction::<_, (), ChatError>(move |txn| {
189189 Box::pin(async move {
@@ -231,11 +231,11 @@
231231 .await?;
232232
233233 let thread = threads::Entity::find_by_id(thread_id)
234 .one(self.db().read())
234 .one(self.database.read())
235235 .await?
236236 .ok_or(ChatError::Internal("thread vanished after insert".into()))?;
237237 let event = RoomEvent::ThreadUpdated { room_id, thread_id };
238 self.realtime.publish_room(&event).await;
238 self.audience.realtime.publish_room(&event).await;
239239
240240 let items = self.build_thread_dto(vec![thread]).await?;
241241 Ok(items.into_iter().next().unwrap())
@@ -251,21 +251,21 @@
251251 let (limit, offset) = export::pagination::page_bounds(
252252 q.limit,
253253 q.offset,
254 self.config().chat.default_pagination,
255 self.config().chat.max_pagination,
254 self.settings.chat.default_pagination,
255 self.settings.chat.max_pagination,
256256 );
257257
258258 let mut select = threads::Entity::find().filter(threads::Column::RoomId.eq(room_id));
259259 if let Some(archived) = q.archived {
260260 select = select.filter(threads::Column::Archived.eq(archived));
261261 }
262 let total = select.clone().count(self.db().read()).await? as i64;
262 let total = select.clone().count(self.database.read()).await? as i64;
263263 let rows = select
264264 .order_by_desc(threads::Column::LastReplyAt)
265265 .order_by_desc(threads::Column::CreatedAt)
266266 .limit(limit)
267267 .offset(offset)
268 .all(self.db().read())
268 .all(self.database.read())
269269 .await?;
270270 let items = self.build_thread_dto(rows).await?;
271271 Ok(ThreadPage {
@@ -281,7 +281,7 @@
281281 .filter(thread_members::Column::UserId.eq(caller))
282282 .order_by_desc(thread_members::Column::UpdatedAt)
283283 .limit(100)
284 .all(self.db().read())
284 .all(self.database.read())
285285 .await?;
286286 let thread_ids: Vec<Uuid> = memberships.into_iter().map(|m| m.thread_id).collect();
287287 if thread_ids.is_empty() {
@@ -289,14 +289,14 @@
289289 }
290290 let rows = threads::Entity::find()
291291 .filter(threads::Column::Id.is_in(thread_ids))
292 .all(self.db().read())
292 .all(self.database.read())
293293 .await?;
294294 self.build_thread_dto(rows).await
295295 }
296296
297297 pub async fn get_thread(&self, caller: i64, thread_id: Uuid) -> Result<ThreadDto, ChatError> {
298298 let thread = threads::Entity::find_by_id(thread_id)
299 .one(self.db().read())
299 .one(self.database.read())
300300 .await?
301301 .ok_or(ChatError::NotFound)?;
302302 let _ctx = lookup::require_room_readable(self, thread.room_id, caller).await?;
@@ -311,7 +311,7 @@
311311 cmd: UpdateThreadCmd,
312312 ) -> Result<ThreadDto, ChatError> {
313313 let thread = threads::Entity::find_by_id(thread_id)
314 .one(self.db().read())
314 .one(self.database.read())
315315 .await?
316316 .ok_or(ChatError::NotFound)?;
317317 let ctx = lookup::require_room_readable(self, thread.room_id, caller).await?;
@@ -328,7 +328,7 @@
328328 let mut am: threads::ActiveModel = thread.clone().into();
329329 if let Some(name) = cmd.name {
330330 let name = name.trim().to_string();
331 if name.chars().count() > self.config().chat.max_thread_name_len {
331 if name.chars().count() > self.settings.chat.max_thread_name_len {
332332 return Err(ChatError::BadRequest("thread name too long".to_string()));
333333 }
334334 am.name = Set(if name.is_empty() { None } else { Some(name) });
@@ -340,16 +340,16 @@
340340 am.locked = Set(locked);
341341 }
342342 am.updated_at = Set(now);
343 am.update(self.db().write()).await?;
343 am.update(self.database.write()).await?;
344344
345345 let event = RoomEvent::ThreadUpdated {
346346 room_id: thread.room_id,
347347 thread_id,
348348 };
349 self.realtime.publish_room(&event).await;
349 self.audience.realtime.publish_room(&event).await;
350350
351351 let updated = threads::Entity::find_by_id(thread_id)
352 .one(self.db().read())
352 .one(self.database.read())
353353 .await?
354354 .ok_or(ChatError::NotFound)?;
355355 let items = self.build_thread_dto(vec![updated]).await?;
@@ -363,14 +363,14 @@
363363 cmd: ThreadMemberCmd,
364364 ) -> Result<(), ChatError> {
365365 let thread = threads::Entity::find_by_id(thread_id)
366 .one(self.db().read())
366 .one(self.database.read())
367367 .await?
368368 .ok_or(ChatError::NotFound)?;
369369 let _ctx = lookup::require_room_readable(self, thread.room_id, caller).await?;
370370
371371 let now = OffsetDateTime::now_utc();
372372 let notify_level = cmd.notify_level.unwrap_or(ThreadNotifyLevel::All);
373 self.db()
373 self.database
374374 .primary()
375375 .transaction::<_, (), ChatError>(move |txn| {
376376 Box::pin(async move {
@@ -415,12 +415,12 @@
415415
416416 pub async fn leave_thread(&self, caller: i64, thread_id: Uuid) -> Result<(), ChatError> {
417417 let thread = threads::Entity::find_by_id(thread_id)
418 .one(self.db().read())
418 .one(self.database.read())
419419 .await?
420420 .ok_or(ChatError::NotFound)?;
421421 let _ctx = lookup::require_room_readable(self, thread.room_id, caller).await?;
422422
423 self.db()
423 self.database
424424 .primary()
425425 .transaction::<_, (), ChatError>(move |txn| {
426426 Box::pin(async move {
@@ -453,7 +453,7 @@
453453 ) -> Result<(), ChatError> {
454454 let now = OffsetDateTime::now_utc();
455455 let member = thread_members::Entity::find_by_id((thread_id, caller))
456 .one(self.db().write())
456 .one(self.database.write())
457457 .await?;
458458 match member {
459459 Some(row) if row.last_read_seq < seq => {
@@ -461,7 +461,7 @@
461461 am.last_read_message_id = Set(Some(message_id));
462462 am.last_read_seq = Set(seq);
463463 am.updated_at = Set(now);
464 am.update(self.db().write()).await?;
464 am.update(self.database.write()).await?;
465465 }
466466 Some(_) => {}
467467 None => {
@@ -474,7 +474,7 @@
474474 created_at: Set(now),
475475 updated_at: Set(now),
476476 };
477 match am.insert(self.db().write()).await {
477 match am.insert(self.database.write()).await {
478478 Ok(_) => {
479479 threads::Entity::update_many()
480480 .filter(threads::Column::Id.eq(thread_id))
@@ -482,7 +482,7 @@
482482 threads::Column::ParticipantCount,
483483 Expr::col(threads::Column::ParticipantCount).add(1),
484484 )
485 .exec(self.db().write())
485 .exec(self.database.write())
486486 .await?;
487487 }
488488 Err(err) => {
@@ -501,7 +501,7 @@
501501 Ok(room_members::Entity::find()
502502 .filter(room_members::Column::RoomId.eq(room_id))
503503 .filter(room_members::Column::LeftAt.is_null())
504 .count(self.db().read())
504 .count(self.database.read())
505505 .await?)
506506 }
507507}
Modified lib/chat/src/services/unfurl.rs View lib/chat/src/services/unfurl.rs
@@ -77,7 +77,7 @@
7777 return;
7878 }
7979 let Some(row) = messages::Entity::find_by_id(message_id)
80 .one(self.db().write())
80 .one(self.database.write())
8181 .await
8282 .ok()
8383 .flatten()
@@ -92,11 +92,11 @@
9292 }
9393 let mut am: messages::ActiveModel = row.into();
9494 am.embeds = Set(serde_json::json!(embeds));
95 if am.update(self.db().write()).await.is_err() {
95 if am.update(self.database.write()).await.is_err() {
9696 return;
9797 }
9898 let Some(updated) = messages::Entity::find_by_id(message_id)
99 .one(self.db().read())
99 .one(self.database.read())
100100 .await
101101 .ok()
102102 .flatten()
@@ -112,13 +112,13 @@
112112 let Ok(payload) = serde_json::to_value(&dto) else {
113113 return;
114114 };
115 let seq = notify::current_room_seq(self.db().read(), room_id).await;
115 let seq = notify::current_room_seq(self.database.read(), room_id).await;
116116 let event = RoomEvent::MessageEdited {
117117 room_id,
118118 seq,
119119 message: payload,
120120 };
121 self.realtime.publish_room(&event).await;
121 self.audience.realtime.publish_room(&event).await;
122122 }
123123
124124 /// 解析单条链接,返回可直接塞进 `MessageDto::embeds` 的一项。
Modified lib/chat/src/services/voice.rs View lib/chat/src/services/voice.rs
@@ -239,7 +239,7 @@
239239}
240240
241241fn livekit(service: &ChatService) -> Result<LiveKitApi, ChatError> {
242 let cfg = &service.config().livekit;
242 let cfg = &service.settings.livekit;
243243 if !cfg.enabled {
244244 return Err(ChatError::BadRequest("livekit is not enabled".to_string()));
245245 }
@@ -311,7 +311,7 @@
311311 uids: &[i64],
312312) -> HashMap<i64, (String, Option<String>)> {
313313 let mut map = HashMap::new();
314 let users = match service.load_user_briefs(uids).await {
314 let users = match service.audience.load_user_briefs(uids).await {
315315 Ok(users) => users,
316316 Err(_) => return map,
317317 };
@@ -338,9 +338,9 @@
338338 room_id: Uuid,
339339 member: &VoiceMember,
340340) -> Result<(), ChatError> {
341 let ttl = service.config().livekit.presence_ttl_secs.max(60);
341 let ttl = service.settings.livekit.presence_ttl_secs.max(60);
342342 let value = serde_json::to_string(member)?;
343 let mut conn = service.state.redis.connection_clone();
343 let mut conn = service.participants.redis.connection_clone();
344344 use export::RedisConnection;
345345 let key = voice_key(room_id);
346346 let field = member.user_id.to_string();
@@ -380,7 +380,7 @@
380380 room_id: Uuid,
381381 uid: i64,
382382) -> Result<(), ChatError> {
383 let mut conn = service.state.redis.connection_clone();
383 let mut conn = service.participants.redis.connection_clone();
384384 use export::RedisConnection;
385385 let key = voice_key(room_id);
386386 let field = uid.to_string();
@@ -421,7 +421,7 @@
421421}
422422
423423pub async fn clear_room(service: &ChatService, room_id: Uuid) -> Result<(), ChatError> {
424 let mut conn = service.state.redis.connection_clone();
424 let mut conn = service.participants.redis.connection_clone();
425425 use export::RedisConnection;
426426 for key in [voice_key(room_id), hands_key(room_id)] {
427427 let _: Result<i64, redis::RedisError> = match &mut conn {
@@ -437,7 +437,7 @@
437437 service: &ChatService,
438438 room_id: Uuid,
439439) -> Result<Vec<VoiceMember>, ChatError> {
440 let mut conn = service.state.redis.connection_clone();
440 let mut conn = service.participants.redis.connection_clone();
441441 use export::RedisConnection;
442442 let key = voice_key(room_id);
443443 let raw: Result<HashMap<String, String>, redis::RedisError> = match &mut conn {
@@ -458,7 +458,7 @@
458458 room_id: Uuid,
459459 uid: i64,
460460) -> Result<Option<HandEntry>, ChatError> {
461 let mut conn = service.state.redis.connection_clone();
461 let mut conn = service.participants.redis.connection_clone();
462462 use export::RedisConnection;
463463 let key = hands_key(room_id);
464464 let field = uid.to_string();
@@ -488,7 +488,7 @@
488488 service: &ChatService,
489489 room_id: Uuid,
490490) -> Result<HashMap<i64, HandEntry>, ChatError> {
491 let mut conn = service.state.redis.connection_clone();
491 let mut conn = service.participants.redis.connection_clone();
492492 use export::RedisConnection;
493493 let key = hands_key(room_id);
494494 let raw: Result<HashMap<String, String>, redis::RedisError> = match &mut conn {
@@ -513,9 +513,9 @@
513513 uid: i64,
514514 entry: &HandEntry,
515515) -> Result<(), ChatError> {
516 let ttl = service.config().livekit.hand_ttl_secs.max(60);
516 let ttl = service.settings.livekit.hand_ttl_secs.max(60);
517517 let value = serde_json::to_string(entry)?;
518 let mut conn = service.state.redis.connection_clone();
518 let mut conn = service.participants.redis.connection_clone();
519519 use export::RedisConnection;
520520 let key = hands_key(room_id);
521521 let field = uid.to_string();
@@ -553,7 +553,7 @@
553553impl ChatService {
554554 /// 前端搭接参数。
555555 pub fn voice_config(&self) -> VoiceConfigDto {
556 let cfg = &self.config().livekit;
556 let cfg = &self.settings.livekit;
557557 VoiceConfigDto {
558558 enabled: cfg.enabled,
559559 url: cfg.browser_url().to_string(),
@@ -567,7 +567,7 @@
567567 caller: i64,
568568 room_id: Uuid,
569569 ) -> Result<VoiceTokenDto, ChatError> {
570 let cfg = self.config().livekit.clone();
570 let cfg = self.settings.livekit.clone();
571571 if !cfg.enabled {
572572 return Err(ChatError::BadRequest("livekit is not enabled".to_string()));
573573 }
@@ -747,13 +747,13 @@
747747 let space = lookup_space_by_name(self, space_name).await?;
748748 crate::share::lookup::require_space_member(self, &space, caller).await?;
749749 let rooms = models::room::rooms::Entity::find()
750 .filter(models::room::rooms::Column::SpaceId.eq(space.id))
750 .filter(models::room::rooms::Column::SpaceId.eq(space.id()))
751751 .filter(models::room::rooms::Column::IsActive.eq(true))
752752 .filter(models::room::rooms::Column::Kind.is_in([
753753 models::room::rooms::RoomKind::Voice,
754754 models::room::rooms::RoomKind::Stage,
755755 ]))
756 .all(self.db().read())
756 .all(self.database.read())
757757 .await?;
758758 let mut out = Vec::with_capacity(rooms.len());
759759 for room in rooms {
@@ -778,7 +778,7 @@
778778
779779 /// 纯读取,不做权限校验:调用方要么刚校验过,要么只用于合并写入。
780780 async fn voice_layout_raw(&self, room_id: Uuid) -> Result<VoiceLayoutDto, ChatError> {
781 let mut conn = self.state.redis.connection_clone();
781 let mut conn = self.participants.redis.connection_clone();
782782 use export::RedisConnection;
783783 let key = layout_key(room_id);
784784 let raw: Result<Option<String>, redis::RedisError> = match &mut conn {
@@ -810,8 +810,8 @@
810810 "removed": patch.removed,
811811 })
812812 .to_string();
813 let ttl = self.config().livekit.presence_ttl_secs.max(60);
814 let mut conn = self.state.redis.connection_clone();
813 let ttl = self.settings.livekit.presence_ttl_secs.max(60);
814 let mut conn = self.participants.redis.connection_clone();
815815 use export::RedisConnection;
816816 let key = layout_key(room_id);
817817 let mut command = redis::cmd("EVAL");
@@ -836,7 +836,8 @@
836836 ),
837837 Err(error) => Err(ChatError::from(error)),
838838 }?;
839 self.realtime
839 self.audience
840 .realtime
840841 .publish_room(&RoomEvent::VoiceLayout { room_id })
841842 .await;
842843 Ok(VoiceLayoutSavedDto {
@@ -861,7 +862,8 @@
861862 decided_by: None,
862863 };
863864 write_hand(self, room_id, caller, &entry).await?;
864 self.realtime
865 self.audience
866 .realtime
865867 .publish_room(&RoomEvent::VoiceHands { room_id })
866868 .await;
867869 let names = display_names(self, &[caller]).await;
@@ -886,7 +888,7 @@
886888 "an approved speaker must be revoked by a moderator".to_string(),
887889 ));
888890 }
889 let mut conn = self.state.redis.connection_clone();
891 let mut conn = self.participants.redis.connection_clone();
890892 use export::RedisConnection;
891893 let key = hands_key(room_id);
892894 let field = caller.to_string();
@@ -906,7 +908,8 @@
906908 .await
907909 }
908910 };
909 self.realtime
911 self.audience
912 .realtime
910913 .publish_room(&RoomEvent::VoiceHands { room_id })
911914 .await;
912915 Ok(())
@@ -974,7 +977,8 @@
974977 if approve {
975978 let _ = self.apply_publish_permission(room_id, target, true).await;
976979 }
977 self.realtime
980 self.audience
981 .realtime
978982 .publish_room(&RoomEvent::VoiceHands { room_id })
979983 .await;
980984 let action = if approve {
@@ -982,7 +986,8 @@
982986 } else {
983987 "speak_denied"
984988 };
985 self.realtime
989 self.audience
990 .realtime
986991 .publish_room(&RoomEvent::VoiceModeration {
987992 room_id,
988993 user_id: target,
@@ -1042,10 +1047,12 @@
10421047 )
10431048 .await?;
10441049 let _ = self.apply_publish_permission(room_id, target, false).await;
1045 self.realtime
1050 self.audience
1051 .realtime
10461052 .publish_room(&RoomEvent::VoiceHands { room_id })
10471053 .await;
1048 self.realtime
1054 self.audience
1055 .realtime
10491056 .publish_room(&RoomEvent::VoiceModeration {
10501057 room_id,
10511058 user_id: target,
@@ -1164,7 +1171,8 @@
11641171 write_member(self, room_id, &member).await?;
11651172 }
11661173 let action = if muted { "muted" } else { "unmuted" }.to_string();
1167 self.realtime
1174 self.audience
1175 .realtime
11681176 .publish_room(&RoomEvent::VoiceModeration {
11691177 room_id,
11701178 user_id: target,
@@ -1197,7 +1205,8 @@
11971205 let identity = target.to_string();
11981206 let _ = api.room().remove_participant(&room_name, &identity).await;
11991207 remove_member(self, room_id, target).await?;
1200 self.realtime
1208 self.audience
1209 .realtime
12011210 .publish_room(&RoomEvent::VoiceModeration {
12021211 room_id,
12031212 user_id: target,
@@ -1237,7 +1246,7 @@
12371246 if !can_moderate_voice(self, room_id, caller).await? {
12381247 return Err(ChatError::Forbidden);
12391248 }
1240 if !self.config().livekit.recording_enabled {
1249 if !self.settings.livekit.recording_enabled {
12411250 return Err(ChatError::BadRequest(
12421251 "recording is not enabled".to_string(),
12431252 ));
Modified lib/chat/src/services/webhooks.rs View lib/chat/src/services/webhooks.rs
@@ -88,20 +88,7 @@
8888 room_id: Uuid,
8989 cmd: CreateWebhookCmd,
9090 ) -> Result<WebhookSecretDto, ChatError> {
91 if !self
92 .state
93 .redis
94 .quota_allow(
95 &format!("quota:webhook_create:{caller}"),
96 self.config().quota.webhook_create_per_day,
97 86_400,
98 )
99 .await
100 {
101 return Err(ChatError::RateLimited(
102 "webhook creation quota exceeded, retry tomorrow".to_string(),
103 ));
104 }
91 self.participants.allow_webhook_creation(caller).await?;
10592 let ctx = require_room_readable(self, room_id, caller).await?;
10693 match &ctx.space {
10794 Some(space) => {
@@ -112,7 +99,7 @@
11299 None => return Err(ChatError::Forbidden),
113100 }
114101 let name = cmd.name.trim().to_string();
115 if name.is_empty() || name.chars().count() > self.config().chat.max_webhook_name_len {
102 if name.is_empty() || name.chars().count() > self.settings.chat.max_webhook_name_len {
116103 return Err(ChatError::BadRequest("invalid webhook name".to_string()));
117104 }
118105
@@ -140,7 +127,7 @@
140127 last_used_at: Set(None),
141128 created_at: Set(now),
142129 }
143 .insert(self.db().write())
130 .insert(self.database.write())
144131 .await?;
145132
146133 Ok(WebhookSecretDto {
@@ -169,7 +156,7 @@
169156 }
170157 let rows = room_webhooks::Entity::find()
171158 .filter(room_webhooks::Column::RoomId.eq(room_id))
172 .all(self.db().read())
159 .all(self.database.read())
173160 .await?;
174161 Ok(rows.into_iter().map(WebhookDto::from).collect())
175162 }
@@ -195,14 +182,14 @@
195182 }
196183 }
197184 let row = room_webhooks::Entity::find_by_id(webhook_id)
198 .one(self.db().write())
185 .one(self.database.write())
199186 .await?
200187 .filter(|w| w.room_id == room_id)
201188 .ok_or(ChatError::NotFound)?;
202189 let mut am: room_webhooks::ActiveModel = row.into();
203190 if let Some(name) = cmd.name {
204191 let name = name.trim();
205 if name.is_empty() || name.chars().count() > self.config().chat.max_webhook_name_len {
192 if name.is_empty() || name.chars().count() > self.settings.chat.max_webhook_name_len {
206193 return Err(ChatError::BadRequest("invalid webhook name".to_string()));
207194 }
208195 am.name = Set(name.to_string());
@@ -210,7 +197,7 @@
210197 if let Some(disabled) = cmd.disabled {
211198 am.disabled = Set(disabled);
212199 }
213 let updated = am.update(self.db().write()).await?;
200 let updated = am.update(self.database.write()).await?;
214201 Ok(WebhookDto::from(updated))
215202 }
216203
@@ -232,7 +219,7 @@
232219 let deleted = room_webhooks::Entity::delete_many()
233220 .filter(room_webhooks::Column::Id.eq(webhook_id))
234221 .filter(room_webhooks::Column::RoomId.eq(room_id))
235 .exec(self.db().write())
222 .exec(self.database.write())
236223 .await?;
237224 if deleted.rows_affected == 0 {
238225 return Err(ChatError::NotFound);
@@ -246,23 +233,12 @@
246233 cmd: WebhookPostCmd,
247234 ) -> Result<serde_json::Value, ChatError> {
248235 let hash = token_hash(token);
249 if !self
250 .state
251 .redis
252 .quota_allow(
253 &format!("chat:webhook:{hash}"),
254 HOOK_MAX_PER_WINDOW as i64,
255 HOOK_WINDOW_SECS,
256 )
257 .await
258 {
259 return Err(ChatError::RateLimited(
260 "webhook rate limit exceeded, retry in 60s".to_string(),
261 ));
262 }
236 self.participants
237 .allow_webhook_delivery(&hash, HOOK_MAX_PER_WINDOW as i64, HOOK_WINDOW_SECS)
238 .await?;
263239 let webhook = room_webhooks::Entity::find()
264240 .filter(room_webhooks::Column::TokenHash.eq(&hash))
265 .one(self.db().write())
241 .one(self.database.write())
266242 .await?
267243 .ok_or(ChatError::Unauthorized)?;
268244 if webhook.disabled {
@@ -276,11 +252,11 @@
276252 let now = OffsetDateTime::now_utc();
277253 let mut am: room_webhooks::ActiveModel = webhook.clone().into();
278254 am.last_used_at = Set(Some(now));
279 am.update(self.db().write()).await?;
255 am.update(self.database.write()).await?;
280256
281257 if let Some(thread_id) = cmd.thread_id {
282258 let thread = models::chat::threads::Entity::find_by_id(thread_id)
283 .one(self.db().read())
259 .one(self.database.read())
284260 .await?
285261 .filter(|t| t.room_id == webhook.room_id)
286262 .ok_or(ChatError::NotFound)?;
@@ -323,7 +299,7 @@
323299 if content.is_empty() {
324300 return Err(ChatError::BadRequest("content is empty".to_string()));
325301 }
326 if content.chars().count() > self.config().chat.max_message_len {
302 if content.chars().count() > self.settings.chat.max_message_len {
327303 return Err(ChatError::BadRequest("message too long".to_string()));
328304 }
329305
@@ -332,7 +308,7 @@
332308 .filter(chat_messages::Column::RoomId.eq(room_id))
333309 .filter(chat_messages::Column::AuthorId.eq(webhook.user_id))
334310 .filter(chat_messages::Column::Nonce.eq(nonce.as_str()))
335 .one(self.db().read())
311 .one(self.database.read())
336312 .await?;
337313 if let Some(existing) = existing {
338314 let items = self
@@ -350,7 +326,7 @@
350326 let thread_id = cmd.thread_id;
351327
352328 let seq = self
353 .db()
329 .database
354330 .primary()
355331 .transaction::<_, i64, ChatError>(move |txn| {
356332 Box::pin(async move {
@@ -406,7 +382,7 @@
406382 .await?;
407383
408384 let row = chat_messages::Entity::find_by_id(message_id)
409 .one(self.db().read())
385 .one(self.database.read())
410386 .await?
411387 .ok_or(ChatError::Internal("message vanished".into()))?;
412388 let items = self.build_message_dto(vec![row], owner).await?;
Modified lib/chat/src/share/authz.rs View lib/chat/src/share/authz.rs
@@ -1,27 +1,22 @@
1use models::space::spaces;
2use space::share::authz::{MANAGE_CHANNELS, effective_permissions};
1use space::Space;
32
43use crate::errors::ChatError;
54use crate::services::service::ChatService;
65
76pub async fn can_manage_space(
8 service: &ChatService,
9 space: &spaces::Model,
7 _service: &ChatService,
8 space: &Space,
109 uid: i64,
1110) -> Result<bool, ChatError> {
12 if space.owner_user_id == uid {
13 return Ok(true);
14 }
15 let perms = effective_permissions(&service.state, space, uid).await?;
16 Ok(perms.has(MANAGE_CHANNELS))
11 Ok(space.can(uid, space::Capability::ManageChannels).await?)
1712}
1813
1914pub async fn require_space_manager(
20 service: &ChatService,
21 space: &spaces::Model,
15 _service: &ChatService,
16 space: &Space,
2217 uid: i64,
2318) -> Result<(), ChatError> {
24 if can_manage_space(service, space, uid).await? {
19 if can_manage_space(_service, space, uid).await? {
2520 Ok(())
2621 } else {
2722 Err(ChatError::Forbidden)
Modified lib/chat/src/share/events.rs View lib/chat/src/share/events.rs
@@ -1,4 +1,4 @@
1use export::AppState;
1use export::AppDatabase;
22use models::chat::messages;
33use models::room::rooms;
44use sea_orm::EntityTrait;
@@ -72,7 +72,7 @@
7272 }
7373}
7474
75pub async fn enqueue_room(state: &AppState, event: &RoomEvent) -> Result<(), ChatError> {
75pub async fn enqueue_room(database: &AppDatabase, event: &RoomEvent) -> Result<(), ChatError> {
7676 let mut labels = queue::Labels::new().with("room_id", event.room_id());
7777 if let Some(actor) = room_actor(event) {
7878 labels.insert("user_id", actor);
@@ -88,48 +88,31 @@
8888 } {
8989 labels.insert("thread_id", thread_id);
9090 }
91 let request_id = export::middleware::request_id();
92 if let Some(request_id) = request_id.as_deref() {
93 labels.insert("request_id", request_id);
94 }
9591
9692 let mut payload =
9793 serde_json::to_value(event).map_err(|error| ChatError::Internal(error.to_string()))?;
98 if let Some(room) = room_detail(state, event.room_id()).await {
94 if let Some(room) = room_detail(database, event.room_id()).await {
9995 payload["room"] = room;
10096 }
101 if let Some(actor) = room_actor(event)
102 && let Some(actor) = user_detail(state, actor).await
103 {
104 payload["actor"] = actor;
105 }
10697 if let Some(message_id) = room_message_id(event)
10798 && !matches!(
10899 event,
109100 RoomEvent::MessagePublished { .. } | RoomEvent::MessageEdited { .. }
110101 )
111 && let Some(message) = message_detail(state, message_id).await
102 && let Some(message) = message_detail(database, message_id).await
112103 {
113104 payload["message"] = message;
114105 }
115106
116 labels.insert(
117 events::label::ORDERING_KEY,
118 format!("chat:room:{}", event.room_id()),
119 );
120 events::outbox::enqueue_now(
121 state.database.write(),
122 room_kind(event),
123 labels,
124 &payload,
125 request_id.as_deref().unwrap_or_default(),
126 request_id.as_deref(),
127 )
128 .await?;
107 events::emit(database.write(), room_kind(event), labels, &payload).await?;
129108 Ok(())
130109}
131110
132pub async fn enqueue_user(state: &AppState, uid: i64, event: &Value) -> Result<(), ChatError> {
111pub async fn enqueue_user(
112 database: &AppDatabase,
113 uid: i64,
114 event: &Value,
115) -> Result<(), ChatError> {
133116 let Some(kind) = user_kind(event) else {
134117 let unknown = event
135118 .get("kind")
@@ -139,21 +122,19 @@
139122 return Ok(());
140123 };
141124
142 let mut labels = queue::Labels::new().with("user_id", uid);
125 let mut labels = queue::Labels::new()
126 .with("user_id", uid)
127 .with(events::label::RECIPIENT_USER_ID, uid);
143128 if let Some(room_id) = event.get("room_id").and_then(Value::as_str) {
144129 labels.insert("room_id", room_id);
145130 }
146 let request_id = export::middleware::request_id();
147 if let Some(request_id) = request_id.as_deref() {
148 labels.insert("request_id", request_id);
149 }
150131
151132 let mut payload = event.clone();
152133 if let Some(room_id) = event
153134 .get("room_id")
154135 .and_then(Value::as_str)
155136 .and_then(|value| Uuid::parse_str(value).ok())
156 && let Some(room) = room_detail(state, room_id).await
137 && let Some(room) = room_detail(database, room_id).await
157138 {
158139 payload["room"] = room;
159140 }
@@ -161,32 +142,18 @@
161142 .get("message_id")
162143 .and_then(Value::as_str)
163144 .and_then(|value| Uuid::parse_str(value).ok())
164 && let Some(message) = message_detail(state, message_id).await
145 && let Some(message) = message_detail(database, message_id).await
165146 {
166 if let Some(author_id) = message.get("author_id").and_then(Value::as_i64)
167 && let Some(actor) = user_detail(state, author_id).await
168 {
169 payload["actor"] = actor;
170 }
171147 payload["message"] = message;
172148 }
173149
174 labels.insert(events::label::ORDERING_KEY, format!("chat:user:{uid}"));
175 events::outbox::enqueue_now(
176 state.database.write(),
177 kind,
178 labels,
179 &payload,
180 request_id.as_deref().unwrap_or_default(),
181 request_id.as_deref(),
182 )
183 .await?;
150 events::emit(database.write(), kind, labels, &payload).await?;
184151 Ok(())
185152}
186153
187async fn room_detail(state: &AppState, room_id: Uuid) -> Option<Value> {
154async fn room_detail(database: &AppDatabase, room_id: Uuid) -> Option<Value> {
188155 let room = rooms::Entity::find_by_id(room_id)
189 .one(state.database.read())
156 .one(database.read())
190157 .await
191158 .ok()??;
192159 Some(json!({
@@ -198,12 +165,12 @@
198165 }))
199166}
200167
201async fn message_detail(state: &AppState, message_id: Uuid) -> Option<Value> {
168async fn message_detail(database: &AppDatabase, message_id: Uuid) -> Option<Value> {
202169 let message = messages::Entity::find_by_id(message_id)
203 .one(state.database.read())
170 .one(database.read())
204171 .await
205172 .ok()??;
206 let mut detail = json!({
173 let detail = json!({
207174 "id": message.id,
208175 "room_id": message.room_id,
209176 "author_id": message.author_id,
@@ -218,18 +185,5 @@
218185 "edited_at": message.edited_at,
219186 "deleted_at": message.deleted_at,
220187 });
221 if let Some(actor) = user_detail(state, message.author_id).await {
222 detail["author"] = actor;
223 }
224188 Some(detail)
225}
226
227async fn user_detail(state: &AppState, uid: i64) -> Option<Value> {
228 let user = export::users::get_public_user(state, uid).await.ok()??;
229 Some(json!({
230 "id": user.user_id,
231 "username": user.username,
232 "display_name": user.displayname,
233 "avatar_url": export::resolve_user_avatar_url(user.user_id, &user.avatar_url),
234 }))
235189}
Modified lib/chat/src/share/lookup.rs View lib/chat/src/share/lookup.rs
@@ -1,8 +1,8 @@
11use models::room::rooms::{self};
22use models::room::{room_bans, room_members};
3use models::space::{space_members, spaces};
43use sea_orm::EntityTrait;
5use space::share::authz::{MESSAGE, effective_permissions};
4use space::{Member, Space};
5
66use uuid::Uuid;
77
88use crate::errors::ChatError;
@@ -10,7 +10,7 @@
1010
1111pub struct RoomContext {
1212 pub room: rooms::Model,
13 pub space: Option<spaces::Model>,
13 pub space: Option<Space>,
1414 pub is_dm: bool,
1515}
1616
@@ -21,15 +21,18 @@
2121/// `/app/{slug}/chat/{room}` without a second round trip.
2222pub async fn space_slug_for(service: &ChatService, space_id: Option<Uuid>) -> Option<String> {
2323 let space_id = space_id?;
24 space::authority::space_by_id(&service.state, 0, space_id)
24 service
25 .participants
26 .access
27 .by_id(space_id)
2528 .await
2629 .ok()
27 .map(|space| space.slug)
30 .map(|space| space.slug().to_owned())
2831}
2932
3033pub async fn lookup_room(service: &ChatService, room_id: Uuid) -> Result<rooms::Model, ChatError> {
3134 rooms::Entity::find_by_id(room_id)
32 .one(service.db().read())
35 .one(service.database.read())
3336 .await?
3437 .filter(|r| r.is_active)
3538 .ok_or(ChatError::NotFound)
@@ -41,7 +44,7 @@
4144) -> Result<RoomContext, ChatError> {
4245 let is_dm = room.space_id.is_none();
4346 let space = match room.space_id {
44 Some(space_id) => Some(space::authority::space_by_id(&service.state, 0, space_id).await?),
47 Some(space_id) => Some(service.participants.access.by_id(space_id).await?),
4548 None => None,
4649 };
4750 Ok(RoomContext { room, space, is_dm })
@@ -61,7 +64,7 @@
6164 uid: i64,
6265) -> Result<bool, ChatError> {
6366 Ok(room_members::Entity::find_by_id((room_id, uid))
64 .one(service.db().read())
67 .one(service.database.read())
6568 .await?
6669 .is_some_and(|m| m.left_at.is_none()))
6770}
@@ -69,18 +72,16 @@
6972pub async fn lookup_space_by_name(
7073 service: &ChatService,
7174 space_name: &str,
72) -> Result<spaces::Model, ChatError> {
73 Ok(space::share::lookup::find_active_space_by_name(&service.state, 0, space_name).await?)
75) -> Result<Space, ChatError> {
76 Ok(service.participants.access.by_slug(space_name).await?)
7477}
7578
7679pub async fn require_space_member(
77 service: &ChatService,
78 space: &spaces::Model,
80 _service: &ChatService,
81 space: &Space,
7982 uid: i64,
80) -> Result<space_members::Model, ChatError> {
81 space::authority::require_member(&service.state, space.id, uid)
82 .await
83 .map_err(|_| ChatError::Forbidden)
83) -> Result<Member, ChatError> {
84 Ok(space.require_member(uid).await?)
8485}
8586
8687pub async fn require_room_writable(
@@ -90,10 +91,7 @@
9091) -> Result<RoomContext, ChatError> {
9192 let ctx = require_room_readable(service, room_id, uid).await?;
9293 if let Some(space) = &ctx.space {
93 let perms = effective_permissions(&service.state, space, uid).await?;
94 if !perms.has(MESSAGE) {
95 return Err(ChatError::Forbidden);
96 }
94 space::require(space, uid, space::Capability::Message).await?;
9795 }
9896 Ok(ctx)
9997}
@@ -105,7 +103,7 @@
105103) -> Result<RoomContext, ChatError> {
106104 let ctx = lookup_room_context(service, room_id).await?;
107105 if let Some(ban) = room_bans::Entity::find_by_id((room_id, uid))
108 .one(service.db().read())
106 .one(service.database.read())
109107 .await?
110108 {
111109 let now = time::OffsetDateTime::now_utc();
Modified lib/chat/src/share/mention.rs View lib/chat/src/share/mention.rs
@@ -1,6 +1,6 @@
11use models::room::room_members;
2use models::space::{MemberStatus, space_groups};
3use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
2use sea_orm::EntityTrait;
3use space::MemberStatus;
44use uuid::Uuid;
55
66use crate::errors::ChatError;
@@ -24,7 +24,7 @@
2424 return Ok(ParsedMentions::default());
2525 }
2626
27 let max = service.config().chat.max_mentions_per_message;
27 let max = service.settings.chat.max_mentions_per_message;
2828 let mut parsed = ParsedMentions::default();
2929
3030 for token in content.split_whitespace() {
@@ -74,37 +74,30 @@
7474) -> Result<Resolved, ChatError> {
7575 // Targeted lookup by username/name, then verify the resolved user is
7676 // actually visible in this room's context (avoids full member scans).
77 let candidate = export::users::find_by_username(&service.state, lower).await?;
77 let candidate = auth::users::find_by_username(&service.audience.accounts, lower).await?;
7878 if let Some(space) = &ctx.space {
7979 if let Some(candidate) = &candidate {
80 if space.owner_user_id == candidate.user_id {
80 if space.owner() == candidate.user_id {
8181 return Ok(Resolved::User(candidate.user_id));
8282 }
83 let member = space::authority::member(
84 &service.state,
85 space.owner_user_id,
86 space.id,
87 candidate.user_id,
88 )
89 .await?;
83 let member = service
84 .participants
85 .access
86 .member(space.id(), candidate.user_id)
87 .await?;
9088 if member.is_some_and(|m| m.status() == MemberStatus::Active) {
9189 return Ok(Resolved::User(candidate.user_id));
9290 }
9391 }
94 let group = space_groups::Entity::find()
95 .filter(space_groups::Column::SpaceId.eq(space.id))
96 .filter(space_groups::Column::Name.eq(lower))
97 .one(service.db().read())
98 .await?;
99 if let Some(group) = group {
100 return Ok(Resolved::Group(group.id));
92 if let Some(group) = space.group_id_by_name(lower).await? {
93 return Ok(Resolved::Group(group));
10194 }
10295 return Ok(Resolved::None);
10396 }
10497
10598 if let Some(candidate) = candidate {
10699 let member = room_members::Entity::find_by_id((ctx.room.id, candidate.user_id))
107 .one(service.db().read())
100 .one(service.database.read())
108101 .await?;
109102 if member.is_some_and(|m| m.left_at.is_none()) {
110103 return Ok(Resolved::User(candidate.user_id));
Modified lib/chat/src/share/notify.rs View lib/chat/src/share/notify.rs
@@ -1,13 +1,13 @@
11use models::chat::room_seq;
22use models::chat::room_user_settings::{self, RoomNotificationLevel};
33use models::room::room_members;
4use models::space::MessageNotifications;
5use models::users::user_notifications::{self, NotificationType};
4use models::users::user_notifications::NotificationType;
65use models::users::user_recent_mentions;
76use sea_orm::{
87 ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, QueryFilter, Set,
98 Statement,
109};
10use space::MessageNotifications;
1111use time::OffsetDateTime;
1212use uuid::Uuid;
1313
@@ -21,7 +21,7 @@
2121 room_members::Entity::find()
2222 .filter(room_members::Column::RoomId.eq(room_id))
2323 .filter(room_members::Column::LeftAt.is_null())
24 .all(service.db().read())
24 .all(service.database.read())
2525 .await
2626 .unwrap_or_default()
2727 .into_iter()
@@ -94,19 +94,7 @@
9494) -> Result<(), ChatError> {
9595 // The row stores already-rendered text: there is no schema column for the
9696 // language, so callers resolve it per recipient and pass finished copy in.
97 let row = user_notifications::ActiveModel {
98 id: Set(Uuid::now_v7()),
99 user_id: Set(recipient),
100 notification_type: Set(ntype),
101 title: Set(title),
102 body: Set(body),
103 data_json: Set(data),
104 actor_uid: Set(actor_uid),
105 read_at: Set(None),
106 created_at: Set(OffsetDateTime::now_utc()),
107 }
108 .insert(db)
109 .await?;
97 let row = export::notify::insert(db, recipient, ntype, title, body, data, actor_uid).await?;
11098 pending.push(recipient, row.to_event_value());
11199 Ok(())
112100}
@@ -130,7 +118,7 @@
130118 everyone: bool,
131119) -> Result<NotifyDecision, ChatError> {
132120 if let Some(setting) = room_user_settings::Entity::find_by_id((uid, room_id))
133 .one(service.db().read())
121 .one(service.database.read())
134122 .await?
135123 {
136124 if mute_active(setting.muted, setting.mute_until) {
@@ -154,13 +142,9 @@
154142 }
155143
156144 if let Some(space_id) = space_id
157 && let Some(member) = space::authority::member(&service.state, uid, space_id, uid).await?
145 && let Some(member) = service.participants.access.member(space_id, uid).await?
158146 {
159 if mute_active(
160 member.muted,
161 space::authority::to_time_opt(member.mute_until),
162 ) && !(mentioned && !everyone)
163 {
147 if mute_active(member.muted, member.mute_until) && !(mentioned && !everyone) {
164148 return Ok(NotifyDecision::Skip);
165149 }
166150 return Ok(match member.message_notifications {
@@ -207,14 +191,17 @@
207191 room_members::Entity::find()
208192 .filter(room_members::Column::RoomId.eq(room_id))
209193 .filter(room_members::Column::LeftAt.is_null())
210 .all(service.db().read())
194 .all(service.database.read())
211195 .await
212196 .unwrap_or_default()
213197 .into_iter()
214198 .map(|m| m.user_id)
215199 .collect()
216200 } else if let Some(space_id) = space_id {
217 space::authority::list_members(&service.state, actor, space_id, false, false)
201 service
202 .participants
203 .access
204 .members(space_id, actor)
218205 .await
219206 .unwrap_or_default()
220207 .into_iter()
@@ -230,7 +217,6 @@
230217 "message_id": message_id,
231218 "everyone": true,
232219 });
233 let pending = export::notify::PendingNotifications::default();
234220 for uid in candidates {
235221 if uid == actor || explicit.contains(&uid) {
236222 continue;
@@ -247,29 +233,30 @@
247233 message_id: Set(message_id),
248234 created_at: Set(now),
249235 }
250 .insert(service.db().write())
236 .insert(service.database.write())
251237 .await;
252 let _ = notify_user(
253 service.db().write(),
254 &pending,
255 uid,
256 NotificationType::Mention,
257 i18n::catalog::notification_mention_message_title(service.recipient_locale(uid).await)
238 let _ = service
239 .audience
240 .notifications
241 .create(
242 uid,
243 NotificationType::Mention,
244 i18n::catalog::notification_mention_message_title(
245 service.audience.recipient_locale(uid).await,
246 )
258247 .to_string(),
259 Some(body.clone()),
260 Some(data.clone()),
261 Some(actor),
262 )
263 .await;
264 service
265 .realtime
248 Some(body.clone()),
249 Some(data.clone()),
250 Some(actor),
251 )
252 .await;
253 service.audience.realtime
266254 .publish_user(
267255 uid,
268256 &serde_json::json!({"kind": "mention", "room_id": room_id, "message_id": message_id, "everyone": true}),
269257 )
270258 .await;
271259 }
272 pending.flush(&service.state).await;
273260}
274261
275262/// Mention e-mails honour per-room / per-space mute and notification level:
@@ -281,7 +268,7 @@
281268 uid: i64,
282269) -> Result<bool, ChatError> {
283270 if let Some(setting) = room_user_settings::Entity::find_by_id((uid, room_id))
284 .one(service.db().read())
271 .one(service.database.read())
285272 .await?
286273 {
287274 if mute_active(setting.muted, setting.mute_until) {
@@ -294,12 +281,9 @@
294281 }
295282 }
296283 if let Some(space_id) = space_id
297 && let Some(member) = space::authority::member(&service.state, uid, space_id, uid).await?
284 && let Some(member) = service.participants.access.member(space_id, uid).await?
298285 {
299 if mute_active(
300 member.muted,
301 space::authority::to_time_opt(member.mute_until),
302 ) {
286 if mute_active(member.muted, member.mute_until) {
303287 return Ok(false);
304288 }
305289 return Ok(!matches!(
@@ -330,7 +314,7 @@
330314 )
331315 .await;
332316 }
333 service.realtime.publish_room(&event).await;
317 service.audience.realtime.publish_room(&event).await;
334318}
335319
336320pub async fn advance_read_state(
Modified lib/chat/src/share/realtime.rs View lib/chat/src/share/realtime.rs
@@ -115,23 +115,24 @@
115115/// KenEvent(MQ → socket),本服务只负责把"发生了什么"持久化下来。
116116#[derive(Clone)]
117117pub struct Realtime {
118 state: export::AppState,
118 database: std::sync::Arc<export::AppDatabase>,
119119}
120120
121121impl Realtime {
122 pub fn new(state: export::AppState) -> Self {
123 Self { state }
122 pub fn new(database: std::sync::Arc<export::AppDatabase>) -> Self {
123 Self { database }
124124 }
125125
126126 pub async fn publish_room(&self, event: &RoomEvent) {
127 if let Err(error) = crate::share::events::enqueue_room(&self.state, event).await {
127 if let Err(error) = crate::share::events::enqueue_room(&self.database, event).await {
128128 tracing::warn!(%error, room_id = %event.room_id(), "chat room event enqueue failed");
129129 }
130130 }
131131
132132 pub async fn publish_user<T: Serialize>(&self, uid: i64, event: &T) {
133133 if let Ok(value) = serde_json::to_value(event)
134 && let Err(error) = crate::share::events::enqueue_user(&self.state, uid, &value).await
134 && let Err(error) =
135 crate::share::events::enqueue_user(&self.database, uid, &value).await
135136 {
136137 tracing::warn!(%error, %uid, "chat user event enqueue failed");
137138 }
Modified lib/chat/src/errors.rs View lib/chat/src/errors.rs
@@ -34,7 +34,7 @@
3434export::redis_error_conversion!(ChatError);
3535export::export_error_conversion!(ChatError);
3636export::json_payload_conversion!(ChatError);
37export::domain_error_passthrough!(ChatError from space::errors::SpaceError);
37export::access_error_conversion!(ChatError, BadRequest);
3838
3939export::api_error! {
4040 log: "chat",
Modified lib/chat/src/lib.rs View lib/chat/src/lib.rs
@@ -1,3 +1,4 @@
1pub use chat_room_store as room_commands;
12pub mod errors;
23pub mod handlers;
34pub mod openapi;
Modified lib/chat/src/openapi.rs View lib/chat/src/openapi.rs
@@ -120,7 +120,7 @@
120120 export::pagination::Page<crate::services::rooms::RoomInviteListItemDto>,
121121 AppResponseData<export::rest::NoData>,
122122 export::rest::NoData,
123 space::services::dto::UserDto,
123 export::users::UserDto,
124124 models::chat::messages::AuthorType,
125125 models::chat::messages::ChatMessageType,
126126 models::chat::attachments::AttachmentStatus,
Modified lib/chat/src/worker.rs View lib/chat/src/worker.rs
@@ -1,6 +1,5 @@
11use std::time::Duration;
22
3use export::AppState;
43use models::users::user_recent_mentions;
54use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
65use time::OffsetDateTime;
@@ -16,14 +15,13 @@
1615 let cutoff = OffsetDateTime::now_utc() - time::Duration::days(MENTION_RETENTION_DAYS);
1716 user_recent_mentions::Entity::delete_many()
1817 .filter(user_recent_mentions::Column::CreatedAt.lt(cutoff))
19 .exec(self.db().write())
18 .exec(self.database.write())
2019 .await?;
2120 Ok(())
2221 }
2322}
2423
25pub async fn run_background_tasks(state: AppState) {
26 let service = ChatService::new(state);
24pub async fn run_background_tasks(service: ChatService) {
2725 let mut interval = tokio::time::interval(Duration::from_secs(30));
2826 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2927 let mut tick: u32 = 0;
Modified lib/chat/Cargo.toml View lib/chat/Cargo.toml
@@ -4,6 +4,9 @@
44edition.workspace = true
55
66[dependencies]
7auth = { workspace = true }
8chat-room-store = { path = "../chat-room-store" }
9email = { workspace = true }
710async-trait = { workspace = true }
811config = { workspace = true }
912export = { workspace = true }
Added lib/chat-room-store/src/lib.rs View lib/chat-room-store/src/lib.rs

Diff too large to display.

Added lib/chat-room-store/Cargo.toml View lib/chat-room-store/Cargo.toml

Diff too large to display.

Modified lib/deletion/src/lib.rs View lib/deletion/src/lib.rs
@@ -65,13 +65,6 @@
6565 DeletionError::KenShell(error.to_string())
6666}
6767
68fn client(state: &AppState) -> Result<&dyn export::AuthApi, DeletionError> {
69 state
70 .kenauth
71 .as_deref()
72 .ok_or_else(|| DeletionError::Config("kenoauth is not configured".into()))
73}
74
7568fn now() -> OffsetDateTime {
7669 OffsetDateTime::now_utc()
7770}
@@ -104,6 +97,7 @@
10497
10598pub async fn request_account_deletion(
10699 state: &AppState,
100 accounts: &auth::Accounts,
107101 user_id: i64,
108102 reason: &str,
109103) -> Result<AccountDeletionStatus, DeletionError> {
@@ -137,7 +131,7 @@
137131 ));
138132 }
139133 }
140 let client = client(state)?;
134 let client = accounts;
141135 let status = client.get_user_status(user_id).await.map_err(ken_auth)?;
142136 if status.deletion_requested_at.is_none() && status.deleted_at.is_none() {
143137 client
@@ -184,8 +178,12 @@
184178 Ok(status_of_account(&row))
185179}
186180
187pub async fn cancel_account_deletion(state: &AppState, user_id: i64) -> Result<(), DeletionError> {
188 client(state)?
181pub async fn cancel_account_deletion(
182 state: &AppState,
183 accounts: &auth::Accounts,
184 user_id: i64,
185) -> Result<(), DeletionError> {
186 accounts
189187 .cancel_account_deletion(user_id)
190188 .await
191189 .map_err(ken_auth)?;
@@ -217,19 +215,19 @@
217215 }
218216}
219217
220pub async fn run_worker(state: AppState) {
218pub async fn run_worker(state: AppState, accounts: auth::Accounts) {
221219 tracing::info!("deletion worker started");
222220 let mut interval = tokio::time::interval(Duration::from_secs(30));
223221 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
224222 loop {
225223 interval.tick().await;
226 if let Err(error) = purge_due(&state).await {
224 if let Err(error) = purge_due(&state, &accounts).await {
227225 tracing::warn!(%error, "deletion worker pass failed");
228226 }
229227 }
230228}
231229
232async fn purge_due(state: &AppState) -> Result<(), DeletionError> {
230async fn purge_due(state: &AppState, accounts: &auth::Accounts) -> Result<(), DeletionError> {
233231 let db = state.database.read();
234232 let cutoff = now();
235233 let spaces = space_deletion::Entity::find()
@@ -243,13 +241,13 @@
243241 }
244242 }
245243
246 let accounts = account_deletion::Entity::find()
244 let pending_accounts = account_deletion::Entity::find()
247245 .filter(account_deletion::Column::Status.eq("pending"))
248246 .filter(account_deletion::Column::AvailableAt.lte(cutoff))
249247 .all(db)
250248 .await?;
251 for row in accounts {
252 if let Err(error) = purge_account(state, &row).await {
249 for row in pending_accounts {
250 if let Err(error) = purge_account(state, accounts, &row).await {
253251 record_account_failure(state, &row, &error).await?;
254252 }
255253 }
@@ -437,6 +435,7 @@
437435
438436async fn purge_account(
439437 state: &AppState,
438 accounts: &auth::Accounts,
440439 row: &account_deletion::Model,
441440) -> Result<(), DeletionError> {
442441 let db = state.database.write();
@@ -521,7 +520,7 @@
521520 active.update(db).await?;
522521 }
523522
524 let client = client(state)?;
523 let client = accounts;
525524 match client.purge_account(row.user_id).await {
526525 Ok(()) | Err(KenAuthError::NotFound) => {}
527526 Err(error) => return Err(ken_auth(error)),
@@ -566,3 +565,14 @@
566565 tracing::warn!(user_id = %row.user_id, %error, attempts, "account purge failed; will retry");
567566 Ok(())
568567}
568
569impl From<DeletionError> for auth::AuthBusinessError {
570 fn from(error: DeletionError) -> Self {
571 match error {
572 DeletionError::Database(error) => Self::from(error),
573 DeletionError::Conflict(message) => Self::Conflict(message),
574 DeletionError::NotFound(_) => Self::NotFound,
575 other => Self::Internal(other.to_string()),
576 }
577 }
578}
Modified lib/deletion/Cargo.toml View lib/deletion/Cargo.toml
@@ -4,6 +4,7 @@
44edition.workspace = true
55
66[dependencies]
7auth = { workspace = true }
78config = { workspace = true }
89export = { workspace = true }
910proto = { workspace = true }
Modified lib/email/src/lib.rs View lib/email/src/lib.rs
@@ -12,3 +12,17 @@
1212};
1313pub use publisher::MailPublisher;
1414pub use transport::{EmailTransport, EmailTransportBuilder, build_message};
15
16pub async fn send_optional(
17 publisher: Option<&MailPublisher>,
18 to: &str,
19 subject: &str,
20 text: &str,
21 html: &str,
22) -> Result<(), EmailError> {
23 let Some(publisher) = publisher else {
24 tracing::warn!(to, "mail publisher disabled; skipping email");
25 return Ok(());
26 };
27 publisher.send_mail(to, subject, text, html, None).await
28}
Modified lib/envelope/src/response.rs View lib/envelope/src/response.rs
@@ -22,6 +22,9 @@
2222 pub error_code: Option<String>,
2323 #[serde(default)]
2424 pub request_id: String,
25 #[serde(skip)]
26 #[schema(ignore)]
27 retry_after_secs: Option<u64>,
2528}
2629
2730impl<T: Serialize> Serialize for Envelope<T> {
@@ -56,6 +59,7 @@
5659 error: None,
5760 error_code: None,
5861 request_id: String::new(),
62 retry_after_secs: None,
5963 }
6064 }
6165
@@ -67,6 +71,7 @@
6771 error: None,
6872 error_code: None,
6973 request_id: String::new(),
74 retry_after_secs: None,
7075 }
7176 }
7277
@@ -85,6 +90,11 @@
8590 self
8691 }
8792
93 pub fn with_retry_after(mut self, seconds: u64) -> Self {
94 self.retry_after_secs = Some(seconds);
95 self
96 }
97
8898 pub fn status(&self) -> StatusCode {
8999 status_of(self.code)
90100 }
@@ -103,6 +113,7 @@
103113 error: None,
104114 error_code: None,
105115 request_id: String::new(),
116 retry_after_secs: None,
106117 }
107118 }
108119}
@@ -128,7 +139,11 @@
128139impl<T: Serialize> Envelope<T> {
129140 pub fn into_http_response(self) -> HttpResponse<BoxBody> {
130141 let status = status_of(self.code);
131 HttpResponse::build(status)
142 let mut response = HttpResponse::build(status);
143 if let Some(seconds) = self.retry_after_secs {
144 response.insert_header((actix_web::http::header::RETRY_AFTER, seconds.to_string()));
145 }
146 response
132147 .content_type("application/json")
133148 .body(body_of(&self))
134149 }
Added lib/events/src/emit.rs View lib/events/src/emit.rs

Diff too large to display.

Modified lib/events/src/label.rs View lib/events/src/label.rs
@@ -1,5 +1,6 @@
11use serde_json::Value;
22
3pub const RECIPIENT_USER_ID: &str = "recipient_user_id";
34pub const USER_ID: &str = "user_id";
45pub const ACTOR_USER_ID: &str = "actor_user_id";
56pub const ORGANIZATION_ID: &str = "organization_id";
Modified lib/events/src/lib.rs View lib/events/src/lib.rs
@@ -1,3 +1,5 @@
1mod emit;
2pub use emit::{actor_snapshot, emit};
13pub mod handler;
24pub mod label;
35pub mod outbox;
Modified lib/events/src/outbox.rs View lib/events/src/outbox.rs
@@ -98,6 +98,24 @@
9898 }
9999 let domain = domain(kind);
100100 let key = match domain {
101 "chat" => {
102 if let Some(uid) = labels.get(label::RECIPIENT_USER_ID) {
103 return Ok(format!("chat:user:{uid}"));
104 }
105 if let Some(room) = labels.get(label::ROOM_ID) {
106 return Ok(format!("chat:room:{room}"));
107 }
108 labels.get(label::USER_ID)
109 }
110 "notify" => {
111 if let Some(uid) = labels
112 .get(label::RECIPIENT_USER_ID)
113 .or_else(|| labels.get(label::USER_ID))
114 {
115 return Ok(format!("notify:user:{uid}"));
116 }
117 None
118 }
101119 "guild" => labels.get(label::GUILD_ID),
102120 "git" => labels.get(label::REPOSITORY_ID),
103121 "pull" => labels
Added lib/export/src/access_error.rs View lib/export/src/access_error.rs

Diff too large to display.

Modified lib/export/src/api_error.rs View lib/export/src/api_error.rs
@@ -263,9 +263,8 @@
263263 $( Self::$variant(envelope) => ($status, (**envelope).clone()), )*
264264 };
265265 envelope.request_id = $crate::middleware::request_id().unwrap_or_default();
266 let status = ::actix_web::http::StatusCode::from_u16(status)
267 .unwrap_or(::actix_web::http::StatusCode::INTERNAL_SERVER_ERROR);
268 ::actix_web::HttpResponse::build(status).json(envelope)
266 envelope.code = status;
267 envelope.into_http_response()
269268 }
270269 }
271270
Deleted lib/export/src/auth_api.rs

Diff too large to display.

Modified lib/export/src/database.rs View lib/export/src/database.rs
@@ -1,5 +1,3 @@
1use std::sync::atomic::{AtomicUsize, Ordering};
2
31use crate::error::ExportError;
42use config::database::{DatabaseConfig, DatabaseRole as ConfigRole};
53use sea_orm::prelude::async_trait::async_trait;
@@ -29,44 +27,12 @@
2927 DatabaseRole::Primary => ConfigRole::Primary,
3028 DatabaseRole::Replica => ConfigRole::Replica,
3129 }
32 }
33}
34
35pub trait Database: Send + Sync {
36 fn config(&self) -> &DatabaseConfig;
37 fn primary(&self) -> &DatabaseConnection;
38 fn replicas(&self) -> &[DatabaseConnection];
39 fn read(&self) -> &DatabaseConnection;
40 fn write(&self) -> &DatabaseConnection;
41}
42
43impl Database for AppDatabase {
44 fn config(&self) -> &DatabaseConfig {
45 AppDatabase::config(self)
46 }
47
48 fn primary(&self) -> &DatabaseConnection {
49 AppDatabase::primary(self)
50 }
51
52 fn replicas(&self) -> &[DatabaseConnection] {
53 AppDatabase::replicas(self)
54 }
55
56 fn read(&self) -> &DatabaseConnection {
57 AppDatabase::read(self)
58 }
59
60 fn write(&self) -> &DatabaseConnection {
61 AppDatabase::write(self)
6230 }
6331}
6432
6533pub struct AppDatabase {
6634 config: DatabaseConfig,
67 primary: DatabaseConnection,
68 replicas: Vec<DatabaseConnection>,
69 round_robin: AtomicUsize,
35 connection: db::DbConn,
7036}
7137
7238impl AppDatabase {
@@ -89,9 +55,7 @@
8955
9056 Ok(Self {
9157 config,
92 primary,
93 replicas,
94 round_robin: AtomicUsize::new(0),
58 connection: db::DbConn::new(primary, replicas),
9559 })
9660 }
9761
@@ -100,69 +64,56 @@
10064 }
10165
10266 pub fn primary(&self) -> &DatabaseConnection {
103 &self.primary
67 self.connection.write()
10468 }
10569
10670 pub fn replicas(&self) -> &[DatabaseConnection] {
107 &self.replicas
71 self.connection.replicas()
10872 }
10973
11074 pub fn replica(&self, index: usize) -> Option<&DatabaseConnection> {
111 self.replicas.get(index)
75 self.connection.replicas().get(index)
11276 }
11377
11478 pub fn replica_count(&self) -> usize {
115 self.replicas.len()
79 self.connection.replicas().len()
11680 }
11781
11882 pub fn has_replicas(&self) -> bool {
119 !self.replicas.is_empty()
83 !self.connection.replicas().is_empty()
12084 }
12185
12286 pub fn write(&self) -> &DatabaseConnection {
123 &self.primary
124 }
125
126 pub fn read(&self) -> &DatabaseConnection {
127 self.pick_replica().unwrap_or(&self.primary)
87 self.connection.write()
12888 }
12989
130 pub fn route_for(&self, sql: &str) -> &DatabaseConnection {
131 if db::requires_primary(sql) {
132 self.write()
133 } else {
134 self.read()
135 }
90 pub fn read(&self) -> &db::DbConn {
91 self.connection.read()
13692 }
137
138 fn pick_replica(&self) -> Option<&DatabaseConnection> {
139 if self.replicas.is_empty() {
140 return None;
141 }
142 let idx = self.round_robin.fetch_add(1, Ordering::Relaxed) % self.replicas.len();
143 Some(&self.replicas[idx])
93 pub fn connection(&self) -> &db::DbConn {
94 &self.connection
14495 }
14596}
14697
14798#[async_trait]
14899impl sea_orm::ConnectionTrait for AppDatabase {
149100 fn get_database_backend(&self) -> DbBackend {
150 self.primary.get_database_backend()
101 self.connection.get_database_backend()
151102 }
152103
153104 async fn execute_raw(&self, stmt: Statement) -> Result<ExecResult, DbErr> {
154 self.route_for(&stmt.sql).execute_raw(stmt).await
105 self.connection.read().execute_raw(stmt).await
155106 }
156107
157108 async fn execute_unprepared(&self, sql: &str) -> Result<ExecResult, DbErr> {
158 self.route_for(sql).execute_unprepared(sql).await
109 self.connection.read().execute_unprepared(sql).await
159110 }
160111
161112 async fn query_one_raw(&self, stmt: Statement) -> Result<Option<QueryResult>, DbErr> {
162 self.route_for(&stmt.sql).query_one_raw(stmt).await
113 self.connection.read().query_one_raw(stmt).await
163114 }
164115
165116 async fn query_all_raw(&self, stmt: Statement) -> Result<Vec<QueryResult>, DbErr> {
166 self.route_for(&stmt.sql).query_all_raw(stmt).await
117 self.connection.read().query_all_raw(stmt).await
167118 }
168119}
Modified lib/export/src/health.rs View lib/export/src/health.rs
@@ -4,13 +4,12 @@
44use sea_orm::{ConnectionTrait, DbBackend, Statement};
55
66use crate::http::{Router, State, get};
7use crate::{AppRedis, AppState, AuthApi, Database};
7use crate::{AppDatabase, AppRedis, AppState};
88
99#[derive(Clone)]
1010struct HealthState {
11 database: Option<Arc<dyn Database>>,
11 database: Option<Arc<AppDatabase>>,
1212 redis: AppRedis,
13 kenauth: Option<Arc<dyn AuthApi>>,
1413}
1514
1615async fn healthz() -> HttpResponse {
@@ -39,36 +38,20 @@
3938 redis::cmd("PING").query_async::<String>(c).await.is_ok()
4039 }
4140 };
42 let kenauth_ok = match state.kenauth.as_ref() {
43 None => true,
44 Some(client) => client.check_health().await.is_ok(),
45 };
46 if db_ok && redis_ok && kenauth_ok {
41 if db_ok && redis_ok {
4742 HttpResponse::Ok().body("ready")
4843 } else {
4944 HttpResponse::ServiceUnavailable().body("not ready")
5045 }
5146}
5247
53pub fn router_with(
54 database: Option<Arc<dyn Database>>,
55 redis: AppRedis,
56 kenauth: Option<Arc<dyn AuthApi>>,
57) -> Router {
48pub fn router_with(database: Option<Arc<AppDatabase>>, redis: AppRedis) -> Router {
5849 Router::<()>::new()
5950 .route("/healthz", get(healthz))
6051 .route("/readyz", get(readyz))
61 .with_state(HealthState {
62 database,
63 redis,
64 kenauth,
65 })
52 .with_state(HealthState { database, redis })
6653}
6754
6855pub fn router(state: AppState) -> Router {
69 router_with(
70 Some(state.database.clone()),
71 state.redis.clone(),
72 state.kenauth.clone(),
73 )
56 router_with(Some(state.database.clone()), state.redis.clone())
7457}
Deleted lib/export/src/identity_client.rs

Diff too large to display.

Added lib/export/src/identity_error.rs View lib/export/src/identity_error.rs

Diff too large to display.

Modified lib/export/src/imaging.rs View lib/export/src/imaging.rs
@@ -21,7 +21,7 @@
2121/// [`preview_key_base`]; because the encoder choice (JPEG vs WebP) is not
2222/// derivable from the original's extension, both candidates are probed.
2323pub async fn find_stored_preview(
24 client: &dyn storage::BlobStore,
24 client: &storage::S3Client,
2525 key_base: &str,
2626) -> Option<PreviewState> {
2727 for candidate in preview_candidate_keys(key_base) {
@@ -50,7 +50,7 @@
5050/// `None` means "nothing recorded", which is exactly the legacy-row case this is
5151/// meant to heal without a batch job.
5252pub async fn ensure_preview(
53 client: &dyn storage::BlobStore,
53 client: &storage::S3Client,
5454 redis: &AppRedis,
5555 key_base: &str,
5656 original_key: &str,
@@ -106,7 +106,7 @@
106106/// Read the original, render a derivative, store it, and drop the other
107107/// encoding so a JPEG->WebP switch cannot leave two "current" previews behind.
108108async fn generate_preview(
109 client: &dyn storage::BlobStore,
109 client: &storage::S3Client,
110110 key_base: &str,
111111 original_key: &str,
112112 content_type: &str,
Modified lib/export/src/lib.rs View lib/export/src/lib.rs
@@ -1,10 +1,9 @@
11mod api_error;
2pub mod auth_api;
32pub mod database;
43pub mod error;
54pub mod health;
65pub mod http;
7pub mod identity_client;
6mod identity_error;
87pub mod imaging;
98pub mod middleware;
109pub mod notify;
@@ -20,22 +19,22 @@
2019use std::sync::Arc;
2120
2221pub use api_error::is_unique_violation;
23pub use auth_api::{AuthApi, KenAuthError};
2422use config::AppConfig;
25pub use database::{AppDatabase, Database, DatabaseRole};
23pub use database::{AppDatabase, DatabaseRole};
24pub use db::DbConn;
2625pub use error::ExportError;
26pub use identity_error::KenAuthError;
2727pub use redis::{AppRedis, RedisConnection};
28pub use storage::{BlobStore, S3Client};
28pub use storage::S3Client;
2929pub use urls::{resolve_space_image_url, resolve_user_avatar_url};
3030
3131#[derive(Clone)]
3232pub struct AppState {
33 pub database: Arc<dyn Database>,
33 pub database: Arc<AppDatabase>,
3434 pub redis: AppRedis,
3535 pub cache: ::cache::Cache,
36 pub storage: Arc<dyn BlobStore>,
36 pub storage: Arc<S3Client>,
3737 pub config: AppConfig,
38 pub kenauth: Option<Arc<dyn AuthApi>>,
3938 pub mail: Option<::email::MailPublisher>,
4039 pub events: Option<::events::EventHandler>,
4140}
@@ -48,15 +47,14 @@
4847 }
4948
5049 pub async fn with_config(config: AppConfig) -> Result<Self, ExportError> {
51 let database: Arc<dyn Database> =
50 let database: Arc<AppDatabase> =
5251 Arc::new(AppDatabase::connect(config.database.clone()).await?);
53 Self::with_identity(config, database, None, None, None).await
52 Self::with_identity(config, database, None, None).await
5453 }
5554
5655 pub async fn with_identity(
5756 config: AppConfig,
58 database: Arc<dyn Database>,
59 identity: Option<Arc<dyn AuthApi>>,
57 database: Arc<AppDatabase>,
6058 mail: Option<::email::MailPublisher>,
6159 events: Option<::events::EventHandler>,
6260 ) -> Result<Self, ExportError> {
@@ -67,27 +65,21 @@
6765 // 入队点遍布各业务 crate,但只有这里有配置:把开关写进句柄,让
6866 // `export::push::enqueue` 能自己判断该不该产生任务。
6967 redis.set_push_enabled(config.push.enabled);
70 let storage: Arc<dyn BlobStore> =
68 let storage: Arc<S3Client> =
7169 Arc::new(S3Client::new(&config.s3).map_err(ExportError::from)?);
72
73 let kenauth = identity;
74 if let Some(client) = &kenauth {
75 identity_client::install(client.clone());
76 }
7770
7871 Ok(Self::from_parts(
79 database, redis, cache, storage, config, kenauth, mail, events,
72 database, redis, cache, storage, config, mail, events,
8073 ))
8174 }
8275
8376 #[allow(clippy::too_many_arguments)]
8477 pub fn from_parts(
85 database: Arc<dyn Database>,
78 database: Arc<AppDatabase>,
8679 redis: AppRedis,
8780 cache: ::cache::Cache,
88 storage: Arc<dyn BlobStore>,
81 storage: Arc<S3Client>,
8982 config: AppConfig,
90 kenauth: Option<Arc<dyn AuthApi>>,
9183 mail: Option<::email::MailPublisher>,
9284 events: Option<::events::EventHandler>,
9385 ) -> Self {
@@ -97,9 +89,11 @@
9789 cache,
9890 storage,
9991 config,
100 kenauth,
10192 mail,
10293 events,
10394 }
10495 }
10596}
97
98mod access_error;
99pub use access_error::AccessError;
Modified lib/export/src/notify.rs View lib/export/src/notify.rs
@@ -3,62 +3,52 @@
33use queue::Labels;
44use serde_json::Value;
55
6use crate::AppState;
6use crate::{AppDatabase, AppRedis};
77
8pub fn notification_event_json(notification: Value) -> Value {
9 serde_json::json!({
10 "kind": "notification",
11 "notification": notification,
12 })
8#[derive(Clone)]
9pub struct Notifications {
10 database: Arc<AppDatabase>,
11 redis: AppRedis,
1312}
1413
15pub async fn publish_user_event(state: &AppState, uid: i64, event: &Value) {
16 let redis = &state.redis;
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.
19 // Every `notification` user event is also a candidate for an OS push.
20 // Enqueueing here — rather than at each of the nine writers — is what makes
21 // "websocket fan-out and push fan-out never drift" true by construction.
22 if event.get("kind").and_then(Value::as_str) == Some("notification") {
23 let mut event = event.clone();
24 if let Some(notification) = event.get_mut("notification") {
25 if let Some(actor_id) = notification.get("actor_id").and_then(Value::as_i64)
26 && let Ok(Some(actor)) = crate::users::get_public_user(state, actor_id).await
27 {
28 notification["actor"] = serde_json::json!({
29 "id": actor.user_id,
30 "username": actor.username,
31 "display_name": actor.displayname,
32 "avatar_url": crate::resolve_user_avatar_url(actor.user_id, &actor.avatar_url),
33 });
34 }
35 if let Some(job) = crate::push::PushJob::from_notification(uid, notification) {
36 crate::push::enqueue(redis, &job).await;
37 }
38 }
39 let request_id = crate::middleware::request_id();
40 let labels = Labels::new()
41 .with("user_id", uid)
42 .with_opt("request_id", request_id.clone())
43 .with(events::label::ORDERING_KEY, format!("notify:user:{uid}"));
44 if let Err(error) = events::outbox::enqueue_now(
45 state.database.write(),
46 "notify.notification.create",
47 labels,
48 &event,
49 request_id.as_deref().unwrap_or_default(),
50 request_id.as_deref(),
51 )
52 .await
53 {
54 tracing::warn!(%error, %uid, "notification event enqueue failed");
55 }
14impl Notifications {
15 pub fn new(database: Arc<AppDatabase>, redis: AppRedis) -> Self {
16 Self { database, redis }
5617 }
5718}
5819
59pub async fn publish_notification(state: &AppState, uid: i64, notification: Value) {
60 let event = notification_event_json(notification);
61 publish_user_event(state, uid, &event).await;
20impl Notifications {
21 #[allow(clippy::too_many_arguments)]
22 pub async fn create(
23 &self,
24 recipient: i64,
25 kind: models::users::user_notifications::NotificationType,
26 title: String,
27 body: Option<String>,
28 data: Option<Value>,
29 actor: Option<i64>,
30 ) -> Result<models::users::user_notifications::Model, sea_orm::DbErr> {
31 use sea_orm::TransactionTrait;
32 let row = self
33 .database
34 .write()
35 .transaction::<_, models::users::user_notifications::Model, sea_orm::DbErr>(move |tx| {
36 Box::pin(async move { insert(tx, recipient, kind, title, body, data, actor).await })
37 })
38 .await
39 .map_err(|error| match error {
40 sea_orm::TransactionError::Connection(error)
41 | sea_orm::TransactionError::Transaction(error) => error,
42 })?;
43 self.deliver(recipient, row.to_event_value()).await;
44 Ok(row)
45 }
46
47 pub async fn deliver(&self, uid: i64, notification: Value) {
48 if let Some(job) = crate::push::PushJob::from_notification(uid, &notification) {
49 crate::push::enqueue(&self.redis, &job).await;
50 }
51 }
6252}
6353
6454/// Notifications created inside a DB transaction; flush after commit.
@@ -70,20 +60,57 @@
7060impl PendingNotifications {
7161 pub fn push(&self, uid: i64, notification: Value) {
7262 match self.inner.lock() {
73 Ok(mut pending) => pending.push((uid, notification_event_json(notification))),
63 Ok(mut pending) => pending.push((uid, notification)),
7464 Err(_) => {
7565 tracing::warn!("pending notifications lock poisoned");
7666 }
7767 }
7868 }
7969
80 pub async fn flush(&self, state: &AppState) {
70 pub async fn flush(&self, notifications: &Notifications) {
8171 let batch: Vec<(i64, Value)> = match self.inner.lock() {
8272 Ok(mut pending) => std::mem::take(&mut *pending),
8373 Err(_) => return,
8474 };
8575 for (uid, event) in batch {
86 publish_user_event(state, uid, &event).await;
76 notifications.deliver(uid, event).await;
8777 }
8878 }
8979}
80
81#[allow(clippy::too_many_arguments)]
82pub async fn insert(
83 tx: &impl sea_orm::ConnectionTrait,
84 recipient: i64,
85 kind: models::users::user_notifications::NotificationType,
86 title: String,
87 body: Option<String>,
88 data: Option<Value>,
89 actor: Option<i64>,
90) -> Result<models::users::user_notifications::Model, sea_orm::DbErr> {
91 use sea_orm::{ActiveModelTrait, Set};
92 let row = models::users::user_notifications::ActiveModel {
93 id: Set(uuid::Uuid::now_v7()),
94 user_id: Set(recipient),
95 notification_type: Set(kind),
96 title: Set(title),
97 body: Set(body),
98 data_json: Set(data),
99 actor_uid: Set(actor),
100 read_at: Set(None),
101 created_at: Set(time::OffsetDateTime::now_utc()),
102 }
103 .insert(tx)
104 .await?;
105 let labels = Labels::new()
106 .with(events::label::RECIPIENT_USER_ID, recipient)
107 .with(events::label::USER_ID, recipient);
108 events::emit(
109 tx,
110 "notify.notification.create",
111 labels,
112 serde_json::json!({ "kind": "notification", "notification": row.to_event_value() }),
113 )
114 .await?;
115 Ok(row)
116}
Modified lib/export/src/users.rs View lib/export/src/users.rs
@@ -1,8 +1,5 @@
1use std::collections::HashMap;
2
31use proto::auth::common::v1::UserPublic;
42
5use crate::AppState;
63use crate::error::ExportError;
74
85#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
@@ -28,103 +25,17 @@
2825 }
2926}
3027
31pub async fn load_user_briefs(
32 state: &AppState,
33 uids: &[i64],
34) -> Result<HashMap<i64, UserDto>, ExportError> {
35 let users = load_public_users(state, uids).await?;
36 Ok(users
37 .into_iter()
38 .map(|(uid, public)| (uid, UserDto::from_public(&public)))
39 .collect())
40}
41
42/// Bulk identity lookup. KenApi keeps no local copy of usernames, display
43/// names or avatars: every caller goes through KenOAuth.
44pub async fn load_public_users(
45 state: &AppState,
46 uids: &[i64],
47) -> Result<HashMap<i64, UserPublic>, ExportError> {
48 if uids.is_empty() {
49 return Ok(HashMap::new());
50 }
51 let mut ids: Vec<i64> = uids.to_vec();
52 ids.sort_unstable();
53 ids.dedup();
54 let client = state
55 .kenauth
56 .as_ref()
57 .ok_or_else(|| ExportError::InvalidConfig("kenoauth is not configured".to_string()))?;
58 let users = client
59 .batch_get_user_public(ids)
60 .await
61 .map_err(|error| ExportError::Connection(error.to_string()))?;
62 Ok(users.into_iter().map(|user| (user.user_id, user)).collect())
63}
64
65pub async fn get_public_user(
66 state: &AppState,
67 uid: i64,
68) -> Result<Option<UserPublic>, ExportError> {
69 let client = state
70 .kenauth
71 .as_ref()
72 .ok_or_else(|| ExportError::InvalidConfig("kenoauth is not configured".to_string()))?;
73 match client.get_user_public(uid).await {
74 Ok(user) => Ok(Some(user)),
75 Err(crate::KenAuthError::NotFound) => Ok(None),
76 Err(error) => Err(ExportError::Connection(error.to_string())),
77 }
78}
79
80pub async fn find_by_email(
81 state: &AppState,
82 email: &str,
83) -> Result<Option<UserPublic>, ExportError> {
84 let client = state
85 .kenauth
86 .as_ref()
87 .ok_or_else(|| ExportError::InvalidConfig("kenoauth is not configured".to_string()))?;
88 match client
89 .get_user_public_by_ref(proto::auth::common::v1::user_ref::Key::Email(
90 email.to_string(),
91 ))
92 .await
93 {
94 Ok(user) => Ok(Some(user)),
95 Err(crate::KenAuthError::NotFound) => Ok(None),
96 Err(error) => Err(ExportError::Connection(error.to_string())),
97 }
98}
99
100pub async fn find_by_username(
101 state: &AppState,
102 username: &str,
103) -> Result<Option<UserPublic>, ExportError> {
104 let client = state
105 .kenauth
106 .as_ref()
107 .ok_or_else(|| ExportError::InvalidConfig("kenoauth is not configured".to_string()))?;
108 match client
109 .get_user_public_by_ref(proto::auth::common::v1::user_ref::Key::Username(
110 username.to_string(),
111 ))
112 .await
113 {
114 Ok(user) => Ok(Some(user)),
115 Err(crate::KenAuthError::NotFound) => Ok(None),
116 Err(error) => Err(ExportError::Connection(error.to_string())),
117 }
118}
119
12028/// Create the local settings/anchor row on demand. It contains no identity
12129/// projection; it exists so local preferences and foreign keys have a stable
12230/// row even when the user never went through KenApi's login callback.
123pub async fn ensure_local_user(state: &AppState, uid: i64) -> Result<(), ExportError> {
31pub async fn ensure_local_user(
32 database: &impl sea_orm::ConnectionTrait,
33 uid: i64,
34) -> Result<(), ExportError> {
12435 use models::users::user::{self, ProfileVisibility};
12536 use sea_orm::{ActiveModelTrait, EntityTrait, Set};
12637
127 let db = state.database.write();
38 let db = database;
12839 if user::Entity::find_by_id(uid).one(db).await?.is_some() {
12940 return Ok(());
13041 }
Modified lib/export/Cargo.toml View lib/export/Cargo.toml
@@ -30,6 +30,5 @@
3030bytes = { workspace = true }
3131thiserror = { workspace = true }
3232async-trait = { workspace = true }
33tonic = { workspace = true }
3433time = { workspace = true }
3534prost-types = { workspace = true }
Modified lib/git/src/context/facade.rs View lib/git/src/context/facade.rs
@@ -2,7 +2,7 @@
22use std::sync::Arc;
33
44use ::cache::Cache;
5use export::Database;
5use export::AppDatabase;
66use models::repo::repos;
77use proto::kenshell::common::v1::{GitActor, RepositoryRef, WriteOptions};
88
@@ -10,7 +10,7 @@
1010pub struct GitContext {
1111 pub model: repos::Model,
1212 pub cache: Cache,
13 pub database: Arc<dyn Database>,
13 pub database: Arc<AppDatabase>,
1414 pub space_name: String,
1515 pub repo_root: PathBuf,
1616 pub ks: proto::KenShellClient,
Modified lib/git/src/lfs/lock.rs View lib/git/src/lfs/lock.rs
@@ -182,7 +182,7 @@
182182}
183183
184184async fn owner_name(svc: &GitService, uid: i64) -> String {
185 match export::users::get_public_user(&svc.state, uid).await {
185 match auth::users::get_public_user(&svc.accounts, uid).await {
186186 Ok(Some(user)) if !user.displayname.trim().is_empty() => user.displayname,
187187 Ok(Some(user)) => user.username,
188188 _ => uid.to_string(),
Modified lib/git/src/service/branches.rs View lib/git/src/service/branches.rs
@@ -67,7 +67,7 @@
6767 .await
6868 .map_err(map_baby)?;
6969 let mut rows = self.branch_rows(&ctx).await?;
70 let policy = load_branch_policy(self.state.database.as_ref(), ctx.model.id).await?;
70 let policy = load_branch_policy(self.database.as_ref(), ctx.model.id).await?;
7171
7272 for b in &branches {
7373 if !rows.contains_key(&b.name) && !b.name.is_empty() {
@@ -218,7 +218,7 @@
218218 force: bool,
219219 ) -> Result<(), GitError> {
220220 let name = validate::validate_branch_name(&name)?;
221 let policy = load_branch_policy(self.state.database.as_ref(), ctx.model.id).await?;
221 let policy = load_branch_policy(self.database.as_ref(), ctx.model.id).await?;
222222 if policy.is_match(&name) {
223223 return Err(GitError::Forbidden);
224224 }
Modified lib/git/src/service/code_comments.rs View lib/git/src/service/code_comments.rs
@@ -1,5 +1,6 @@
11use crate::context::facade::GitContext;
22use export::pagination::Page;
3use export::users::UserDto;
34use models::repo::code_comment;
45use sea_orm::ActiveValue::Set;
56use sea_orm::{
@@ -7,7 +8,6 @@
78 QuerySelect,
89};
910use serde::{Deserialize, Serialize};
10use space::services::dto::UserDto;
1111use time::OffsetDateTime;
1212use utoipa::ToSchema;
1313use uuid::Uuid;
Modified lib/git/src/service/commits.rs View lib/git/src/service/commits.rs
@@ -127,7 +127,7 @@
127127 pub description: Option<String>,
128128 pub target_url: Option<String>,
129129 pub creator_id: i64,
130 pub creator: Option<space::services::dto::UserDto>,
130 pub creator: Option<export::users::UserDto>,
131131 pub created_at: OffsetDateTime,
132132 pub updated_at: Option<OffsetDateTime>,
133133}
@@ -147,8 +147,8 @@
147147 }
148148}
149149
150async fn uid_for_email(state: &export::AppState, email: &str) -> Result<Option<i64>, GitError> {
151 Ok(export::users::find_by_email(state, email)
150async fn uid_for_email(accounts: &auth::Accounts, email: &str) -> Result<Option<i64>, GitError> {
151 Ok(auth::users::find_by_email(accounts, email)
152152 .await?
153153 .map(|user| user.user_id))
154154}
@@ -320,7 +320,7 @@
320320 let existing = git_commits::Entity::find_by_id((ctx.model.id, sha.clone()))
321321 .one(self.db_read())
322322 .await?;
323 let author_id = uid_for_email(&self.state, &info.author.email).await?;
323 let author_id = uid_for_email(&self.accounts, &info.author.email).await?;
324324 let now = OffsetDateTime::now_utc();
325325 let row = match existing {
326326 Some(cur) => {
Modified lib/git/src/service/content.rs View lib/git/src/service/content.rs
@@ -37,7 +37,7 @@
3737 caller: i64,
3838 when: OffsetDateTime,
3939 ) -> Result<SignatureDto, GitError> {
40 let ident = identity::load_identity(&self.state, caller).await?;
40 let ident = identity::load_identity(&self.accounts, caller).await?;
4141 Ok(SignatureDto {
4242 name: ident.name,
4343 email: ident.email,
Modified lib/git/src/service/context.rs View lib/git/src/service/context.rs
@@ -1,5 +1,3 @@
1use std::path::PathBuf;
2
31use models::repo::repos;
42use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
53
@@ -18,10 +16,7 @@
1816 if space_lower.is_empty() {
1917 return Err(GitError::NotFound);
2018 }
21 let space = space::authority::space_by_slug(&self.state, 0, &space_lower).await?;
22 if !space::authority::space_is_active(&space) {
23 return Err(GitError::NotFound);
24 }
19 let space = self.access.by_slug(&space_lower).await?;
2520
2621 let repo_lower = repo_name.trim().to_lowercase();
2722 if repo_lower.is_empty() {
@@ -34,9 +29,9 @@
3429 let repo = {
3530 let mut found = None;
3631 for candidate in candidates {
37 if access::space_for_dir(&self.state, candidate.dir_id)
32 if access::space_for_dir(&self.access, candidate.dir_id)
3833 .await?
39 .is_some_and(|s| s.id == space.id)
34 .is_some_and(|s| s.id() == space.id())
4035 {
4136 found = Some(candidate);
4237 break;
@@ -45,16 +40,8 @@
4540 found.ok_or(GitError::NotFound)?
4641 };
4742
48 let cache = self.state.cache.clone();
49 let git = &self.state.config.git;
50
51 Ok(GitContext {
52 model: repo,
53 cache,
54 database: self.state.database.clone(),
55 space_name: space.slug,
56 repo_root: PathBuf::from(&git.repo_root),
57 ks: self.ks().clone(),
58 })
43 Ok(self
44 .state
45 .context(self.database.clone(), repo, space.slug().to_owned()))
5946 }
6047}
Modified lib/git/src/service/git_service.rs View lib/git/src/service/git_service.rs
@@ -1,7 +1,6 @@
11use std::sync::Arc;
2use std::time::Duration;
32
4use export::AppState;
3use export::AppDatabase;
54use proto::KenShellClient;
65use sea_orm::DatabaseConnection;
76
@@ -10,33 +9,29 @@
109
1110#[derive(Clone)]
1211pub struct GitService {
13 pub state: AppState,
14 ks: Arc<KenShellClient>,
12 pub(crate) state: GitBackend,
13 pub(crate) database: Arc<AppDatabase>,
14 pub(crate) accounts: auth::Accounts,
15 pub(crate) access: space::Access,
1516}
1617
1718impl GitService {
18 pub fn new(state: AppState) -> Self {
19 let cfg = &state.config.git;
20 let timeout = Duration::from_secs(cfg.kenshell_timeout_secs.max(1));
21 let ks = KenShellClient::connect_with_key(
22 &cfg.kenshell_endpoint,
23 &cfg.kenshell_rpc_key,
24 timeout,
25 )
26 .unwrap_or_else(|error| {
27 panic!(
28 "invalid git.kenshell_endpoint `{}`: {error}",
29 cfg.kenshell_endpoint
30 )
31 });
19 pub fn new(
20 database: Arc<AppDatabase>,
21 accounts: auth::Accounts,
22 access: space::Access,
23 backend: GitBackend,
24 ) -> Self {
3225 Self {
33 state,
34 ks: Arc::new(ks),
26 database,
27 accounts,
28 access,
29 state: backend,
3530 }
3631 }
3732
3833 pub fn ks(&self) -> &KenShellClient {
39 &self.ks
34 &self.state.client
4035 }
4136
4237 pub fn baby(&self, ctx: &GitContext) -> GitBaby {
@@ -44,10 +39,52 @@
4439 }
4540
4641 pub fn db_write(&self) -> &DatabaseConnection {
47 self.state.database.write()
42 self.database.write()
4843 }
4944
50 pub fn db_read(&self) -> &DatabaseConnection {
51 self.state.database.read()
45 pub fn db_read(&self) -> &export::DbConn {
46 self.database.read()
5247 }
5348}
49
50#[derive(Clone)]
51pub struct GitBackend {
52 pub(crate) config: GitSettings,
53 cache: cache::Cache,
54 client: Arc<KenShellClient>,
55}
56
57impl GitBackend {
58 pub fn new(
59 cache: cache::Cache,
60 config: config::git::GitConfig,
61 client: Arc<KenShellClient>,
62 ) -> Self {
63 Self {
64 cache,
65 client,
66 config: GitSettings { git: config },
67 }
68 }
69
70 pub(crate) fn context(
71 &self,
72 database: Arc<AppDatabase>,
73 model: models::repo::repos::Model,
74 space_name: String,
75 ) -> GitContext {
76 GitContext {
77 model,
78 cache: self.cache.clone(),
79 database,
80 space_name,
81 repo_root: std::path::PathBuf::from(&self.config.git.repo_root),
82 ks: self.client.as_ref().clone(),
83 }
84 }
85}
86
87#[derive(Clone)]
88pub(crate) struct GitSettings {
89 pub git: config::git::GitConfig,
90}
Modified lib/git/src/service/mod.rs View lib/git/src/service/mod.rs
@@ -1,6 +1,6 @@
11mod git_service;
22
3pub use git_service::GitService;
3pub use git_service::{GitBackend, GitService};
44
55pub mod analysis;
66pub mod archives;
Modified lib/git/src/service/refs.rs View lib/git/src/service/refs.rs
@@ -100,7 +100,7 @@
100100 .list_branches(crate::rpcgit::branch::ListBranchesOptions::default())
101101 .await
102102 .map_err(map_baby)?;
103 let policy = load_branch_policy(self.state.database.as_ref(), ctx.model.id).await?;
103 let policy = load_branch_policy(self.database.as_ref(), ctx.model.id).await?;
104104 let mut out: Vec<BranchInfoDto> = branches
105105 .into_iter()
106106 .map(|b| BranchInfoDto {
@@ -177,13 +177,12 @@
177177 name: &str,
178178 ) -> Result<bool, GitError> {
179179 if let Some(branch) = name.strip_prefix("refs/heads/") {
180 let policy = load_branch_policy(self.state.database.as_ref(), ctx.model.id).await?;
180 let policy = load_branch_policy(self.database.as_ref(), ctx.model.id).await?;
181181 return Ok(policy.is_match(branch));
182182 }
183183 if let Some(tag) = name.strip_prefix("refs/tags/") {
184184 let policy =
185 crate::share::policy::load_tag_policy(self.state.database.as_ref(), ctx.model.id)
186 .await?;
185 crate::share::policy::load_tag_policy(self.database.as_ref(), ctx.model.id).await?;
187186 return Ok(policy.is_match(tag));
188187 }
189188 Ok(false)
Modified lib/git/src/service/releases.rs View lib/git/src/service/releases.rs
@@ -1,4 +1,5 @@
11use export::pagination::Page;
2use export::users::UserDto;
23use models::repo::repo_release;
34use sea_orm::ActiveValue::Set;
45use sea_orm::{
@@ -6,7 +7,6 @@
67 QuerySelect,
78};
89use serde::{Deserialize, Serialize};
9use space::services::dto::UserDto;
1010use time::OffsetDateTime;
1111use utoipa::{IntoParams, ToSchema};
1212use uuid::Uuid;
@@ -148,7 +148,7 @@
148148 .map_err(crate::share::errors::map_baby)?
149149 .to_string(),
150150 };
151 let publisher_row = export::users::get_public_user(&self.state, publisher)
151 let publisher_row = auth::users::get_public_user(&self.accounts, publisher)
152152 .await?
153153 .ok_or(GitError::Unauthorized)?;
154154 let now = OffsetDateTime::now_utc();
@@ -205,7 +205,7 @@
205205 if let Some(content) = cmd.content.as_deref() {
206206 validate_len(content, MAX_CONTENT_LEN, "release content")?;
207207 }
208 let actor_row = export::users::get_public_user(&self.state, actor)
208 let actor_row = auth::users::get_public_user(&self.accounts, actor)
209209 .await?
210210 .ok_or(GitError::Unauthorized)?;
211211 let mut model: repo_release::ActiveModel = row.into();
Modified lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -1,12 +1,12 @@
11use crate::rpcgit::refs::types::{ListRefsOptions, RefType};
22use models::dir::dirs::{self, DirKind};
33use models::repo::{repo_collaborations, repo_license, repos};
4use models::space::spaces;
54use sea_orm::{
65 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
76 TransactionTrait, Unchanged,
87};
98use serde::{Deserialize, Serialize};
9use space::Space;
1010use time::OffsetDateTime;
1111use uuid::Uuid;
1212
@@ -280,23 +280,20 @@
280280 }
281281
282282 pub async fn repo_dto(&self, m: repos::Model) -> Result<RepoDto, GitError> {
283 let space = crate::share::access::space_for_dir(&self.state, m.dir_id)
283 let space = crate::share::access::space_for_dir(&self.access, m.dir_id)
284284 .await?
285285 .ok_or(GitError::NotFound)?;
286286 let mut dto = RepoDto::from(m);
287 self.fill_derived(&mut dto, &space.name);
287 self.fill_derived(&mut dto, &space.name());
288288 Ok(dto)
289289 }
290290
291 async fn lookup_space(&self, space_name: &str) -> Result<spaces::Model, GitError> {
291 async fn lookup_space(&self, space_name: &str) -> Result<Space, GitError> {
292292 let lower = space_name.trim().to_lowercase();
293293 if lower.is_empty() {
294294 return Err(GitError::NotFound);
295295 }
296 let space = space::authority::space_by_slug(&self.state, 0, &lower).await?;
297 if !space::authority::space_is_active(&space) {
298 return Err(GitError::NotFound);
299 }
296 let space = self.access.by_slug(&lower).await?;
300297 Ok(space)
301298 }
302299
@@ -307,7 +304,7 @@
307304 cmd: CreateRepoCmd,
308305 ) -> Result<RepoDto, GitError> {
309306 let space = self.lookup_space(&space_name).await?;
310 if space.owner_user_id != caller {
307 if space.owner() != caller {
311308 return Err(GitError::Forbidden);
312309 }
313310 let name = validate_repo_name(&cmd.name)?;
@@ -315,7 +312,7 @@
315312 validate_branch_name(cmd.default_branch.as_deref().unwrap_or(DEFAULT_BRANCH))?;
316313 if let Some(p) = cmd.parent_dir_id {
317314 let parent = dirs::Entity::find_by_id(p)
318 .filter(dirs::Column::SpaceId.eq(space.id))
315 .filter(dirs::Column::SpaceId.eq(space.id()))
319316 .one(self.db_read())
320317 .await?
321318 .ok_or(GitError::NotFound)?;
@@ -342,7 +339,7 @@
342339 let txn = self.db_write().begin().await?;
343340 let position = {
344341 let max_node = dirs::Entity::find()
345 .filter(dirs::Column::SpaceId.eq(space.id))
342 .filter(dirs::Column::SpaceId.eq(space.id()))
346343 .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
347344 .order_by_desc(dirs::Column::Position)
348345 .lock(sea_orm::sea_query::LockType::Update)
@@ -353,7 +350,7 @@
353350 let now = OffsetDateTime::now_utc();
354351 let dir_row = dirs::ActiveModel {
355352 id: Set(Uuid::now_v7()),
356 space_id: Set(Some(space.id)),
353 space_id: Set(Some(space.id())),
357354 name: Set(name.clone()),
358355 kind: Set(DirKind::Object),
359356 parent_dir_id: Set(cmd.parent_dir_id),
@@ -413,16 +410,9 @@
413410 (dir, repo)
414411 };
415412
416 let cache = self.state.cache.clone();
417 let git_cfg = &self.state.config.git;
418 let ctx = GitContext {
419 model: repo.clone(),
420 cache,
421 database: self.state.database.clone(),
422 space_name: space.slug.clone(),
423 repo_root: std::path::PathBuf::from(&git_cfg.repo_root),
424 ks: self.ks().clone(),
425 };
413 let ctx = self
414 .state
415 .context(self.database.clone(), repo.clone(), space.slug().to_owned());
426416
427417 let init_result: Result<(), GitError> = async {
428418 self.baby(&ctx)
@@ -454,7 +444,7 @@
454444 .await?
455445 .ok_or(GitError::NotFound)?;
456446 let mut dto = RepoDto::from(fresh);
457 self.fill_derived(&mut dto, &space.name);
447 self.fill_derived(&mut dto, &space.name());
458448 Ok(dto)
459449 }
460450
@@ -497,7 +487,7 @@
497487 ) -> Result<export::pagination::Page<RepoSummaryDto>, GitError> {
498488 let space = self.lookup_space(&space_name).await?;
499489 let dirs = dirs::Entity::find()
500 .filter(dirs::Column::SpaceId.eq(space.id))
490 .filter(dirs::Column::SpaceId.eq(space.id()))
501491 .filter(dirs::Column::Kind.eq(DirKind::Object))
502492 .all(self.db_read())
503493 .await?;
@@ -513,7 +503,7 @@
513503 };
514504 let rows = sel.all(self.db_read()).await?;
515505
516 let owner_view = caller.is_some_and(|uid| space.owner_user_id == uid);
506 let owner_view = caller.is_some_and(|uid| space.owner() == uid);
517507 let mut accessible: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
518508 if let Some(uid) = caller
519509 && !owner_view
@@ -553,7 +543,7 @@
553543 cmd: UpdateRepoCmd,
554544 ) -> Result<RepoDto, GitError> {
555545 let space = self.repo_space(&ctx).await?;
556 if space.owner_user_id != caller {
546 if space.owner() != caller {
557547 return Err(GitError::Forbidden);
558548 }
559549 let new_branch = match &cmd.default_branch {
@@ -607,7 +597,7 @@
607597 }
608598 }
609599 let mut dto = RepoDto::from(saved);
610 self.fill_derived(&mut dto, &space.name);
600 self.fill_derived(&mut dto, &space.name());
611601 Ok(dto)
612602 }
613603
@@ -618,7 +608,7 @@
618608 archived: bool,
619609 ) -> Result<RepoDto, GitError> {
620610 let space = self.repo_space(&ctx).await?;
621 if space.owner_user_id != caller {
611 if space.owner() != caller {
622612 return Err(GitError::Forbidden);
623613 }
624614 if ctx.model.is_archived == archived {
@@ -638,12 +628,12 @@
638628 };
639629 let saved = am.update(self.db_write()).await?;
640630 let mut dto = RepoDto::from(saved);
641 self.fill_derived(&mut dto, &space.name);
631 self.fill_derived(&mut dto, &space.name());
642632 Ok(dto)
643633 }
644634
645 async fn repo_space(&self, ctx: &GitContext) -> Result<spaces::Model, GitError> {
646 crate::share::access::space_for_dir(&self.state, ctx.model.dir_id)
635 async fn repo_space(&self, ctx: &GitContext) -> Result<Space, GitError> {
636 crate::share::access::space_for_dir(&self.access, ctx.model.dir_id)
647637 .await?
648638 .ok_or(GitError::NotFound)
649639 }
Modified lib/git/src/service/stars.rs View lib/git/src/service/stars.rs
@@ -132,7 +132,7 @@
132132 return Ok(Vec::new());
133133 }
134134 let user_ids: Vec<i64> = rows.iter().map(|r| r.user_id).collect();
135 let by_id = export::users::load_public_users(&self.state, &user_ids).await?;
135 let by_id = auth::users::load_public_users(&self.accounts, &user_ids).await?;
136136 Ok(rows
137137 .into_iter()
138138 .filter_map(|row| {
Modified lib/git/src/service/tags.rs View lib/git/src/service/tags.rs
@@ -76,8 +76,7 @@
7676 "invalid tag name or target".to_string(),
7777 ));
7878 }
79 let tag_policy =
80 policy::load_tag_policy(self.state.database.as_ref(), ctx.model.id).await?;
79 let tag_policy = policy::load_tag_policy(self.database.as_ref(), ctx.model.id).await?;
8180 if tag_policy.is_match(&cmd.name) {
8281 return Err(GitError::Forbidden);
8382 }
@@ -205,8 +204,7 @@
205204 }
206205
207206 pub async fn delete_tag(&self, ctx: &GitContext, name: String) -> Result<(), GitError> {
208 let tag_policy =
209 policy::load_tag_policy(self.state.database.as_ref(), ctx.model.id).await?;
207 let tag_policy = policy::load_tag_policy(self.database.as_ref(), ctx.model.id).await?;
210208 if tag_policy.is_match(&name) {
211209 return Err(GitError::Forbidden);
212210 }
Modified lib/git/src/service/watches.rs View lib/git/src/service/watches.rs
@@ -128,7 +128,7 @@
128128 return Ok(Vec::new());
129129 }
130130 let user_ids: Vec<i64> = rows.iter().map(|r| r.user_id).collect();
131 let by_id = export::users::load_public_users(&self.state, &user_ids).await?;
131 let by_id = auth::users::load_public_users(&self.accounts, &user_ids).await?;
132132 Ok(rows
133133 .into_iter()
134134 .filter_map(|row| {
Modified lib/git/src/service/whitelists.rs View lib/git/src/service/whitelists.rs
@@ -17,7 +17,7 @@
1717 pub policy_id: Uuid,
1818 pub kind: WhitelistKind,
1919 pub user_id: Option<i64>,
20 pub user: Option<space::services::dto::UserDto>,
20 pub user: Option<export::users::UserDto>,
2121 pub group_id: Option<Uuid>,
2222 pub created_at: OffsetDateTime,
2323}
@@ -47,7 +47,7 @@
4747pub struct TagWhitelistDto {
4848 pub policy_id: Uuid,
4949 pub user_id: i64,
50 pub user: Option<space::services::dto::UserDto>,
50 pub user: Option<export::users::UserDto>,
5151}
5252
5353impl From<repo_tag_protection_whitelists::Model> for TagWhitelistDto {
@@ -83,14 +83,14 @@
8383 pub(crate) async fn user_dto_map(
8484 &self,
8585 uids: &[i64],
86 ) -> Result<std::collections::HashMap<i64, space::services::dto::UserDto>, GitError> {
86 ) -> Result<std::collections::HashMap<i64, export::users::UserDto>, GitError> {
8787 let mut map = std::collections::HashMap::new();
8888 if uids.is_empty() {
8989 return Ok(map);
9090 }
91 let rows = export::users::load_public_users(&self.state, uids).await?;
91 let rows = auth::users::load_public_users(&self.accounts, uids).await?;
9292 for (uid, user) in rows {
93 map.insert(uid, space::services::dto::UserDto::from_public(&user));
93 map.insert(uid, export::users::UserDto::from_public(&user));
9494 }
9595 Ok(map)
9696 }
Modified lib/git/src/share/access.rs View lib/git/src/share/access.rs
@@ -4,9 +4,7 @@
44use actix_web::http::header;
55use actix_web::{FromRequest, HttpRequest};
66use base64::Engine as _;
7use models::repo::repo_collaborations::{self, CollaborationMode};
87use models::users::access_scope::AccessKeyScope;
9use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
108use uuid::Uuid;
119
1210use crate::context::facade::GitContext;
@@ -137,76 +135,36 @@
137135 want_write: bool,
138136 require_admin: bool,
139137) -> Result<(), GitError> {
140 let repo = &ctx.model;
141 let db = svc.db_read();
142
143 // Walk dir_id up to the owning space row, then load the space to
144 // compare owner_id. Mirrors `ssh/handler/command.rs::space_id_for`.
145 let space = find_space_for_repo(&svc.state, repo).await?;
146 if space.owner_user_id == uid {
147 return Ok(());
148 }
149
150 let collab = repo_collaborations::Entity::find()
151 .filter(repo_collaborations::Column::RepoId.eq(repo.id))
152 .filter(repo_collaborations::Column::UserId.eq(uid))
153 .one(db)
154 .await?;
155
156 if require_admin {
157 return match collab.map(|c| c.mode) {
158 Some(CollaborationMode::Admin) => Ok(()),
159 _ => Err(GitError::Forbidden),
160 };
161 }
162
163 if want_write {
164 return match collab.map(|c| c.mode) {
165 Some(CollaborationMode::Write) | Some(CollaborationMode::Admin) => Ok(()),
166 _ => Err(GitError::Forbidden),
167 };
168 }
169
170 if !repo.is_private {
171 return Ok(());
172 }
173 if collab.is_some() {
174 Ok(())
138 let owner = find_space_for_repo(svc, &ctx.model).await?;
139 let capability = if require_admin {
140 space::Capability::RepositoryAdmin {
141 repository: ctx.model.id,
142 }
143 } else if want_write {
144 space::Capability::RepositoryWrite {
145 repository: ctx.model.id,
146 }
175147 } else {
176 Err(GitError::Forbidden)
177 }
148 space::Capability::RepositoryRead {
149 repository: ctx.model.id,
150 private: ctx.model.is_private,
151 }
152 };
153 Ok(space::require(&owner, uid, capability).await?)
178154}
179155
180156async fn find_space_for_repo(
181 state: &export::AppState,
157 service: &GitService,
182158 repo: &models::repo::repos::Model,
183) -> Result<models::space::spaces::Model, GitError> {
184 space_for_dir(state, repo.dir_id)
159) -> Result<space::Space, GitError> {
160 space_for_dir(&service.access, repo.dir_id)
185161 .await?
186162 .ok_or_else(|| GitError::Internal(format!("space for repo {} not found", repo.id)))
187163}
188164
189pub const MAX_DIR_DEPTH: usize = 64;
190
191165pub async fn space_for_dir(
192 state: &export::AppState,
193 mut dir_id: Uuid,
194) -> Result<Option<models::space::spaces::Model>, GitError> {
195 for _ in 0..MAX_DIR_DEPTH {
196 let row = models::dir::dirs::Entity::find_by_id(dir_id)
197 .one(state.database.read())
198 .await?
199 .ok_or_else(|| GitError::Internal(format!("dir {dir_id} not found")))?;
200 if let Some(space_id) = row.space_id {
201 let space = space::authority::space_by_id(state, 0, space_id).await?;
202 return Ok(Some(space));
203 }
204 match row.parent_dir_id {
205 Some(parent) => dir_id = parent,
206 None => return Ok(None),
207 }
208 }
209 Err(GitError::Internal(format!(
210 "dir tree deeper than {MAX_DIR_DEPTH} levels"
211 )))
166 access: &space::Access,
167 dir_id: Uuid,
168) -> Result<Option<space::Space>, GitError> {
169 Ok(access.for_dir(dir_id).await?)
212170}
Modified lib/git/src/share/errors.rs View lib/git/src/share/errors.rs
@@ -31,18 +31,7 @@
3131 }
3232}
3333
34impl From<space::errors::SpaceError> for GitError {
35 fn from(error: space::errors::SpaceError) -> Self {
36 match error {
37 space::errors::SpaceError::BadRequest(message) => Self::InvalidArgument(message),
38 space::errors::SpaceError::Unauthorized => Self::Unauthorized,
39 space::errors::SpaceError::Forbidden => Self::Forbidden,
40 space::errors::SpaceError::NotFound => Self::NotFound,
41 space::errors::SpaceError::Conflict(message) => Self::Conflict(message),
42 other => Self::Internal(other.to_string()),
43 }
44 }
45}
34export::access_error_conversion!(GitError, InvalidArgument);
4635
4736impl From<sea_orm::DbErr> for GitError {
4837 fn from(err: sea_orm::DbErr) -> Self {
Modified lib/git/src/share/identity.rs View lib/git/src/share/identity.rs
@@ -6,8 +6,8 @@
66 pub email: String,
77}
88
9pub async fn load_identity(state: &export::AppState, uid: i64) -> Result<GitIdentity, GitError> {
10 let user = export::users::get_public_user(state, uid)
9pub async fn load_identity(accounts: &auth::Accounts, uid: i64) -> Result<GitIdentity, GitError> {
10 let user = auth::users::get_public_user(accounts, uid)
1111 .await?
1212 .ok_or(GitError::NotFound)?;
1313 if user.email.trim().is_empty() || !user.email_verified {
Modified lib/git/src/share/policy.rs View lib/git/src/share/policy.rs
@@ -34,7 +34,7 @@
3434}
3535
3636pub async fn load_branch_policy(
37 db: &dyn export::Database,
37 db: &export::AppDatabase,
3838 repo_id: Uuid,
3939) -> Result<BranchPolicy, GitError> {
4040 let rows = repo_branch_protection_policy::Entity::find()
@@ -45,7 +45,7 @@
4545}
4646
4747pub async fn load_tag_policy(
48 db: &dyn export::Database,
48 db: &export::AppDatabase,
4949 repo_id: Uuid,
5050) -> Result<BranchPolicy, GitError> {
5151 let rows = repo_tag_protection_policy::Entity::find()
@@ -72,7 +72,7 @@
7272impl WhitelistSet {}
7373
7474pub async fn load_branch_whitelists(
75 db: &sea_orm::DatabaseConnection,
75 db: &impl sea_orm::ConnectionTrait,
7676 policy_ids: &[Uuid],
7777) -> Result<WhitelistSet, GitError> {
7878 if policy_ids.is_empty() {
@@ -100,7 +100,7 @@
100100
101101/// 按分支名匹配启用的保护政策(精确名优先,其次最长通配)。
102102pub async fn match_branch_policies(
103 db: &sea_orm::DatabaseConnection,
103 db: &impl sea_orm::ConnectionTrait,
104104 repo_id: Uuid,
105105 branch: &str,
106106) -> Result<Vec<repo_branch_protection_policy::Model>, GitError> {
@@ -132,7 +132,7 @@
132132
133133/// 政策对应的白名单集合。
134134pub async fn branch_policy_whitelists(
135 db: &sea_orm::DatabaseConnection,
135 db: &impl sea_orm::ConnectionTrait,
136136 policies: &[repo_branch_protection_policy::Model],
137137) -> Result<WhitelistSet, GitError> {
138138 let ids: Vec<Uuid> = policies.iter().map(|row| row.id).collect();
Added lib/git/src/facade.rs View lib/git/src/facade.rs

Diff too large to display.

Modified lib/git/src/lib.rs View lib/git/src/lib.rs
@@ -1,5 +1,6 @@
11pub mod context;
22pub mod errors;
3pub mod facade;
34pub mod handler;
45pub mod lfs;
56pub mod openapi;
Modified lib/git/Cargo.toml View lib/git/Cargo.toml
@@ -4,6 +4,7 @@
44edition.workspace = true
55
66[dependencies]
7auth = { workspace = true }
78proto = { workspace = true }
89tonic = { workspace = true }
910prost-types = { workspace = true }
Added lib/issues/src/services/admission.rs View lib/issues/src/services/admission.rs

Diff too large to display.

Modified lib/issues/src/services/attachments.rs View lib/issues/src/services/attachments.rs
@@ -125,7 +125,7 @@
125125 caller: i64,
126126 cmd: CreateIssueAttachmentCmd,
127127 ) -> Result<IssueUploadIntentDto, IssueError> {
128 let max_size = self.config().issues.attachment.max_size;
128 let max_size = self.settings.issues.attachment.max_size;
129129 if cmd.size <= 0 || cmd.size > max_size {
130130 return Err(IssueError::BadRequest(format!(
131131 "file size must be within 1..{max_size} bytes"
@@ -145,20 +145,23 @@
145145 let pending = issue_attachment::Entity::find()
146146 .filter(issue_attachment::Column::UploaderId.eq(caller))
147147 .filter(issue_attachment::Column::IssueId.is_null())
148 .count(self.db().read())
148 .count(self.database.read())
149149 .await?;
150 if pending >= self.config().issues.attachment.max_pending_per_user {
150 if pending >= self.settings.issues.attachment.max_pending_per_user {
151151 return Err(IssueError::RateLimited(format!(
152152 "too many unused attachments (max {})",
153 self.config().issues.attachment.max_pending_per_user
153 self.settings.issues.attachment.max_pending_per_user
154154 )));
155155 }
156156
157157 let now = OffsetDateTime::now_utc();
158158 let id = Uuid::now_v7();
159159 let filename = sanitize_filename(&cmd.filename);
160 let key = format!("{ATTACHMENT_PREFIX}/{}/{caller}/{id}/{filename}", space.id);
161 let bucket = self.state.storage.bucket_name().to_string();
160 let key = format!(
161 "{ATTACHMENT_PREFIX}/{}/{caller}/{id}/{filename}",
162 space.id()
163 );
164 let bucket = self.storage.bucket_name().to_string();
162165
163166 issue_attachment::ActiveModel {
164167 id: Set(id),
@@ -173,11 +176,11 @@
173176 created_at: Set(now),
174177 updated_at: Set(now),
175178 }
176 .insert(self.db().write())
179 .insert(self.database.write())
177180 .await?;
178181
179182 let ttl = self
180 .config()
183 .settings
181184 .issues
182185 .attachment
183186 .presign_ttl_secs
@@ -189,7 +192,6 @@
189192 put_headers.insert(http::header::CONTENT_TYPE, value);
190193 }
191194 let upload_url = self
192 .state
193195 .storage
194196 .presign_put_with_headers(&key, ttl as u32, put_headers)
195197 .map_err(storage_failure)?;
@@ -208,7 +210,7 @@
208210 attachment_id: Uuid,
209211 ) -> Result<IssueAttachmentDto, IssueError> {
210212 let row = issue_attachment::Entity::find_by_id(attachment_id)
211 .one(self.db().write())
213 .one(self.database.write())
212214 .await?
213215 .filter(|row| row.uploader_id == caller)
214216 .ok_or(IssueError::NotFound)?;
@@ -219,14 +221,13 @@
219221 }
220222
221223 let head = self
222 .state
223224 .storage
224225 .head(&row.s3_key)
225226 .await
226227 .map_err(storage_failure)?
227228 .ok_or_else(|| IssueError::BadRequest("object not uploaded yet".to_string()))?;
228229 let (actual_size, actual_content_type) = head;
229 let max_size = self.config().issues.attachment.max_size;
230 let max_size = self.settings.issues.attachment.max_size;
230231 if actual_size == 0 || actual_size > max_size as u64 {
231232 return Err(IssueError::BadRequest(format!(
232233 "uploaded object size {actual_size} outside 1..{max_size} bytes"
@@ -249,7 +250,7 @@
249250 active.status = Set(IssueAttachmentStatus::Uploaded);
250251 active.updated_at = Set(OffsetDateTime::now_utc());
251252 Ok(IssueAttachmentDto::from(
252 active.update(self.db().write()).await?,
253 active.update(self.database.write()).await?,
253254 ))
254255 }
255256
@@ -261,7 +262,7 @@
261262 attachment_id: Uuid,
262263 ) -> Result<(), IssueError> {
263264 let row = issue_attachment::Entity::find_by_id(attachment_id)
264 .one(self.db().write())
265 .one(self.database.write())
265266 .await?
266267 .ok_or(IssueError::NotFound)?;
267268 if row.uploader_id != caller {
@@ -269,12 +270,12 @@
269270 }
270271
271272 if row.status == IssueAttachmentStatus::Uploaded
272 && let Err(err) = self.state.storage.delete(&row.s3_key).await
273 && let Err(err) = self.storage.delete(&row.s3_key).await
273274 {
274275 tracing::warn!(error = %err, key = %row.s3_key, "failed to delete issue attachment object");
275276 }
276277 issue_attachment::Entity::delete_by_id(row.id)
277 .exec(self.db().write())
278 .exec(self.database.write())
278279 .await?;
279280 Ok(())
280281 }
@@ -293,7 +294,7 @@
293294 .filter(issue_attachment::Column::IssueId.eq(issue_id))
294295 .filter(issue_attachment::Column::Status.eq(IssueAttachmentStatus::Uploaded))
295296 .order_by_asc(issue_attachment::Column::CreatedAt)
296 .all(self.db().read())
297 .all(self.database.read())
297298 .await?;
298299 Ok(rows.into_iter().map(IssueAttachmentDto::from).collect())
299300 }
@@ -313,7 +314,7 @@
313314 IssueError,
314315 > {
315316 let row = issue_attachment::Entity::find_by_id(attachment_id)
316 .one(self.db().read())
317 .one(self.database.read())
317318 .await?
318319 .ok_or(IssueError::NotFound)?;
319320 if row.uploader_id != caller {
@@ -325,7 +326,6 @@
325326 }
326327
327328 let stream = self
328 .state
329329 .storage
330330 .get_stream(&row.s3_key)
331331 .await
Added lib/issues/src/services/audience.rs View lib/issues/src/services/audience.rs

Diff too large to display.

Modified lib/issues/src/services/boards.rs View lib/issues/src/services/boards.rs
@@ -83,7 +83,7 @@
8383 let prefix = trimmed.to_ascii_uppercase();
8484 let first_is_letter = prefix.starts_with(|c: char| c.is_ascii_alphabetic());
8585 let valid = first_is_letter
86 && prefix.chars().count() <= self.config().issues.max_board_prefix_len
86 && prefix.chars().count() <= self.settings.issues.max_board_prefix_len
8787 && prefix.chars().all(|c| c.is_ascii_alphanumeric());
8888 if !valid {
8989 return Err(IssueError::BadRequest(
@@ -105,7 +105,7 @@
105105 let dir_ids: Vec<Uuid> = dirs::Entity::find()
106106 .filter(dirs::Column::SpaceId.eq(Some(space_id)))
107107 .filter(dirs::Column::Kind.eq(DirKind::Object))
108 .all(self.db().read())
108 .all(self.database.read())
109109 .await?
110110 .into_iter()
111111 .map(|d| d.id)
@@ -119,7 +119,7 @@
119119 if let Some(board_id) = exclude_board {
120120 select = select.filter(issues::Column::Id.ne(board_id));
121121 }
122 Ok(select.one(self.db().read()).await?.is_some())
122 Ok(select.one(self.database.read()).await?.is_some())
123123 }
124124
125125 fn board_dto(
@@ -158,7 +158,7 @@
158158 .filter(issue_item::Column::CollectionId.is_in(board_ids.to_vec()))
159159 .group_by(issue_item::Column::CollectionId)
160160 .into_tuple()
161 .all(self.db().read())
161 .all(self.database.read())
162162 .await?;
163163 for (collection_id, cnt) in total_rows {
164164 total_counts.insert(collection_id, cnt);
@@ -172,7 +172,7 @@
172172 .filter(issue_item::Column::ClosedAt.is_not_null())
173173 .group_by(issue_item::Column::CollectionId)
174174 .into_tuple()
175 .all(self.db().read())
175 .all(self.database.read())
176176 .await?;
177177 for (collection_id, cnt) in closed_rows {
178178 closed_counts.insert(collection_id, cnt);
@@ -199,7 +199,7 @@
199199 authz::require_board_manage(self, &space, caller).await?;
200200
201201 let name = cmd.name.trim();
202 if name.is_empty() || name.chars().count() > self.config().issues.max_board_name_len {
202 if name.is_empty() || name.chars().count() > self.settings.issues.max_board_name_len {
203203 return Err(IssueError::BadRequest(
204204 "board name is empty or too long".to_string(),
205205 ));
@@ -210,7 +210,7 @@
210210 None => None,
211211 };
212212 if let Some(ref prefix) = prefix
213 && self.board_prefix_taken(space.id, prefix, None).await?
213 && self.board_prefix_taken(space.id(), prefix, None).await?
214214 {
215215 return Err(IssueError::BadRequest(
216216 "board prefix is already used by another board".to_string(),
@@ -219,8 +219,8 @@
219219
220220 if let Some(p) = cmd.parent_dir_id {
221221 let parent = dirs::Entity::find_by_id(p)
222 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
223 .one(self.db().read())
222 .filter(dirs::Column::SpaceId.eq(Some(space.id())))
223 .one(self.database.read())
224224 .await?
225225 .ok_or(IssueError::NotFound)?;
226226
@@ -232,10 +232,10 @@
232232 }
233233
234234 let (_dir, board) = {
235 let txn = self.db().write().begin().await?;
235 let txn = self.database.write().begin().await?;
236236 let position = {
237237 let max_node = dirs::Entity::find()
238 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
238 .filter(dirs::Column::SpaceId.eq(Some(space.id())))
239239 .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
240240 .order_by_desc(dirs::Column::Position)
241241 .lock(sea_orm::sea_query::LockType::Update)
@@ -246,7 +246,7 @@
246246 let now = OffsetDateTime::now_utc();
247247 let dir_row = dirs::ActiveModel {
248248 id: Set(Uuid::now_v7()),
249 space_id: Set(Some(space.id)),
249 space_id: Set(Some(space.id())),
250250 name: Set(name.to_string()),
251251 kind: Set(DirKind::Object),
252252 parent_dir_id: Set(cmd.parent_dir_id),
@@ -304,9 +304,9 @@
304304 let (limit, offset) = self.paginate(q.limit, q.offset);
305305
306306 let dirs_in_space = dirs::Entity::find()
307 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
307 .filter(dirs::Column::SpaceId.eq(Some(space.id())))
308308 .filter(dirs::Column::Kind.eq(DirKind::Object))
309 .all(self.db().read())
309 .all(self.database.read())
310310 .await?;
311311
312312 if dirs_in_space.is_empty() {
@@ -348,11 +348,11 @@
348348
349349 select = select.order_by_desc(issues::Column::CreatedAt);
350350
351 let total = select.clone().count(self.db().read()).await? as i64;
351 let total = select.clone().count(self.database.read()).await? as i64;
352352 let rows = select
353353 .limit(limit)
354354 .offset(offset)
355 .all(self.db().read())
355 .all(self.database.read())
356356 .await?;
357357
358358 let board_ids: Vec<Uuid> = rows.iter().map(|b| b.id).collect();
@@ -407,7 +407,7 @@
407407
408408 if let Some(new_name) = cmd.name {
409409 let name = new_name.trim();
410 if name.is_empty() || name.chars().count() > self.config().issues.max_board_name_len {
410 if name.is_empty() || name.chars().count() > self.settings.issues.max_board_name_len {
411411 return Err(IssueError::BadRequest(
412412 "board name is empty or too long".to_string(),
413413 ));
@@ -423,7 +423,7 @@
423423 };
424424 if let Some(ref prefix) = prefix
425425 && self
426 .board_prefix_taken(space.id, prefix, Some(board_id))
426 .board_prefix_taken(space.id(), prefix, Some(board_id))
427427 .await?
428428 {
429429 return Err(IssueError::BadRequest(
@@ -446,7 +446,7 @@
446446 am.updated_at = Set(Some(now));
447447
448448 let updated = {
449 let txn = self.db().write().begin().await?;
449 let txn = self.database.write().begin().await?;
450450 if dir_changed {
451451 dir_am.updated_at = Set(now);
452452 dir_am.update(&txn).await?;
@@ -470,7 +470,7 @@
470470 authz::require_board_manage(self, &space, caller).await?;
471471 let board = lookup_board(self, &space, board_id, caller).await?;
472472
473 self.db()
473 self.database
474474 .primary()
475475 .transaction::<_, (), IssueError>(|txn| {
476476 Box::pin(async move {
Modified lib/issues/src/services/comments.rs View lib/issues/src/services/comments.rs
@@ -1,3 +1,4 @@
1use export::users::UserDto;
12use models::issue::issue_event_line::IssueEventType;
23use models::issue::issue_member::{self, IssueMemberKind};
34use models::issue::{comment, issue_content_history};
@@ -8,7 +9,6 @@
89 QuerySelect, Set, TransactionTrait,
910};
1011use serde::{Deserialize, Serialize};
11use space::services::dto::UserDto;
1212use time::OffsetDateTime;
1313use utoipa::{IntoParams, ToSchema};
1414use uuid::Uuid;
@@ -76,25 +76,12 @@
7676 issue_id: Uuid,
7777 cmd: CreateCommentCmd,
7878 ) -> Result<CommentDto, IssueError> {
79 if !self
80 .state
81 .redis
82 .quota_allow(
83 &format!("quota:comment_create:{caller}"),
84 self.config().quota.comment_create_per_day,
85 86_400,
86 )
87 .await
88 {
89 return Err(IssueError::RateLimited(
90 "comment creation quota exceeded, retry tomorrow".to_string(),
91 ));
92 }
79 self.access.allow_comment(caller).await?;
9380 let (space, board, item) =
9481 issue_context(self, caller, space_name, board_id, issue_id).await?;
9582
9683 let content = cmd.content.trim();
97 if content.is_empty() || content.chars().count() > self.config().issues.max_comment_len {
84 if content.is_empty() || content.chars().count() > self.settings.issues.max_comment_len {
9885 return Err(IssueError::BadRequest(
9986 "comment content is empty or too long".to_string(),
10087 ));
@@ -103,7 +90,7 @@
10390 let mut parent_poster_id: Option<i64> = None;
10491 if let Some(pid) = cmd.parent_id {
10592 let parent_comment = comment::Entity::find_by_id(pid)
106 .one(self.db().read())
93 .one(self.database.read())
10794 .await?
10895 .ok_or_else(|| IssueError::BadRequest("parent comment not found".to_string()))?;
10996
@@ -124,100 +111,101 @@
124111 let content_str = content.to_string();
125112 let parent_opt = cmd.parent_id;
126113 let this = self.clone();
127 let space_lower_for_txn = space.slug.clone();
114 let space_lower_for_txn = space.slug().to_owned();
128115 let item_title_for_txn = item.title.clone();
129116 let mentioned_for_txn = mentioned_uids.clone();
130117 let pending = export::notify::PendingNotifications::default();
131118 let pending_txn = pending.clone();
132119
133 let inserted = self
134 .db()
135 .primary()
136 .transaction::<_, comment::Model, IssueError>(move |txn| {
137 let content_clone = content_str.clone();
138 let mentioned_clone = mentioned_for_txn;
139 let space_name_clone = space_lower_for_txn;
140 let issue_num = item.autoincrement_id;
141 let issue_title_clone = item_title_for_txn;
142 Box::pin(async move {
143 let row = comment::ActiveModel {
144 id: Set(comment_id),
145 issue_id: Set(Some(issue_id)),
146 project_id: Set(None),
147 parent_id: Set(parent_opt),
148 poster_id: Set(caller),
149 content: Set(content_clone.clone()),
150 created_at: Set(now),
151 updated_at: Set(None),
152 };
153 let ins = row.insert(txn).await?;
120 let inserted =
121 self.database
122 .primary()
123 .transaction::<_, comment::Model, IssueError>(move |txn| {
124 let content_clone = content_str.clone();
125 let mentioned_clone = mentioned_for_txn;
126 let space_name_clone = space_lower_for_txn;
127 let issue_num = item.autoincrement_id;
128 let issue_title_clone = item_title_for_txn;
129 Box::pin(async move {
130 let row = comment::ActiveModel {
131 id: Set(comment_id),
132 issue_id: Set(Some(issue_id)),
133 project_id: Set(None),
134 parent_id: Set(parent_opt),
135 poster_id: Set(caller),
136 content: Set(content_clone.clone()),
137 created_at: Set(now),
138 updated_at: Set(None),
139 };
140 let ins = row.insert(txn).await?;
154141
155 this.write_event(
156 txn,
157 issue_id,
158 caller,
159 IssueEventType::CommentCreated,
160 IssueEventDetail {
161 comment_id: Some(comment_id),
162 old_title: None,
163 new_title: None,
164 content: None,
165 },
166 )
167 .await?;
168
169 if let Some(pp_uid) = parent_poster_id {
170 let data = serde_json::json!({
171 "space_name": space_name_clone,
172 "board_id": board.id,
173 "issue_id": issue_id,
174 "comment_id": comment_id,
175 "autoincrement_id": issue_num,
176 });
177 this.notify_user(
142 this.write_event(
178143 txn,
179 &pending_txn,
180 pp_uid,
181 NotificationType::Reply,
182 format!("New reply on issue #{issue_num}: {issue_title_clone}"),
183 Some(content_clone.clone()),
184 Some(data),
185 Some(caller),
144 issue_id,
145 caller,
146 IssueEventType::CommentCreated,
147 IssueEventDetail {
148 comment_id: Some(comment_id),
149 old_title: None,
150 new_title: None,
151 content: None,
152 },
186153 )
187154 .await?;
188 }
189155
190 for m_uid in mentioned_clone {
191 if Some(m_uid) == parent_poster_id {
192 continue;
156 if let Some(pp_uid) = parent_poster_id {
157 let data = serde_json::json!({
158 "space_name": space_name_clone,
159 "board_id": board.id,
160 "issue_id": issue_id,
161 "comment_id": comment_id,
162 "autoincrement_id": issue_num,
163 });
164 this.audience
165 .notify_user(
166 txn,
167 &pending_txn,
168 pp_uid,
169 NotificationType::Reply,
170 format!("New reply on issue #{issue_num}: {issue_title_clone}"),
171 Some(content_clone.clone()),
172 Some(data),
173 Some(caller),
174 )
175 .await?;
193176 }
194177
195 let existing_member = issue_member::Entity::find()
196 .filter(issue_member::Column::IssueId.eq(issue_id))
197 .filter(issue_member::Column::UserId.eq(m_uid))
198 .filter(issue_member::Column::Kind.eq(IssueMemberKind::Mentioned))
199 .one(txn)
200 .await?;
201 if existing_member.is_none() {
202 let m_row = issue_member::ActiveModel {
203 id: Set(Uuid::now_v7()),
204 issue_id: Set(issue_id),
205 user_id: Set(m_uid),
206 kind: Set(IssueMemberKind::Mentioned),
207 created_at: Set(now),
208 updated_at: Set(None),
209 };
210 m_row.insert(txn).await?;
211 }
178 for m_uid in mentioned_clone {
179 if Some(m_uid) == parent_poster_id {
180 continue;
181 }
182
183 let existing_member = issue_member::Entity::find()
184 .filter(issue_member::Column::IssueId.eq(issue_id))
185 .filter(issue_member::Column::UserId.eq(m_uid))
186 .filter(issue_member::Column::Kind.eq(IssueMemberKind::Mentioned))
187 .one(txn)
188 .await?;
189 if existing_member.is_none() {
190 let m_row = issue_member::ActiveModel {
191 id: Set(Uuid::now_v7()),
192 issue_id: Set(issue_id),
193 user_id: Set(m_uid),
194 kind: Set(IssueMemberKind::Mentioned),
195 created_at: Set(now),
196 updated_at: Set(None),
197 };
198 m_row.insert(txn).await?;
199 }
212200
213 let data = serde_json::json!({
214 "space_name": space_name_clone,
215 "board_id": board.id,
216 "issue_id": issue_id,
217 "comment_id": comment_id,
218 "autoincrement_id": issue_num,
219 });
220 this.notify_user(
201 let data = serde_json::json!({
202 "space_name": space_name_clone,
203 "board_id": board.id,
204 "issue_id": issue_id,
205 "comment_id": comment_id,
206 "autoincrement_id": issue_num,
207 });
208 this.audience.notify_user(
221209 txn,
222210 &pending_txn,
223211 m_uid,
@@ -230,13 +218,13 @@
230218 Some(caller),
231219 )
232220 .await?;
233 }
221 }
234222
235 Ok(ins)
223 Ok(ins)
224 })
236225 })
237 })
238 .await?;
239 pending.flush(&self.state).await;
226 .await?;
227 pending.flush(&self.audience.notifications).await;
240228
241229 let mut email_recipients = Vec::new();
242230 if let Some(pp_uid) = parent_poster_id {
@@ -249,9 +237,9 @@
249237 }
250238
251239 if !email_recipients.is_empty() {
252 let emails = self.primary_emails(&email_recipients).await?;
240 let emails = self.audience.primary_emails(&email_recipients).await?;
253241 for (uid, addr) in emails {
254 let locale = self.recipient_locale(uid).await;
242 let locale = self.audience.recipient_locale(uid).await;
255243 let is_reply = Some(uid) == parent_poster_id;
256244 let subject = if is_reply {
257245 i18n::catalog::issue_reply_subject(locale, item.autoincrement_id, &item.title)
@@ -260,8 +248,8 @@
260248 };
261249 let link = format!(
262250 "{}/issues/{}/{}/{}",
263 self.config().web.web_base_url,
264 space.slug,
251 self.settings.web.web_base_url,
252 space.slug(),
265253 board.id,
266254 item.id
267255 );
@@ -270,13 +258,20 @@
270258 r#"<p>{subject}</p><p><a href="{link}">{}</a></p>"#,
271259 i18n::catalog::view_issue(locale)
272260 );
273 if let Err(err) = self.send_email(&addr, &subject, &text, &html).await {
261 if let Err(err) = self
262 .audience
263 .send_email(&addr, &subject, &text, &html)
264 .await
265 {
274266 tracing::warn!(error = %err, "failed to send comment notification email");
275267 }
276268 }
277269 }
278270
279 let user_map = self.load_user_briefs(&[inserted.poster_id]).await?;
271 let user_map = self
272 .audience
273 .load_user_briefs(&[inserted.poster_id])
274 .await?;
280275 let poster = user_map.get(&inserted.poster_id).cloned();
281276
282277 Ok(CommentDto {
@@ -315,15 +310,15 @@
315310 _ => select = select.order_by_asc(comment::Column::CreatedAt),
316311 }
317312
318 let total = select.clone().count(self.db().read()).await? as i64;
313 let total = select.clone().count(self.database.read()).await? as i64;
319314 let rows = select
320315 .limit(limit)
321316 .offset(offset)
322 .all(self.db().read())
317 .all(self.database.read())
323318 .await?;
324319
325320 let poster_ids: Vec<i64> = rows.iter().map(|c| c.poster_id).collect();
326 let user_map = self.load_user_briefs(&poster_ids).await?;
321 let user_map = self.audience.load_user_briefs(&poster_ids).await?;
327322
328323 let items = rows
329324 .into_iter()
@@ -361,11 +356,11 @@
361356 let rows = comment::Entity::find()
362357 .filter(comment::Column::IssueId.eq(Some(issue_id)))
363358 .order_by_asc(comment::Column::CreatedAt)
364 .all(self.db().read())
359 .all(self.database.read())
365360 .await?;
366361
367362 let poster_ids: Vec<i64> = rows.iter().map(|c| c.poster_id).collect();
368 let user_map = self.load_user_briefs(&poster_ids).await?;
363 let user_map = self.audience.load_user_briefs(&poster_ids).await?;
369364
370365 let dtos: Vec<CommentDto> = rows
371366 .into_iter()
@@ -418,11 +413,11 @@
418413
419414 let c = comment::Entity::find_by_id(comment_id)
420415 .filter(comment::Column::IssueId.eq(Some(issue_id)))
421 .one(self.db().read())
416 .one(self.database.read())
422417 .await?
423418 .ok_or(IssueError::NotFound)?;
424419
425 let user_map = self.load_user_briefs(&[c.poster_id]).await?;
420 let user_map = self.audience.load_user_briefs(&[c.poster_id]).await?;
426421 let poster = user_map.get(&c.poster_id).cloned();
427422
428423 Ok(CommentDto {
@@ -450,7 +445,7 @@
450445
451446 let c = comment::Entity::find_by_id(comment_id)
452447 .filter(comment::Column::IssueId.eq(Some(issue_id)))
453 .one(self.db().read())
448 .one(self.database.read())
454449 .await?
455450 .ok_or(IssueError::NotFound)?;
456451
@@ -459,7 +454,7 @@
459454 }
460455
461456 let content = cmd.content.trim();
462 if content.is_empty() || content.chars().count() > self.config().issues.max_comment_len {
457 if content.is_empty() || content.chars().count() > self.settings.issues.max_comment_len {
463458 return Err(IssueError::BadRequest(
464459 "comment content is empty or too long".to_string(),
465460 ));
@@ -474,7 +469,7 @@
474469 let this = self.clone();
475470
476471 let updated = self
477 .db()
472 .database
478473 .primary()
479474 .transaction::<_, comment::Model, IssueError>(move |txn| {
480475 let content_change_clone = content_changed.clone();
@@ -512,7 +507,7 @@
512507 })
513508 .await?;
514509
515 let user_map = self.load_user_briefs(&[updated.poster_id]).await?;
510 let user_map = self.audience.load_user_briefs(&[updated.poster_id]).await?;
516511 let poster = user_map.get(&updated.poster_id).cloned();
517512
518513 Ok(CommentDto {
@@ -539,7 +534,7 @@
539534
540535 let c = comment::Entity::find_by_id(comment_id)
541536 .filter(comment::Column::IssueId.eq(Some(issue_id)))
542 .one(self.db().read())
537 .one(self.database.read())
543538 .await?
544539 .ok_or(IssueError::NotFound)?;
545540
@@ -549,7 +544,7 @@
549544
550545 let this = self.clone();
551546
552 self.db()
547 self.database
553548 .primary()
554549 .transaction::<_, (), IssueError>(move |txn| {
555550 Box::pin(async move {
Modified lib/issues/src/services/content_history.rs View lib/issues/src/services/content_history.rs
@@ -1,7 +1,7 @@
1use export::users::UserDto;
12use models::issue::issue_content_history;
23use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};
34use serde::{Deserialize, Serialize};
4use space::services::dto::UserDto;
55use time::OffsetDateTime;
66use utoipa::{IntoParams, ToSchema};
77use uuid::Uuid;
@@ -60,15 +60,15 @@
6060
6161 select = select.order_by_desc(issue_content_history::Column::EditedAt);
6262
63 let total = select.clone().count(self.db().read()).await? as i64;
63 let total = select.clone().count(self.database.read()).await? as i64;
6464 let rows = select
6565 .limit(limit)
6666 .offset(offset)
67 .all(self.db().read())
67 .all(self.database.read())
6868 .await?;
6969
7070 let poster_ids: Vec<i64> = rows.iter().map(|h| h.poster_id).collect();
71 let user_map = self.load_user_briefs(&poster_ids).await?;
71 let user_map = self.audience.load_user_briefs(&poster_ids).await?;
7272
7373 let items = rows
7474 .into_iter()
Modified lib/issues/src/services/dependencies.rs View lib/issues/src/services/dependencies.rs
@@ -71,7 +71,7 @@
7171 let existing = issue_dependency::Entity::find()
7272 .filter(issue_dependency::Column::IssueId.eq(issue_id))
7373 .filter(issue_dependency::Column::DependencyId.eq(cmd.dependency_id))
74 .one(self.db().read())
74 .one(self.database.read())
7575 .await?;
7676
7777 if existing.is_some() {
@@ -86,7 +86,7 @@
8686 let this = self.clone();
8787
8888 let inserted = self
89 .db()
89 .database
9090 .primary()
9191 .transaction::<_, issue_dependency::Model, IssueError>(move |txn| {
9292 Box::pin(async move {
@@ -132,7 +132,7 @@
132132 let rows = issue_dependency::Entity::find()
133133 .filter(issue_dependency::Column::DependencyId.eq(issue_id))
134134 .order_by_asc(issue_dependency::Column::CreatedAt)
135 .all(self.db().read())
135 .all(self.database.read())
136136 .await?;
137137
138138 Ok(rows.into_iter().map(DependencyDto::from).collect())
@@ -150,7 +150,7 @@
150150 let rows = issue_dependency::Entity::find()
151151 .filter(issue_dependency::Column::IssueId.eq(issue_id))
152152 .order_by_asc(issue_dependency::Column::CreatedAt)
153 .all(self.db().read())
153 .all(self.database.read())
154154 .await?;
155155
156156 Ok(rows.into_iter().map(DependencyDto::from).collect())
@@ -172,14 +172,14 @@
172172
173173 let dep = issue_dependency::Entity::find_by_id(dep_id)
174174 .filter(issue_dependency::Column::IssueId.eq(issue_id))
175 .one(self.db().read())
175 .one(self.database.read())
176176 .await?
177177 .ok_or(IssueError::NotFound)?;
178178
179179 let dep_target = dep.dependency_id;
180180 let this = self.clone();
181181
182 self.db()
182 self.database
183183 .primary()
184184 .transaction::<_, (), IssueError>(move |txn| {
185185 Box::pin(async move {
Modified lib/issues/src/services/events.rs View lib/issues/src/services/events.rs
@@ -1,7 +1,7 @@
1use export::users::UserDto;
12use models::issue::issue_event_line::{self, IssueEventType};
23use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};
34use serde::{Deserialize, Serialize};
4use space::services::dto::UserDto;
55use time::OffsetDateTime;
66use utoipa::{IntoParams, ToSchema};
77use uuid::Uuid;
@@ -58,15 +58,15 @@
5858 .filter(issue_event_line::Column::IssueId.eq(Some(issue_id)))
5959 .order_by_desc(issue_event_line::Column::CreatedAt);
6060
61 let total = select.clone().count(self.db().read()).await? as i64;
61 let total = select.clone().count(self.database.read()).await? as i64;
6262 let rows = select
6363 .limit(limit)
6464 .offset(offset)
65 .all(self.db().read())
65 .all(self.database.read())
6666 .await?;
6767
6868 let user_ids: Vec<i64> = rows.iter().map(|e| e.user_id).collect();
69 let user_map = self.load_user_briefs(&user_ids).await?;
69 let user_map = self.audience.load_user_briefs(&user_ids).await?;
7070
7171 let items = rows
7272 .into_iter()
Modified lib/issues/src/services/issue_users.rs View lib/issues/src/services/issue_users.rs
@@ -1,10 +1,10 @@
1use export::users::UserDto;
12use models::issue::issue_user;
23use sea_orm::{
34 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
45 QuerySelect, Set,
56};
67use serde::{Deserialize, Serialize};
7use space::services::dto::UserDto;
88use time::OffsetDateTime;
99use utoipa::{IntoParams, ToSchema};
1010use uuid::Uuid;
@@ -62,15 +62,15 @@
6262 .filter(issue_user::Column::IssueId.eq(issue_id))
6363 .order_by_desc(issue_user::Column::CreatedAt);
6464
65 let total = select.clone().count(self.db().read()).await? as i64;
65 let total = select.clone().count(self.database.read()).await? as i64;
6666 let rows = select
6767 .limit(limit)
6868 .offset(offset)
69 .all(self.db().read())
69 .all(self.database.read())
7070 .await?;
7171
7272 let uids: Vec<i64> = rows.iter().map(|u| u.uid).collect();
73 let user_map = self.load_user_briefs(&uids).await?;
73 let user_map = self.audience.load_user_briefs(&uids).await?;
7474
7575 let items = rows
7676 .into_iter()
@@ -114,7 +114,7 @@
114114 let existing = issue_user::Entity::find()
115115 .filter(issue_user::Column::Uid.eq(uid))
116116 .filter(issue_user::Column::IssueId.eq(issue_id))
117 .one(self.db().read())
117 .one(self.database.read())
118118 .await?;
119119
120120 let now = OffsetDateTime::now_utc();
@@ -123,7 +123,7 @@
123123 if let Some(r) = cmd.is_read {
124124 am.is_read = Set(r);
125125 }
126 am.update(self.db().write()).await?
126 am.update(self.database.write()).await?
127127 } else {
128128 let row = issue_user::ActiveModel {
129129 id: Set(Uuid::now_v7()),
@@ -132,10 +132,10 @@
132132 is_read: Set(cmd.is_read.unwrap_or(false)),
133133 created_at: Set(now),
134134 };
135 row.insert(self.db().write()).await?
135 row.insert(self.database.write()).await?
136136 };
137137
138 let user_map = self.load_user_briefs(&[uid]).await?;
138 let user_map = self.audience.load_user_briefs(&[uid]).await?;
139139 let user = user_map.get(&uid).cloned();
140140
141141 Ok(IssueUserDto {
Modified lib/issues/src/services/issues.rs View lib/issues/src/services/issues.rs
@@ -1,3 +1,4 @@
1use export::users::UserDto;
12use models::issue::issue_event_line::IssueEventType;
23use models::issue::issue_item::IssuePriority;
34use models::issue::{
@@ -6,14 +7,13 @@
67 issue_relation_pull, issue_relation_repo, issue_user, issue_watch, issues, milestone,
78 stopwatch, tracked_time,
89};
9use models::space::MemberStatus;
1010use models::users::user_notifications::NotificationType;
1111use sea_orm::{
1212 ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, EntityTrait, PaginatorTrait,
1313 QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait,
1414};
1515use serde::{Deserialize, Serialize};
16use space::services::dto::UserDto;
16use space::MemberStatus;
1717use time::OffsetDateTime;
1818use utoipa::{IntoParams, ToSchema};
1919use uuid::Uuid;
@@ -327,10 +327,10 @@
327327 let rows = issue_member::Entity::find()
328328 .filter(issue_member::Column::IssueId.is_in(issue_ids.to_vec()))
329329 .filter(issue_member::Column::Kind.eq(issue_member::IssueMemberKind::Assignee))
330 .all(self.db().read())
330 .all(self.database.read())
331331 .await?;
332332 let uids: Vec<i64> = rows.iter().map(|r| r.user_id).collect();
333 let user_map = self.load_user_briefs(&uids).await?;
333 let user_map = self.audience.load_user_briefs(&uids).await?;
334334 for r in rows {
335335 if let Some(brief) = user_map.get(&r.user_id) {
336336 map.entry(r.issue_id)
@@ -353,7 +353,7 @@
353353 }
354354 let rels = issue_relation_label::Entity::find()
355355 .filter(issue_relation_label::Column::IssueId.is_in(issue_ids.to_vec()))
356 .all(self.db().read())
356 .all(self.database.read())
357357 .await?;
358358 if rels.is_empty() {
359359 return Ok(map);
@@ -361,7 +361,7 @@
361361 let label_ids: Vec<Uuid> = rels.iter().map(|r| r.label_id).collect();
362362 let labels = issue_label::Entity::find()
363363 .filter(issue_label::Column::Id.is_in(label_ids))
364 .all(self.db().read())
364 .all(self.database.read())
365365 .await?;
366366 let by_id: std::collections::HashMap<Uuid, LabelDto> = labels
367367 .into_iter()
@@ -383,7 +383,7 @@
383383 caller: i64,
384384 item: issue_item::Model,
385385 ) -> Result<IssueDto, IssueError> {
386 let user_map = self.load_user_briefs(&[item.author_id]).await?;
386 let user_map = self.audience.load_user_briefs(&[item.author_id]).await?;
387387 let author = user_map.get(&item.author_id).cloned();
388388 let assignees = self
389389 .load_assignee_map(&[item.id])
@@ -398,7 +398,7 @@
398398 let viewer_watch = issue_watch::Entity::find()
399399 .filter(issue_watch::Column::IssueId.eq(item.id))
400400 .filter(issue_watch::Column::UserId.eq(caller))
401 .one(self.db().read())
401 .one(self.database.read())
402402 .await?
403403 .map(|w| w.mode);
404404 Ok(IssueDto {
@@ -429,36 +429,23 @@
429429 board_id: Uuid,
430430 cmd: CreateIssueCmd,
431431 ) -> Result<IssueDto, IssueError> {
432 if !self
433 .state
434 .redis
435 .quota_allow(
436 &format!("quota:issue_create:{caller}"),
437 self.config().quota.issue_create_per_day,
438 86_400,
439 )
440 .await
441 {
442 return Err(IssueError::RateLimited(
443 "issue creation quota exceeded, retry tomorrow".to_string(),
444 ));
445 }
432 self.access.allow_issue(caller).await?;
446433 let (space, board) = board_context(self, caller, space_name, board_id).await?;
447434
448435 let title = cmd.title.trim();
449 if title.is_empty() || title.chars().count() > self.config().issues.max_title_len {
436 if title.is_empty() || title.chars().count() > self.settings.issues.max_title_len {
450437 return Err(IssueError::BadRequest(
451438 "issue title is empty or too long".to_string(),
452439 ));
453440 }
454 if cmd.content.chars().count() > self.config().issues.max_body_len {
441 if cmd.content.chars().count() > self.settings.issues.max_body_len {
455442 return Err(IssueError::BadRequest("issue body is too long".to_string()));
456443 }
457444
458445 if let Some(mid) = cmd.milestone_id {
459446 milestone::Entity::find_by_id(mid)
460447 .filter(milestone::Column::CollectionId.eq(board.id))
461 .one(self.db().read())
448 .one(self.database.read())
462449 .await?
463450 .ok_or_else(|| {
464451 IssueError::BadRequest("milestone does not belong to this board".to_string())
@@ -466,8 +453,10 @@
466453 }
467454
468455 for a_uid in &cmd.assignee_ids {
469 let is_member = *a_uid == space.owner_user_id
470 || space::authority::member(&self.state, space.owner_user_id, space.id, *a_uid)
456 let is_member = *a_uid == space.owner()
457 || self
458 .access
459 .member(space.id(), *a_uid)
471460 .await?
472461 .is_some_and(|m| m.status() == MemberStatus::Active);
473462 if !is_member {
@@ -480,7 +469,7 @@
480469 let count = issue_label::Entity::find()
481470 .filter(issue_label::Column::Id.is_in(cmd.label_ids.clone()))
482471 .filter(issue_label::Column::DirId.eq(board.dir_id))
483 .count(self.db().read())
472 .count(self.database.read())
484473 .await?;
485474 if count != cmd.label_ids.len() as u64 {
486475 return Err(IssueError::BadRequest(
@@ -505,14 +494,14 @@
505494 let assignee_ids = cmd.assignee_ids.clone();
506495 let label_ids = cmd.label_ids.clone();
507496 let attachment_ids = cmd.attachment_ids.clone();
508 let space_lower_for_txn = space.slug.clone();
497 let space_lower_for_txn = space.slug().to_owned();
509498 let mentioned_for_txn = mentioned_uids.clone();
510499 let this = self.clone();
511500 let pending = export::notify::PendingNotifications::default();
512501 let pending_txn = pending.clone();
513502
514503 let inserted = self
515 .db()
504 .database
516505 .primary()
517506 .transaction::<_, issue_item::Model, IssueError>(move |txn| {
518507 let title_clone = title_str.clone();
@@ -623,29 +612,30 @@
623612 "issue_id": issue_id,
624613 "autoincrement_id": next_num,
625614 });
626 this.notify_user(
627 txn,
628 &pending_txn,
629 m_uid,
630 NotificationType::Mention,
631 format!("You were mentioned in issue #{next_num}: {title_clone}"),
632 Some(title_clone.clone()),
633 Some(data),
634 Some(caller),
635 )
636 .await?;
615 this.audience
616 .notify_user(
617 txn,
618 &pending_txn,
619 m_uid,
620 NotificationType::Mention,
621 format!("You were mentioned in issue #{next_num}: {title_clone}"),
622 Some(title_clone.clone()),
623 Some(data),
624 Some(caller),
625 )
626 .await?;
637627 }
638628
639629 Ok(ins)
640630 })
641631 })
642632 .await?;
643 pending.flush(&self.state).await;
633 pending.flush(&self.audience.notifications).await;
644634
645635 if !mentioned_uids.is_empty() {
646 let emails = self.primary_emails(&mentioned_uids).await?;
636 let emails = self.audience.primary_emails(&mentioned_uids).await?;
647637 for (uid, addr) in emails {
648 let locale = self.recipient_locale(uid).await;
638 let locale = self.audience.recipient_locale(uid).await;
649639 let subject = i18n::catalog::issue_mention_subject(
650640 locale,
651641 inserted.autoincrement_id,
@@ -653,8 +643,8 @@
653643 );
654644 let link = format!(
655645 "{}/issues/{}/{}/{}",
656 self.config().web.web_base_url,
657 space.slug,
646 self.settings.web.web_base_url,
647 space.slug(),
658648 board.id,
659649 inserted.id
660650 );
@@ -663,7 +653,11 @@
663653 r#"<p>{subject}</p><p><a href="{link}">{}</a></p>"#,
664654 i18n::catalog::view_issue(locale)
665655 );
666 if let Err(err) = self.send_email(&addr, &subject, &text, &html).await {
656 if let Err(err) = self
657 .audience
658 .send_email(&addr, &subject, &text, &html)
659 .await
660 {
667661 tracing::warn!(error = %err, "failed to send issue mention email");
668662 }
669663 }
@@ -728,7 +722,7 @@
728722 // checkbox semantics rather than intersecting them.
729723 let issue_ids: Vec<Uuid> = issue_relation_label::Entity::find()
730724 .filter(issue_relation_label::Column::LabelId.is_in(label_ids))
731 .all(self.db().read())
725 .all(self.database.read())
732726 .await?
733727 .into_iter()
734728 .map(|r| r.issue_id)
@@ -740,7 +734,7 @@
740734 let issue_ids: Vec<Uuid> = issue_member::Entity::find()
741735 .filter(issue_member::Column::UserId.is_in(assignee_ids))
742736 .filter(issue_member::Column::Kind.eq(issue_member::IssueMemberKind::Assignee))
743 .all(self.db().read())
737 .all(self.database.read())
744738 .await?
745739 .into_iter()
746740 .map(|r| r.issue_id)
@@ -760,15 +754,15 @@
760754 _ => select = select.order_by_desc(issue_item::Column::CreatedAt),
761755 }
762756
763 let total = select.clone().count(self.db().read()).await? as i64;
757 let total = select.clone().count(self.database.read()).await? as i64;
764758 let rows = select
765759 .limit(limit)
766760 .offset(offset)
767 .all(self.db().read())
761 .all(self.database.read())
768762 .await?;
769763
770764 let author_ids: Vec<i64> = rows.iter().map(|i| i.author_id).collect();
771 let user_map = self.load_user_briefs(&author_ids).await?;
765 let user_map = self.audience.load_user_briefs(&author_ids).await?;
772766 let issue_ids: Vec<Uuid> = rows.iter().map(|i| i.id).collect();
773767 let assignee_map = self.load_assignee_map(&issue_ids).await?;
774768 let label_map = self.load_label_map(&issue_ids).await?;
@@ -847,7 +841,7 @@
847841 let mut title_changed = None;
848842 if let Some(new_title) = cmd.title {
849843 let t = new_title.trim();
850 if t.is_empty() || t.chars().count() > self.config().issues.max_title_len {
844 if t.is_empty() || t.chars().count() > self.settings.issues.max_title_len {
851845 return Err(IssueError::BadRequest(
852846 "issue title is empty or too long".to_string(),
853847 ));
@@ -860,7 +854,7 @@
860854 if cmd
861855 .content
862856 .as_ref()
863 .is_some_and(|b| b.chars().count() > self.config().issues.max_body_len)
857 .is_some_and(|b| b.chars().count() > self.settings.issues.max_body_len)
864858 {
865859 return Err(IssueError::BadRequest("issue body is too long".to_string()));
866860 }
@@ -871,7 +865,7 @@
871865 if let Some(mid) = mid_opt {
872866 milestone::Entity::find_by_id(mid)
873867 .filter(milestone::Column::CollectionId.eq(board.id))
874 .one(self.db().read())
868 .one(self.database.read())
875869 .await?
876870 .ok_or_else(|| {
877871 IssueError::BadRequest(
@@ -886,8 +880,10 @@
886880 }
887881
888882 for a_uid in cmd.assignee_ids.iter().flatten() {
889 let is_member = *a_uid == space.owner_user_id
890 || space::authority::member(&self.state, space.owner_user_id, space.id, *a_uid)
883 let is_member = *a_uid == space.owner()
884 || self
885 .access
886 .member(space.id(), *a_uid)
891887 .await?
892888 .is_some_and(|m| m.status() == MemberStatus::Active);
893889 if !is_member {
@@ -902,7 +898,7 @@
902898 let count = issue_label::Entity::find()
903899 .filter(issue_label::Column::Id.is_in(label_ids.clone()))
904900 .filter(issue_label::Column::DirId.eq(board.dir_id))
905 .count(self.db().read())
901 .count(self.database.read())
906902 .await?;
907903 if count != label_ids.len() as u64 {
908904 return Err(IssueError::BadRequest(
@@ -945,7 +941,7 @@
945941 let this = self.clone();
946942
947943 let updated = self
948 .db()
944 .database
949945 .primary()
950946 .transaction::<_, issue_item::Model, IssueError>(move |txn| {
951947 let title_change_clone = title_changed.clone();
@@ -1167,10 +1163,10 @@
11671163 sea_orm::sea_query::Expr::value(now),
11681164 )
11691165 .filter(issue_item::Column::Id.eq(item.id))
1170 .exec(self.db().write())
1166 .exec(self.database.write())
11711167 .await?;
11721168 self.write_event(
1173 self.db().write(),
1169 self.database.write(),
11741170 item.id,
11751171 caller,
11761172 if should_close {
@@ -1205,8 +1201,8 @@
12051201 // Objects live outside the transaction: collect the keys first, drop
12061202 // the rows in the cascade, then clean storage best effort.
12071203 let attachment_keys =
1208 IssueService::issue_attachment_keys(self.db().read(), issue_id).await?;
1209 self.db()
1204 IssueService::issue_attachment_keys(self.database.read(), issue_id).await?;
1205 self.database
12101206 .primary()
12111207 .transaction::<_, (), IssueError>(|txn| {
12121208 Box::pin(async move {
@@ -1217,7 +1213,7 @@
12171213 .await?;
12181214
12191215 for key in attachment_keys {
1220 if let Err(err) = self.state.storage.delete(&key).await {
1216 if let Err(err) = self.storage.delete(&key).await {
12211217 tracing::warn!(error = %err, key = %key, "failed to delete issue attachment object");
12221218 }
12231219 }
@@ -1258,7 +1254,7 @@
12581254 let this = self.clone();
12591255
12601256 let updated = self
1261 .db()
1257 .database
12621258 .primary()
12631259 .transaction::<_, issue_item::Model, IssueError>(move |txn| {
12641260 Box::pin(async move {
Modified lib/issues/src/services/labels.rs View lib/issues/src/services/labels.rs
@@ -123,7 +123,7 @@
123123 let _ = lookup_issue(self, &board, issue_id).await?;
124124 let label = issue_label::Entity::find_by_id(label_id)
125125 .filter(issue_label::Column::DirId.eq(board.dir_id))
126 .one(self.db().read())
126 .one(self.database.read())
127127 .await?
128128 .ok_or(IssueError::NotFound)?;
129129 Ok(label)
@@ -142,14 +142,14 @@
142142 let board = lookup_board(self, &space, board_id, caller).await?;
143143
144144 let name = cmd.name.trim();
145 if name.is_empty() || name.chars().count() > self.config().issues.max_label_name_len {
145 if name.is_empty() || name.chars().count() > self.settings.issues.max_label_name_len {
146146 return Err(IssueError::BadRequest(
147147 "label name is empty or too long".to_string(),
148148 ));
149149 }
150150
151151 let color = cmd.color.trim();
152 if !validate_hex_color(color, self.config().issues.max_label_color_len) {
152 if !validate_hex_color(color, self.settings.issues.max_label_color_len) {
153153 return Err(IssueError::BadRequest(
154154 "invalid color format: must be #rrggbb hex code".to_string(),
155155 ));
@@ -177,7 +177,7 @@
177177 updated_at: Set(None),
178178 };
179179
180 let inserted = row.insert(self.db().write()).await?;
180 let inserted = row.insert(self.database.write()).await?;
181181 Ok(LabelDto::from(inserted))
182182 }
183183
@@ -208,11 +208,11 @@
208208
209209 select = select.order_by_desc(issue_label::Column::CreatedAt);
210210
211 let total = select.clone().count(self.db().read()).await? as i64;
211 let total = select.clone().count(self.database.read()).await? as i64;
212212 let rows = select
213213 .limit(limit)
214214 .offset(offset)
215 .all(self.db().read())
215 .all(self.database.read())
216216 .await?;
217217
218218 let items = rows.into_iter().map(LabelDto::from).collect();
@@ -235,7 +235,7 @@
235235
236236 let l = issue_label::Entity::find_by_id(label_id)
237237 .filter(issue_label::Column::DirId.eq(board.dir_id))
238 .one(self.db().read())
238 .one(self.database.read())
239239 .await?
240240 .ok_or(IssueError::NotFound)?;
241241
@@ -255,7 +255,7 @@
255255
256256 let l = issue_label::Entity::find_by_id(label_id)
257257 .filter(issue_label::Column::DirId.eq(board.dir_id))
258 .one(self.db().read())
258 .one(self.database.read())
259259 .await?
260260 .ok_or(IssueError::NotFound)?;
261261
@@ -263,7 +263,7 @@
263263
264264 if let Some(new_name) = cmd.name {
265265 let name = new_name.trim();
266 if name.is_empty() || name.chars().count() > self.config().issues.max_label_name_len {
266 if name.is_empty() || name.chars().count() > self.settings.issues.max_label_name_len {
267267 return Err(IssueError::BadRequest(
268268 "label name is empty or too long".to_string(),
269269 ));
@@ -273,7 +273,7 @@
273273
274274 if let Some(new_color) = cmd.color {
275275 let color = new_color.trim();
276 if !validate_hex_color(color, self.config().issues.max_label_color_len) {
276 if !validate_hex_color(color, self.settings.issues.max_label_color_len) {
277277 return Err(IssueError::BadRequest(
278278 "invalid color format: must be #rrggbb hex code".to_string(),
279279 ));
@@ -302,7 +302,7 @@
302302 let now = OffsetDateTime::now_utc();
303303 am.updated_at = Set(Some(now));
304304
305 let updated = am.update(self.db().write()).await?;
305 let updated = am.update(self.database.write()).await?;
306306 Ok(LabelDto::from(updated))
307307 }
308308
@@ -318,7 +318,7 @@
318318
319319 let _ = issue_label::Entity::find_by_id(label_id)
320320 .filter(issue_label::Column::DirId.eq(board.dir_id))
321 .one(self.db().read())
321 .one(self.database.read())
322322 .await?
323323 .ok_or(IssueError::NotFound)?;
324324 Ok(())
@@ -335,7 +335,7 @@
335335
336336 let rels = issue_relation_label::Entity::find()
337337 .filter(issue_relation_label::Column::IssueId.eq(issue_id))
338 .all(self.db().read())
338 .all(self.database.read())
339339 .await?;
340340
341341 if rels.is_empty() {
@@ -345,7 +345,7 @@
345345 let label_ids: Vec<Uuid> = rels.into_iter().map(|r| r.label_id).collect();
346346 let labels = issue_label::Entity::find()
347347 .filter(issue_label::Column::Id.is_in(label_ids))
348 .all(self.db().read())
348 .all(self.database.read())
349349 .await?;
350350
351351 Ok(labels.into_iter().map(LabelDto::from).collect())
@@ -367,7 +367,7 @@
367367 let existing = issue_relation_label::Entity::find()
368368 .filter(issue_relation_label::Column::IssueId.eq(issue_id))
369369 .filter(issue_relation_label::Column::LabelId.eq(label_id))
370 .one(self.db().read())
370 .one(self.database.read())
371371 .await?;
372372
373373 if existing.is_some() {
@@ -380,7 +380,7 @@
380380 let label_name = label.name.clone();
381381 let this = self.clone();
382382
383 self.db()
383 self.database
384384 .primary()
385385 .transaction::<_, (), IssueError>(move |txn| {
386386 let name_clone = label_name.clone();
@@ -431,14 +431,14 @@
431431 let rel = issue_relation_label::Entity::find()
432432 .filter(issue_relation_label::Column::IssueId.eq(issue_id))
433433 .filter(issue_relation_label::Column::LabelId.eq(label_id))
434 .one(self.db().read())
434 .one(self.database.read())
435435 .await?
436436 .ok_or(IssueError::NotFound)?;
437437
438438 let label_name = label.name.clone();
439439 let this = self.clone();
440440
441 self.db()
441 self.database
442442 .primary()
443443 .transaction::<_, (), IssueError>(move |txn| {
444444 let name_clone = label_name.clone();
@@ -469,3 +469,49 @@
469469 Ok(())
470470 }
471471}
472
473impl IssueService {
474 pub async fn resolve_directory_label(
475 &self,
476 dir_id: Uuid,
477 reference: &str,
478 ) -> Result<LabelDto, IssueError> {
479 let reference = reference.trim();
480 let select = issue_label::Entity::find()
481 .filter(issue_label::Column::DirId.eq(dir_id))
482 .filter(issue_label::Column::IsArchived.eq(false));
483 let row = if let Ok(id) = Uuid::parse_str(reference) {
484 select.filter(issue_label::Column::Id.eq(id))
485 } else {
486 select.filter(issue_label::Column::Name.eq(reference))
487 }
488 .one(self.database.read())
489 .await?;
490 row.map(LabelDto::from).ok_or_else(|| {
491 IssueError::BadRequest(format!(
492 "label `{reference}` not found in this repository directory"
493 ))
494 })
495 }
496
497 pub async fn directory_labels(&self, dir_id: Uuid) -> Result<Vec<LabelDto>, IssueError> {
498 let rows = issue_label::Entity::find()
499 .filter(issue_label::Column::DirId.eq(dir_id))
500 .filter(issue_label::Column::IsArchived.eq(false))
501 .order_by_asc(issue_label::Column::Name)
502 .all(self.database.read())
503 .await?;
504 Ok(rows.into_iter().map(LabelDto::from).collect())
505 }
506
507 pub async fn labels_by_ids(&self, ids: Vec<Uuid>) -> Result<Vec<LabelDto>, IssueError> {
508 if ids.is_empty() {
509 return Ok(Vec::new());
510 }
511 let rows = issue_label::Entity::find()
512 .filter(issue_label::Column::Id.is_in(ids))
513 .all(self.database.read())
514 .await?;
515 Ok(rows.into_iter().map(LabelDto::from).collect())
516 }
517}
Modified lib/issues/src/services/members.rs View lib/issues/src/services/members.rs
@@ -1,13 +1,13 @@
1use export::users::UserDto;
12use models::issue::issue_event_line::IssueEventType;
23use models::issue::issue_member::{self, IssueMemberKind};
3use models::space::MemberStatus;
44use models::users::user_notifications::NotificationType;
55use sea_orm::{
66 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
77 QuerySelect, Set, TransactionTrait,
88};
99use serde::{Deserialize, Serialize};
10use space::services::dto::UserDto;
10use space::MemberStatus;
1111use time::OffsetDateTime;
1212use utoipa::{IntoParams, ToSchema};
1313use uuid::Uuid;
@@ -65,10 +65,11 @@
6565 let board = lookup_board(self, &space, board_id, caller).await?;
6666 let item = lookup_issue(self, &board, issue_id).await?;
6767
68 let target_is_member = if cmd.user_id == space.owner_user_id {
68 let target_is_member = if cmd.user_id == space.owner() {
6969 true
7070 } else {
71 space::authority::member(&self.state, space.owner_user_id, space.id, cmd.user_id)
71 self.access
72 .member(space.id(), cmd.user_id)
7273 .await?
7374 .is_some_and(|m| m.status() == MemberStatus::Active)
7475 };
@@ -80,7 +81,7 @@
8081 .filter(issue_member::Column::IssueId.eq(issue_id))
8182 .filter(issue_member::Column::UserId.eq(cmd.user_id))
8283 .filter(issue_member::Column::Kind.eq(cmd.kind))
83 .one(self.db().read())
84 .one(self.database.read())
8485 .await?;
8586 if existing.is_some() {
8687 return Err(IssueError::Conflict(
@@ -94,51 +95,51 @@
9495 let target_uid = cmd.user_id;
9596 let this = self.clone();
9697 let item_title_for_txn = item.title.clone();
97 let space_lower_for_txn = space.slug.clone();
98 let space_lower_for_txn = space.slug().to_owned();
9899 let pending = export::notify::PendingNotifications::default();
99100 let pending_txn = pending.clone();
100101
101 let inserted = self
102 .db()
103 .primary()
104 .transaction::<_, issue_member::Model, IssueError>(move |txn| {
105 let space_name_clone = space_lower_for_txn;
106 let issue_num = item.autoincrement_id;
107 let issue_title_clone = item_title_for_txn;
108 Box::pin(async move {
109 let row = issue_member::ActiveModel {
110 id: Set(member_id),
111 issue_id: Set(issue_id),
112 user_id: Set(target_uid),
113 kind: Set(kind_enum),
114 created_at: Set(now),
115 updated_at: Set(None),
116 };
117 let ins = row.insert(txn).await?;
102 let inserted =
103 self.database
104 .primary()
105 .transaction::<_, issue_member::Model, IssueError>(move |txn| {
106 let space_name_clone = space_lower_for_txn;
107 let issue_num = item.autoincrement_id;
108 let issue_title_clone = item_title_for_txn;
109 Box::pin(async move {
110 let row = issue_member::ActiveModel {
111 id: Set(member_id),
112 issue_id: Set(issue_id),
113 user_id: Set(target_uid),
114 kind: Set(kind_enum),
115 created_at: Set(now),
116 updated_at: Set(None),
117 };
118 let ins = row.insert(txn).await?;
118119
119 let content_str = format!("{:?} {}", kind_enum, target_uid);
120 this.write_event(
121 txn,
122 issue_id,
123 caller,
124 IssueEventType::MemberAdded,
125 IssueEventDetail {
126 comment_id: None,
127 old_title: None,
128 new_title: None,
129 content: Some(content_str),
130 },
131 )
132 .await?;
120 let content_str = format!("{:?} {}", kind_enum, target_uid);
121 this.write_event(
122 txn,
123 issue_id,
124 caller,
125 IssueEventType::MemberAdded,
126 IssueEventDetail {
127 comment_id: None,
128 old_title: None,
129 new_title: None,
130 content: Some(content_str),
131 },
132 )
133 .await?;
133134
134 if kind_enum == IssueMemberKind::Assignee && target_uid != caller {
135 let data = serde_json::json!({
136 "space_name": space_name_clone,
137 "board_id": board.id,
138 "issue_id": issue_id,
139 "autoincrement_id": issue_num,
140 });
141 this.notify_user(
135 if kind_enum == IssueMemberKind::Assignee && target_uid != caller {
136 let data = serde_json::json!({
137 "space_name": space_name_clone,
138 "board_id": board.id,
139 "issue_id": issue_id,
140 "autoincrement_id": issue_num,
141 });
142 this.audience.notify_user(
142143 txn,
143144 &pending_txn,
144145 target_uid,
@@ -149,18 +150,18 @@
149150 Some(caller),
150151 )
151152 .await?;
152 }
153 }
153154
154 Ok(ins)
155 Ok(ins)
156 })
155157 })
156 })
157 .await?;
158 pending.flush(&self.state).await;
158 .await?;
159 pending.flush(&self.audience.notifications).await;
159160
160161 if kind_enum == IssueMemberKind::Assignee && target_uid != caller {
161 let emails = self.primary_emails(&[target_uid]).await?;
162 let emails = self.audience.primary_emails(&[target_uid]).await?;
162163 if let Some(addr) = emails.get(&target_uid) {
163 let locale = self.recipient_locale(target_uid).await;
164 let locale = self.audience.recipient_locale(target_uid).await;
164165 let subject = i18n::catalog::issue_assigned_subject(
165166 locale,
166167 item.autoincrement_id,
@@ -168,8 +169,8 @@
168169 );
169170 let link = format!(
170171 "{}/issues/{}/{}/{}",
171 self.config().web.web_base_url,
172 space.slug,
172 self.settings.web.web_base_url,
173 space.slug(),
173174 board.id,
174175 item.id
175176 );
@@ -178,13 +179,13 @@
178179 r#"<p>{subject}</p><p><a href="{link}">{}</a></p>"#,
179180 i18n::catalog::view_issue(locale)
180181 );
181 if let Err(err) = self.send_email(addr, &subject, &text, &html).await {
182 if let Err(err) = self.audience.send_email(addr, &subject, &text, &html).await {
182183 tracing::warn!(error = %err, "failed to send issue assignment email");
183184 }
184185 }
185186 }
186187
187 let user_map = self.load_user_briefs(&[cmd.user_id]).await?;
188 let user_map = self.audience.load_user_briefs(&[cmd.user_id]).await?;
188189 let user_brief = user_map.get(&cmd.user_id).cloned();
189190
190191 Ok(IssueMemberDto {
@@ -218,15 +219,15 @@
218219
219220 select = select.order_by_asc(issue_member::Column::CreatedAt);
220221
221 let total = select.clone().count(self.db().read()).await? as i64;
222 let total = select.clone().count(self.database.read()).await? as i64;
222223 let rows = select
223224 .limit(limit)
224225 .offset(offset)
225 .all(self.db().read())
226 .all(self.database.read())
226227 .await?;
227228
228229 let user_ids: Vec<i64> = rows.iter().map(|m| m.user_id).collect();
229 let user_map = self.load_user_briefs(&user_ids).await?;
230 let user_map = self.audience.load_user_briefs(&user_ids).await?;
230231
231232 let items = rows
232233 .into_iter()
@@ -267,14 +268,14 @@
267268
268269 let m = issue_member::Entity::find_by_id(member_id)
269270 .filter(issue_member::Column::IssueId.eq(issue_id))
270 .one(self.db().read())
271 .one(self.database.read())
271272 .await?
272273 .ok_or(IssueError::NotFound)?;
273274
274275 let content_str = format!("{:?} {}", m.kind, m.user_id);
275276 let this = self.clone();
276277
277 self.db()
278 self.database
278279 .primary()
279280 .transaction::<_, (), IssueError>(move |txn| {
280281 Box::pin(async move {
Modified lib/issues/src/services/milestones.rs View lib/issues/src/services/milestones.rs
@@ -112,7 +112,7 @@
112112 .filter(issue_item::Column::MilestoneId.is_in(opt_ids.clone()))
113113 .group_by(issue_item::Column::MilestoneId)
114114 .into_tuple()
115 .all(self.db().read())
115 .all(self.database.read())
116116 .await?;
117117 for (milestone_id, cnt) in total_rows {
118118 if let Some(mid) = milestone_id {
@@ -128,7 +128,7 @@
128128 .filter(issue_item::Column::ClosedAt.is_not_null())
129129 .group_by(issue_item::Column::MilestoneId)
130130 .into_tuple()
131 .all(self.db().read())
131 .all(self.database.read())
132132 .await?;
133133 for (milestone_id, cnt) in closed_rows {
134134 if let Some(mid) = milestone_id {
@@ -159,7 +159,7 @@
159159 let board = lookup_board(self, &space, board_id, caller).await?;
160160
161161 let name = cmd.name.trim();
162 if name.is_empty() || name.chars().count() > self.config().issues.max_milestone_title_len {
162 if name.is_empty() || name.chars().count() > self.settings.issues.max_milestone_title_len {
163163 return Err(IssueError::BadRequest(
164164 "milestone name is empty or too long".to_string(),
165165 ));
@@ -168,7 +168,7 @@
168168 if cmd
169169 .description
170170 .as_ref()
171 .is_some_and(|d| d.chars().count() > self.config().issues.max_milestone_description_len)
171 .is_some_and(|d| d.chars().count() > self.settings.issues.max_milestone_description_len)
172172 {
173173 return Err(IssueError::BadRequest(
174174 "milestone description is too long".to_string(),
@@ -189,7 +189,7 @@
189189 updated_at: Set(None),
190190 };
191191
192 let inserted = row.insert(self.db().write()).await?;
192 let inserted = row.insert(self.database.write()).await?;
193193 Ok(MilestoneDto {
194194 id: inserted.id,
195195 board_id: inserted.collection_id,
@@ -231,11 +231,11 @@
231231
232232 select = select.order_by_desc(milestone::Column::CreatedAt);
233233
234 let total = select.clone().count(self.db().read()).await? as i64;
234 let total = select.clone().count(self.database.read()).await? as i64;
235235 let rows = select
236236 .limit(limit)
237237 .offset(offset)
238 .all(self.db().read())
238 .all(self.database.read())
239239 .await?;
240240
241241 let milestone_ids: Vec<Uuid> = rows.iter().map(|m| m.id).collect();
@@ -269,7 +269,7 @@
269269
270270 let m = milestone::Entity::find_by_id(milestone_id)
271271 .filter(milestone::Column::CollectionId.eq(board.id))
272 .one(self.db().read())
272 .one(self.database.read())
273273 .await?
274274 .ok_or(IssueError::NotFound)?;
275275
@@ -291,7 +291,7 @@
291291
292292 let m = milestone::Entity::find_by_id(milestone_id)
293293 .filter(milestone::Column::CollectionId.eq(board.id))
294 .one(self.db().read())
294 .one(self.database.read())
295295 .await?
296296 .ok_or(IssueError::NotFound)?;
297297
@@ -300,7 +300,7 @@
300300 if let Some(new_name) = cmd.name {
301301 let name = new_name.trim();
302302 if name.is_empty()
303 || name.chars().count() > self.config().issues.max_milestone_title_len
303 || name.chars().count() > self.settings.issues.max_milestone_title_len
304304 {
305305 return Err(IssueError::BadRequest(
306306 "milestone name is empty or too long".to_string(),
@@ -311,7 +311,7 @@
311311
312312 if let Some(desc) = cmd.description {
313313 if desc.as_ref().is_some_and(|d| {
314 d.chars().count() > self.config().issues.max_milestone_description_len
314 d.chars().count() > self.settings.issues.max_milestone_description_len
315315 }) {
316316 return Err(IssueError::BadRequest(
317317 "milestone description is too long".to_string(),
@@ -330,7 +330,7 @@
330330 let now = OffsetDateTime::now_utc();
331331 am.updated_at = Set(Some(now));
332332
333 let updated = am.update(self.db().write()).await?;
333 let updated = am.update(self.database.write()).await?;
334334 self.build_milestone_dto(updated).await
335335 }
336336
@@ -348,11 +348,11 @@
348348
349349 let _ = milestone::Entity::find_by_id(milestone_id)
350350 .filter(milestone::Column::CollectionId.eq(board.id))
351 .one(self.db().read())
351 .one(self.database.read())
352352 .await?
353353 .ok_or(IssueError::NotFound)?;
354354
355 self.db()
355 self.database
356356 .primary()
357357 .transaction::<_, (), IssueError>(|txn| {
358358 Box::pin(async move {
Modified lib/issues/src/services/mod.rs View lib/issues/src/services/mod.rs
@@ -1,4 +1,6 @@
1pub mod admission;
12pub mod attachments;
3pub mod audience;
24pub mod boards;
35pub mod comments;
46pub mod content_history;
Modified lib/issues/src/services/pins.rs View lib/issues/src/services/pins.rs
@@ -63,7 +63,7 @@
6363 authz::require_board_manage(self, &space, caller).await?;
6464 let row = issue_pin::Entity::find_by_id(pin_id)
6565 .filter(issue_pin::Column::CollectionId.eq(board_id))
66 .one(self.db().write())
66 .one(self.database.write())
6767 .await?
6868 .ok_or(IssueError::NotFound)?;
6969 if cmd.pin_order < 0 {
@@ -71,7 +71,7 @@
7171 }
7272 let mut am: issue_pin::ActiveModel = row.into();
7373 am.pin_order = Set(cmd.pin_order);
74 let updated = am.update(self.db().write()).await?;
74 let updated = am.update(self.database.write()).await?;
7575 Ok(PinDto::from(updated))
7676 }
7777}
@@ -99,7 +99,7 @@
9999 let existing = issue_pin::Entity::find()
100100 .filter(issue_pin::Column::CollectionId.eq(board.id))
101101 .filter(issue_pin::Column::IssueId.eq(Some(cmd.issue_id)))
102 .one(self.db().read())
102 .one(self.database.read())
103103 .await?;
104104
105105 if existing.is_some() {
@@ -116,7 +116,7 @@
116116 created_at: Set(now),
117117 };
118118
119 let inserted = row.insert(self.db().write()).await?;
119 let inserted = row.insert(self.database.write()).await?;
120120 Ok(PinDto::from(inserted))
121121 }
122122
@@ -132,7 +132,7 @@
132132 .filter(issue_pin::Column::CollectionId.eq(board.id))
133133 .order_by_asc(issue_pin::Column::PinOrder)
134134 .order_by_asc(issue_pin::Column::CreatedAt)
135 .all(self.db().read())
135 .all(self.database.read())
136136 .await?;
137137
138138 Ok(rows.into_iter().map(PinDto::from).collect())
@@ -152,12 +152,12 @@
152152
153153 let p = issue_pin::Entity::find_by_id(pin_id)
154154 .filter(issue_pin::Column::CollectionId.eq(board.id))
155 .one(self.db().read())
155 .one(self.database.read())
156156 .await?
157157 .ok_or(IssueError::NotFound)?;
158158
159159 issue_pin::Entity::delete_by_id(p.id)
160 .exec(self.db().write())
160 .exec(self.database.write())
161161 .await?;
162162
163163 Ok(())
Modified lib/issues/src/services/reactions.rs View lib/issues/src/services/reactions.rs
@@ -1,10 +1,10 @@
11use std::collections::HashMap;
22
3use export::users::UserDto;
34use models::issue::{comment, issue_reactions};
4use models::space::SpaceJoinTrigger;
55use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
66use serde::{Deserialize, Serialize};
7use space::services::dto::UserDto;
7use space::SpaceJoinTrigger;
88use space::services::join_rules;
99use time::OffsetDateTime;
1010use utoipa::{IntoParams, ToSchema};
@@ -86,7 +86,7 @@
8686impl IssueService {
8787 fn validate_emoji(&self, emoji: &str) -> Result<String, IssueError> {
8888 let clean = emoji.trim();
89 if clean.is_empty() || clean.chars().count() > self.config().issues.max_reaction_emoji_len {
89 if clean.is_empty() || clean.chars().count() > self.settings.issues.max_reaction_emoji_len {
9090 return Err(IssueError::BadRequest(
9191 "emoji is empty or too long".to_string(),
9292 ));
@@ -111,7 +111,7 @@
111111 .filter(issue_reactions::Column::IssueId.eq(Some(issue_id)))
112112 .filter(issue_reactions::Column::UserId.eq(caller))
113113 .filter(issue_reactions::Column::Emoji.eq(&emoji))
114 .one(self.db().read())
114 .one(self.database.read())
115115 .await?;
116116
117117 if existing.is_some() {
@@ -129,10 +129,10 @@
129129 created_at: Set(now),
130130 };
131131
132 let inserted = row.insert(self.db().write()).await?;
132 let inserted = row.insert(self.database.write()).await?;
133133 if let Err(e) = join_rules::apply_space_join_rules(
134 &self.state,
135 space.id,
134 &self.access,
135 space.id(),
136136 caller,
137137 SpaceJoinTrigger::IssueReaction,
138138 &emoji,
@@ -141,7 +141,7 @@
141141 {
142142 tracing::warn!(%e, "space join rule application failed");
143143 }
144 let user_map = self.load_user_briefs(&[caller]).await?;
144 let user_map = self.audience.load_user_briefs(&[caller]).await?;
145145 let user = user_map.get(&caller).cloned();
146146
147147 Ok(ReactionDto {
@@ -168,7 +168,7 @@
168168
169169 let c = comment::Entity::find_by_id(comment_id)
170170 .filter(comment::Column::IssueId.eq(Some(issue_id)))
171 .one(self.db().read())
171 .one(self.database.read())
172172 .await?
173173 .ok_or(IssueError::NotFound)?;
174174
@@ -178,7 +178,7 @@
178178 .filter(issue_reactions::Column::CommentId.eq(Some(c.id)))
179179 .filter(issue_reactions::Column::UserId.eq(caller))
180180 .filter(issue_reactions::Column::Emoji.eq(&emoji))
181 .one(self.db().read())
181 .one(self.database.read())
182182 .await?;
183183
184184 if existing.is_some() {
@@ -196,10 +196,10 @@
196196 created_at: Set(now),
197197 };
198198
199 let inserted = row.insert(self.db().write()).await?;
199 let inserted = row.insert(self.database.write()).await?;
200200 if let Err(e) = join_rules::apply_space_join_rules(
201 &self.state,
202 space.id,
201 &self.access,
202 space.id(),
203203 caller,
204204 SpaceJoinTrigger::IssueCommentReaction,
205205 &emoji,
@@ -208,7 +208,7 @@
208208 {
209209 tracing::warn!(%e, "space join rule application failed");
210210 }
211 let user_map = self.load_user_briefs(&[caller]).await?;
211 let user_map = self.audience.load_user_briefs(&[caller]).await?;
212212 let user = user_map.get(&caller).cloned();
213213
214214 Ok(ReactionDto {
@@ -232,7 +232,7 @@
232232
233233 let rows = issue_reactions::Entity::find()
234234 .filter(issue_reactions::Column::IssueId.eq(Some(issue_id)))
235 .all(self.db().read())
235 .all(self.database.read())
236236 .await?;
237237
238238 Ok(aggregate_reactions(rows))
@@ -250,13 +250,13 @@
250250
251251 let _ = comment::Entity::find_by_id(comment_id)
252252 .filter(comment::Column::IssueId.eq(Some(issue_id)))
253 .one(self.db().read())
253 .one(self.database.read())
254254 .await?
255255 .ok_or(IssueError::NotFound)?;
256256
257257 let rows = issue_reactions::Entity::find()
258258 .filter(issue_reactions::Column::CommentId.eq(Some(comment_id)))
259 .all(self.db().read())
259 .all(self.database.read())
260260 .await?;
261261
262262 Ok(aggregate_reactions(rows))
@@ -279,11 +279,11 @@
279279 .filter(issue_reactions::Column::CommentId.is_null())
280280 .filter(issue_reactions::Column::UserId.eq(caller))
281281 .filter(issue_reactions::Column::Emoji.eq(&emoji))
282 .one(self.db().write())
282 .one(self.database.write())
283283 .await?
284284 .ok_or(IssueError::NotFound)?;
285285 issue_reactions::Entity::delete_by_id(row.id)
286 .exec(self.db().write())
286 .exec(self.database.write())
287287 .await?;
288288 Ok(())
289289 }
@@ -301,7 +301,7 @@
301301 require_issue_access(self, caller, space_name, board_id, issue_id).await?;
302302 let _ = comment::Entity::find_by_id(comment_id)
303303 .filter(comment::Column::IssueId.eq(Some(issue_id)))
304 .one(self.db().read())
304 .one(self.database.read())
305305 .await?
306306 .ok_or(IssueError::NotFound)?;
307307
@@ -310,11 +310,11 @@
310310 .filter(issue_reactions::Column::CommentId.eq(Some(comment_id)))
311311 .filter(issue_reactions::Column::UserId.eq(caller))
312312 .filter(issue_reactions::Column::Emoji.eq(&emoji))
313 .one(self.db().write())
313 .one(self.database.write())
314314 .await?
315315 .ok_or(IssueError::NotFound)?;
316316 issue_reactions::Entity::delete_by_id(row.id)
317 .exec(self.db().write())
317 .exec(self.database.write())
318318 .await?;
319319 Ok(())
320320 }
@@ -332,7 +332,7 @@
332332
333333 let r = issue_reactions::Entity::find_by_id(reaction_id)
334334 .filter(issue_reactions::Column::UserId.eq(caller))
335 .one(self.db().read())
335 .one(self.database.read())
336336 .await?
337337 .ok_or(IssueError::NotFound)?;
338338
@@ -341,7 +341,7 @@
341341 None => match r.comment_id {
342342 Some(cid) => comment::Entity::find_by_id(cid)
343343 .filter(comment::Column::IssueId.eq(Some(issue_id)))
344 .one(self.db().read())
344 .one(self.database.read())
345345 .await?
346346 .is_some(),
347347 None => false,
@@ -352,7 +352,7 @@
352352 }
353353
354354 issue_reactions::Entity::delete_by_id(r.id)
355 .exec(self.db().write())
355 .exec(self.database.write())
356356 .await?;
357357
358358 Ok(())
Modified lib/issues/src/services/repo_relations.rs View lib/issues/src/services/repo_relations.rs
@@ -59,14 +59,14 @@
5959 let _ = lookup_issue(self, &board, issue_id).await?;
6060
6161 let _ = repos::Entity::find_by_id(cmd.repo_id)
62 .one(self.db().read())
62 .one(self.database.read())
6363 .await?
6464 .ok_or(IssueError::NotFound)?;
6565
6666 let existing = issue_relation_repo::Entity::find()
6767 .filter(issue_relation_repo::Column::IssueId.eq(issue_id))
6868 .filter(issue_relation_repo::Column::RepoId.eq(cmd.repo_id))
69 .one(self.db().read())
69 .one(self.database.read())
7070 .await?;
7171
7272 if existing.is_some() {
@@ -83,7 +83,7 @@
8383 created_at: Set(now),
8484 };
8585
86 let inserted = row.insert(self.db().write()).await?;
86 let inserted = row.insert(self.database.write()).await?;
8787 Ok(RepoRelDto::from(inserted))
8888 }
8989
@@ -99,7 +99,7 @@
9999 let rows = issue_relation_repo::Entity::find()
100100 .filter(issue_relation_repo::Column::IssueId.eq(issue_id))
101101 .order_by_asc(issue_relation_repo::Column::CreatedAt)
102 .all(self.db().read())
102 .all(self.database.read())
103103 .await?;
104104
105105 Ok(rows.into_iter().map(RepoRelDto::from).collect())
@@ -121,12 +121,12 @@
121121
122122 let r = issue_relation_repo::Entity::find_by_id(rel_id)
123123 .filter(issue_relation_repo::Column::IssueId.eq(issue_id))
124 .one(self.db().read())
124 .one(self.database.read())
125125 .await?
126126 .ok_or(IssueError::NotFound)?;
127127
128128 issue_relation_repo::Entity::delete_by_id(r.id)
129 .exec(self.db().write())
129 .exec(self.database.write())
130130 .await?;
131131
132132 Ok(())
Modified lib/issues/src/services/service.rs View lib/issues/src/services/service.rs
@@ -2,15 +2,11 @@
22use std::sync::Arc;
33
44use config::AppConfig;
5use export::AppState;
6use export::database::Database;
7use i18n;
5use export::database::AppDatabase;
86use models::issue::issue_event_line::{self, IssueEventType};
97use models::issue::issue_item;
10use models::space::spaces;
11use models::users::user_notifications::{self, NotificationType};
128use sea_orm::{ActiveModelTrait, ConnectionTrait, EntityTrait, Set};
13use space::services::dto::UserDto;
9use space::Space;
1410use time::OffsetDateTime;
1511use uuid::Uuid;
1612
@@ -18,7 +14,11 @@
1814
1915#[derive(Clone)]
2016pub struct IssueService {
21 pub state: AppState,
17 pub(crate) database: Arc<AppDatabase>,
18 pub(crate) storage: Arc<storage::S3Client>,
19 pub(crate) audience: crate::services::audience::Audience,
20 pub(crate) settings: IssueSettings,
21 pub(crate) access: crate::services::admission::Admission,
2222}
2323
2424/// The optional "what changed" columns of an issue activity row. Grouped into a
@@ -33,34 +33,31 @@
3333}
3434
3535impl IssueService {
36 pub fn new(state: AppState) -> Self {
37 Self { state }
38 }
39
40 pub fn config(&self) -> &AppConfig {
41 &self.state.config
36 pub fn new(
37 database: Arc<AppDatabase>,
38 storage: Arc<storage::S3Client>,
39 audience: crate::services::audience::Audience,
40 settings: IssueSettings,
41 access: crate::services::admission::Admission,
42 ) -> Self {
43 Self {
44 database,
45 storage,
46 audience,
47 settings,
48 access,
49 }
4250 }
4351
4452 pub fn paginate(&self, limit: Option<u64>, offset: Option<u64>) -> (u64, u64) {
4553 export::pagination::page_bounds(
4654 limit,
4755 offset,
48 self.config().issues.default_pagination,
49 self.config().issues.max_pagination,
56 self.settings.issues.default_pagination,
57 self.settings.issues.max_pagination,
5058 )
5159 }
5260
53 pub fn db(&self) -> &Arc<dyn Database> {
54 &self.state.database
55 }
56
57 pub async fn load_user_briefs(
58 &self,
59 uids: &[i64],
60 ) -> Result<HashMap<i64, UserDto>, IssueError> {
61 Ok(export::users::load_user_briefs(&self.state, uids).await?)
62 }
63
6461 #[allow(clippy::too_many_arguments)]
6562 pub async fn resolve_issue_ref(
6663 &self,
@@ -119,14 +116,7 @@
119116 "closed_at": issue.closed_at,
120117 });
121118 }
122 if let Ok(Some(actor)) = export::users::get_public_user(&self.state, user_id).await {
123 payload["actor"] = serde_json::json!({
124 "id": actor.user_id,
125 "username": actor.username,
126 "display_name": actor.displayname,
127 "avatar_url": export::resolve_user_avatar_url(actor.user_id, &actor.avatar_url),
128 });
129 }
119
130120 issue_event_line::ActiveModel {
131121 id: Set(Uuid::now_v7()),
132122 issue_id: Set(Some(issue_id)),
@@ -145,80 +135,33 @@
145135 .insert(txn)
146136 .await?;
147137
148 let mut labels = queue::Labels::new()
138 let labels = queue::Labels::new()
149139 .with("issue_id", issue_id)
150140 .with("user_id", user_id);
151 let request_id = export::middleware::request_id();
152 if let Some(request_id) = request_id.as_deref() {
153 labels.insert("request_id", request_id);
154 }
155 labels.insert(events::label::ORDERING_KEY, format!("issue:{issue_id}"));
156 events::outbox::enqueue_now(
157 txn,
158 event_kind(event_type),
159 labels,
160 payload,
161 request_id.as_deref().unwrap_or_default(),
162 request_id.as_deref(),
163 )
164 .await?;
141 events::emit(txn, event_kind(event_type), labels, payload).await?;
165142 Ok(())
166143 }
167144
168145 #[allow(clippy::too_many_arguments)]
169 pub(crate) async fn notify_user(
170 &self,
171 txn: &impl ConnectionTrait,
172 pending: &export::notify::PendingNotifications,
173 recipient: i64,
174 ntype: NotificationType,
175 title: String,
176 body: Option<String>,
177 data: Option<serde_json::Value>,
178 actor_uid: Option<i64>,
179 ) -> Result<(), IssueError> {
180 let row = user_notifications::ActiveModel {
181 id: Set(Uuid::now_v7()),
182 user_id: Set(recipient),
183 notification_type: Set(ntype),
184 title: Set(title),
185 body: Set(body),
186 data_json: Set(data),
187 actor_uid: Set(actor_uid),
188 read_at: Set(None),
189 created_at: Set(OffsetDateTime::now_utc()),
190 }
191 .insert(txn)
192 .await?;
193 pending.push(recipient, row.to_event_value());
194 Ok(())
195 }
196146
197147 pub(crate) async fn collect_mentioned_uids(
198148 &self,
199 space: &spaces::Model,
149 space: &Space,
200150 text: &str,
201151 exclude: i64,
202152 ) -> Result<Vec<i64>, IssueError> {
203 let members = space::authority::list_members(
204 &self.state,
205 space.owner_user_id,
206 space.id,
207 false,
208 false,
209 )
210 .await?;
153 let members = self.access.members(space.id(), space.owner()).await?;
211154
212155 let mut member_uids: Vec<i64> = members.into_iter().map(|m| m.user_id).collect();
213 if !member_uids.contains(&space.owner_user_id) {
214 member_uids.push(space.owner_user_id);
156 if !member_uids.contains(&space.owner()) {
157 member_uids.push(space.owner());
215158 }
216159
217160 if member_uids.is_empty() {
218161 return Ok(Vec::new());
219162 }
220163
221 let users = export::users::load_public_users(&self.state, &member_uids).await?;
164 let users = auth::users::load_public_users(&self.audience.accounts, &member_uids).await?;
222165
223166 let mut name_to_uid: HashMap<String, i64> = HashMap::new();
224167 for (uid, user) in users {
@@ -248,53 +191,12 @@
248191
249192 let mut uids: Vec<i64> = result_set.into_iter().collect();
250193 uids.sort();
251 let max_mentions = self.config().issues.max_mentions_per_message;
194 let max_mentions = self.settings.issues.max_mentions_per_message;
252195 if uids.len() > max_mentions {
253196 uids.truncate(max_mentions);
254197 }
255198
256199 Ok(uids)
257 }
258
259 /// Language for one recipient's issue mail.
260 ///
261 /// Account preference only: an issue event fans out to watchers, the
262 /// assignee and mentioned users, each of whom may read a different language.
263 pub(crate) async fn recipient_locale(&self, uid: i64) -> i18n::UiLocale {
264 let fallback = i18n::config_fallback(self.config().email.default_locale());
265 i18n::resolve_user_locale(self.db().read(), uid, fallback).await
266 }
267
268 pub(crate) async fn primary_emails(
269 &self,
270 uids: &[i64],
271 ) -> Result<HashMap<i64, String>, IssueError> {
272 if uids.is_empty() {
273 return Ok(HashMap::new());
274 }
275 let users = export::users::load_public_users(&self.state, uids).await?;
276 Ok(users
277 .into_iter()
278 .filter(|(_, user)| user.email_verified && !user.email.trim().is_empty())
279 .map(|(uid, user)| (uid, user.email.to_lowercase()))
280 .collect())
281 }
282
283 pub(crate) async fn send_email(
284 &self,
285 to: &str,
286 subject: &str,
287 text: &str,
288 html: &str,
289 ) -> Result<(), IssueError> {
290 let Some(client) = self.state.mail.as_ref() else {
291 tracing::warn!(to, "email rpc disabled; skipping issue mail");
292 return Ok(());
293 };
294 client
295 .send_mail(to, subject, text, html, None)
296 .await
297 .map_err(|error| IssueError::Internal(format!("email send failed: {error}")))
298200 }
299201}
300202
@@ -326,3 +228,17 @@
326228 IssueEventType::PullUnlinked => "issue.pull-link.delete",
327229 }
328230}
231
232#[derive(Clone)]
233pub struct IssueSettings {
234 pub issues: config::issues::IssueConfig,
235 pub web: config::web::WebConfig,
236}
237impl IssueSettings {
238 pub fn from_config(config: &AppConfig) -> Self {
239 Self {
240 issues: config.issues.clone(),
241 web: config.web.clone(),
242 }
243 }
244}
Modified lib/issues/src/services/stopwatch.rs View lib/issues/src/services/stopwatch.rs
@@ -1,7 +1,7 @@
1use export::users::UserDto;
12use models::issue::{stopwatch, tracked_time};
23use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait};
34use serde::{Deserialize, Serialize};
4use space::services::dto::UserDto;
55use time::OffsetDateTime;
66use utoipa::{IntoParams, ToSchema};
77use uuid::Uuid;
@@ -44,7 +44,7 @@
4444 let sw = stopwatch::Entity::find()
4545 .filter(stopwatch::Column::IssueId.eq(issue_id))
4646 .filter(stopwatch::Column::UserId.eq(caller))
47 .one(self.db().read())
47 .one(self.database.read())
4848 .await?;
4949
5050 let Some(sw) = sw else {
@@ -58,7 +58,7 @@
5858 });
5959 };
6060
61 let user_map = self.load_user_briefs(&[sw.user_id]).await?;
61 let user_map = self.audience.load_user_briefs(&[sw.user_id]).await?;
6262 let user = user_map.get(&sw.user_id).cloned();
6363
6464 Ok(StopwatchDto {
@@ -84,7 +84,7 @@
8484 let existing = stopwatch::Entity::find()
8585 .filter(stopwatch::Column::IssueId.eq(issue_id))
8686 .filter(stopwatch::Column::UserId.eq(caller))
87 .one(self.db().read())
87 .one(self.database.read())
8888 .await?;
8989
9090 if existing.is_some() {
@@ -102,8 +102,8 @@
102102 created_at: Set(now),
103103 };
104104
105 let inserted = row.insert(self.db().write()).await?;
106 let user_map = self.load_user_briefs(&[caller]).await?;
105 let inserted = row.insert(self.database.write()).await?;
106 let user_map = self.audience.load_user_briefs(&[caller]).await?;
107107 let user = user_map.get(&caller).cloned();
108108
109109 Ok(StopwatchDto {
@@ -129,14 +129,14 @@
129129 let sw = stopwatch::Entity::find()
130130 .filter(stopwatch::Column::IssueId.eq(issue_id))
131131 .filter(stopwatch::Column::UserId.eq(caller))
132 .one(self.db().read())
132 .one(self.database.read())
133133 .await?
134134 .ok_or(IssueError::NotFound)?;
135135
136136 let now = OffsetDateTime::now_utc();
137137 let duration = (now - sw.started_at).whole_seconds().max(0);
138138
139 self.db()
139 self.database
140140 .primary()
141141 .transaction::<_, (), IssueError>(|txn| {
142142 Box::pin(async move {
Modified lib/issues/src/services/tracked_time.rs View lib/issues/src/services/tracked_time.rs
@@ -1,10 +1,10 @@
1use export::users::UserDto;
12use models::issue::tracked_time;
23use sea_orm::{
34 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
45 QuerySelect, Set,
56};
67use serde::{Deserialize, Serialize};
7use space::services::dto::UserDto;
88use time::OffsetDateTime;
99use utoipa::{IntoParams, ToSchema};
1010use uuid::Uuid;
@@ -79,9 +79,9 @@
7979 seconds: Set(cmd.seconds),
8080 created_at: Set(OffsetDateTime::now_utc()),
8181 }
82 .insert(self.db().write())
82 .insert(self.database.write())
8383 .await?;
84 let user_map = self.load_user_briefs(&[row.user_id]).await?;
84 let user_map = self.audience.load_user_briefs(&[row.user_id]).await?;
8585 Ok(TrackedTimeDto {
8686 id: row.id,
8787 issue_id: row.issue_id,
@@ -109,7 +109,7 @@
109109 lookup::require_issue_access(self, caller, space_name, board_id, issue_id).await?;
110110 let row = tracked_time::Entity::find_by_id(time_id)
111111 .filter(tracked_time::Column::IssueId.eq(issue_id))
112 .one(self.db().write())
112 .one(self.database.write())
113113 .await?
114114 .ok_or(IssueError::NotFound)?;
115115 if row.user_id != caller {
@@ -117,8 +117,8 @@
117117 }
118118 let mut am: tracked_time::ActiveModel = row.into();
119119 am.seconds = Set(cmd.seconds);
120 let updated = am.update(self.db().write()).await?;
121 let user_map = self.load_user_briefs(&[updated.user_id]).await?;
120 let updated = am.update(self.database.write()).await?;
121 let user_map = self.audience.load_user_briefs(&[updated.user_id]).await?;
122122 Ok(TrackedTimeDto {
123123 id: updated.id,
124124 issue_id: updated.issue_id,
@@ -140,14 +140,14 @@
140140 lookup::require_issue_access(self, caller, space_name, board_id, issue_id).await?;
141141 let row = tracked_time::Entity::find_by_id(time_id)
142142 .filter(tracked_time::Column::IssueId.eq(issue_id))
143 .one(self.db().read())
143 .one(self.database.read())
144144 .await?
145145 .ok_or(IssueError::NotFound)?;
146146 if row.user_id != caller {
147147 return Err(IssueError::Forbidden);
148148 }
149149 tracked_time::Entity::delete_by_id(time_id)
150 .exec(self.db().write())
150 .exec(self.database.write())
151151 .await?;
152152 Ok(())
153153 }
@@ -173,15 +173,15 @@
173173
174174 select = select.order_by_desc(tracked_time::Column::CreatedAt);
175175
176 let total = select.clone().count(self.db().read()).await? as i64;
176 let total = select.clone().count(self.database.read()).await? as i64;
177177 let rows = select
178178 .limit(limit)
179179 .offset(offset)
180 .all(self.db().read())
180 .all(self.database.read())
181181 .await?;
182182
183183 let user_ids: Vec<i64> = rows.iter().map(|t| t.user_id).collect();
184 let user_map = self.load_user_briefs(&user_ids).await?;
184 let user_map = self.audience.load_user_briefs(&user_ids).await?;
185185
186186 let items = rows
187187 .into_iter()
Modified lib/issues/src/services/watches.rs View lib/issues/src/services/watches.rs
@@ -1,8 +1,8 @@
1use export::users::UserDto;
12use models::common::watch_mode::WatchMode;
23use models::issue::issue_watch;
34use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
45use serde::{Deserialize, Serialize};
5use space::services::dto::UserDto;
66use time::OffsetDateTime;
77use utoipa::{IntoParams, ToSchema};
88use uuid::Uuid;
@@ -48,7 +48,7 @@
4848 let existing = issue_watch::Entity::find()
4949 .filter(issue_watch::Column::IssueId.eq(Some(issue_id)))
5050 .filter(issue_watch::Column::UserId.eq(caller))
51 .one(self.db().read())
51 .one(self.database.read())
5252 .await?;
5353
5454 let now = OffsetDateTime::now_utc();
@@ -56,7 +56,7 @@
5656 let mut am: issue_watch::ActiveModel = w.into();
5757 am.mode = Set(cmd.mode);
5858 am.updated_at = Set(Some(now));
59 let u = am.update(self.db().write()).await?;
59 let u = am.update(self.database.write()).await?;
6060 (u.id, u.created_at, u.updated_at)
6161 } else {
6262 let row = issue_watch::ActiveModel {
@@ -68,11 +68,11 @@
6868 created_at: Set(now),
6969 updated_at: Set(None),
7070 };
71 let ins = row.insert(self.db().write()).await?;
71 let ins = row.insert(self.database.write()).await?;
7272 (ins.id, ins.created_at, ins.updated_at)
7373 };
7474
75 let user_map = self.load_user_briefs(&[caller]).await?;
75 let user_map = self.audience.load_user_briefs(&[caller]).await?;
7676 let user = user_map.get(&caller).cloned();
7777
7878 Ok(WatchDto {
@@ -98,7 +98,7 @@
9898 let res = issue_watch::Entity::delete_many()
9999 .filter(issue_watch::Column::IssueId.eq(Some(issue_id)))
100100 .filter(issue_watch::Column::UserId.eq(caller))
101 .exec(self.db().write())
101 .exec(self.database.write())
102102 .await?;
103103
104104 if res.rows_affected == 0 {
Modified lib/issues/src/share/authz.rs View lib/issues/src/share/authz.rs
@@ -1,15 +1,14 @@
1use models::space::spaces;
2use space::share::authz::{MANAGE_CHANNELS, effective_permissions};
1use space::Space;
32
43use crate::errors::IssueError;
54use crate::services::service::IssueService;
65
76pub async fn require_board_manage(
8 service: &IssueService,
9 space: &spaces::Model,
7 _service: &IssueService,
8 space: &Space,
109 caller: i64,
1110) -> Result<(), IssueError> {
12 if is_board_manager(service, space, caller).await? {
11 if is_board_manager(_service, space, caller).await? {
1312 Ok(())
1413 } else {
1514 Err(IssueError::Forbidden)
@@ -17,15 +16,9 @@
1716}
1817
1918pub async fn is_board_manager(
20 service: &IssueService,
21 space: &spaces::Model,
19 _service: &IssueService,
20 space: &Space,
2221 caller: i64,
2322) -> Result<bool, IssueError> {
24 if space.owner_user_id == caller {
25 return Ok(true);
26 }
27 let perms = effective_permissions(&service.state, space, caller)
28 .await
29 .map_err(IssueError::from)?;
30 Ok(perms.has(MANAGE_CHANNELS))
23 Ok(space.can(caller, space::Capability::ManageChannels).await?)
3124}
Modified lib/issues/src/share/lookup.rs View lib/issues/src/share/lookup.rs
@@ -1,8 +1,6 @@
1use models::dir::dirs::{self, DirKind};
21use models::issue::{issue_item, issues};
3use models::space::spaces;
42use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
5use space::share::authz::{READ, VIEW_SPACE, effective_permissions};
3use space::Space;
64use uuid::Uuid;
75
86use crate::errors::IssueError;
@@ -11,42 +9,25 @@
119pub async fn lookup_space_by_name(
1210 service: &IssueService,
1311 space_name: &str,
14) -> Result<spaces::Model, IssueError> {
15 Ok(space::share::lookup::find_active_space_by_name(&service.state, 0, space_name).await?)
12) -> Result<Space, IssueError> {
13 Ok(service.access.by_slug(space_name).await?)
1614}
1715
1816pub async fn lookup_board(
1917 service: &IssueService,
20 space: &spaces::Model,
18 space: &Space,
2119 board_id: Uuid,
2220 caller: i64,
2321) -> Result<issues::Model, IssueError> {
2422 let board = issues::Entity::find_by_id(board_id)
25 .one(service.db().read())
26 .await?
27 .ok_or(IssueError::NotFound)?;
28
29 let dir = dirs::Entity::find_by_id(board.dir_id)
30 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
31 .one(service.db().read())
23 .one(service.database.read())
3224 .await?
3325 .ok_or(IssueError::NotFound)?;
3426
35 if dir.kind != DirKind::Object {
27 if !space.object_visible(caller, board.dir_id).await? {
3628 return Err(IssueError::NotFound);
3729 }
3830
39 if !space::share::dir_visibility::is_dir_visible(
40 service.db().read(),
41 space,
42 board.dir_id,
43 caller,
44 )
45 .await?
46 {
47 return Err(IssueError::NotFound);
48 }
49
5031 Ok(board)
5132}
5233
@@ -57,7 +38,7 @@
5738) -> Result<issue_item::Model, IssueError> {
5839 issue_item::Entity::find_by_id(issue_id)
5940 .filter(issue_item::Column::CollectionId.eq(board.id))
60 .one(service.db().read())
41 .one(service.database.read())
6142 .await?
6243 .ok_or(IssueError::NotFound)
6344}
@@ -70,11 +51,11 @@
7051 let base = issue_item::Entity::find().filter(issue_item::Column::CollectionId.eq(board.id));
7152 let item = if let Ok(id) = Uuid::parse_str(issue_ref) {
7253 base.filter(issue_item::Column::Id.eq(id))
73 .one(service.db().read())
54 .one(service.database.read())
7455 .await?
7556 } else if let Ok(number) = issue_ref.parse::<i32>() {
7657 base.filter(issue_item::Column::AutoincrementId.eq(number))
77 .one(service.db().read())
58 .one(service.database.read())
7859 .await?
7960 } else if let Some(number) = {
8061 let __inline = |prefix: Option<&str>, issue_ref: &str| -> Option<i32> {
@@ -90,7 +71,7 @@
9071 // `CAD-12` keeps pasted links readable; only this board's own prefix
9172 // resolves, so a foreign key never silently points at another board.
9273 base.filter(issue_item::Column::AutoincrementId.eq(number))
93 .one(service.db().read())
74 .one(service.database.read())
9475 .await?
9576 } else {
9677 None
@@ -99,25 +80,18 @@
9980}
10081
10182pub async fn require_member(
102 service: &IssueService,
103 space: &spaces::Model,
83 _service: &IssueService,
84 space: &Space,
10485 caller: i64,
10586) -> Result<(), IssueError> {
106 let perms = effective_permissions(&service.state, space, caller)
107 .await
108 .map_err(IssueError::from)?;
109 if perms.has(VIEW_SPACE) || perms.has(READ) {
110 Ok(())
111 } else {
112 Err(IssueError::Forbidden)
113 }
87 Ok(space::require(space, caller, space::Capability::Read).await?)
11488}
11589
11690pub async fn space_context(
11791 service: &IssueService,
11892 caller: i64,
11993 space_name: &str,
120) -> Result<spaces::Model, IssueError> {
94) -> Result<Space, IssueError> {
12195 let space = lookup_space_by_name(service, space_name).await?;
12296 require_member(service, &space, caller).await?;
12397 Ok(space)
@@ -128,7 +102,7 @@
128102 caller: i64,
129103 space_name: &str,
130104 board_id: Uuid,
131) -> Result<(spaces::Model, issues::Model), IssueError> {
105) -> Result<(Space, issues::Model), IssueError> {
132106 let space = space_context(service, caller, space_name).await?;
133107 let board = lookup_board(service, &space, board_id, caller).await?;
134108 Ok((space, board))
@@ -140,7 +114,7 @@
140114 space_name: &str,
141115 board_id: Uuid,
142116 issue_id: Uuid,
143) -> Result<(spaces::Model, issues::Model, issue_item::Model), IssueError> {
117) -> Result<(Space, issues::Model, issue_item::Model), IssueError> {
144118 let (space, board) = board_context(service, caller, space_name, board_id).await?;
145119 let item = lookup_issue(service, &board, issue_id).await?;
146120 Ok((space, board, item))
@@ -163,25 +137,20 @@
163137 service: &IssueService,
164138 caller: i64,
165139 issue_id: Uuid,
166) -> Result<(spaces::Model, issues::Model, issue_item::Model), IssueError> {
140) -> Result<(Space, issues::Model, issue_item::Model), IssueError> {
167141 let item = issue_item::Entity::find_by_id(issue_id)
168 .one(service.db().read())
142 .one(service.database.read())
169143 .await?
170144 .ok_or(IssueError::NotFound)?;
171145 let board = issues::Entity::find_by_id(item.collection_id)
172 .one(service.db().read())
146 .one(service.database.read())
173147 .await?
174148 .ok_or(IssueError::NotFound)?;
175 let dir = dirs::Entity::find_by_id(board.dir_id)
176 .one(service.db().read())
149 let space = service
150 .access
151 .for_dir(board.dir_id)
177152 .await?
178153 .ok_or(IssueError::NotFound)?;
179 let space =
180 space::authority::space_by_id(&service.state, 0, dir.space_id.ok_or(IssueError::NotFound)?)
181 .await?;
182 if !space::authority::space_is_active(&space) {
183 return Err(IssueError::NotFound);
184 }
185154 require_member(service, &space, caller).await?;
186155 let board = lookup_board(service, &space, board.id, caller).await?;
187156 Ok((space, board, item))
Modified lib/issues/src/errors.rs View lib/issues/src/errors.rs
@@ -30,7 +30,7 @@
3030export::db_error_conversions!(IssueError);
3131export::export_error_conversion!(IssueError);
3232export::json_payload_conversion!(IssueError);
33export::domain_error_passthrough!(IssueError from space::errors::SpaceError);
33export::access_error_conversion!(IssueError, BadRequest);
3434
3535export::api_error! {
3636 log: "issues",
Modified lib/issues/src/openapi.rs View lib/issues/src/openapi.rs
@@ -106,7 +106,7 @@
106106 AppResponseData<crate::services::repo_relations::RepoRelDto>,
107107 AppResponseData<Vec<crate::services::repo_relations::RepoRelDto>>,
108108 export::rest::NoData,
109 space::services::dto::UserDto,
109 export::users::UserDto,
110110 space::services::dto::SpacePath,
111111 crate::services::boards::BoardDto,
112112 crate::services::boards::BoardPage,
Modified lib/issues/Cargo.toml View lib/issues/Cargo.toml
@@ -4,6 +4,9 @@
44edition.workspace = true
55
66[dependencies]
7storage = { workspace = true }
8auth = { workspace = true }
9email = { workspace = true }
710bytes = { workspace = true }
811futures = { workspace = true }
912async-trait = { workspace = true }
Modified lib/models/Cargo.toml View lib/models/Cargo.toml
@@ -10,6 +10,5 @@
1010uuid = { workspace = true }
1111serde = { workspace = true, features = ["derive"] }
1212serde_json = { workspace = true }
13proto = { workspace = true }
1413utoipa = { workspace = true }
15tokio = { workspace = true }
No newline at end of file
14tokio = { workspace = true }
Modified lib/pulls/src/handlers/labels/list_label_options.rs View lib/pulls/src/handlers/labels/list_label_options.rs
@@ -1,7 +1,7 @@
11use ::session::Session;
22use export::http::{Path, State};
33use export::rest::AppResponseData;
4use git::handler::RepoPath;
4use git::facade::RepoPath;
55
66use crate::errors::ApiError;
77use crate::services::service::PullService;
Modified lib/pulls/src/handlers/pulls/create_pull.rs View lib/pulls/src/handlers/pulls/create_pull.rs
@@ -1,7 +1,7 @@
11use ::session::Session;
22use export::http::{Json, Path, State};
33use export::rest::AppResponseData;
4use git::handler::RepoPath;
4use git::facade::RepoPath;
55
66use crate::errors::ApiError;
77use crate::services::pulls::{CreatePullCmd, PullDto};
Modified lib/pulls/src/handlers/pulls/get_pull_files.rs View lib/pulls/src/handlers/pulls/get_pull_files.rs
@@ -1,7 +1,7 @@
11use ::session::Session;
22use export::http::State;
33use export::rest::AppResponseData;
4use git::service::diffs::ChangedPathDiffDto;
4use git::facade::ChangedPathDiffDto;
55
66use crate::errors::ApiError;
77use crate::services::service::PullService;
Modified lib/pulls/src/handlers/pulls/list_pull_commits.rs View lib/pulls/src/handlers/pulls/list_pull_commits.rs
@@ -1,7 +1,7 @@
11use ::session::Session;
22use export::http::State;
33use export::rest::AppResponseData;
4use git::service::merge::MergeCommitDto;
4use git::facade::MergeCommitDto;
55
66use crate::errors::ApiError;
77use crate::services::service::PullService;
Modified lib/pulls/src/handlers/pulls/list_pulls.rs View lib/pulls/src/handlers/pulls/list_pulls.rs
@@ -1,7 +1,7 @@
11use ::session::Session;
22use export::http::{Path, Query, State};
33use export::rest::AppResponseData;
4use git::handler::RepoPath;
4use git::facade::RepoPath;
55
66use crate::errors::ApiError;
77use crate::services::pulls::{ListPullQuery, PullPage};
Modified lib/pulls/src/handlers/pulls/update_branch_pull.rs View lib/pulls/src/handlers/pulls/update_branch_pull.rs
@@ -1,7 +1,7 @@
11use ::session::Session;
22use export::http::State;
33use export::rest::AppResponseData;
4use git::service::merge::MergeResultDto;
4use git::facade::MergeResultDto;
55
66use crate::errors::ApiError;
77use crate::services::pulls::PullDto;
Modified lib/pulls/src/handlers/relations/mod.rs View lib/pulls/src/handlers/relations/mod.rs
@@ -14,18 +14,18 @@
1414 path: &IssuePath,
1515) -> Result<(uuid::Uuid, uuid::Uuid, uuid::Uuid), ApiError> {
1616 let caller = PullService::uid(session).map_err(ApiError::from)?;
17 let issues = issues::services::service::IssueService::new(service.state.clone());
18 let space = issues::share::lookup::lookup_space_by_name(&issues, &path.space_name)
17 let issues = &service.issues;
18 let space = issues::share::lookup::lookup_space_by_name(issues, &path.space_name)
1919 .await
2020 .map_err(PullError::from)?;
21 issues::share::lookup::require_member(&issues, &space, caller)
21 issues::share::lookup::require_member(issues, &space, caller)
2222 .await
2323 .map_err(PullError::from)?;
24 let board = issues::share::lookup::lookup_board(&issues, &space, path.board_id, caller)
24 let board = issues::share::lookup::lookup_board(issues, &space, path.board_id, caller)
2525 .await
2626 .map_err(PullError::from)?;
27 let issue = issues::share::lookup::lookup_issue_by_ref(&issues, &board, &path.issue_id)
27 let issue = issues::share::lookup::lookup_issue_by_ref(issues, &board, &path.issue_id)
2828 .await
2929 .map_err(PullError::from)?;
30 Ok((space.id, board.id, issue.id))
30 Ok((space.id(), board.id, issue.id))
3131}
Modified lib/pulls/src/handlers/relations/unlink_issue_pull.rs View lib/pulls/src/handlers/relations/unlink_issue_pull.rs
@@ -52,8 +52,9 @@
5252 .await
5353 .map_err(PullError::from)?
5454 .ok_or(PullError::NotFound)?;
55 let repo = models::repo::repos::Entity::find_by_id(pull.repo_id)
56 .one(service.db_read())
55 let repo = service
56 .git
57 .repository(pull.repo_id)
5758 .await
5859 .map_err(PullError::from)?
5960 .ok_or(PullError::NotFound)?;
Added lib/pulls/src/services/actors.rs View lib/pulls/src/services/actors.rs

Diff too large to display.

Modified lib/pulls/src/services/approvals.rs View lib/pulls/src/services/approvals.rs
@@ -1,7 +1,6 @@
11use std::collections::HashMap;
22
3use git::context::facade::GitContext;
4use git::share::policy::{branch_policy_whitelists, match_branch_policies};
3use git::facade::GitContext;
54use globset::{Glob, GlobSet, GlobSetBuilder};
65use models::pull_request::{pull_request_review, pull_request_reviewer, pull_requests};
76use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
@@ -117,10 +116,12 @@
117116 admin: bool,
118117 summary: &PullSummaryDto,
119118 ) -> Result<MergeGateDto, PullError> {
120 let policies =
121 match_branch_policies(self.db_read(), ctx.model.id, &pull.base_branch).await?;
119 let policy = self
120 .git
121 .merge_requirements(ctx.model.id, &pull.base_branch, caller)
122 .await?;
122123 let outdated = summary.commits_behind > 0;
123 if policies.is_empty() {
124 let Some(policy) = policy else {
124125 return Ok(MergeGateDto {
125126 allowed: true,
126127 reasons: Vec::new(),
@@ -131,23 +132,13 @@
131132 outdated,
132133 bypassed: false,
133134 });
134 }
135 let whitelists = branch_policy_whitelists(self.db_read(), &policies).await?;
136 let required = policies
137 .iter()
138 .map(|policy| policy.required_approvals)
139 .max()
140 .unwrap_or(0);
141 let dismiss_stale = policies.iter().any(|policy| policy.dismiss_stale_approvals);
142 let merge_whitelist_on = policies.iter().any(|policy| policy.enable_merge_whitelist);
143 let bypassed = admin || (merge_whitelist_on && whitelists.merge_allowed.contains(&caller));
135 };
136 let required = policy.required_approvals;
137 let dismiss_stale = policy.dismiss_stale_approvals;
138 let bypassed = admin || policy.whitelist_bypass;
144139 let mut reasons: Vec<String> = Vec::new();
145140
146 let verdicts = if required > 0
147 || policies
148 .iter()
149 .any(|policy| policy.block_on_change_requested_reviews)
150 {
141 let verdicts = if required > 0 || policy.block_on_change_requested_reviews {
151142 self.latest_verdicts(pull.id, &summary.head_sha, dismiss_stale)
152143 .await?
153144 } else {
@@ -168,32 +159,20 @@
168159 "{required} approval(s) required, {approvals} granted on the current head commit"
169160 ));
170161 }
171 if change_requests > 0
172 && policies
173 .iter()
174 .any(|policy| policy.block_on_rejected_reviews)
175 {
162 if change_requests > 0 && policy.block_on_rejected_reviews {
176163 reasons.push(format!("change requested by {change_requests} reviewer(s)"));
177164 }
178 if change_requests > 0
179 && policies
180 .iter()
181 .any(|policy| policy.block_on_change_requested_reviews)
182 {
165 if change_requests > 0 && policy.block_on_change_requested_reviews {
183166 reasons.push(format!(
184167 "unaddressed change request from {change_requests} reviewer(s)"
185168 ));
186169 }
187 if outdated
188 && policies
189 .iter()
190 .any(|policy| policy.block_on_outdated_branch)
191 {
170 if outdated && policy.block_on_outdated_branch {
192171 reasons.push(
193172 "base branch has new commits; sync the branch before merging".to_string(),
194173 );
195174 }
196 if policies.iter().any(|policy| policy.require_signed_commits) {
175 if policy.require_signed_commits {
197176 let commits = self
198177 .git
199178 .merge_commits(
@@ -219,37 +198,26 @@
219198 }
220199 }
221200
222 let required_contexts: Vec<String> = policies
223 .iter()
224 .flat_map(|policy| {
225 policy
226 .required_status_contexts
227 .split(['\n', ','])
228 .map(|value| value.trim().to_string())
229 .collect::<Vec<_>>()
230 })
231 .filter(|value| !value.is_empty())
232 .collect();
201 let required_contexts = &policy.required_status_contexts;
233202 if !required_contexts.is_empty() {
234203 let statuses = self
235204 .git
236205 .list_commit_statuses(ctx, summary.head_sha.clone())
237206 .await?;
238 for context in &required_contexts {
207 for context in required_contexts {
239208 let state = statuses
240209 .iter()
241210 .find(|status| status.context == *context)
242211 .map(|status| status.state);
243212 match state {
244 Some(git::service::commits::CommitStatusDtoState::Success) => {}
213 Some(git::facade::CommitStatusDtoState::Success) => {}
245214 Some(other) => reasons.push(format!(
246215 "required check `{context}` is {}",
247216 match other {
248 git::service::commits::CommitStatusDtoState::Pending => "pending",
249 git::service::commits::CommitStatusDtoState::Failure => "failed",
250 git::service::commits::CommitStatusDtoState::Error => "errored",
251 git::service::commits::CommitStatusDtoState::Success =>
252 "successful",
217 git::facade::CommitStatusDtoState::Pending => "pending",
218 git::facade::CommitStatusDtoState::Failure => "failed",
219 git::facade::CommitStatusDtoState::Error => "errored",
220 git::facade::CommitStatusDtoState::Success => "successful",
253221 }
254222 )),
255223 None => {
@@ -259,16 +227,8 @@
259227 }
260228 }
261229
262 let protected_raw: String = policies
263 .iter()
264 .map(|policy| policy.protected_file_patterns.as_str())
265 .collect::<Vec<_>>()
266 .join(",");
267 let unprotected_raw: String = policies
268 .iter()
269 .map(|policy| policy.unprotected_file_patterns.as_str())
270 .collect::<Vec<_>>()
271 .join(",");
230 let protected_raw = &policy.protected_file_patterns;
231 let unprotected_raw = &policy.unprotected_file_patterns;
272232 if !protected_raw.trim().is_empty() || !unprotected_raw.trim().is_empty() {
273233 let from = summary
274234 .merge_base
@@ -278,8 +238,8 @@
278238 .git
279239 .diff_files_changed_between(ctx, from, summary.head_sha.clone())
280240 .await?;
281 let protected = build_glob_set(&protected_raw)?;
282 let unprotected = build_glob_set(&unprotected_raw)?;
241 let protected = build_glob_set(protected_raw)?;
242 let unprotected = build_glob_set(unprotected_raw)?;
283243 let blocked: Vec<String> = changed
284244 .into_iter()
285245 .filter(|path| {
Modified lib/pulls/src/services/assignees.rs View lib/pulls/src/services/assignees.rs
@@ -1,8 +1,8 @@
1use export::users::UserDto;
12use models::pull_request::{pull_request_assignee, pull_requests};
23use sea_orm::ActiveValue::Set;
34use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
45use serde::{Deserialize, Serialize};
5use space::services::dto::UserDto;
66use time::OffsetDateTime;
77use utoipa::ToSchema;
88use uuid::Uuid;
@@ -34,7 +34,8 @@
3434 .all(self.db_read())
3535 .await?;
3636 let users = self
37 .user_briefs(&reviews::unique_ids(rows.iter().map(|row| row.user_id)))
37 .actors
38 .briefs(&reviews::unique_ids(rows.iter().map(|row| row.user_id)))
3839 .await?;
3940 Ok(rows
4041 .iter()
@@ -53,7 +54,7 @@
5354 reference: &str,
5455 ) -> Result<Vec<PullAssigneeDto>, PullError> {
5556 authz::require_unlocked(pull)?;
56 let uid = self.resolve_user_id(reference).await?;
57 let uid = self.actors.resolve(reference).await?;
5758 let existing = pull_request_assignee::Entity::find()
5859 .filter(pull_request_assignee::Column::PullRequestId.eq(pull.id))
5960 .filter(pull_request_assignee::Column::UserId.eq(uid))
@@ -87,7 +88,7 @@
8788 reference: &str,
8889 ) -> Result<Vec<PullAssigneeDto>, PullError> {
8990 authz::require_unlocked(pull)?;
90 let uid = self.resolve_user_id(reference).await?;
91 let uid = self.actors.resolve(reference).await?;
9192 let deleted = pull_request_assignee::Entity::delete_many()
9293 .filter(pull_request_assignee::Column::PullRequestId.eq(pull.id))
9394 .filter(pull_request_assignee::Column::UserId.eq(uid))
Modified lib/pulls/src/services/changes.rs View lib/pulls/src/services/changes.rs
@@ -1,6 +1,4 @@
1use git::context::facade::GitContext;
2use git::service::diffs::{ChangedPathDiffDto, DiffOptionsDto};
3use git::service::merge::{MergeCommitDto, MergePlanDto};
1use git::facade::{ChangedPathDiffDto, DiffOptionsDto, GitContext, MergeCommitDto, MergePlanDto};
42use models::pull_request::pull_requests;
53use sea_orm::ActiveValue::Set;
64use sea_orm::{ActiveModelTrait, IntoActiveModel};
@@ -98,7 +96,7 @@
9896 let head_ref = format!("refs/heads/{}", pull.head_branch);
9997 let head_now = match self.git.rev_parse(&repo_ctx, head_ref).await {
10098 Ok(sha) => Some(sha),
101 Err(git::share::errors::GitError::NotFound) => None,
99 Err(git::facade::GitError::NotFound) => None,
102100 Err(other) => return Err(PullError::from(other)),
103101 };
104102 let moved = head_now.as_deref().is_some_and(|sha| sha != pull.head_sha);
@@ -127,7 +125,6 @@
127125 let updated = model.update(self.db_write()).await?;
128126 if moved {
129127 write_event(
130 &self.state,
131128 self.db_write(),
132129 &updated,
133130 actor,
Modified lib/pulls/src/services/checks.rs View lib/pulls/src/services/checks.rs
@@ -1,5 +1,4 @@
1use git::context::facade::GitContext;
2use git::service::commits::CommitStatusDto;
1use git::facade::{CommitStatusDto, GitContext};
32use models::pull_request::pull_requests;
43use serde::Serialize;
54use utoipa::ToSchema;
Modified lib/pulls/src/services/comments.rs View lib/pulls/src/services/comments.rs
@@ -1,5 +1,6 @@
11use export::pagination::{Page, PageQuery};
2use git::context::facade::GitContext;
2use export::users::UserDto;
3use git::facade::GitContext;
34use models::pull_request::{pull_request_comment, pull_requests};
45use sea_orm::ActiveValue::Set;
56use sea_orm::{
@@ -7,7 +8,6 @@
78 QuerySelect,
89};
910use serde::{Deserialize, Serialize};
10use space::services::dto::UserDto;
1111use std::collections::HashMap;
1212use time::OffsetDateTime;
1313use utoipa::ToSchema;
@@ -85,20 +85,7 @@
8585 if cmd.body.trim().is_empty() || cmd.body.chars().count() > MAX_COMMENT_LEN {
8686 return Err(PullError::BadRequest("invalid comment body".to_string()));
8787 }
88 if !self
89 .state
90 .redis
91 .quota_allow(
92 &format!("quota:comment_create:{caller}"),
93 self.config().quota.comment_create_per_day,
94 86_400,
95 )
96 .await
97 {
98 return Err(PullError::RateLimited(
99 "comment creation quota exceeded, retry tomorrow".to_string(),
100 ));
101 }
88 self.actors.allow_comment(caller).await?;
10289 let inline = cmd.path.as_deref().is_some_and(|p| !p.trim().is_empty());
10390 let commit_sha = match (inline, cmd.commit_sha.clone()) {
10491 (false, _) => None,
@@ -324,7 +311,8 @@
324311 &self,
325312 rows: &[pull_request_comment::Model],
326313 ) -> Result<HashMap<i64, UserDto>, PullError> {
327 self.user_briefs(&reviews::unique_ids(rows.iter().map(|row| row.user_id)))
314 self.actors
315 .briefs(&reviews::unique_ids(rows.iter().map(|row| row.user_id)))
328316 .await
329317 }
330318
Modified lib/pulls/src/services/events.rs View lib/pulls/src/services/events.rs
@@ -1,5 +1,5 @@
1use export::AppState;
21use export::pagination::{Page, PageQuery};
2use export::users::UserDto;
33use models::pull_request::{pull_request_event_line, pull_requests};
44use sea_orm::ActiveValue::Set;
55use sea_orm::{
@@ -7,8 +7,6 @@
77 QueryOrder, QuerySelect,
88};
99use serde::{Deserialize, Serialize};
10use space::services::dto::UserDto;
11use std::collections::HashMap;
1210use time::OffsetDateTime;
1311use uuid::Uuid;
1412
@@ -49,7 +47,6 @@
4947pub type EventPage = Page<PullEventDto>;
5048
5149pub async fn write_event(
52 state: &AppState,
5350 db: &impl ConnectionTrait,
5451 pull: &pull_requests::Model,
5552 actor: Option<i64>,
@@ -61,7 +58,7 @@
6158 Some(actor) => actor,
6259 None => pull.author_id,
6360 };
64 let mut payload = serde_json::json!({
61 let payload = serde_json::json!({
6562 "pull_request_id": pull.id,
6663 "user_id": actor,
6764 "event_type": event_type,
@@ -97,14 +94,7 @@
9794 "closed_at": pull.closed_at,
9895 },
9996 });
100 if let Ok(Some(actor)) = export::users::get_public_user(state, actor).await {
101 payload["actor"] = serde_json::json!({
102 "id": actor.user_id,
103 "username": actor.username,
104 "display_name": actor.displayname,
105 "avatar_url": export::resolve_user_avatar_url(actor.user_id, &actor.avatar_url),
106 });
107 }
97
10898 let row = pull_request_event_line::ActiveModel {
10999 id: Set(Uuid::now_v7()),
110100 pull_request_id: Set(pull.id),
@@ -119,24 +109,10 @@
119109 let Some(kind) = event_kind(event_type) else {
120110 return Ok(());
121111 };
122 let mut labels = queue::Labels::new()
112 let labels = queue::Labels::new()
123113 .with("pull_id", pull.id)
124114 .with("user_id", actor);
125 let request_id = export::middleware::request_id();
126 if let Some(request_id) = request_id.as_deref() {
127 labels.insert("request_id", request_id);
128 }
129 labels.insert(events::label::ORDERING_KEY, format!("pull:{}", pull.id));
130 events::outbox::enqueue_now(
131 db,
132 kind,
133 labels,
134 payload,
135 request_id.as_deref().unwrap_or_default(),
136 request_id.as_deref(),
137 )
138 .await
139 .map(|_| ())
115 events::emit(db, kind, labels, payload).await.map(|_| ())
140116}
141117
142118fn event_kind(event_type: &str) -> Option<&'static str> {
@@ -174,7 +150,6 @@
174150 content: Option<String>,
175151 ) -> Result<(), PullError> {
176152 write_event(
177 &self.state,
178153 self.db_write(),
179154 pull,
180155 actor,
@@ -204,7 +179,8 @@
204179 .all(self.db_read())
205180 .await?;
206181 let actors = self
207 .user_briefs(&{
182 .actors
183 .briefs(&{
208184 let rows = &rows;
209185 let mut ids: Vec<i64> = rows.iter().map(|row| row.user_id).collect();
210186 ids.sort();
@@ -230,19 +206,5 @@
230206 limit,
231207 offset,
232208 })
233 }
234
235 pub(crate) async fn user_briefs(
236 &self,
237 ids: &[i64],
238 ) -> Result<HashMap<i64, UserDto>, PullError> {
239 if ids.is_empty() {
240 return Ok(HashMap::new());
241 }
242 let users = export::users::load_public_users(&self.state, ids).await?;
243 Ok(users
244 .into_iter()
245 .map(|(uid, user)| (uid, UserDto::from_public(&user)))
246 .collect())
247209 }
248210}
Modified lib/pulls/src/services/labels.rs View lib/pulls/src/services/labels.rs
@@ -1,4 +1,3 @@
1use models::issue::issue_label;
21use models::pull_request::{pull_request_label, pull_requests};
32use sea_orm::ActiveValue::Set;
43use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
@@ -22,40 +21,18 @@
2221 &self,
2322 dir_id: Uuid,
2423 reference: &str,
25 ) -> Result<issue_label::Model, PullError> {
26 let reference = reference.trim();
27 let select = issue_label::Entity::find()
28 .filter(issue_label::Column::DirId.eq(dir_id))
29 .filter(issue_label::Column::IsArchived.eq(false));
30 let row = if let Ok(id) = Uuid::parse_str(reference) {
31 select.filter(issue_label::Column::Id.eq(id))
32 } else {
33 select.filter(issue_label::Column::Name.eq(reference))
34 }
35 .one(self.db_read())
36 .await?;
37 row.ok_or(PullError::BadRequest(format!(
38 "label `{reference}` not found in this repository directory"
39 )))
24 ) -> Result<issues::services::labels::LabelDto, PullError> {
25 Ok(self
26 .issues
27 .resolve_directory_label(dir_id, reference)
28 .await?)
4029 }
41
42 /// 可附加到本仓库合并请求的标签候选(同目录、未归档),供侧栏搜索下拉使用。
4330 pub async fn list_label_options(
4431 &self,
4532 dir_id: Uuid,
4633 ) -> Result<Vec<issues::services::labels::LabelDto>, PullError> {
47 let rows = issue_label::Entity::find()
48 .filter(issue_label::Column::DirId.eq(dir_id))
49 .filter(issue_label::Column::IsArchived.eq(false))
50 .order_by_asc(issue_label::Column::Name)
51 .all(self.db_read())
52 .await?;
53 Ok(rows
54 .into_iter()
55 .map(issues::services::labels::LabelDto::from)
56 .collect())
34 Ok(self.issues.directory_labels(dir_id).await?)
5735 }
58
5936 pub async fn list_labels(
6037 &self,
6138 pull_id: Uuid,
@@ -69,14 +46,7 @@
6946 return Ok(Vec::new());
7047 }
7148 let ids: Vec<Uuid> = links.iter().map(|link| link.label_id).collect();
72 let rows = issue_label::Entity::find()
73 .filter(issue_label::Column::Id.is_in(ids))
74 .all(self.db_read())
75 .await?;
76 Ok(rows
77 .into_iter()
78 .map(issues::services::labels::LabelDto::from)
79 .collect())
49 Ok(self.issues.labels_by_ids(ids).await?)
8050 }
8151
8252 pub async fn attach_label(
Modified lib/pulls/src/services/merge.rs View lib/pulls/src/services/merge.rs
@@ -1,5 +1,4 @@
1use git::context::facade::GitContext;
2use git::service::merge::{ExecuteMergeCmd, MergeResultDto};
1use git::facade::{ExecuteMergeCmd, GitContext, MergeResultDto};
32use models::pull_request::pull_requests;
43use models::pull_request::pull_requests::MergeMethod;
54use sea_orm::ActiveValue::Set;
Modified lib/pulls/src/services/mod.rs View lib/pulls/src/services/mod.rs
@@ -1,3 +1,4 @@
1pub mod actors;
12pub mod approvals;
23pub mod assignees;
34pub mod changes;
Modified lib/pulls/src/services/pulls.rs View lib/pulls/src/services/pulls.rs
@@ -1,18 +1,17 @@
11use export::pagination::Page;
2use git::context::facade::GitContext;
2use export::users::UserDto;
3use git::facade::GitContext;
34use models::issue::issue_relation_pull;
45use models::pull_request::{
56 pull_request_assignee, pull_request_comment, pull_request_event_line, pull_request_label,
67 pull_request_review, pull_request_reviewer, pull_requests,
78};
8use models::repo::repos;
99use sea_orm::ActiveValue::Set;
1010use sea_orm::{
1111 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
1212 QuerySelect, TransactionTrait,
1313};
1414use serde::{Deserialize, Serialize};
15use space::services::dto::UserDto;
1615use std::collections::HashMap;
1716use time::OffsetDateTime;
1817use utoipa::{IntoParams, ToSchema};
@@ -221,7 +220,7 @@
221220 }
222221 }
223222 if let Some(author) = q.author.as_deref().filter(|a| !a.trim().is_empty()) {
224 let uid = self.resolve_user_id(author).await?;
223 let uid = self.actors.resolve(author).await?;
225224 select = select.filter(pull_requests::Column::AuthorId.eq(uid));
226225 }
227226 if let Some(base) = q.base.as_deref().filter(|b| !b.trim().is_empty()) {
@@ -299,21 +298,8 @@
299298 let body = cmd.body.clone().unwrap_or_default();
300299 if body.chars().count() > MAX_BODY_LEN {
301300 return Err(PullError::BadRequest("body too large".to_string()));
302 }
303 if !self
304 .state
305 .redis
306 .quota_allow(
307 &format!("quota:pull_create:{caller}"),
308 self.config().quota.pull_create_per_day,
309 86_400,
310 )
311 .await
312 {
313 return Err(PullError::RateLimited(
314 "pull request creation quota exceeded, retry tomorrow".to_string(),
315 ));
316301 }
302 self.actors.allow_pull(caller).await?;
317303
318304 let base_branch = cmd
319305 .base_branch
@@ -342,7 +328,7 @@
342328 )
343329 .await
344330 .map_err(|e| match e {
345 git::share::errors::GitError::NotFound => PullError::BadRequest(
331 git::facade::GitError::NotFound => PullError::BadRequest(
346332 "head or base branch not found in this repository".to_string(),
347333 ),
348334 other => PullError::from(other),
@@ -356,11 +342,8 @@
356342 .diff_stats_for(&space_name, &repo_name, &plan, &plan.head_sha)
357343 .await?;
358344
359 let txn = self.state.database.write().begin().await?;
360 let _ = repos::Entity::find_by_id(ctx.model.id)
361 .lock_exclusive()
362 .one(&txn)
363 .await?;
345 let txn = self.database.write().begin().await?;
346 self.git.lock_repository(&txn, ctx.model.id).await?;
364347 let last = pull_requests::Entity::find()
365348 .filter(pull_requests::Column::RepoId.eq(ctx.model.id))
366349 .order_by_desc(pull_requests::Column::AutoincrementId)
@@ -400,7 +383,6 @@
400383 };
401384 let pull = created.insert(&txn).await?;
402385 write_event(
403 &self.state,
404386 &txn,
405387 &pull,
406388 Some(caller),
@@ -546,7 +528,7 @@
546528 ) -> Result<(), PullError> {
547529 let _ = caller;
548530 let id = pull.id;
549 let txn = self.state.database.write().begin().await?;
531 let txn = self.database.write().begin().await?;
550532 pull_request_comment::Entity::delete_many()
551533 .filter(pull_request_comment::Column::PullRequestId.eq(id))
552534 .exec(&txn)
@@ -598,22 +580,7 @@
598580 if ids.is_empty() {
599581 return Ok(HashMap::new());
600582 }
601 let users = export::users::load_public_users(&self.state, &ids).await?;
602 Ok(users
603 .into_iter()
604 .map(|(uid, user)| (uid, UserDto::from_public(&user)))
605 .collect())
606 }
607
608 pub(crate) async fn resolve_user_id(&self, reference: &str) -> Result<i64, PullError> {
609 let reference = reference.trim();
610 if let Ok(uid) = reference.parse::<i64>() {
611 return Ok(uid);
612 }
613 let user = export::users::find_by_username(&self.state, reference)
614 .await?
615 .ok_or_else(|| PullError::BadRequest(format!("user `{reference}` not found")))?;
616 Ok(user.user_id)
583 self.actors.briefs(&ids).await
617584 }
618585
619586 /// (additions, deletions, files_changed)
@@ -621,7 +588,7 @@
621588 &self,
622589 space_name: &str,
623590 repo_name: &str,
624 plan: &git::service::merge::MergePlanDto,
591 plan: &git::facade::MergePlanDto,
625592 head: &str,
626593 ) -> Result<(i32, i32, i32), PullError> {
627594 let from = plan
Modified lib/pulls/src/services/relations.rs View lib/pulls/src/services/relations.rs
@@ -1,4 +1,4 @@
1use git::context::facade::GitContext;
1use git::facade::GitContext;
22use models::issue::issue_event_line::{self, IssueEventType};
33use models::issue::issue_relation_pull;
44use models::pull_request::pull_requests;
@@ -153,16 +153,10 @@
153153 .await?;
154154 let mut visible: Vec<pull_requests::Model> = Vec::with_capacity(rows.len());
155155 for row in rows {
156 let repo = models::repo::repos::Entity::find_by_id(row.repo_id)
157 .one(self.db_read())
156 let show = self
157 .git
158 .repository_visible_from_space(row.repo_id, space_id)
158159 .await?;
159 let show = match repo {
160 None => false,
161 Some(repo) if !repo.is_private => true,
162 Some(repo) => git::share::access::space_for_dir(&self.state, repo.dir_id)
163 .await?
164 .is_some_and(|space| space.id == space_id),
165 };
166160 if show {
167161 visible.push(row);
168162 }
Modified lib/pulls/src/services/reviewers.rs View lib/pulls/src/services/reviewers.rs
@@ -1,8 +1,8 @@
1use export::users::UserDto;
12use models::pull_request::{pull_request_review, pull_request_reviewer, pull_requests};
23use sea_orm::ActiveValue::Set;
34use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
45use serde::{Deserialize, Serialize};
5use space::services::dto::UserDto;
66use time::OffsetDateTime;
77use utoipa::ToSchema;
88use uuid::Uuid;
@@ -88,7 +88,7 @@
8888 }
8989 let mut added: Vec<i64> = Vec::new();
9090 for reference in &cmd.reviewers {
91 let uid = self.resolve_user_id(reference).await?;
91 let uid = self.actors.resolve(reference).await?;
9292 if uid == pull.author_id {
9393 return Err(PullError::BadRequest(
9494 "the pull request author cannot be a requested reviewer".to_string(),
@@ -128,7 +128,6 @@
128128 added.push(uid);
129129 }
130130 write_event(
131 &self.state,
132131 self.db_write(),
133132 pull,
134133 Some(caller),
@@ -162,7 +161,6 @@
162161 .exec(self.db_write())
163162 .await?;
164163 write_event(
165 &self.state,
166164 self.db_write(),
167165 pull,
168166 Some(caller),
@@ -181,7 +179,8 @@
181179 .all(self.db_read())
182180 .await?;
183181 let users = self
184 .user_briefs(&reviews::unique_ids(
182 .actors
183 .briefs(&reviews::unique_ids(
185184 rows.iter()
186185 .flat_map(|row| [row.user_id, row.requested_by_id]),
187186 ))
Modified lib/pulls/src/services/reviews.rs View lib/pulls/src/services/reviews.rs
@@ -1,4 +1,5 @@
1use git::context::facade::GitContext;
1use export::users::UserDto;
2use git::facade::GitContext;
23use models::pull_request::{pull_request_comment, pull_request_review, pull_requests};
34use sea_orm::ActiveValue::Set;
45use sea_orm::{
@@ -6,7 +7,6 @@
67 Set as AmSet, TransactionTrait,
78};
89use serde::{Deserialize, Serialize};
9use space::services::dto::UserDto;
1010use time::OffsetDateTime;
1111use utoipa::ToSchema;
1212use uuid::Uuid;
@@ -94,7 +94,7 @@
9494 }
9595 }
9696 let now = OffsetDateTime::now_utc();
97 let txn = self.state.database.write().begin().await?;
97 let txn = self.database.write().begin().await?;
9898 let review = pull_request_review::ActiveModel {
9999 id: Set(Uuid::now_v7()),
100100 pull_request_id: Set(pull.id),
@@ -138,7 +138,6 @@
138138 }
139139 if state != ReviewState::Pending {
140140 write_event(
141 &self.state,
142141 &txn,
143142 pull,
144143 Some(caller),
@@ -283,7 +282,8 @@
283282 rows: &[pull_request_review::Model],
284283 ) -> Result<Vec<PullReviewDto>, PullError> {
285284 let users = self
286 .user_briefs(&unique_ids(rows.iter().map(|row| row.user_id)))
285 .actors
286 .briefs(&unique_ids(rows.iter().map(|row| row.user_id)))
287287 .await?;
288288 let mut out = Vec::with_capacity(rows.len());
289289 for row in rows {
Modified lib/pulls/src/services/service.rs View lib/pulls/src/services/service.rs
@@ -1,39 +1,42 @@
11use ::session::Session;
2use config::AppConfig;
3use export::AppState;
4use git::service::GitService;
2use export::AppDatabase;
3use git::facade::GitService;
54use models::pull_request::pull_requests;
6use models::repo::repos;
7use sea_orm::ActiveValue::Set;
8use sea_orm::{
9 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
10};
5use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter};
6use std::sync::Arc;
117use uuid::Uuid;
128
139use crate::errors::PullError;
1410
1511#[derive(Clone)]
1612pub struct PullService {
17 pub state: AppState,
13 pub(crate) database: Arc<AppDatabase>,
14 pub(crate) actors: crate::services::actors::Actors,
1815 pub git: GitService,
16 pub issues: issues::services::service::IssueService,
1917}
2018
2119impl PullService {
22 pub fn new(state: AppState) -> Self {
23 let git = GitService::new(state.clone());
24 Self { state, git }
25 }
26
27 pub fn config(&self) -> &AppConfig {
28 &self.state.config
20 pub fn new(
21 database: Arc<AppDatabase>,
22 git: GitService,
23 issues: issues::services::service::IssueService,
24 actors: crate::services::actors::Actors,
25 ) -> Self {
26 Self {
27 database,
28 git,
29 issues,
30 actors,
31 }
2932 }
3033
31 pub fn db_read(&self) -> &DatabaseConnection {
32 self.state.database.read()
34 pub fn db_read(&self) -> &export::DbConn {
35 self.database.read()
3336 }
3437
3538 pub fn db_write(&self) -> &DatabaseConnection {
36 self.state.database.write()
39 self.database.write()
3740 }
3841
3942 pub fn uid(session: &Session) -> Result<i64, PullError> {
@@ -48,12 +51,9 @@
4851 .filter(pull_requests::Column::ClosedAt.is_null())
4952 .count(self.db_write())
5053 .await?;
51 let repo = repos::ActiveModel {
52 id: Set(repo_id),
53 nums_pr: Set(open.cast_signed() as i32),
54 ..Default::default()
55 };
56 repo.update(self.db_write()).await?;
54 self.git
55 .set_pull_count(repo_id, open.cast_signed() as i32)
56 .await?;
5757 Ok(())
5858 }
5959}
Modified lib/pulls/src/share/lookup.rs View lib/pulls/src/share/lookup.rs
@@ -1,6 +1,6 @@
11use ::session::Session;
2use git::context::facade::GitContext;
3use git::share::access;
2use git::facade as access;
3use git::facade::GitContext;
44use models::pull_request::pull_requests;
55use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
66use uuid::Uuid;
Modified lib/pulls/src/errors.rs View lib/pulls/src/errors.rs
@@ -32,9 +32,9 @@
3232export::json_payload_conversion!(PullError);
3333export::domain_error_passthrough!(PullError from issues::errors::IssueError);
3434
35impl From<git::share::errors::GitError> for PullError {
36 fn from(error: git::share::errors::GitError) -> Self {
37 use git::share::errors::GitError as G;
35impl From<git::facade::GitError> for PullError {
36 fn from(error: git::facade::GitError) -> Self {
37 use git::facade::GitError as G;
3838 match error {
3939 G::InvalidArgument(msg) => Self::BadRequest(msg),
4040 G::NotFound => Self::NotFound,
Modified lib/pulls/src/openapi.rs View lib/pulls/src/openapi.rs
@@ -57,8 +57,8 @@
5757 AppResponseData<crate::services::pulls::PullPage>,
5858 AppResponseData<crate::services::comments::CommentPage>,
5959 AppResponseData<crate::services::events::EventPage>,
60 AppResponseData<Vec<git::service::merge::MergeCommitDto>>,
61 AppResponseData<Vec<git::service::diffs::ChangedPathDiffDto>>,
60 AppResponseData<Vec<git::facade::MergeCommitDto>>,
61 AppResponseData<Vec<git::facade::ChangedPathDiffDto>>,
6262 AppResponseData<crate::services::changes::PullSummaryDto>,
6363 AppResponseData<crate::services::merge::MergePullDto>,
6464 AppResponseData<crate::handlers::pulls::update_branch_pull::UpdateBranchDto>,
@@ -74,7 +74,7 @@
7474 AppResponseData<crate::services::checks::PullGateDto>,
7575 AppResponseData<crate::services::inbox::InboxPage>,
7676 NoData,
77 space::services::dto::UserDto,
77 export::users::UserDto,
7878 issues::services::labels::LabelDto,
7979 crate::services::pulls::PullDto,
8080 crate::services::pulls::PullRefDto,
@@ -106,8 +106,8 @@
106106 export::pagination::Page<crate::services::pulls::PullDto>,
107107 export::pagination::Page<crate::services::events::PullEventDto>,
108108 export::pagination::Page<crate::services::comments::PullCommentDto>,
109 git::service::merge::MergeCommitDto,
110 git::service::merge::MergeResultDto,
109 git::facade::MergeCommitDto,
110 git::facade::MergeResultDto,
111111 models::pull_request::pull_requests::MergeMethod,
112112 models::pull_request::pull_request_review::ReviewState,
113113 models::pull_request::pull_request_reviewer::ReviewerState,
Modified lib/pulls/Cargo.toml View lib/pulls/Cargo.toml
@@ -4,6 +4,7 @@
44edition.workspace = true
55
66[dependencies]
7auth = { workspace = true }
78config = { workspace = true }
89export = { workspace = true }
910events = { workspace = true }
Modified lib/push/src/worker.rs View lib/push/src/worker.rs
@@ -225,7 +225,7 @@
225225 async fn build_payload(&self, job: &PushJob) -> Result<NotificationPayload, PushError> {
226226 let (title, icon) = match job.actor_id {
227227 Some(actor_id) => {
228 let actor = export::users::get_public_user(&self.state, actor_id)
228 let actor = auth::users::get_public_user(&auth::Accounts::new(self.state.database.connection().clone(), self.state.cache.clone()), actor_id)
229229 .await
230230 .map_err(|error| {
231231 PushError::Internal(format!("actor lookup failed: {error}"))
Modified lib/push/Cargo.toml View lib/push/Cargo.toml
@@ -4,6 +4,7 @@
44edition.workspace = true
55
66[dependencies]
7auth = { workspace = true }
78config = { workspace = true }
89export = { workspace = true }
910models = { workspace = true }
Modified lib/space/src/handlers/delete_space_image.rs View lib/space/src/handlers/delete_space_image.rs
@@ -27,6 +27,7 @@
2727) -> Result<AppResponseData<NoData>, ApiError> {
2828 let uid = session::require_uid(&session)?;
2929 service
30 .images
3031 .delete_space_image(uid, &path.space_name, path.kind)
3132 .await?;
3233 Ok(AppResponseData::empty())
Modified lib/space/src/handlers/get_space_image.rs View lib/space/src/handlers/get_space_image.rs
@@ -25,6 +25,7 @@
2525) -> Result<AppResponseData<Option<SpaceImageDto>>, ApiError> {
2626 let caller = session.uid();
2727 let img = service
28 .images
2829 .get_space_image(caller, &path.space_name, path.kind)
2930 .await?;
3031 Ok(AppResponseData::ok(img))
Modified lib/space/src/handlers/get_space_image_file.rs View lib/space/src/handlers/get_space_image_file.rs
@@ -36,6 +36,7 @@
3636 Query(query): Query<SpaceImageVariantQuery>,
3737) -> Result<Response, ApiError> {
3838 let url = service
39 .images
3940 .space_image_file_url(
4041 session.uid(),
4142 &path.space_name,
Modified lib/space/src/handlers/space_admin.rs View lib/space/src/handlers/space_admin.rs
@@ -1,4 +1,4 @@
1use crate::errors::SpaceError;
1use crate::errors::ApiError;
22use crate::services::dto::SpacePath;
33use crate::services::service::SpaceService;
44use crate::services::types::{
@@ -7,10 +7,10 @@
77 RoleSummary, SetMemberRoleBody, SetRolePermissionsBody, UpdateBanBody, UpdateMemberBody,
88 UpdateOrganizationBody,
99};
10use actix_web::web;
11use envelope::{Envelope, NoData};
12use export::http::State;
13use session::{RequestId, Session};
10use export::http::{Extension, Json, Path, Query, State};
11use export::middleware::RequestId;
12use export::rest::{AppResponseData, NoData};
13use session::Session;
1414use uuid::Uuid;
1515
1616#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
@@ -129,55 +129,56 @@
129129
130130#[utoipa::path(
131131 post,
132 path = "/api/v1/spaces",
133 tag = "auth",
132 path = "/spaces",
133 tag = "space",
134134 operation_id = "space_create",
135135 summary = "新建组织",
136136 description = "创建者自动成为 OWNER 并写入 owner/admin/member 三个内置角色。slug 全局唯一且只允许小写字母、数字与连字符。",
137137 request_body = CreateOrganizationBody,
138138 responses(
139 (status = 200, description = "组织已创建", body = Envelope<OrganizationSummary>),
140 (status = 401, description = "会话未绑定账号", body = Envelope<NoData>),
141 (status = 409, description = "slug 已被占用", body = Envelope<NoData>)
139 (status = 200, description = "组织已创建", body = AppResponseData<OrganizationSummary>),
140 (status = 401, description = "会话未绑定账号", body = AppResponseData<NoData>),
141 (status = 409, description = "slug 已被占用", body = AppResponseData<NoData>)
142142 )
143143)]
144144pub async fn create_space(
145145 State(svc): State<SpaceService>,
146146 session: Session,
147 request_id: RequestId,
148 body: web::Json<CreateOrganizationBody>,
149) -> Result<Envelope<OrganizationSummary>, SpaceError> {
147 Extension(request_id): Extension<RequestId>,
148 body: Json<CreateOrganizationBody>,
149) -> Result<AppResponseData<OrganizationSummary>, ApiError> {
150150 let user_id = svc
151 .require_session(&session, Some(request_id.as_str()))
151 .policy
152 .require_session(&session, Some(request_id.0.as_str()))
152153 .await?;
153154 let summary = svc
154 .create_space(user_id, body.into_inner(), Some(request_id.as_str()))
155 .create_space(user_id, body.into_inner(), Some(request_id.0.as_str()))
155156 .await?;
156 Ok(Envelope::ok(summary))
157 Ok(AppResponseData::ok(summary))
157158}
158159
159160#[utoipa::path(
160161 get,
161 path = "/api/v1/spaces",
162 tag = "auth",
162 path = "/spaces",
163 tag = "space",
163164 operation_id = "space_list",
164165 summary = "列示我加入的组织",
165166 description = "默认只返回 ACTIVE 组织;`include_archived=true` 时一并返回已归档组织。",
166167 responses(
167 (status = 200, description = "组织列表", body = Envelope<Vec<OrganizationSummary>>),
168 (status = 401, description = "会话未绑定账号", body = Envelope<NoData>)
168 (status = 200, description = "组织列表", body = AppResponseData<Vec<OrganizationSummary>>),
169 (status = 401, description = "会话未绑定账号", body = AppResponseData<NoData>)
169170 )
170171)]
171172pub async fn list_spaces(
172173 State(svc): State<SpaceService>,
173174 session: Session,
174 request_id: RequestId,
175 query: web::Query<OrgListQuery>,
176) -> Result<Envelope<Vec<OrganizationSummary>>, SpaceError> {
175 Extension(request_id): Extension<RequestId>,
176 Query(query): Query<OrgListQuery>,
177) -> Result<AppResponseData<Vec<OrganizationSummary>>, ApiError> {
177178 let user_id = svc
178 .require_session(&session, Some(request_id.as_str()))
179 .policy
180 .require_session(&session, Some(request_id.0.as_str()))
179181 .await?;
180 let query = query.into_inner();
181182 let rows = svc
182183 .list_spaces(
183184 user_id,
@@ -185,64 +186,66 @@
185186 query.include_archived.unwrap_or(false),
186187 query.limit(),
187188 query.offset(),
188 Some(request_id.as_str()),
189 Some(request_id.0.as_str()),
189190 )
190191 .await?;
191 Ok(Envelope::ok(rows))
192 Ok(AppResponseData::ok(rows))
192193}
193194
194195#[utoipa::path(
195196 get,
196 path = "/api/v1/spaces/{space_name}",
197 tag = "auth",
197 path = "/spaces/{space_name}",
198 tag = "space",
198199 operation_id = "space_get",
199200 summary = "组织详情",
200201 params(("space_name" = String, Path, description = "空间 slug")),
201202 responses(
202 (status = 200, description = "组织详情", body = Envelope<OrganizationSummary>),
203 (status = 404, description = "组织不存在", body = Envelope<NoData>)
203 (status = 200, description = "组织详情", body = AppResponseData<OrganizationSummary>),
204 (status = 404, description = "组织不存在", body = AppResponseData<NoData>)
204205 )
205206)]
206207pub async fn get_space(
207208 State(svc): State<SpaceService>,
208209 session: Session,
209 request_id: RequestId,
210 path: web::Path<SpacePath>,
211) -> Result<Envelope<OrganizationSummary>, SpaceError> {
210 Extension(request_id): Extension<RequestId>,
211 Path(path): Path<SpacePath>,
212) -> Result<AppResponseData<OrganizationSummary>, ApiError> {
212213 let user_id = svc
213 .require_session(&session, Some(request_id.as_str()))
214 .policy
215 .require_session(&session, Some(request_id.0.as_str()))
214216 .await?;
215217 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
216218 let (summary, _) = svc
217 .get_space(user_id, Some(space_id), None, Some(request_id.as_str()))
219 .get_space(user_id, Some(space_id), None, Some(request_id.0.as_str()))
218220 .await?;
219 Ok(Envelope::ok(summary))
221 Ok(AppResponseData::ok(summary))
220222}
221223
222224#[utoipa::path(
223225 patch,
224 path = "/api/v1/spaces/{space_name}",
225 tag = "auth",
226 path = "/spaces/{space_name}",
227 tag = "space",
226228 operation_id = "space_update",
227229 summary = "修改组织",
228230 description = "需要 `org.update`。`expected_version` 与当前 version 不一致时返回 409。",
229231 params(("space_name" = String, Path, description = "空间 slug")),
230232 request_body = UpdateOrganizationBody,
231233 responses(
232 (status = 200, description = "已更新", body = Envelope<OrganizationSummary>),
233 (status = 403, description = "权限不足", body = Envelope<NoData>),
234 (status = 409, description = "版本冲突", body = Envelope<NoData>)
234 (status = 200, description = "已更新", body = AppResponseData<OrganizationSummary>),
235 (status = 403, description = "权限不足", body = AppResponseData<NoData>),
236 (status = 409, description = "版本冲突", body = AppResponseData<NoData>)
235237 )
236238)]
237239pub async fn update_space(
238240 State(svc): State<SpaceService>,
239241 session: Session,
240 request_id: RequestId,
241 path: web::Path<SpacePath>,
242 body: web::Json<UpdateOrganizationBody>,
243) -> Result<Envelope<OrganizationSummary>, SpaceError> {
242 Extension(request_id): Extension<RequestId>,
243 Path(path): Path<SpacePath>,
244 body: Json<UpdateOrganizationBody>,
245) -> Result<AppResponseData<OrganizationSummary>, ApiError> {
244246 let user_id = svc
245 .require_session(&session, Some(request_id.as_str()))
247 .policy
248 .require_session(&session, Some(request_id.0.as_str()))
246249 .await?;
247250 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
248251 let summary = svc
@@ -250,35 +253,36 @@
250253 user_id,
251254 space_id,
252255 body.into_inner(),
253 Some(request_id.as_str()),
256 Some(request_id.0.as_str()),
254257 )
255258 .await?;
256 Ok(Envelope::ok(summary))
259 Ok(AppResponseData::ok(summary))
257260}
258261
259262#[utoipa::path(
260263 post,
261 path = "/api/v1/spaces/{space_name}/archive",
262 tag = "auth",
264 path = "/spaces/{space_name}/archive",
265 tag = "space",
263266 operation_id = "space_archive",
264267 summary = "归档组织",
265268 description = "需要 `org.archive`。归档后全部权限判定返回 false,可恢复。",
266269 params(("space_name" = String, Path, description = "空间 slug")),
267270 request_body = ReasonBody,
268271 responses(
269 (status = 200, description = "已归档", body = Envelope<OrganizationSummary>),
270 (status = 403, description = "权限不足", body = Envelope<NoData>)
272 (status = 200, description = "已归档", body = AppResponseData<OrganizationSummary>),
273 (status = 403, description = "权限不足", body = AppResponseData<NoData>)
271274 )
272275)]
273276pub async fn archive_space(
274277 State(svc): State<SpaceService>,
275278 session: Session,
276 request_id: RequestId,
277 path: web::Path<SpacePath>,
278 body: web::Json<ReasonBody>,
279) -> Result<Envelope<OrganizationSummary>, SpaceError> {
279 Extension(request_id): Extension<RequestId>,
280 Path(path): Path<SpacePath>,
281 body: Json<ReasonBody>,
282) -> Result<AppResponseData<OrganizationSummary>, ApiError> {
280283 let user_id = svc
281 .require_session(&session, Some(request_id.as_str()))
284 .policy
285 .require_session(&session, Some(request_id.0.as_str()))
282286 .await?;
283287 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
284288 let summary = svc
@@ -286,45 +290,46 @@
286290 user_id,
287291 space_id,
288292 body.reason.as_deref(),
289 Some(request_id.as_str()),
293 Some(request_id.0.as_str()),
290294 )
291295 .await?;
292 Ok(Envelope::ok(summary))
296 Ok(AppResponseData::ok(summary))
293297}
294298
295299#[utoipa::path(
296300 post,
297 path = "/api/v1/spaces/{space_name}/restore",
298 tag = "auth",
301 path = "/spaces/{space_name}/restore",
302 tag = "space",
299303 operation_id = "space_restore",
300304 summary = "恢复归档组织",
301305 description = "仅 OWNER 或平台管理员可执行。",
302306 params(("space_name" = String, Path, description = "空间 slug")),
303307 responses(
304 (status = 200, description = "已恢复", body = Envelope<OrganizationSummary>),
305 (status = 403, description = "权限不足", body = Envelope<NoData>)
308 (status = 200, description = "已恢复", body = AppResponseData<OrganizationSummary>),
309 (status = 403, description = "权限不足", body = AppResponseData<NoData>)
306310 )
307311)]
308312pub async fn restore_space(
309313 State(svc): State<SpaceService>,
310314 session: Session,
311 request_id: RequestId,
312 path: web::Path<SpacePath>,
313) -> Result<Envelope<OrganizationSummary>, SpaceError> {
315 Extension(request_id): Extension<RequestId>,
316 Path(path): Path<SpacePath>,
317) -> Result<AppResponseData<OrganizationSummary>, ApiError> {
314318 let user_id = svc
315 .require_session(&session, Some(request_id.as_str()))
319 .policy
320 .require_session(&session, Some(request_id.0.as_str()))
316321 .await?;
317322 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
318323 let summary = svc
319 .restore_space(user_id, space_id, Some(request_id.as_str()))
324 .restore_space(user_id, space_id, Some(request_id.0.as_str()))
320325 .await?;
321 Ok(Envelope::ok(summary))
326 Ok(AppResponseData::ok(summary))
322327}
323328
324329#[utoipa::path(
325330 delete,
326 path = "/api/v1/spaces/{space_name}",
327 tag = "auth",
331 path = "/spaces/{space_name}",
332 tag = "space",
328333 operation_id = "space_delete",
329334 summary = "删除组织",
330335 description = "仍存在活跃成员且未指定 `force=true` 时返回 409。删除会清理成员、角色与邀请。",
@@ -334,59 +339,60 @@
334339 ("reason" = Option<String>, Query, description = "删除原因,最长 512 字符")
335340 ),
336341 responses(
337 (status = 200, description = "已删除", body = Envelope<bool>),
338 (status = 403, description = "权限不足", body = Envelope<NoData>),
339 (status = 409, description = "仍有活跃成员", body = Envelope<NoData>)
342 (status = 200, description = "已删除", body = AppResponseData<bool>),
343 (status = 403, description = "权限不足", body = AppResponseData<NoData>),
344 (status = 409, description = "仍有活跃成员", body = AppResponseData<NoData>)
340345 )
341346)]
342347pub async fn delete_space(
343348 State(svc): State<SpaceService>,
344349 session: Session,
345 request_id: RequestId,
346 path: web::Path<SpacePath>,
347 query: web::Query<DeleteOrgQuery>,
348) -> Result<Envelope<bool>, SpaceError> {
350 Extension(request_id): Extension<RequestId>,
351 Path(path): Path<SpacePath>,
352 Query(query): Query<DeleteOrgQuery>,
353) -> Result<AppResponseData<bool>, ApiError> {
349354 let user_id = svc
350 .require_session(&session, Some(request_id.as_str()))
355 .policy
356 .require_session(&session, Some(request_id.0.as_str()))
351357 .await?;
352358 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
353 let query = query.into_inner();
354359 let deleted = svc
355360 .delete_space(
356361 user_id,
357362 space_id,
358363 query.force,
359364 query.reason.as_deref(),
360 Some(request_id.as_str()),
365 Some(request_id.0.as_str()),
361366 )
362367 .await?;
363 Ok(Envelope::ok(deleted))
368 Ok(AppResponseData::ok(deleted))
364369}
365370
366371#[utoipa::path(
367372 post,
368 path = "/api/v1/spaces/{space_name}/transfer",
369 tag = "auth",
373 path = "/spaces/{space_name}/transfer",
374 tag = "space",
370375 operation_id = "space_transfer",
371376 summary = "转移 OWNER",
372377 description = "目标必须是活跃成员。原 Owner 降为 ADMIN。",
373378 params(("space_name" = String, Path, description = "空间 slug")),
374379 request_body = TransferBody,
375380 responses(
376 (status = 200, description = "已转移", body = Envelope<OrganizationSummary>),
377 (status = 403, description = "权限不足", body = Envelope<NoData>),
378 (status = 404, description = "目标不是活跃成员", body = Envelope<NoData>)
381 (status = 200, description = "已转移", body = AppResponseData<OrganizationSummary>),
382 (status = 403, description = "权限不足", body = AppResponseData<NoData>),
383 (status = 404, description = "目标不是活跃成员", body = AppResponseData<NoData>)
379384 )
380385)]
381386pub async fn transfer_ownership(
382387 State(svc): State<SpaceService>,
383388 session: Session,
384 request_id: RequestId,
385 path: web::Path<SpacePath>,
386 body: web::Json<TransferBody>,
387) -> Result<Envelope<OrganizationSummary>, SpaceError> {
389 Extension(request_id): Extension<RequestId>,
390 Path(path): Path<SpacePath>,
391 body: Json<TransferBody>,
392) -> Result<AppResponseData<OrganizationSummary>, ApiError> {
388393 let user_id = svc
389 .require_session(&session, Some(request_id.as_str()))
394 .policy
395 .require_session(&session, Some(request_id.0.as_str()))
390396 .await?;
391397 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
392398 let summary = svc
@@ -394,16 +400,16 @@
394400 user_id,
395401 space_id,
396402 body.target_user_id,
397 Some(request_id.as_str()),
403 Some(request_id.0.as_str()),
398404 )
399405 .await?;
400 Ok(Envelope::ok(summary))
406 Ok(AppResponseData::ok(summary))
401407}
402408
403409#[utoipa::path(
404410 get,
405 path = "/api/v1/spaces/{space_name}/members",
406 tag = "auth",
411 path = "/spaces/{space_name}/members",
412 tag = "space",
407413 operation_id = "space_member_list",
408414 summary = "列示组织成员",
409415 description = "需要 `member.view`。默认只返回活跃成员。",
@@ -413,22 +419,22 @@
413419 ("include_banned" = Option<bool>, Query, description = "包含已封禁成员")
414420 ),
415421 responses(
416 (status = 200, description = "成员列表", body = Envelope<Vec<MemberSummary>>),
417 (status = 403, description = "权限不足", body = Envelope<NoData>)
422 (status = 200, description = "成员列表", body = AppResponseData<Vec<MemberSummary>>),
423 (status = 403, description = "权限不足", body = AppResponseData<NoData>)
418424 )
419425)]
420426pub async fn list_members(
421427 State(svc): State<SpaceService>,
422428 session: Session,
423 request_id: RequestId,
424 path: web::Path<SpacePath>,
425 query: web::Query<MemberListQuery>,
426) -> Result<Envelope<Vec<MemberSummary>>, SpaceError> {
429 Extension(request_id): Extension<RequestId>,
430 Path(path): Path<SpacePath>,
431 Query(query): Query<MemberListQuery>,
432) -> Result<AppResponseData<Vec<MemberSummary>>, ApiError> {
427433 let user_id = svc
428 .require_session(&session, Some(request_id.as_str()))
434 .policy
435 .require_session(&session, Some(request_id.0.as_str()))
429436 .await?;
430437 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
431 let query = query.into_inner();
432438 let rows = svc
433439 .admin_list_members(
434440 user_id,
@@ -437,16 +443,16 @@
437443 query.include_banned.unwrap_or(false),
438444 query.limit(),
439445 query.offset(),
440 Some(request_id.as_str()),
446 Some(request_id.0.as_str()),
441447 )
442448 .await?;
443 Ok(Envelope::ok(rows))
449 Ok(AppResponseData::ok(rows))
444450}
445451
446452#[utoipa::path(
447453 get,
448 path = "/api/v1/spaces/{space_name}/members/{member_id}",
449 tag = "auth",
454 path = "/spaces/{space_name}/members/{member_id}",
455 tag = "space",
450456 operation_id = "space_member_get",
451457 summary = "成员详情",
452458 description = "需要 `member.view`。包含别名、通知偏好与封禁附加信息。",
@@ -455,18 +461,19 @@
455461 ("member_id" = Uuid, Path, description = "会籍 id")
456462 ),
457463 responses(
458 (status = 200, description = "成员详情", body = Envelope<MemberSummary>),
459 (status = 404, description = "成员不存在", body = Envelope<NoData>)
464 (status = 200, description = "成员详情", body = AppResponseData<MemberSummary>),
465 (status = 404, description = "成员不存在", body = AppResponseData<NoData>)
460466 )
461467)]
462468pub async fn get_member(
463469 State(svc): State<SpaceService>,
464470 session: Session,
465 request_id: RequestId,
466 path: web::Path<SpaceMemberPath>,
467) -> Result<Envelope<MemberSummary>, SpaceError> {
471 Extension(request_id): Extension<RequestId>,
472 Path(path): Path<SpaceMemberPath>,
473) -> Result<AppResponseData<MemberSummary>, ApiError> {
468474 let user_id = svc
469 .require_session(&session, Some(request_id.as_str()))
475 .policy
476 .require_session(&session, Some(request_id.0.as_str()))
470477 .await?;
471478 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
472479 let member = svc
@@ -475,35 +482,36 @@
475482 space_id,
476483 Some(path.member_id),
477484 0,
478 Some(request_id.as_str()),
485 Some(request_id.0.as_str()),
479486 )
480487 .await?;
481 Ok(Envelope::ok(member))
488 Ok(AppResponseData::ok(member))
482489}
483490
484491#[utoipa::path(
485492 patch,
486 path = "/api/v1/spaces/{space_name}/members/@me",
487 tag = "auth",
493 path = "/spaces/{space_name}/members/@me",
494 tag = "space",
488495 operation_id = "space_member_update_self",
489496 summary = "更新自己的会籍资料",
490497 description = "只允许修改自己的别名、头像与通知偏好;无需 `member.role.update`。",
491498 params(("space_name" = String, Path, description = "空间 slug")),
492499 request_body = UpdateMemberBody,
493500 responses(
494 (status = 200, description = "已更新", body = Envelope<MemberSummary>),
495 (status = 403, description = "不是活跃成员", body = Envelope<NoData>)
501 (status = 200, description = "已更新", body = AppResponseData<MemberSummary>),
502 (status = 403, description = "不是活跃成员", body = AppResponseData<NoData>)
496503 )
497504)]
498505pub async fn update_self_member(
499506 State(svc): State<SpaceService>,
500507 session: Session,
501 request_id: RequestId,
502 path: web::Path<SpacePath>,
503 body: web::Json<UpdateMemberBody>,
504) -> Result<Envelope<MemberSummary>, SpaceError> {
508 Extension(request_id): Extension<RequestId>,
509 Path(path): Path<SpacePath>,
510 body: Json<UpdateMemberBody>,
511) -> Result<AppResponseData<MemberSummary>, ApiError> {
505512 let user_id = svc
506 .require_session(&session, Some(request_id.as_str()))
513 .policy
514 .require_session(&session, Some(request_id.0.as_str()))
507515 .await?;
508516 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
509517 let member = svc
@@ -511,16 +519,16 @@
511519 user_id,
512520 space_id,
513521 body.into_inner(),
514 Some(request_id.as_str()),
522 Some(request_id.0.as_str()),
515523 )
516524 .await?;
517 Ok(Envelope::ok(member))
525 Ok(AppResponseData::ok(member))
518526}
519527
520528#[utoipa::path(
521529 patch,
522 path = "/api/v1/spaces/{space_name}/members/{member_id}",
523 tag = "auth",
530 path = "/spaces/{space_name}/members/{member_id}",
531 tag = "space",
524532 operation_id = "space_member_update",
525533 summary = "更新成员资料",
526534 description = "需要 `member.role.update`。修改别名、头像、通知偏好与免打扰;封禁中的会籍走封禁接口。",
@@ -530,20 +538,21 @@
530538 ),
531539 request_body = UpdateMemberBody,
532540 responses(
533 (status = 200, description = "已更新", body = Envelope<MemberSummary>),
534 (status = 403, description = "权限不足或目标已封禁", body = Envelope<NoData>),
535 (status = 409, description = "版本冲突", body = Envelope<NoData>)
541 (status = 200, description = "已更新", body = AppResponseData<MemberSummary>),
542 (status = 403, description = "权限不足或目标已封禁", body = AppResponseData<NoData>),
543 (status = 409, description = "版本冲突", body = AppResponseData<NoData>)
536544 )
537545)]
538546pub async fn update_member(
539547 State(svc): State<SpaceService>,
540548 session: Session,
541 request_id: RequestId,
542 path: web::Path<SpaceMemberPath>,
543 body: web::Json<UpdateMemberBody>,
544) -> Result<Envelope<MemberSummary>, SpaceError> {
549 Extension(request_id): Extension<RequestId>,
550 Path(path): Path<SpaceMemberPath>,
551 body: Json<UpdateMemberBody>,
552) -> Result<AppResponseData<MemberSummary>, ApiError> {
545553 let user_id = svc
546 .require_session(&session, Some(request_id.as_str()))
554 .policy
555 .require_session(&session, Some(request_id.0.as_str()))
547556 .await?;
548557 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
549558 let member = svc
@@ -552,36 +561,37 @@
552561 space_id,
553562 path.member_id,
554563 body.into_inner(),
555 Some(request_id.as_str()),
564 Some(request_id.0.as_str()),
556565 )
557566 .await?;
558 Ok(Envelope::ok(member))
567 Ok(AppResponseData::ok(member))
559568}
560569
561570#[utoipa::path(
562571 post,
563 path = "/api/v1/spaces/{space_name}/members",
564 tag = "auth",
572 path = "/spaces/{space_name}/members",
573 tag = "space",
565574 operation_id = "space_member_add",
566575 summary = "直接添加成员",
567576 description = "需要 `member.add`。已退出的会籍按复活处理,已封禁的返回 403。",
568577 params(("space_name" = String, Path, description = "空间 slug")),
569578 request_body = AddMemberBody,
570579 responses(
571 (status = 200, description = "成员已添加", body = Envelope<MemberSummary>),
572 (status = 403, description = "权限不足或成员已封禁", body = Envelope<NoData>),
573 (status = 404, description = "用户不存在", body = Envelope<NoData>)
580 (status = 200, description = "成员已添加", body = AppResponseData<MemberSummary>),
581 (status = 403, description = "权限不足或成员已封禁", body = AppResponseData<NoData>),
582 (status = 404, description = "用户不存在", body = AppResponseData<NoData>)
574583 )
575584)]
576585pub async fn add_member(
577586 State(svc): State<SpaceService>,
578587 session: Session,
579 request_id: RequestId,
580 path: web::Path<SpacePath>,
581 body: web::Json<AddMemberBody>,
582) -> Result<Envelope<MemberSummary>, SpaceError> {
588 Extension(request_id): Extension<RequestId>,
589 Path(path): Path<SpacePath>,
590 body: Json<AddMemberBody>,
591) -> Result<AppResponseData<MemberSummary>, ApiError> {
583592 let user_id = svc
584 .require_session(&session, Some(request_id.as_str()))
593 .policy
594 .require_session(&session, Some(request_id.0.as_str()))
585595 .await?;
586596 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
587597 let (member, _) = svc
@@ -589,16 +599,16 @@
589599 user_id,
590600 space_id,
591601 body.into_inner(),
592 Some(request_id.as_str()),
602 Some(request_id.0.as_str()),
593603 )
594604 .await?;
595 Ok(Envelope::ok(member))
605 Ok(AppResponseData::ok(member))
596606}
597607
598608#[utoipa::path(
599609 delete,
600 path = "/api/v1/spaces/{space_name}/members/{member_id}",
601 tag = "auth",
610 path = "/spaces/{space_name}/members/{member_id}",
611 tag = "space",
602612 operation_id = "space_member_remove",
603613 summary = "移除成员",
604614 description = "需要 `member.remove`。OWNER 不能移除,需先转移所有权。会籍行保留用于复活。",
@@ -608,19 +618,20 @@
608618 ("reason" = Option<String>, Query, description = "移除原因")
609619 ),
610620 responses(
611 (status = 200, description = "已移除", body = Envelope<bool>),
612 (status = 403, description = "权限不足或目标是 OWNER", body = Envelope<NoData>)
621 (status = 200, description = "已移除", body = AppResponseData<bool>),
622 (status = 403, description = "权限不足或目标是 OWNER", body = AppResponseData<NoData>)
613623 )
614624)]
615625pub async fn remove_member(
616626 State(svc): State<SpaceService>,
617627 session: Session,
618 request_id: RequestId,
619 path: web::Path<SpaceMemberPath>,
620 query: web::Query<ReasonBody>,
621) -> Result<Envelope<bool>, SpaceError> {
628 Extension(request_id): Extension<RequestId>,
629 Path(path): Path<SpaceMemberPath>,
630 Query(query): Query<ReasonBody>,
631) -> Result<AppResponseData<bool>, ApiError> {
622632 let user_id = svc
623 .require_session(&session, Some(request_id.as_str()))
633 .policy
634 .require_session(&session, Some(request_id.0.as_str()))
624635 .await?;
625636 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
626637 let removed = svc
@@ -630,45 +641,46 @@
630641 Some(path.member_id),
631642 0,
632643 query.reason.as_deref(),
633 Some(request_id.as_str()),
644 Some(request_id.0.as_str()),
634645 )
635646 .await?;
636 Ok(Envelope::ok(removed))
647 Ok(AppResponseData::ok(removed))
637648}
638649
639650#[utoipa::path(
640651 post,
641 path = "/api/v1/spaces/{space_name}/leave",
642 tag = "auth",
652 path = "/spaces/{space_name}/leave",
653 tag = "space",
643654 operation_id = "space_leave",
644655 summary = "退出组织",
645656 description = "OWNER 必须先转移所有权,否则返回 409。",
646657 params(("space_name" = String, Path, description = "空间 slug")),
647658 responses(
648 (status = 200, description = "已退出", body = Envelope<bool>),
649 (status = 409, description = "OWNER 需先转移所有权", body = Envelope<NoData>)
659 (status = 200, description = "已退出", body = AppResponseData<bool>),
660 (status = 409, description = "OWNER 需先转移所有权", body = AppResponseData<NoData>)
650661 )
651662)]
652663pub async fn leave_organization(
653664 State(svc): State<SpaceService>,
654665 session: Session,
655 request_id: RequestId,
656 path: web::Path<SpacePath>,
657) -> Result<Envelope<bool>, SpaceError> {
666 Extension(request_id): Extension<RequestId>,
667 Path(path): Path<SpacePath>,
668) -> Result<AppResponseData<bool>, ApiError> {
658669 let user_id = svc
659 .require_session(&session, Some(request_id.as_str()))
670 .policy
671 .require_session(&session, Some(request_id.0.as_str()))
660672 .await?;
661673 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
662674 let left = svc
663 .leave_organization(user_id, space_id, Some(request_id.as_str()))
675 .leave_organization(user_id, space_id, Some(request_id.0.as_str()))
664676 .await?;
665 Ok(Envelope::ok(left))
677 Ok(AppResponseData::ok(left))
666678}
667679
668680#[utoipa::path(
669681 patch,
670 path = "/api/v1/spaces/{space_name}/members/{member_id}/role",
671 tag = "auth",
682 path = "/spaces/{space_name}/members/{member_id}/role",
683 tag = "space",
672684 operation_id = "space_member_role",
673685 summary = "改粗粒度角色",
674686 description = "只接受 MEMBER 与 ADMIN。OWNER 只能通过转移所有权变更。",
@@ -678,20 +690,21 @@
678690 ),
679691 request_body = SetMemberRoleBody,
680692 responses(
681 (status = 200, description = "已更新", body = Envelope<MemberSummary>),
682 (status = 403, description = "权限不足", body = Envelope<NoData>),
683 (status = 409, description = "版本冲突", body = Envelope<NoData>)
693 (status = 200, description = "已更新", body = AppResponseData<MemberSummary>),
694 (status = 403, description = "权限不足", body = AppResponseData<NoData>),
695 (status = 409, description = "版本冲突", body = AppResponseData<NoData>)
684696 )
685697)]
686698pub async fn set_member_role(
687699 State(svc): State<SpaceService>,
688700 session: Session,
689 request_id: RequestId,
690 path: web::Path<SpaceMemberPath>,
691 body: web::Json<SetMemberRoleBody>,
692) -> Result<Envelope<MemberSummary>, SpaceError> {
701 Extension(request_id): Extension<RequestId>,
702 Path(path): Path<SpaceMemberPath>,
703 body: Json<SetMemberRoleBody>,
704) -> Result<AppResponseData<MemberSummary>, ApiError> {
693705 let user_id = svc
694 .require_session(&session, Some(request_id.as_str()))
706 .policy
707 .require_session(&session, Some(request_id.0.as_str()))
695708 .await?;
696709 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
697710 let member = svc
@@ -700,16 +713,16 @@
700713 space_id,
701714 path.member_id,
702715 body.into_inner(),
703 Some(request_id.as_str()),
716 Some(request_id.0.as_str()),
704717 )
705718 .await?;
706 Ok(Envelope::ok(member))
719 Ok(AppResponseData::ok(member))
707720}
708721
709722#[utoipa::path(
710723 post,
711 path = "/api/v1/spaces/{space_name}/members/{member_id}/ban",
712 tag = "auth",
724 path = "/spaces/{space_name}/members/{member_id}/ban",
725 tag = "space",
713726 operation_id = "space_member_ban",
714727 summary = "封禁成员",
715728 description = "写 banned_at 并清空角色绑定与直挂权限。封禁优先于一切授权。",
@@ -719,19 +732,20 @@
719732 ),
720733 request_body = BanMemberBody,
721734 responses(
722 (status = 200, description = "已封禁", body = Envelope<MemberSummary>),
723 (status = 403, description = "权限不足", body = Envelope<NoData>)
735 (status = 200, description = "已封禁", body = AppResponseData<MemberSummary>),
736 (status = 403, description = "权限不足", body = AppResponseData<NoData>)
724737 )
725738)]
726739pub async fn ban_member(
727740 State(svc): State<SpaceService>,
728741 session: Session,
729 request_id: RequestId,
730 path: web::Path<SpaceMemberPath>,
731 body: web::Json<BanMemberBody>,
732) -> Result<Envelope<MemberSummary>, SpaceError> {
742 Extension(request_id): Extension<RequestId>,
743 Path(path): Path<SpaceMemberPath>,
744 body: Json<BanMemberBody>,
745) -> Result<AppResponseData<MemberSummary>, ApiError> {
733746 let user_id = svc
734 .require_session(&session, Some(request_id.as_str()))
747 .policy
748 .require_session(&session, Some(request_id.0.as_str()))
735749 .await?;
736750 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
737751 let member = svc
@@ -740,16 +754,16 @@
740754 space_id,
741755 path.member_id,
742756 body.reason.as_deref(),
743 Some(request_id.as_str()),
757 Some(request_id.0.as_str()),
744758 )
745759 .await?;
746 Ok(Envelope::ok(member))
760 Ok(AppResponseData::ok(member))
747761}
748762
749763#[utoipa::path(
750764 post,
751 path = "/api/v1/spaces/{space_name}/members/{member_id}/unban",
752 tag = "auth",
765 path = "/spaces/{space_name}/members/{member_id}/unban",
766 tag = "space",
753767 operation_id = "space_member_unban",
754768 summary = "解封成员",
755769 description = "清空 banned_at。已清空的角色不会自动恢复。",
@@ -758,30 +772,36 @@
758772 ("member_id" = Uuid, Path, description = "会籍 id")
759773 ),
760774 responses(
761 (status = 200, description = "已解封", body = Envelope<MemberSummary>),
762 (status = 403, description = "权限不足", body = Envelope<NoData>)
775 (status = 200, description = "已解封", body = AppResponseData<MemberSummary>),
776 (status = 403, description = "权限不足", body = AppResponseData<NoData>)
763777 )
764778)]
765779pub async fn unban_member(
766780 State(svc): State<SpaceService>,
767781 session: Session,
768 request_id: RequestId,
769 path: web::Path<SpaceMemberPath>,
770) -> Result<Envelope<MemberSummary>, SpaceError> {
782 Extension(request_id): Extension<RequestId>,
783 Path(path): Path<SpaceMemberPath>,
784) -> Result<AppResponseData<MemberSummary>, ApiError> {
771785 let user_id = svc
772 .require_session(&session, Some(request_id.as_str()))
786 .policy
787 .require_session(&session, Some(request_id.0.as_str()))
773788 .await?;
774789 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
775790 let member = svc
776 .unban_member(user_id, space_id, path.member_id, Some(request_id.as_str()))
791 .unban_member(
792 user_id,
793 space_id,
794 path.member_id,
795 Some(request_id.0.as_str()),
796 )
777797 .await?;
778 Ok(Envelope::ok(member))
798 Ok(AppResponseData::ok(member))
779799}
780800
781801#[utoipa::path(
782802 get,
783 path = "/api/v1/spaces/{space_name}/bans",
784 tag = "auth",
803 path = "/spaces/{space_name}/bans",
804 tag = "space",
785805 operation_id = "space_ban_list",
786806 summary = "封禁列表",
787807 description = "需要 `member.ban`。按封禁时间倒序。",
@@ -791,19 +811,20 @@
791811 ("page_size" = Option<u64>, Query, description = "每页条数")
792812 ),
793813 responses(
794 (status = 200, description = "封禁列表", body = Envelope<Vec<MemberSummary>>),
795 (status = 403, description = "权限不足", body = Envelope<NoData>)
814 (status = 200, description = "封禁列表", body = AppResponseData<Vec<MemberSummary>>),
815 (status = 403, description = "权限不足", body = AppResponseData<NoData>)
796816 )
797817)]
798818pub async fn list_bans(
799819 State(svc): State<SpaceService>,
800820 session: Session,
801 request_id: RequestId,
802 path: web::Path<SpacePath>,
803 query: web::Query<MemberListQuery>,
804) -> Result<Envelope<Vec<MemberSummary>>, SpaceError> {
821 Extension(request_id): Extension<RequestId>,
822 Path(path): Path<SpacePath>,
823 Query(query): Query<MemberListQuery>,
824) -> Result<AppResponseData<Vec<MemberSummary>>, ApiError> {
805825 let user_id = svc
806 .require_session(&session, Some(request_id.as_str()))
826 .policy
827 .require_session(&session, Some(request_id.0.as_str()))
807828 .await?;
808829 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
809830 let rows = svc
@@ -812,16 +833,16 @@
812833 space_id,
813834 query.limit(),
814835 query.offset(),
815 Some(request_id.as_str()),
836 Some(request_id.0.as_str()),
816837 )
817838 .await?;
818 Ok(Envelope::ok(rows))
839 Ok(AppResponseData::ok(rows))
819840}
820841
821842#[utoipa::path(
822843 get,
823 path = "/api/v1/spaces/{space_name}/bans/{user_id}",
824 tag = "auth",
844 path = "/spaces/{space_name}/bans/{user_id}",
845 tag = "space",
825846 operation_id = "space_ban_get",
826847 summary = "封禁详情",
827848 description = "需要 `member.ban`。未封禁返回 404。",
@@ -830,30 +851,31 @@
830851 ("user_id" = i64, Path, description = "被封禁用户 id")
831852 ),
832853 responses(
833 (status = 200, description = "封禁详情", body = Envelope<MemberSummary>),
834 (status = 404, description = "未封禁或成员不存在", body = Envelope<NoData>)
854 (status = 200, description = "封禁详情", body = AppResponseData<MemberSummary>),
855 (status = 404, description = "未封禁或成员不存在", body = AppResponseData<NoData>)
835856 )
836857)]
837858pub async fn get_ban(
838859 State(svc): State<SpaceService>,
839860 session: Session,
840 request_id: RequestId,
841 path: web::Path<SpaceBanPath>,
842) -> Result<Envelope<MemberSummary>, SpaceError> {
861 Extension(request_id): Extension<RequestId>,
862 Path(path): Path<SpaceBanPath>,
863) -> Result<AppResponseData<MemberSummary>, ApiError> {
843864 let user_id = svc
844 .require_session(&session, Some(request_id.as_str()))
865 .policy
866 .require_session(&session, Some(request_id.0.as_str()))
845867 .await?;
846868 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
847869 let member = svc
848 .admin_get_ban(user_id, space_id, path.user_id, Some(request_id.as_str()))
870 .admin_get_ban(user_id, space_id, path.user_id, Some(request_id.0.as_str()))
849871 .await?;
850 Ok(Envelope::ok(member))
872 Ok(AppResponseData::ok(member))
851873}
852874
853875#[utoipa::path(
854876 patch,
855 path = "/api/v1/spaces/{space_name}/bans/{user_id}",
856 tag = "auth",
877 path = "/spaces/{space_name}/bans/{user_id}",
878 tag = "space",
857879 operation_id = "space_ban_update",
858880 summary = "更新封禁",
859881 description = "需要 `member.ban`。改原因、到期时间与四个放行位;未封禁返回 404。",
@@ -863,19 +885,20 @@
863885 ),
864886 request_body = UpdateBanBody,
865887 responses(
866 (status = 200, description = "已更新", body = Envelope<MemberSummary>),
867 (status = 404, description = "未封禁或成员不存在", body = Envelope<NoData>)
888 (status = 200, description = "已更新", body = AppResponseData<MemberSummary>),
889 (status = 404, description = "未封禁或成员不存在", body = AppResponseData<NoData>)
868890 )
869891)]
870892pub async fn update_ban(
871893 State(svc): State<SpaceService>,
872894 session: Session,
873 request_id: RequestId,
874 path: web::Path<SpaceBanPath>,
875 body: web::Json<UpdateBanBody>,
876) -> Result<Envelope<MemberSummary>, SpaceError> {
895 Extension(request_id): Extension<RequestId>,
896 Path(path): Path<SpaceBanPath>,
897 body: Json<UpdateBanBody>,
898) -> Result<AppResponseData<MemberSummary>, ApiError> {
877899 let user_id = svc
878 .require_session(&session, Some(request_id.as_str()))
900 .policy
901 .require_session(&session, Some(request_id.0.as_str()))
879902 .await?;
880903 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
881904 let member = svc
@@ -884,16 +907,16 @@
884907 space_id,
885908 path.user_id,
886909 body.into_inner(),
887 Some(request_id.as_str()),
910 Some(request_id.0.as_str()),
888911 )
889912 .await?;
890 Ok(Envelope::ok(member))
913 Ok(AppResponseData::ok(member))
891914}
892915
893916#[utoipa::path(
894917 delete,
895 path = "/api/v1/spaces/{space_name}/bans/{user_id}",
896 tag = "auth",
918 path = "/spaces/{space_name}/bans/{user_id}",
919 tag = "space",
897920 operation_id = "space_ban_delete",
898921 summary = "按用户解封",
899922 description = "需要 `member.ban`。等价于按会籍 id 解封,便于按用户维度操作。",
@@ -902,33 +925,34 @@
902925 ("user_id" = i64, Path, description = "被解封用户 id")
903926 ),
904927 responses(
905 (status = 200, description = "已解封", body = Envelope<MemberSummary>),
906 (status = 404, description = "成员不存在", body = Envelope<NoData>)
928 (status = 200, description = "已解封", body = AppResponseData<MemberSummary>),
929 (status = 404, description = "成员不存在", body = AppResponseData<NoData>)
907930 )
908931)]
909932pub async fn unban_by_user(
910933 State(svc): State<SpaceService>,
911934 session: Session,
912 request_id: RequestId,
913 path: web::Path<SpaceBanPath>,
914) -> Result<Envelope<MemberSummary>, SpaceError> {
935 Extension(request_id): Extension<RequestId>,
936 Path(path): Path<SpaceBanPath>,
937) -> Result<AppResponseData<MemberSummary>, ApiError> {
915938 let user_id = svc
916 .require_session(&session, Some(request_id.as_str()))
939 .policy
940 .require_session(&session, Some(request_id.0.as_str()))
917941 .await?;
918942 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
919943 let member = svc
920 .admin_get_ban(user_id, space_id, path.user_id, Some(request_id.as_str()))
944 .admin_get_ban(user_id, space_id, path.user_id, Some(request_id.0.as_str()))
921945 .await?;
922946 let unbaned = svc
923 .unban_member(user_id, space_id, member.id, Some(request_id.as_str()))
947 .unban_member(user_id, space_id, member.id, Some(request_id.0.as_str()))
924948 .await?;
925 Ok(Envelope::ok(unbaned))
949 Ok(AppResponseData::ok(unbaned))
926950}
927951
928952#[utoipa::path(
929953 put,
930 path = "/api/v1/spaces/{space_name}/members/{member_id}/permissions",
931 tag = "auth",
954 path = "/spaces/{space_name}/members/{member_id}/permissions",
955 tag = "space",
932956 operation_id = "space_member_permissions",
933957 summary = "设置成员直挂权限",
934958 description = "整表替换语义。需要 `role.permission.update`,OWNER 不接受直挂覆盖。",
@@ -938,19 +962,20 @@
938962 ),
939963 request_body = SetRolePermissionsBody,
940964 responses(
941 (status = 200, description = "已更新", body = Envelope<Vec<String>>),
942 (status = 403, description = "权限不足", body = Envelope<NoData>)
965 (status = 200, description = "已更新", body = AppResponseData<Vec<String>>),
966 (status = 403, description = "权限不足", body = AppResponseData<NoData>)
943967 )
944968)]
945969pub async fn set_member_permissions(
946970 State(svc): State<SpaceService>,
947971 session: Session,
948 request_id: RequestId,
949 path: web::Path<SpaceMemberPath>,
950 body: web::Json<SetRolePermissionsBody>,
951) -> Result<Envelope<Vec<String>>, SpaceError> {
972 Extension(request_id): Extension<RequestId>,
973 Path(path): Path<SpaceMemberPath>,
974 body: Json<SetRolePermissionsBody>,
975) -> Result<AppResponseData<Vec<String>>, ApiError> {
952976 let user_id = svc
953 .require_session(&session, Some(request_id.as_str()))
977 .policy
978 .require_session(&session, Some(request_id.0.as_str()))
954979 .await?;
955980 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
956981 let grants: Vec<PermissionGrant> = body.into_inner().permissions;
@@ -960,45 +985,46 @@
960985 space_id,
961986 path.member_id,
962987 grants,
963 Some(request_id.as_str()),
988 Some(request_id.0.as_str()),
964989 )
965990 .await?;
966 Ok(Envelope::ok(permissions))
991 Ok(AppResponseData::ok(permissions))
967992}
968993
969994#[utoipa::path(
970995 get,
971 path = "/api/v1/spaces/{space_name}/roles",
972 tag = "auth",
996 path = "/spaces/{space_name}/roles",
997 tag = "space",
973998 operation_id = "space_role_list",
974999 summary = "列示组织角色",
9751000 description = "需要 `role.view`。含内置 owner/admin/member 三个系统角色。",
9761001 params(("space_name" = String, Path, description = "空间 slug")),
9771002 responses(
978 (status = 200, description = "角色列表", body = Envelope<Vec<RoleSummary>>),
979 (status = 403, description = "权限不足", body = Envelope<NoData>)
1003 (status = 200, description = "角色列表", body = AppResponseData<Vec<RoleSummary>>),
1004 (status = 403, description = "权限不足", body = AppResponseData<NoData>)
9801005 )
9811006)]
9821007pub async fn list_roles(
9831008 State(svc): State<SpaceService>,
9841009 session: Session,
985 request_id: RequestId,
986 path: web::Path<SpacePath>,
987) -> Result<Envelope<Vec<RoleSummary>>, SpaceError> {
1010 Extension(request_id): Extension<RequestId>,
1011 Path(path): Path<SpacePath>,
1012) -> Result<AppResponseData<Vec<RoleSummary>>, ApiError> {
9881013 let user_id = svc
989 .require_session(&session, Some(request_id.as_str()))
1014 .policy
1015 .require_session(&session, Some(request_id.0.as_str()))
9901016 .await?;
9911017 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
9921018 let rows = svc
993 .list_roles(user_id, space_id, Some(request_id.as_str()))
1019 .list_roles(user_id, space_id, Some(request_id.0.as_str()))
9941020 .await?;
995 Ok(Envelope::ok(rows))
1021 Ok(AppResponseData::ok(rows))
9961022}
9971023
9981024#[utoipa::path(
9991025 get,
1000 path = "/api/v1/spaces/{space_name}/roles/{role_id}",
1001 tag = "auth",
1026 path = "/spaces/{space_name}/roles/{role_id}",
1027 tag = "space",
10021028 operation_id = "space_role_get",
10031029 summary = "角色详情",
10041030 description = "需要 `role.view`。",
@@ -1007,49 +1033,51 @@
10071033 ("role_id" = Uuid, Path, description = "角色 id")
10081034 ),
10091035 responses(
1010 (status = 200, description = "角色详情", body = Envelope<RoleSummary>),
1011 (status = 404, description = "角色不存在", body = Envelope<NoData>)
1036 (status = 200, description = "角色详情", body = AppResponseData<RoleSummary>),
1037 (status = 404, description = "角色不存在", body = AppResponseData<NoData>)
10121038 )
10131039)]
10141040pub async fn get_role(
10151041 State(svc): State<SpaceService>,
10161042 session: Session,
1017 request_id: RequestId,
1018 path: web::Path<SpaceRolePath>,
1019) -> Result<Envelope<RoleSummary>, SpaceError> {
1043 Extension(request_id): Extension<RequestId>,
1044 Path(path): Path<SpaceRolePath>,
1045) -> Result<AppResponseData<RoleSummary>, ApiError> {
10201046 let user_id = svc
1021 .require_session(&session, Some(request_id.as_str()))
1047 .policy
1048 .require_session(&session, Some(request_id.0.as_str()))
10221049 .await?;
10231050 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
10241051 let role = svc
1025 .get_role(user_id, space_id, path.role_id, Some(request_id.as_str()))
1052 .get_role(user_id, space_id, path.role_id, Some(request_id.0.as_str()))
10261053 .await?;
1027 Ok(Envelope::ok(role))
1054 Ok(AppResponseData::ok(role))
10281055}
10291056
10301057#[utoipa::path(
10311058 post,
1032 path = "/api/v1/spaces/{space_name}/roles",
1033 tag = "auth",
1059 path = "/spaces/{space_name}/roles",
1060 tag = "space",
10341061 operation_id = "space_role_create",
10351062 summary = "新建角色",
10361063 description = "需要 `role.create`。key 在组织内唯一,权限键必须在白名单内。",
10371064 params(("space_name" = String, Path, description = "空间 slug")),
10381065 request_body = CreateRoleBody,
10391066 responses(
1040 (status = 200, description = "角色已创建", body = Envelope<RoleSummary>),
1041 (status = 409, description = "key 已占用", body = Envelope<NoData>)
1067 (status = 200, description = "角色已创建", body = AppResponseData<RoleSummary>),
1068 (status = 409, description = "key 已占用", body = AppResponseData<NoData>)
10421069 )
10431070)]
10441071pub async fn create_role(
10451072 State(svc): State<SpaceService>,
10461073 session: Session,
1047 request_id: RequestId,
1048 path: web::Path<SpacePath>,
1049 body: web::Json<CreateRoleBody>,
1050) -> Result<Envelope<RoleSummary>, SpaceError> {
1074 Extension(request_id): Extension<RequestId>,
1075 Path(path): Path<SpacePath>,
1076 body: Json<CreateRoleBody>,
1077) -> Result<AppResponseData<RoleSummary>, ApiError> {
10511078 let user_id = svc
1052 .require_session(&session, Some(request_id.as_str()))
1079 .policy
1080 .require_session(&session, Some(request_id.0.as_str()))
10531081 .await?;
10541082 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
10551083 let role = svc
@@ -1057,16 +1085,16 @@
10571085 user_id,
10581086 space_id,
10591087 body.into_inner(),
1060 Some(request_id.as_str()),
1088 Some(request_id.0.as_str()),
10611089 )
10621090 .await?;
1063 Ok(Envelope::ok(role))
1091 Ok(AppResponseData::ok(role))
10641092}
10651093
10661094#[utoipa::path(
10671095 patch,
1068 path = "/api/v1/spaces/{space_name}/roles/{role_id}",
1069 tag = "auth",
1096 path = "/spaces/{space_name}/roles/{role_id}",
1097 tag = "space",
10701098 operation_id = "space_role_update",
10711099 summary = "修改角色",
10721100 description = "需要 `role.update`。OWNER 系统角色不允许改 rank 与 default_role。",
@@ -1076,19 +1104,20 @@
10761104 ),
10771105 request_body = UpdateRoleBody,
10781106 responses(
1079 (status = 200, description = "已更新", body = Envelope<RoleSummary>),
1080 (status = 409, description = "版本冲突", body = Envelope<NoData>)
1107 (status = 200, description = "已更新", body = AppResponseData<RoleSummary>),
1108 (status = 409, description = "版本冲突", body = AppResponseData<NoData>)
10811109 )
10821110)]
10831111pub async fn update_role(
10841112 State(svc): State<SpaceService>,
10851113 session: Session,
1086 request_id: RequestId,
1087 path: web::Path<SpaceRolePath>,
1088 body: web::Json<UpdateRoleBody>,
1089) -> Result<Envelope<RoleSummary>, SpaceError> {
1114 Extension(request_id): Extension<RequestId>,
1115 Path(path): Path<SpaceRolePath>,
1116 body: Json<UpdateRoleBody>,
1117) -> Result<AppResponseData<RoleSummary>, ApiError> {
10901118 let user_id = svc
1091 .require_session(&session, Some(request_id.as_str()))
1119 .policy
1120 .require_session(&session, Some(request_id.0.as_str()))
10921121 .await?;
10931122 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
10941123 let body = body.into_inner();
@@ -1103,16 +1132,16 @@
11031132 body.default_role,
11041133 body.self_assignable,
11051134 body.expected_version,
1106 Some(request_id.as_str()),
1135 Some(request_id.0.as_str()),
11071136 )
11081137 .await?;
1109 Ok(Envelope::ok(role))
1138 Ok(AppResponseData::ok(role))
11101139}
11111140
11121141#[utoipa::path(
11131142 delete,
1114 path = "/api/v1/spaces/{space_name}/roles/{role_id}",
1115 tag = "auth",
1143 path = "/spaces/{space_name}/roles/{role_id}",
1144 tag = "space",
11161145 operation_id = "space_role_delete",
11171146 summary = "删除角色",
11181147 description = "需要 `role.delete`。系统角色不可删;仍有成员绑定时返回 409。",
@@ -1121,30 +1150,31 @@
11211150 ("role_id" = Uuid, Path, description = "角色 id")
11221151 ),
11231152 responses(
1124 (status = 200, description = "已删除", body = Envelope<bool>),
1125 (status = 409, description = "系统角色或仍被绑定", body = Envelope<NoData>)
1153 (status = 200, description = "已删除", body = AppResponseData<bool>),
1154 (status = 409, description = "系统角色或仍被绑定", body = AppResponseData<NoData>)
11261155 )
11271156)]
11281157pub async fn delete_role(
11291158 State(svc): State<SpaceService>,
11301159 session: Session,
1131 request_id: RequestId,
1132 path: web::Path<SpaceRolePath>,
1133) -> Result<Envelope<bool>, SpaceError> {
1160 Extension(request_id): Extension<RequestId>,
1161 Path(path): Path<SpaceRolePath>,
1162) -> Result<AppResponseData<bool>, ApiError> {
11341163 let user_id = svc
1135 .require_session(&session, Some(request_id.as_str()))
1164 .policy
1165 .require_session(&session, Some(request_id.0.as_str()))
11361166 .await?;
11371167 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
11381168 let deleted = svc
1139 .delete_role(user_id, space_id, path.role_id, Some(request_id.as_str()))
1169 .delete_role(user_id, space_id, path.role_id, Some(request_id.0.as_str()))
11401170 .await?;
1141 Ok(Envelope::ok(deleted))
1171 Ok(AppResponseData::ok(deleted))
11421172}
11431173
11441174#[utoipa::path(
11451175 put,
1146 path = "/api/v1/spaces/{space_name}/roles/{role_id}/permissions",
1147 tag = "auth",
1176 path = "/spaces/{space_name}/roles/{role_id}/permissions",
1177 tag = "space",
11481178 operation_id = "space_role_permissions",
11491179 summary = "设置角色权限",
11501180 description = "整体替换语义,是角色权限的唯一写入口。需要 `role.permission.update`。",
@@ -1154,19 +1184,20 @@
11541184 ),
11551185 request_body = SetRolePermissionsBody,
11561186 responses(
1157 (status = 200, description = "已更新", body = Envelope<RoleSummary>),
1158 (status = 403, description = "权限不足或系统 OWNER 角色", body = Envelope<NoData>)
1187 (status = 200, description = "已更新", body = AppResponseData<RoleSummary>),
1188 (status = 403, description = "权限不足或系统 OWNER 角色", body = AppResponseData<NoData>)
11591189 )
11601190)]
11611191pub async fn set_role_permissions(
11621192 State(svc): State<SpaceService>,
11631193 session: Session,
1164 request_id: RequestId,
1165 path: web::Path<SpaceRolePath>,
1166 body: web::Json<SetRolePermissionsBody>,
1167) -> Result<Envelope<RoleSummary>, SpaceError> {
1194 Extension(request_id): Extension<RequestId>,
1195 Path(path): Path<SpaceRolePath>,
1196 body: Json<SetRolePermissionsBody>,
1197) -> Result<AppResponseData<RoleSummary>, ApiError> {
11681198 let user_id = svc
1169 .require_session(&session, Some(request_id.as_str()))
1199 .policy
1200 .require_session(&session, Some(request_id.0.as_str()))
11701201 .await?;
11711202 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
11721203 let role = svc
@@ -1175,10 +1206,10 @@
11751206 space_id,
11761207 path.role_id,
11771208 body.into_inner(),
1178 Some(request_id.as_str()),
1209 Some(request_id.0.as_str()),
11791210 )
11801211 .await?;
1181 Ok(Envelope::ok(role))
1212 Ok(AppResponseData::ok(role))
11821213}
11831214
11841215#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
@@ -1189,27 +1220,28 @@
11891220
11901221#[utoipa::path(
11911222 post,
1192 path = "/api/v1/spaces/{space_name}/roles/assign",
1193 tag = "auth",
1223 path = "/spaces/{space_name}/roles/assign",
1224 tag = "space",
11941225 operation_id = "space_role_assign",
11951226 summary = "绑定角色到成员",
11961227 description = "需要 `role.assign`。重复绑定幂等。",
11971228 params(("space_name" = String, Path, description = "空间 slug")),
11981229 request_body = AssignRoleBody,
11991230 responses(
1200 (status = 200, description = "已绑定", body = Envelope<Vec<String>>),
1201 (status = 404, description = "成员或角色不存在", body = Envelope<NoData>)
1231 (status = 200, description = "已绑定", body = AppResponseData<Vec<String>>),
1232 (status = 404, description = "成员或角色不存在", body = AppResponseData<NoData>)
12021233 )
12031234)]
12041235pub async fn assign_role(
12051236 State(svc): State<SpaceService>,
12061237 session: Session,
1207 request_id: RequestId,
1208 path: web::Path<SpacePath>,
1209 body: web::Json<AssignRoleBody>,
1210) -> Result<Envelope<Vec<String>>, SpaceError> {
1238 Extension(request_id): Extension<RequestId>,
1239 Path(path): Path<SpacePath>,
1240 body: Json<AssignRoleBody>,
1241) -> Result<AppResponseData<Vec<String>>, ApiError> {
12111242 let user_id = svc
1212 .require_session(&session, Some(request_id.as_str()))
1243 .policy
1244 .require_session(&session, Some(request_id.0.as_str()))
12131245 .await?;
12141246 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
12151247 let keys = svc
@@ -1218,35 +1250,36 @@
12181250 space_id,
12191251 body.member_id,
12201252 &body.role_key,
1221 Some(request_id.as_str()),
1253 Some(request_id.0.as_str()),
12221254 )
12231255 .await?;
1224 Ok(Envelope::ok(keys))
1256 Ok(AppResponseData::ok(keys))
12251257}
12261258
12271259#[utoipa::path(
12281260 post,
1229 path = "/api/v1/spaces/{space_name}/roles/unassign",
1230 tag = "auth",
1261 path = "/spaces/{space_name}/roles/unassign",
1262 tag = "space",
12311263 operation_id = "space_role_unassign",
12321264 summary = "解绑成员角色",
12331265 description = "需要 `role.assign`。系统角色不可解绑。",
12341266 params(("space_name" = String, Path, description = "空间 slug")),
12351267 request_body = AssignRoleBody,
12361268 responses(
1237 (status = 200, description = "已解绑", body = Envelope<Vec<String>>),
1238 (status = 403, description = "系统角色不可解绑", body = Envelope<NoData>)
1269 (status = 200, description = "已解绑", body = AppResponseData<Vec<String>>),
1270 (status = 403, description = "系统角色不可解绑", body = AppResponseData<NoData>)
12391271 )
12401272)]
12411273pub async fn unassign_role(
12421274 State(svc): State<SpaceService>,
12431275 session: Session,
1244 request_id: RequestId,
1245 path: web::Path<SpacePath>,
1246 body: web::Json<AssignRoleBody>,
1247) -> Result<Envelope<Vec<String>>, SpaceError> {
1276 Extension(request_id): Extension<RequestId>,
1277 Path(path): Path<SpacePath>,
1278 body: Json<AssignRoleBody>,
1279) -> Result<AppResponseData<Vec<String>>, ApiError> {
12481280 let user_id = svc
1249 .require_session(&session, Some(request_id.as_str()))
1281 .policy
1282 .require_session(&session, Some(request_id.0.as_str()))
12501283 .await?;
12511284 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
12521285 let keys = svc
@@ -1255,16 +1288,16 @@
12551288 space_id,
12561289 body.member_id,
12571290 &body.role_key,
1258 Some(request_id.as_str()),
1291 Some(request_id.0.as_str()),
12591292 )
12601293 .await?;
1261 Ok(Envelope::ok(keys))
1294 Ok(AppResponseData::ok(keys))
12621295}
12631296
12641297#[utoipa::path(
12651298 post,
1266 path = "/api/v1/spaces/{space_name}/roles/{role_id}/join",
1267 tag = "auth",
1299 path = "/spaces/{space_name}/roles/{role_id}/join",
1300 tag = "space",
12681301 operation_id = "space_role_join",
12691302 summary = "自助加入角色",
12701303 description = "只允许 `self_assignable = true` 的非内置角色。",
@@ -1273,30 +1306,31 @@
12731306 ("role_id" = Uuid, Path, description = "角色 id")
12741307 ),
12751308 responses(
1276 (status = 200, description = "已加入", body = Envelope<MemberSummary>),
1277 (status = 403, description = "角色不可自助加入或不是活跃成员", body = Envelope<NoData>)
1309 (status = 200, description = "已加入", body = AppResponseData<MemberSummary>),
1310 (status = 403, description = "角色不可自助加入或不是活跃成员", body = AppResponseData<NoData>)
12781311 )
12791312)]
12801313pub async fn join_role(
12811314 State(svc): State<SpaceService>,
12821315 session: Session,
1283 request_id: RequestId,
1284 path: web::Path<SpaceRolePath>,
1285) -> Result<Envelope<MemberSummary>, SpaceError> {
1316 Extension(request_id): Extension<RequestId>,
1317 Path(path): Path<SpaceRolePath>,
1318) -> Result<AppResponseData<MemberSummary>, ApiError> {
12861319 let user_id = svc
1287 .require_session(&session, Some(request_id.as_str()))
1320 .policy
1321 .require_session(&session, Some(request_id.0.as_str()))
12881322 .await?;
12891323 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
12901324 let member = svc
1291 .join_role(user_id, space_id, path.role_id, Some(request_id.as_str()))
1325 .join_role(user_id, space_id, path.role_id, Some(request_id.0.as_str()))
12921326 .await?;
1293 Ok(Envelope::ok(member))
1327 Ok(AppResponseData::ok(member))
12941328}
12951329
12961330#[utoipa::path(
12971331 delete,
1298 path = "/api/v1/spaces/{space_name}/roles/{role_id}/join",
1299 tag = "auth",
1332 path = "/spaces/{space_name}/roles/{role_id}/join",
1333 tag = "space",
13001334 operation_id = "space_role_leave",
13011335 summary = "自助退出角色",
13021336 description = "只允许 `self_assignable = true` 的非内置角色;重复退出视为成功。",
@@ -1305,49 +1339,51 @@
13051339 ("role_id" = Uuid, Path, description = "角色 id")
13061340 ),
13071341 responses(
1308 (status = 200, description = "已退出", body = Envelope<MemberSummary>),
1309 (status = 403, description = "角色不可自助加入或不是活跃成员", body = Envelope<NoData>)
1342 (status = 200, description = "已退出", body = AppResponseData<MemberSummary>),
1343 (status = 403, description = "角色不可自助加入或不是活跃成员", body = AppResponseData<NoData>)
13101344 )
13111345)]
13121346pub async fn leave_role(
13131347 State(svc): State<SpaceService>,
13141348 session: Session,
1315 request_id: RequestId,
1316 path: web::Path<SpaceRolePath>,
1317) -> Result<Envelope<MemberSummary>, SpaceError> {
1349 Extension(request_id): Extension<RequestId>,
1350 Path(path): Path<SpaceRolePath>,
1351) -> Result<AppResponseData<MemberSummary>, ApiError> {
13181352 let user_id = svc
1319 .require_session(&session, Some(request_id.as_str()))
1353 .policy
1354 .require_session(&session, Some(request_id.0.as_str()))
13201355 .await?;
13211356 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
13221357 let member = svc
1323 .leave_role(user_id, space_id, path.role_id, Some(request_id.as_str()))
1358 .leave_role(user_id, space_id, path.role_id, Some(request_id.0.as_str()))
13241359 .await?;
1325 Ok(Envelope::ok(member))
1360 Ok(AppResponseData::ok(member))
13261361}
13271362
13281363#[utoipa::path(
13291364 post,
1330 path = "/api/v1/spaces/{space_name}/permissions/check",
1331 tag = "auth",
1365 path = "/spaces/{space_name}/permissions/check",
1366 tag = "space",
13321367 operation_id = "space_permission_check",
13331368 summary = "单权限判定",
13341369 description = "拒绝不作为错误返回:`allowed=false` 附 `reason` 与 `matched_by`,便于调用方聚合。",
13351370 params(("space_name" = String, Path, description = "空间 slug")),
13361371 request_body = CheckPermissionBody,
13371372 responses(
1338 (status = 200, description = "判定结果", body = Envelope<PermissionCheck>),
1339 (status = 403, description = "查询他人权限但不是平台管理员", body = Envelope<NoData>)
1373 (status = 200, description = "判定结果", body = AppResponseData<PermissionCheck>),
1374 (status = 403, description = "查询他人权限但不是平台管理员", body = AppResponseData<NoData>)
13401375 )
13411376)]
13421377pub async fn check_permission(
13431378 State(svc): State<SpaceService>,
13441379 session: Session,
1345 request_id: RequestId,
1346 path: web::Path<SpacePath>,
1347 body: web::Json<CheckPermissionBody>,
1348) -> Result<Envelope<PermissionCheck>, SpaceError> {
1380 Extension(request_id): Extension<RequestId>,
1381 Path(path): Path<SpacePath>,
1382 body: Json<CheckPermissionBody>,
1383) -> Result<AppResponseData<PermissionCheck>, ApiError> {
13491384 let user_id = svc
1350 .require_session(&session, Some(request_id.as_str()))
1385 .policy
1386 .require_session(&session, Some(request_id.0.as_str()))
13511387 .await?;
13521388 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
13531389 let body = body.into_inner();
@@ -1357,35 +1393,36 @@
13571393 space_id,
13581394 body.user_id.unwrap_or(0),
13591395 &body.permission,
1360 Some(request_id.as_str()),
1396 Some(request_id.0.as_str()),
13611397 )
13621398 .await?;
1363 Ok(Envelope::ok(check))
1399 Ok(AppResponseData::ok(check))
13641400}
13651401
13661402#[utoipa::path(
13671403 post,
1368 path = "/api/v1/spaces/{space_name}/invites",
1369 tag = "auth",
1404 path = "/spaces/{space_name}/invites",
1405 tag = "space",
13701406 operation_id = "space_invite_create",
13711407 summary = "创建邀请",
13721408 description = "需要 `invite.create`。明文 token 只在创建响应里返回一次,之后只存 sha256。",
13731409 params(("space_name" = String, Path, description = "空间 slug")),
13741410 request_body = CreateInviteBody,
13751411 responses(
1376 (status = 200, description = "邀请已创建", body = Envelope<CreateInviteResponse>),
1377 (status = 403, description = "权限不足", body = Envelope<NoData>)
1412 (status = 200, description = "邀请已创建", body = AppResponseData<CreateInviteResponse>),
1413 (status = 403, description = "权限不足", body = AppResponseData<NoData>)
13781414 )
13791415)]
13801416pub async fn create_invite(
13811417 State(svc): State<SpaceService>,
13821418 session: Session,
1383 request_id: RequestId,
1384 path: web::Path<SpacePath>,
1385 body: web::Json<CreateInviteBody>,
1386) -> Result<Envelope<CreateInviteResponse>, SpaceError> {
1419 Extension(request_id): Extension<RequestId>,
1420 Path(path): Path<SpacePath>,
1421 body: Json<CreateInviteBody>,
1422) -> Result<AppResponseData<CreateInviteResponse>, ApiError> {
13871423 let user_id = svc
1388 .require_session(&session, Some(request_id.as_str()))
1424 .policy
1425 .require_session(&session, Some(request_id.0.as_str()))
13891426 .await?;
13901427 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
13911428 let body = body.into_inner();
@@ -1397,16 +1434,16 @@
13971434 body.target_user_id,
13981435 body.max_uses,
13991436 body.ttl_secs,
1400 Some(request_id.as_str()),
1437 Some(request_id.0.as_str()),
14011438 )
14021439 .await?;
1403 Ok(Envelope::ok(CreateInviteResponse { invite, token }))
1440 Ok(AppResponseData::ok(CreateInviteResponse { invite, token }))
14041441}
14051442
14061443#[utoipa::path(
14071444 get,
1408 path = "/api/v1/spaces/{space_name}/invites",
1409 tag = "auth",
1445 path = "/spaces/{space_name}/invites",
1446 tag = "space",
14101447 operation_id = "space_invite_list",
14111448 summary = "列示邀请",
14121449 description = "需要 `invite.view`。不含 token 明文。",
@@ -1415,22 +1452,22 @@
14151452 ("include_revoked" = Option<bool>, Query, description = "包含已撤销邀请")
14161453 ),
14171454 responses(
1418 (status = 200, description = "邀请列表", body = Envelope<Vec<InviteSummary>>),
1419 (status = 403, description = "权限不足", body = Envelope<NoData>)
1455 (status = 200, description = "邀请列表", body = AppResponseData<Vec<InviteSummary>>),
1456 (status = 403, description = "权限不足", body = AppResponseData<NoData>)
14201457 )
14211458)]
14221459pub async fn list_invites(
14231460 State(svc): State<SpaceService>,
14241461 session: Session,
1425 request_id: RequestId,
1426 path: web::Path<SpacePath>,
1427 query: web::Query<OrgListQuery>,
1428) -> Result<Envelope<Vec<InviteSummary>>, SpaceError> {
1462 Extension(request_id): Extension<RequestId>,
1463 Path(path): Path<SpacePath>,
1464 Query(query): Query<OrgListQuery>,
1465) -> Result<AppResponseData<Vec<InviteSummary>>, ApiError> {
14291466 let user_id = svc
1430 .require_session(&session, Some(request_id.as_str()))
1467 .policy
1468 .require_session(&session, Some(request_id.0.as_str()))
14311469 .await?;
14321470 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
1433 let query = query.into_inner();
14341471 let rows = svc
14351472 .admin_list_invites(
14361473 user_id,
@@ -1438,16 +1475,16 @@
14381475 query.include_archived.unwrap_or(false),
14391476 query.limit(),
14401477 query.offset(),
1441 Some(request_id.as_str()),
1478 Some(request_id.0.as_str()),
14421479 )
14431480 .await?;
1444 Ok(Envelope::ok(rows))
1481 Ok(AppResponseData::ok(rows))
14451482}
14461483
14471484#[utoipa::path(
14481485 delete,
1449 path = "/api/v1/spaces/{space_name}/invites/{invite_id}",
1450 tag = "auth",
1486 path = "/spaces/{space_name}/invites/{invite_id}",
1487 tag = "space",
14511488 operation_id = "space_invite_revoke",
14521489 summary = "撤销邀请",
14531490 description = "需要 `invite.revoke`。已撤销的邀请再次撤销返回 false。",
@@ -1456,18 +1493,19 @@
14561493 ("invite_id" = Uuid, Path, description = "邀请 id")
14571494 ),
14581495 responses(
1459 (status = 200, description = "已撤销", body = Envelope<bool>),
1460 (status = 404, description = "邀请不存在", body = Envelope<NoData>)
1496 (status = 200, description = "已撤销", body = AppResponseData<bool>),
1497 (status = 404, description = "邀请不存在", body = AppResponseData<NoData>)
14611498 )
14621499)]
14631500pub async fn revoke_invite(
14641501 State(svc): State<SpaceService>,
14651502 session: Session,
1466 request_id: RequestId,
1467 path: web::Path<SpaceInvitePath>,
1468) -> Result<Envelope<bool>, SpaceError> {
1503 Extension(request_id): Extension<RequestId>,
1504 Path(path): Path<SpaceInvitePath>,
1505) -> Result<AppResponseData<bool>, ApiError> {
14691506 let user_id = svc
1470 .require_session(&session, Some(request_id.as_str()))
1507 .policy
1508 .require_session(&session, Some(request_id.0.as_str()))
14711509 .await?;
14721510 let space_id = svc.space_id_by_name(user_id, &path.space_name).await?;
14731511 let revoked = svc
@@ -1476,39 +1514,40 @@
14761514 space_id,
14771515 path.invite_id,
14781516 None,
1479 Some(request_id.as_str()),
1517 Some(request_id.0.as_str()),
14801518 )
14811519 .await?;
1482 Ok(Envelope::ok(revoked))
1520 Ok(AppResponseData::ok(revoked))
14831521}
14841522
14851523#[utoipa::path(
14861524 post,
1487 path = "/api/v1/spaces/invites/redeem",
1488 tag = "auth",
1525 path = "/spaces/invites/redeem",
1526 tag = "space",
14891527 operation_id = "space_invite_redeem",
14901528 summary = "兑换邀请",
14911529 description = "uses 递增与兑换记录写入同事务。定向邀请只能由目标用户兑换。",
14921530 request_body = RedeemInviteBody,
14931531 responses(
1494 (status = 200, description = "已加入", body = Envelope<RedeemInviteResponse>),
1495 (status = 403, description = "定向邀请目标不匹配", body = Envelope<NoData>),
1496 (status = 409, description = "邀请已用尽", body = Envelope<NoData>)
1532 (status = 200, description = "已加入", body = AppResponseData<RedeemInviteResponse>),
1533 (status = 403, description = "定向邀请目标不匹配", body = AppResponseData<NoData>),
1534 (status = 409, description = "邀请已用尽", body = AppResponseData<NoData>)
14971535 )
14981536)]
14991537pub async fn redeem_invite(
15001538 State(svc): State<SpaceService>,
15011539 session: Session,
1502 request_id: RequestId,
1503 body: web::Json<RedeemInviteBody>,
1504) -> Result<Envelope<RedeemInviteResponse>, SpaceError> {
1540 Extension(request_id): Extension<RequestId>,
1541 body: Json<RedeemInviteBody>,
1542) -> Result<AppResponseData<RedeemInviteResponse>, ApiError> {
15051543 let user_id = svc
1506 .require_session(&session, Some(request_id.as_str()))
1544 .policy
1545 .require_session(&session, Some(request_id.0.as_str()))
15071546 .await?;
15081547 let (organization_id, member_id, joined) = svc
1509 .redeem_invite(user_id, &body.token, Some(request_id.as_str()))
1548 .redeem_invite(user_id, &body.token, Some(request_id.0.as_str()))
15101549 .await?;
1511 Ok(Envelope::ok(RedeemInviteResponse {
1550 Ok(AppResponseData::ok(RedeemInviteResponse {
15121551 organization_id,
15131552 member_id,
15141553 joined,
Modified lib/space/src/handlers/upload_space_image.rs View lib/space/src/handlers/upload_space_image.rs
@@ -42,6 +42,7 @@
4242 .unwrap_or("application/octet-stream")
4343 .to_string();
4444 let img = service
45 .images
4546 .upload_space_image(
4647 uid,
4748 &path.space_name,
Modified lib/space/src/services/dir.rs View lib/space/src/services/dir.rs
@@ -165,7 +165,7 @@
165165 async fn dir_in_space(&self, space_id: Uuid, dir_id: Uuid) -> Result<dirs::Model, SpaceError> {
166166 dirs::Entity::find_by_id(dir_id)
167167 .filter(dirs::Column::SpaceId.eq(space_id))
168 .one(self.db().read())
168 .one(self.access.database.read())
169169 .await?
170170 .ok_or(SpaceError::NotFound)
171171 }
@@ -177,7 +177,7 @@
177177 space_name: &str,
178178 cmd: CreateDirCmd,
179179 ) -> Result<DirNodeDto, SpaceError> {
180 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
180 let space = lookup::lookup_space_by_name(self, space_name).await?;
181181 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
182182
183183 let name = cmd.name.trim();
@@ -210,7 +210,7 @@
210210 .filter(dirs::Column::SpaceId.eq(space.id))
211211 .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
212212 .order_by_desc(dirs::Column::Position)
213 .one(self.db().read())
213 .one(self.access.database.read())
214214 .await?;
215215 max_node.map(|n| n.position + 1).unwrap_or(0)
216216 }
@@ -236,7 +236,7 @@
236236 created_by_uid: Set(caller),
237237 };
238238
239 let inserted = row.insert(self.db().write()).await?;
239 let inserted = row.insert(self.access.database.write()).await?;
240240 Ok(DirNodeDto::from(inserted))
241241 }
242242
@@ -246,17 +246,17 @@
246246 space_name: &str,
247247 parent_id: Option<Uuid>,
248248 ) -> Result<Vec<DirNodeDto>, SpaceError> {
249 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
250 let perms = effective_permissions(&self.state, &space, caller).await?;
249 let space = lookup::lookup_space_by_name(self, space_name).await?;
250 let perms = effective_permissions(self, &space, caller).await?;
251251
252252 let can_manage = perms.has(MANAGE_CHANNELS);
253253
254254 if parent_id.is_none() {
255255 let total_nodes = dirs::Entity::find()
256256 .filter(dirs::Column::SpaceId.eq(space.id))
257 .count(self.db().read())
257 .count(self.access.database.read())
258258 .await? as u64;
259 let max_nodes = self.config().space.max_dir_nodes;
259 let max_nodes = self.policy.settings.space.max_dir_nodes;
260260 if total_nodes > max_nodes {
261261 return Err(SpaceError::BadRequest(format!(
262262 "directory tree has {total_nodes} nodes, exceeds max_dir_nodes {max_nodes}; query with parent_id to load one level"
@@ -270,7 +270,7 @@
270270 if let Some(pid) = parent_id {
271271 sel = sel.filter(dirs::Column::ParentDirId.eq(Some(pid)));
272272 }
273 let nodes = sel.all(self.db().read()).await?;
273 let nodes = sel.all(self.access.database.read()).await?;
274274
275275 if nodes.is_empty() {
276276 return Ok(Vec::new());
@@ -280,18 +280,18 @@
280280
281281 let group_ows = dir_overwrite_group::Entity::find()
282282 .filter(dir_overwrite_group::Column::DirId.is_in(node_ids.clone()))
283 .all(self.db().read())
283 .all(self.access.database.read())
284284 .await?;
285285
286286 let user_ows = dir_overwrite_users::Entity::find()
287287 .filter(dir_overwrite_users::Column::DirId.is_in(node_ids.clone()))
288 .all(self.db().read())
288 .all(self.access.database.read())
289289 .await?;
290290
291291 let my_member_groups = space_member_groups::Entity::find()
292292 .filter(space_member_groups::Column::SpaceId.eq(space.id))
293293 .filter(space_member_groups::Column::UserId.eq(caller))
294 .all(self.db().read())
294 .all(self.access.database.read())
295295 .await?;
296296 let my_group_ids: HashSet<Uuid> =
297297 my_member_groups.into_iter().map(|mg| mg.group_id).collect();
@@ -409,7 +409,7 @@
409409 dir_id: Uuid,
410410 cmd: UpdateDirCmd,
411411 ) -> Result<DirNodeDto, SpaceError> {
412 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
412 let space = lookup::lookup_space_by_name(self, space_name).await?;
413413 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
414414
415415 let node: dirs::Model = self.dir_in_space(space.id, dir_id).await?;
@@ -425,7 +425,7 @@
425425 let mut am: dirs::ActiveModel = node.into();
426426 am.name = Set(name.to_string());
427427 am.updated_at = Set(now);
428 let updated = am.update(self.db().write()).await?;
428 let updated = am.update(self.access.database.write()).await?;
429429
430430 Ok(DirNodeDto::from(updated))
431431 }
@@ -437,7 +437,7 @@
437437 dir_id: Uuid,
438438 cmd: MoveDirCmd,
439439 ) -> Result<DirNodeDto, SpaceError> {
440 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
440 let space = lookup::lookup_space_by_name(self, space_name).await?;
441441 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
442442
443443 let node = self.dir_in_space(space.id, dir_id).await?;
@@ -467,7 +467,7 @@
467467 }
468468 let next = dirs::Entity::find_by_id(curr_p)
469469 .filter(dirs::Column::SpaceId.eq(space.id))
470 .one(self.db().read())
470 .one(self.access.database.read())
471471 .await?;
472472 curr = next.and_then(|n| n.parent_dir_id);
473473 }
@@ -490,7 +490,7 @@
490490 .filter(dirs::Column::SpaceId.eq(space.id))
491491 .filter(dirs::Column::ParentDirId.eq(new_parent_id))
492492 .order_by_desc(dirs::Column::Position)
493 .one(self.db().read())
493 .one(self.access.database.read())
494494 .await?;
495495 max_node.map(|n| n.position + 1).unwrap_or(0)
496496 }
@@ -502,7 +502,7 @@
502502 am.parent_dir_id = Set(new_parent_id);
503503 am.position = Set(new_position);
504504 am.updated_at = Set(now);
505 let updated = am.update(self.db().write()).await?;
505 let updated = am.update(self.access.database.write()).await?;
506506
507507 Ok(DirNodeDto::from(updated))
508508 }
@@ -512,14 +512,14 @@
512512 caller: i64,
513513 space_name: &str,
514514 ) -> Result<RepairDirResp, SpaceError> {
515 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
515 let space = lookup::lookup_space_by_name(self, space_name).await?;
516516 if space.owner_user_id != caller {
517517 return Err(SpaceError::Forbidden);
518518 }
519519
520520 let nodes = dirs::Entity::find()
521521 .filter(dirs::Column::SpaceId.eq(space.id))
522 .all(self.db().read())
522 .all(self.access.database.read())
523523 .await?;
524524
525525 let scanned = nodes.len() as i64;
@@ -574,8 +574,9 @@
574574 let fixed = fixed_nodes.len() as i64;
575575
576576 if !fixed_nodes.is_empty() {
577 self.db()
578 .primary()
577 self.access
578 .database
579 .write()
579580 .transaction::<_, (), SpaceError>(|txn| {
580581 Box::pin(async move {
581582 for fid in fixed_nodes {
@@ -602,13 +603,13 @@
602603 space_name: &str,
603604 dir_id: Uuid,
604605 ) -> Result<Vec<GroupOverwriteDto>, SpaceError> {
605 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
606 let space = lookup::lookup_space_by_name(self, space_name).await?;
606607 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
607608 self.dir_in_space(space.id, dir_id).await?;
608609
609610 let rows = dir_overwrite_group::Entity::find()
610611 .filter(dir_overwrite_group::Column::DirId.eq(dir_id))
611 .all(self.db().read())
612 .all(self.access.database.read())
612613 .await?;
613614
614615 Ok(rows
@@ -631,13 +632,13 @@
631632 group_id: Uuid,
632633 cmd: SetOverwriteCmd,
633634 ) -> Result<GroupOverwriteDto, SpaceError> {
634 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
635 let space = lookup::lookup_space_by_name(self, space_name).await?;
635636 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
636637 self.dir_in_space(space.id, dir_id).await?;
637638
638639 space_groups::Entity::find_by_id(group_id)
639640 .filter(space_groups::Column::SpaceId.eq(space.id))
640 .one(self.db().read())
641 .one(self.access.database.read())
641642 .await?
642643 .ok_or(SpaceError::NotFound)?;
643644
@@ -648,14 +649,14 @@
648649 }
649650
650651 let existing = dir_overwrite_group::Entity::find_by_id((dir_id, group_id))
651 .one(self.db().read())
652 .one(self.access.database.read())
652653 .await?;
653654
654655 if let Some(row) = existing {
655656 let mut am: dir_overwrite_group::ActiveModel = row.into();
656657 am.allow_mask = Set(cmd.allow_mask);
657658 am.deny_mask = Set(cmd.deny_mask);
658 let updated = am.update(self.db().write()).await?;
659 let updated = am.update(self.access.database.write()).await?;
659660 Ok(GroupOverwriteDto {
660661 dir_id: updated.dir_id,
661662 group_id: updated.group_id,
@@ -669,7 +670,7 @@
669670 allow_mask: Set(cmd.allow_mask),
670671 deny_mask: Set(cmd.deny_mask),
671672 };
672 let inserted = am.insert(self.db().write()).await?;
673 let inserted = am.insert(self.access.database.write()).await?;
673674 Ok(GroupOverwriteDto {
674675 dir_id: inserted.dir_id,
675676 group_id: inserted.group_id,
@@ -687,14 +688,14 @@
687688 dir_id: Uuid,
688689 group_id: Uuid,
689690 ) -> Result<(), SpaceError> {
690 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
691 let space = lookup::lookup_space_by_name(self, space_name).await?;
691692 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
692693 self.dir_in_space(space.id, dir_id).await?;
693694
694695 let res = dir_overwrite_group::Entity::delete_many()
695696 .filter(dir_overwrite_group::Column::DirId.eq(dir_id))
696697 .filter(dir_overwrite_group::Column::GroupId.eq(group_id))
697 .exec(self.db().write())
698 .exec(self.access.database.write())
698699 .await?;
699700
700701 if res.rows_affected == 0 {
@@ -709,13 +710,13 @@
709710 space_name: &str,
710711 dir_id: Uuid,
711712 ) -> Result<Vec<UserOverwriteDto>, SpaceError> {
712 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
713 let space = lookup::lookup_space_by_name(self, space_name).await?;
713714 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
714715 self.dir_in_space(space.id, dir_id).await?;
715716
716717 let rows = dir_overwrite_users::Entity::find()
717718 .filter(dir_overwrite_users::Column::DirId.eq(dir_id))
718 .all(self.db().read())
719 .all(self.access.database.read())
719720 .await?;
720721
721722 let user_ids: Vec<i64> = rows.iter().map(|r| r.user_id).collect();
@@ -745,12 +746,12 @@
745746 user_id: i64,
746747 cmd: SetOverwriteCmd,
747748 ) -> Result<UserOverwriteDto, SpaceError> {
748 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
749 let space = lookup::lookup_space_by_name(self, space_name).await?;
749750 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
750751 self.dir_in_space(space.id, dir_id).await?;
751752
752753 if space.owner_user_id != user_id {
753 authority::require_member(&self.state, space.id, user_id)
754 authority::require_member(self, space.id, user_id)
754755 .await
755756 .map_err(|_| {
756757 SpaceError::BadRequest(
@@ -766,14 +767,14 @@
766767 }
767768
768769 let existing = dir_overwrite_users::Entity::find_by_id((dir_id, user_id))
769 .one(self.db().read())
770 .one(self.access.database.read())
770771 .await?;
771772
772773 let (final_allow, final_deny) = if let Some(row) = existing {
773774 let mut am: dir_overwrite_users::ActiveModel = row.into();
774775 am.allow_mask = Set(cmd.allow_mask);
775776 am.deny_mask = Set(cmd.deny_mask);
776 let updated = am.update(self.db().write()).await?;
777 let updated = am.update(self.access.database.write()).await?;
777778 (updated.allow_mask, updated.deny_mask)
778779 } else {
779780 let am = dir_overwrite_users::ActiveModel {
@@ -782,7 +783,7 @@
782783 allow_mask: Set(cmd.allow_mask),
783784 deny_mask: Set(cmd.deny_mask),
784785 };
785 let inserted = am.insert(self.db().write()).await?;
786 let inserted = am.insert(self.access.database.write()).await?;
786787 (inserted.allow_mask, inserted.deny_mask)
787788 };
788789
@@ -806,14 +807,14 @@
806807 dir_id: Uuid,
807808 user_id: i64,
808809 ) -> Result<(), SpaceError> {
809 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
810 let space = lookup::lookup_space_by_name(self, space_name).await?;
810811 require_perm(self, &space, caller, MANAGE_OVERWRITES).await?;
811812 self.dir_in_space(space.id, dir_id).await?;
812813
813814 let res = dir_overwrite_users::Entity::delete_many()
814815 .filter(dir_overwrite_users::Column::DirId.eq(dir_id))
815816 .filter(dir_overwrite_users::Column::UserId.eq(user_id))
816 .exec(self.db().write())
817 .exec(self.access.database.write())
817818 .await?;
818819
819820 if res.rows_affected == 0 {
Modified lib/space/src/services/groups.rs View lib/space/src/services/groups.rs
@@ -163,7 +163,7 @@
163163 ) -> Result<String, SpaceError> {
164164 space_roles::Entity::find_by_id(group_id)
165165 .filter(space_roles::Column::OrganizationId.eq(organization_id))
166 .one(self.db().read())
166 .one(self.access.database.read())
167167 .await?
168168 .map(|row| row.key)
169169 .ok_or(SpaceError::SpaceRoleNotFound)
@@ -176,11 +176,11 @@
176176 space_name: &str,
177177 cmd: CreateGroupCmd,
178178 ) -> Result<GroupDto, SpaceError> {
179 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
179 let space = lookup::lookup_space_by_name(self, space_name).await?;
180180 require_perm(self, &space, caller, MANAGE_ROLES).await?;
181181
182182 let name = cmd.name.trim();
183 if name.is_empty() || name.chars().count() > self.config().space.max_group_name_len {
183 if name.is_empty() || name.chars().count() > self.policy.settings.space.max_group_name_len {
184184 return Err(SpaceError::BadRequest(
185185 "group name is empty or too long".to_string(),
186186 ));
@@ -189,7 +189,7 @@
189189 let existing = space_groups::Entity::find()
190190 .filter(space_groups::Column::SpaceId.eq(space.id))
191191 .filter(space_groups::Column::Name.eq(name))
192 .one(self.db().read())
192 .one(self.access.database.read())
193193 .await?;
194194 if existing.is_some() {
195195 return Err(SpaceError::Conflict(format!(
@@ -208,7 +208,7 @@
208208 description: None,
209209 rank: cmd.position.unwrap_or(0) as i32,
210210 default_role: false,
211 self_assignable: self.config().space.allow_self_join_groups,
211 self_assignable: self.policy.settings.space.allow_self_join_groups,
212212 permissions: allow_grants(org_permissions(&permissions)),
213213 },
214214 None,
@@ -234,8 +234,9 @@
234234 let permissions = cmd.permissions;
235235 let perms_clone = permissions.clone();
236236 let inserted = self
237 .db()
238 .primary()
237 .access
238 .database
239 .write()
239240 .transaction::<_, space_groups::Model, SpaceError>(|txn| {
240241 Box::pin(async move {
241242 let g = group_row.insert(txn).await?;
@@ -270,14 +271,14 @@
270271 space_name: &str,
271272 group_id: Uuid,
272273 ) -> Result<GroupDto, SpaceError> {
273 let space = lookup::lookup_space_by_name(self, caller.unwrap_or(0), space_name).await?;
274 let space = lookup::lookup_space_by_name(self, space_name).await?;
274275 if !authority::space_is_active(&space) {
275276 return Err(SpaceError::NotFound);
276277 }
277278
278279 let group = space_groups::Entity::find_by_id(group_id)
279280 .filter(space_groups::Column::SpaceId.eq(space.id))
280 .one(self.db().read())
281 .one(self.access.database.read())
281282 .await?
282283 .ok_or(SpaceError::NotFound)?;
283284
@@ -294,7 +295,7 @@
294295
295296 let perms = space_group_permissions::Entity::find()
296297 .filter(space_group_permissions::Column::GroupId.eq(group.id))
297 .all(self.db().read())
298 .all(self.access.database.read())
298299 .await?;
299300 let permissions = perms.into_iter().map(|p| p.permission).collect();
300301
@@ -325,12 +326,12 @@
325326 group_id: Uuid,
326327 cmd: UpdateGroupCmd,
327328 ) -> Result<GroupDto, SpaceError> {
328 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
329 let space = lookup::lookup_space_by_name(self, space_name).await?;
329330 require_perm(self, &space, caller, MANAGE_ROLES).await?;
330331
331332 let group = space_groups::Entity::find_by_id(group_id)
332333 .filter(space_groups::Column::SpaceId.eq(space.id))
333 .one(self.db().read())
334 .one(self.access.database.read())
334335 .await?
335336 .ok_or(SpaceError::NotFound)?;
336337
@@ -338,7 +339,9 @@
338339
339340 if let Some(new_name) = cmd.name {
340341 let name = new_name.trim();
341 if name.is_empty() || name.chars().count() > self.config().space.max_group_name_len {
342 if name.is_empty()
343 || name.chars().count() > self.policy.settings.space.max_group_name_len
344 {
342345 return Err(SpaceError::BadRequest(
343346 "group name is empty or too long".to_string(),
344347 ));
@@ -347,7 +350,7 @@
347350 .filter(space_groups::Column::SpaceId.eq(space.id))
348351 .filter(space_groups::Column::Name.eq(name))
349352 .filter(space_groups::Column::Id.ne(group_id))
350 .one(self.db().read())
353 .one(self.access.database.read())
351354 .await?;
352355 if existing.is_some() {
353356 return Err(SpaceError::Conflict(format!(
@@ -415,8 +418,9 @@
415418 let perms_to_update = cmd.permissions.clone();
416419
417420 let updated = self
418 .db()
419 .primary()
421 .access
422 .database
423 .write()
420424 .transaction::<_, space_groups::Model, SpaceError>(|txn| {
421425 Box::pin(async move {
422426 let g = am.update(txn).await?;
@@ -430,7 +434,7 @@
430434
431435 let perms = space_group_permissions::Entity::find()
432436 .filter(space_group_permissions::Column::GroupId.eq(updated.id))
433 .all(self.db().read())
437 .all(self.access.database.read())
434438 .await?;
435439 let permissions = perms.into_iter().map(|p| p.permission).collect();
436440
@@ -460,19 +464,20 @@
460464 space_name: &str,
461465 group_id: Uuid,
462466 ) -> Result<(), SpaceError> {
463 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
467 let space = lookup::lookup_space_by_name(self, space_name).await?;
464468 require_perm(self, &space, caller, MANAGE_ROLES).await?;
465469
466470 let group = space_groups::Entity::find_by_id(group_id)
467471 .filter(space_groups::Column::SpaceId.eq(space.id))
468 .one(self.db().read())
472 .one(self.access.database.read())
469473 .await?
470474 .ok_or(SpaceError::NotFound)?;
471475
472476 self.delete_role(caller, space.id, group.id, None).await?;
473477
474 self.db()
475 .primary()
478 self.access
479 .database
480 .write()
476481 .transaction::<_, (), SpaceError>(|txn| {
477482 Box::pin(async move {
478483 space_member_groups::Entity::delete_many()
@@ -503,25 +508,25 @@
503508 space_name: &str,
504509 q: PageQuery,
505510 ) -> Result<GroupPage, SpaceError> {
506 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
511 let space = lookup::lookup_space_by_name(self, space_name).await?;
507512 require_perm(self, &space, caller, MANAGE_ROLES).await?;
508513
509514 let (limit, offset) = export::pagination::page_bounds(
510515 q.limit,
511516 q.offset,
512 self.config().space.default_pagination,
513 self.config().space.max_pagination,
517 self.policy.settings.space.default_pagination,
518 self.policy.settings.space.max_pagination,
514519 );
515520
516521 let select = space_groups::Entity::find()
517522 .filter(space_groups::Column::SpaceId.eq(space.id))
518523 .order_by_asc(space_groups::Column::Position);
519524
520 let total = select.clone().count(self.db().read()).await? as i64;
525 let total = select.clone().count(self.access.database.read()).await? as i64;
521526 let rows = select
522527 .limit(limit)
523528 .offset(offset)
524 .all(self.db().read())
529 .all(self.access.database.read())
525530 .await?;
526531
527532 let group_ids: Vec<Uuid> = rows.iter().map(|g| g.id).collect();
@@ -529,7 +534,7 @@
529534
530535 let all_perms = space_group_permissions::Entity::find()
531536 .filter(space_group_permissions::Column::GroupId.is_in(group_ids))
532 .all(self.db().read())
537 .all(self.access.database.read())
533538 .await?;
534539
535540 let mut perms_map: HashMap<Uuid, Vec<String>> = HashMap::new();
@@ -577,16 +582,16 @@
577582 uid: i64,
578583 cmd: AssignGroupCmd,
579584 ) -> Result<(), SpaceError> {
580 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
585 let space = lookup::lookup_space_by_name(self, space_name).await?;
581586 require_perm(self, &space, caller, MANAGE_ROLES).await?;
582587
583588 space_groups::Entity::find_by_id(cmd.group_id)
584589 .filter(space_groups::Column::SpaceId.eq(space.id))
585 .one(self.db().read())
590 .one(self.access.database.read())
586591 .await?
587592 .ok_or(SpaceError::NotFound)?;
588593
589 let member = find_active(self.db().read(), space.id, uid)
594 let member = find_active(self.access.database.read(), space.id, uid)
590595 .await?
591596 .ok_or(SpaceError::NotFound)?;
592597 let role_key = self.group_role_key(space.id, cmd.group_id).await?;
@@ -600,7 +605,7 @@
600605 group_id: Set(cmd.group_id),
601606 created_at: Set(now),
602607 };
603 row.insert(self.db().write()).await?;
608 row.insert(self.access.database.write()).await?;
604609 Ok(())
605610 }
606611
@@ -612,10 +617,10 @@
612617 uid: i64,
613618 group_id: Uuid,
614619 ) -> Result<(), SpaceError> {
615 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
620 let space = lookup::lookup_space_by_name(self, space_name).await?;
616621 require_perm(self, &space, caller, MANAGE_ROLES).await?;
617622
618 let member = find_active(self.db().read(), space.id, uid)
623 let member = find_active(self.access.database.read(), space.id, uid)
619624 .await?
620625 .ok_or(SpaceError::NotFound)?;
621626 let role_key = self.group_role_key(space.id, group_id).await?;
@@ -626,7 +631,7 @@
626631 .filter(space_member_groups::Column::SpaceId.eq(space.id))
627632 .filter(space_member_groups::Column::UserId.eq(uid))
628633 .filter(space_member_groups::Column::GroupId.eq(group_id))
629 .exec(self.db().write())
634 .exec(self.access.database.write())
630635 .await?;
631636 Ok(())
632637 }
@@ -638,27 +643,27 @@
638643 space_name: &str,
639644 group_id: Uuid,
640645 ) -> Result<(), SpaceError> {
641 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
642 effective_permissions(&self.state, &space, caller).await?;
646 let space = lookup::lookup_space_by_name(self, space_name).await?;
647 effective_permissions(self, &space, caller).await?;
643648
644 if !self.config().space.allow_self_join_groups {
649 if !self.policy.settings.space.allow_self_join_groups {
645650 return Err(SpaceError::Forbidden);
646651 }
647652
648653 space_groups::Entity::find_by_id(group_id)
649654 .filter(space_groups::Column::SpaceId.eq(space.id))
650 .one(self.db().read())
655 .one(self.access.database.read())
651656 .await?
652657 .ok_or(SpaceError::NotFound)?;
653658
654659 let existing = space_member_groups::Entity::find_by_id((space.id, caller, group_id))
655 .one(self.db().read())
660 .one(self.access.database.read())
656661 .await?;
657662 if existing.is_some() {
658663 return Err(SpaceError::Conflict("already in group".to_string()));
659664 }
660665
661 let member = find_active(self.db().read(), space.id, caller)
666 let member = find_active(self.access.database.read(), space.id, caller)
662667 .await?
663668 .ok_or(SpaceError::Forbidden)?;
664669 let role_key = self.group_role_key(space.id, group_id).await?;
@@ -672,7 +677,7 @@
672677 group_id: Set(group_id),
673678 created_at: Set(now),
674679 };
675 row.insert(self.db().write()).await?;
680 row.insert(self.access.database.write()).await?;
676681 Ok(())
677682 }
678683
@@ -683,10 +688,10 @@
683688 space_name: &str,
684689 group_id: Uuid,
685690 ) -> Result<(), SpaceError> {
686 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
687 effective_permissions(&self.state, &space, caller).await?;
691 let space = lookup::lookup_space_by_name(self, space_name).await?;
692 effective_permissions(self, &space, caller).await?;
688693
689 let member = find_active(self.db().read(), space.id, caller)
694 let member = find_active(self.access.database.read(), space.id, caller)
690695 .await?
691696 .ok_or(SpaceError::Forbidden)?;
692697 let role_key = self.group_role_key(space.id, group_id).await?;
@@ -697,7 +702,7 @@
697702 .filter(space_member_groups::Column::SpaceId.eq(space.id))
698703 .filter(space_member_groups::Column::UserId.eq(caller))
699704 .filter(space_member_groups::Column::GroupId.eq(group_id))
700 .exec(self.db().write())
705 .exec(self.access.database.write())
701706 .await?;
702707
703708 if res.rows_affected == 0 {
Modified lib/space/src/services/images.rs View lib/space/src/services/images.rs
@@ -1,3 +1,27 @@
1use crate::{Access, Capability, require};
2use export::AppRedis;
3use std::sync::Arc;
4use storage::S3Client;
5
6#[derive(Clone)]
7pub struct SpaceImages {
8 access: Access,
9 storage: Arc<S3Client>,
10 redis: AppRedis,
11}
12impl SpaceImages {
13 pub fn new(access: Access, storage: Arc<S3Client>, redis: AppRedis) -> Self {
14 Self {
15 access,
16 storage,
17 redis,
18 }
19 }
20 async fn load_space(&self, slug: &str) -> Result<spaces::Model, SpaceError> {
21 Ok(self.access.by_slug(slug).await?.record)
22 }
23}
24
125use imaging::layout::{ImageVariant, PreviewState};
226use imaging::preset::PreviewPreset;
327use models::space::{space_extras, spaces};
@@ -7,9 +31,6 @@
731use uuid::Uuid;
832
933use crate::errors::SpaceError;
10use crate::services::service::SpaceService;
11use crate::share::authz::{MANAGE_CHANNELS, effective_permissions, require_perm};
12use crate::share::lookup;
1334
1435pub const MAX_SPACE_IMAGE_BYTES: usize = 5 * 1024 * 1024;
1536pub const SPACE_IMAGE_PREFIX: &str = "space-images";
@@ -127,7 +148,7 @@
127148}
128149
129150async fn write_space_image(
130 state: &export::AppState,
151 access: &Access,
131152 space_id: Uuid,
132153 kind: SpaceImageKind,
133154 url: Option<String>,
@@ -186,8 +207,20 @@
186207 ),
187208 };
188209 crate::authority::upsert_extras(
189 state, space_id, avatar, None, banner, banner_w, banner_h, invite, invite_w, invite_h,
190 embed, embed_w, embed_h, None,
210 &access.database,
211 space_id,
212 avatar,
213 None,
214 banner,
215 banner_w,
216 banner_h,
217 invite,
218 invite_w,
219 invite_h,
220 embed,
221 embed_w,
222 embed_h,
223 None,
191224 )
192225 .await?;
193226 Ok(())
@@ -232,7 +265,7 @@
232265 pub height: Option<i32>,
233266}
234267
235impl SpaceService {
268impl SpaceImages {
236269 /// Resolve the derivative for one stored space image, generating it on first
237270 /// demand. Returns the `storage://` locator of the derivative, falling back
238271 /// to the original whenever a preview cannot be produced (animation,
@@ -251,8 +284,8 @@
251284 .recorded_space_image_preview(space_id, kind, &key_base)
252285 .await;
253286 let state = export::imaging::ensure_preview(
254 self.storage(),
255 &self.state.redis,
287 self.storage.as_ref(),
288 &self.redis,
256289 &key_base,
257290 &original_key,
258291 {
@@ -284,14 +317,15 @@
284317 key_base: &str,
285318 ) -> Option<PreviewState> {
286319 let meta_key = space_image_meta_key(space_id, kind);
287 let fetched: Result<Vec<u8>, storage::StorageError> = self.storage().get(&meta_key).await;
320 let fetched: Result<Vec<u8>, storage::StorageError> =
321 self.storage.as_ref().get(&meta_key).await;
288322 if let Ok(bytes) = fetched
289323 && let Ok(record) = serde_json::from_slice::<serde_json::Value>(&bytes)
290324 && let Some(state) = export::imaging::recorded_preview(&record)
291325 {
292326 return Some(state);
293327 }
294 export::imaging::find_stored_preview(self.storage(), key_base).await
328 export::imaging::find_stored_preview(self.storage.as_ref(), key_base).await
295329 }
296330
297331 /// Persist the sidecar with the derivative that is currently authoritative.
@@ -322,7 +356,8 @@
322356 meta = export::imaging::record_preview(meta, state);
323357 }
324358 if let Err(error) = self
325 .storage()
359 .storage
360 .as_ref()
326361 .put_with_content_type(
327362 &space_image_meta_key(space_id, kind),
328363 meta.to_string().as_bytes(),
@@ -342,7 +377,7 @@
342377 };
343378 let key_base = export::imaging::preview_key_base_of(&original_key);
344379 for candidate in imaging::layout::preview_candidate_keys(&key_base) {
345 if let Err(error) = self.storage().delete(&candidate).await {
380 if let Err(error) = self.storage.as_ref().delete(&candidate).await {
346381 tracing::warn!(%error, key = %candidate, "failed to delete old space image preview");
347382 }
348383 }
@@ -355,7 +390,7 @@
355390 stored_url: &str,
356391 ) -> Result<String, SpaceError> {
357392 let cache_key = space_image_url_cache_key(space_id, kind);
358 match export::redis::get_string(&self.state.redis, &cache_key).await {
393 match export::redis::get_string(&self.redis, &cache_key).await {
359394 Ok(Some(url)) => return Ok(url),
360395 Ok(None) => {}
361396 Err(error) => {
@@ -364,15 +399,11 @@
364399 }
365400 let key = space_image_object_key(stored_url).ok_or(SpaceError::NotFound)?;
366401 let url = self
367 .storage()
402 .storage
403 .as_ref()
368404 .presign_get(&key, SPACE_IMAGE_URL_TTL_SECS as u32)?;
369 if let Err(error) = export::redis::set_string(
370 &self.state.redis,
371 &cache_key,
372 &url,
373 SPACE_IMAGE_URL_TTL_SECS,
374 )
375 .await
405 if let Err(error) =
406 export::redis::set_string(&self.redis, &cache_key, &url, SPACE_IMAGE_URL_TTL_SECS).await
376407 {
377408 tracing::warn!(%error, "failed to cache space image presigned url");
378409 }
@@ -387,7 +418,7 @@
387418 caller: Option<i64>,
388419 space_name: &str,
389420 ) -> Result<spaces::Model, SpaceError> {
390 let space = lookup::lookup_space_by_name(self, caller.unwrap_or(0), space_name).await?;
421 let space = self.load_space(space_name).await?;
391422 if !crate::authority::space_is_active(&space) {
392423 return Err(SpaceError::NotFound);
393424 }
@@ -395,10 +426,14 @@
395426 let Some(uid) = caller else {
396427 return Err(SpaceError::NotFound);
397428 };
398 match effective_permissions(&self.state, &space, uid).await {
399 Ok(_) => {}
400 Err(SpaceError::Forbidden) => return Err(SpaceError::NotFound),
401 Err(e) => return Err(e),
429 if !self
430 .access
431 .by_id(space.id)
432 .await?
433 .can(uid, Capability::Read)
434 .await?
435 {
436 return Err(SpaceError::NotFound);
402437 }
403438 }
404439 Ok(space)
@@ -415,8 +450,13 @@
415450 dims: SpaceImageUploadQuery,
416451 ) -> Result<SpaceImageDto, SpaceError> {
417452 let SpaceImageUploadQuery { width, height } = dims;
418 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
419 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
453 let space = self.load_space(space_name).await?;
454 require(
455 &self.access.by_id(space.id).await?,
456 caller,
457 Capability::ManageChannels,
458 )
459 .await?;
420460
421461 let fmt = AvatarFormat::from_content_type(content_type).ok_or_else(|| {
422462 SpaceError::BadRequest(format!("unsupported content-type: {content_type}"))
@@ -446,12 +486,13 @@
446486 let stored_url = format!("storage://{object_key}");
447487 let now = OffsetDateTime::now_utc();
448488
449 self.storage()
489 self.storage
490 .as_ref()
450491 .put_with_content_type(&object_key, bytes, fmt.content_type())
451492 .await?;
452493
453494 write_space_image(
454 &self.state,
495 &self.access,
455496 space_id,
456497 kind,
457498 Some(stored_url.clone()),
@@ -460,11 +501,8 @@
460501 )
461502 .await?;
462503
463 if let Err(error) = export::redis::delete(
464 &self.state.redis,
465 &space_image_url_cache_key(space_id, kind),
466 )
467 .await
504 if let Err(error) =
505 export::redis::delete(&self.redis, &space_image_url_cache_key(space_id, kind)).await
468506 {
469507 tracing::warn!(%error, "failed to invalidate space image url cache");
470508 }
@@ -475,8 +513,8 @@
475513 self.delete_space_image_previews(&stored_url).await;
476514 let key_base = export::imaging::preview_key_base_of(&object_key);
477515 let preview = export::imaging::ensure_preview(
478 self.storage(),
479 &self.state.redis,
516 self.storage.as_ref(),
517 &self.redis,
480518 &key_base,
481519 &object_key,
482520 fmt.content_type(),
@@ -529,31 +567,34 @@
529567 space_name: &str,
530568 kind: SpaceImageKind,
531569 ) -> Result<(), SpaceError> {
532 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
533 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
570 let space = self.load_space(space_name).await?;
571 require(
572 &self.access.by_id(space.id).await?,
573 caller,
574 Capability::ManageChannels,
575 )
576 .await?;
534577
535578 let space_id = space.id;
536 let extras = crate::authority::extras(&self.state, space_id).await?;
579 let extras = crate::authority::extras(self.access.database.read(), space_id).await?;
537580 let old_url = stored_space_image_url(&space, extras.as_ref(), kind);
538581
539 write_space_image(&self.state, space_id, kind, None, None, None).await?;
582 write_space_image(&self.access, space_id, kind, None, None, None).await?;
540583
541 if let Err(error) = export::redis::delete(
542 &self.state.redis,
543 &space_image_url_cache_key(space_id, kind),
544 )
545 .await
584 if let Err(error) =
585 export::redis::delete(&self.redis, &space_image_url_cache_key(space_id, kind)).await
546586 {
547587 tracing::warn!(%error, "failed to invalidate space image url cache");
548588 }
549589
550590 if let Some(key) = old_url.as_deref().and_then(space_image_object_key) {
551 if let Err(error) = self.storage().delete(&key).await {
591 if let Err(error) = self.storage.as_ref().delete(&key).await {
552592 tracing::warn!(%error, "failed to delete old space image object");
553593 }
554594 self.delete_space_image_previews(&key).await;
555595 if let Err(error) = self
556 .storage()
596 .storage
597 .as_ref()
557598 .delete(&space_image_meta_key(space_id, kind))
558599 .await
559600 {
@@ -570,7 +611,7 @@
570611 kind: SpaceImageKind,
571612 ) -> Result<Option<SpaceImageDto>, SpaceError> {
572613 let space = self.load_space_for_image_read(caller, space_name).await?;
573 let extras = crate::authority::extras(&self.state, space.id).await?;
614 let extras = crate::authority::extras(self.access.database.read(), space.id).await?;
574615
575616 let Some(url) = stored_space_image_url(&space, extras.as_ref(), kind) else {
576617 return Ok(None);
@@ -595,7 +636,7 @@
595636 let format = AvatarFormat::from_extension(ext).unwrap_or(AvatarFormat::Jpg);
596637 let mut bytes = 0u64;
597638 if let Some(key) = space_image_object_key(&url) {
598 match self.storage().head(&key).await {
639 match self.storage.as_ref().head(&key).await {
599640 Ok(Some((size, _))) => bytes = size,
600641 Ok(None) => {}
601642 Err(error) => {
@@ -635,7 +676,7 @@
635676 variant: ImageVariant,
636677 ) -> Result<String, SpaceError> {
637678 let space = self.load_space_for_image_read(caller, space_name).await?;
638 let extras = crate::authority::extras(&self.state, space.id).await?;
679 let extras = crate::authority::extras(self.access.database.read(), space.id).await?;
639680 let stored_url =
640681 stored_space_image_url(&space, extras.as_ref(), kind).ok_or(SpaceError::NotFound)?;
641682 let locator = match variant {
Modified lib/space/src/services/invite.rs View lib/space/src/services/invite.rs
@@ -74,7 +74,7 @@
7474 if let Some(role_id) = role_id {
7575 let role = space_roles::Entity::find_by_id(role_id)
7676 .filter(space_roles::Column::OrganizationId.eq(organization_id))
77 .one(self.db_conn().read())
77 .one(self.access.database.read())
7878 .await?
7979 .ok_or(SpaceError::SpaceRoleNotFound)?;
8080 if role.key == SYSTEM_ROLE_OWNER {
@@ -88,7 +88,7 @@
8888 for extra_role_id in extra_role_ids {
8989 let role = space_roles::Entity::find_by_id(*extra_role_id)
9090 .filter(space_roles::Column::OrganizationId.eq(organization_id))
91 .one(self.db_conn().read())
91 .one(self.access.database.read())
9292 .await?
9393 .ok_or(SpaceError::SpaceRoleNotFound)?;
9494 if role.key == SYSTEM_ROLE_OWNER {
@@ -103,7 +103,7 @@
103103 Some(user_id) => {
104104 models::users::users::Entity::find_by_id(user_id)
105105 .filter(models::users::users::Column::DeletedAt.is_null())
106 .one(self.db_conn().read())
106 .one(self.access.database.read())
107107 .await?
108108 .ok_or(SpaceError::UserNotFound)?;
109109 SpaceJoinMethod::InviteTargeted
@@ -135,7 +135,7 @@
135135 .clamp(60, 90 * 24 * 60 * 60);
136136 let expires_at = Some(Utc::now() + chrono::Duration::seconds(ttl as i64));
137137
138 let txn = self.db_conn().write().begin().await?;
138 let txn = self.access.database.write().begin().await?;
139139 let row = space_invites::ActiveModel {
140140 organization_id: Set(organization_id),
141141 method: Set(method),
@@ -200,7 +200,7 @@
200200 .order_by_desc(space_invites::Column::CreatedAt)
201201 .limit(limit)
202202 .offset(offset)
203 .all(self.db_conn().read())
203 .all(self.access.database.read())
204204 .await?;
205205 Ok(rows.iter().map(invite_summary).collect())
206206 }
@@ -217,7 +217,7 @@
217217 let reason = normalize_reason(reason)?;
218218 let invite = space_invites::Entity::find_by_id(invite_id)
219219 .filter(space_invites::Column::OrganizationId.eq(organization_id))
220 .one(self.db_conn().read())
220 .one(self.access.database.read())
221221 .await?
222222 .ok_or(SpaceError::SpaceInviteNotFound)?;
223223 if invite.target_user_id != Some(actor_user_id) {
@@ -228,7 +228,7 @@
228228 return Ok(false);
229229 }
230230
231 let txn = self.db_conn().write().begin().await?;
231 let txn = self.access.database.write().begin().await?;
232232 space_invites::Entity::update_many()
233233 .col_expr(space_invites::Column::RevokedAt, Expr::value(Utc::now()))
234234 .filter(space_invites::Column::Id.eq(invite_id))
@@ -271,7 +271,7 @@
271271 let token_hash = crypto::sha256_hex(trimmed.as_bytes());
272272 let invite = space_invites::Entity::find()
273273 .filter(space_invites::Column::TokenHash.eq(token_hash))
274 .one(self.db_conn().read())
274 .one(self.access.database.read())
275275 .await?
276276 .ok_or(SpaceError::SpaceInviteNotFound)?;
277277 if invite.revoked_at.is_some() {
@@ -293,7 +293,7 @@
293293 }
294294 let actor = models::users::users::Entity::find_by_id(actor_user_id)
295295 .filter(models::users::users::Column::DeletedAt.is_null())
296 .one(self.db_conn().read())
296 .one(self.access.database.read())
297297 .await?
298298 .ok_or(SpaceError::UserNotFound)?;
299299 if let Some(email) = invite
@@ -306,7 +306,7 @@
306306 return Err(SpaceError::Forbidden);
307307 }
308308
309 let txn = self.db_conn().write().begin().await?;
309 let txn = self.access.database.write().begin().await?;
310310 let mut consume = space_invites::Entity::update_many()
311311 .col_expr(
312312 space_invites::Column::Uses,
Modified lib/space/src/services/join.rs View lib/space/src/services/join.rs
@@ -35,7 +35,7 @@
3535 row: &space_join_requests::Model,
3636 ) -> SpaceResult<JoinRequestSummary> {
3737 let user = users::Entity::find_by_id(row.user_id)
38 .one(self.db_conn().read())
38 .one(self.access.database.read())
3939 .await?;
4040 Ok(JoinRequestSummary {
4141 id: row.id,
@@ -77,7 +77,7 @@
7777 }
7878 let role = space_roles::Entity::find_by_id(role_id)
7979 .filter(space_roles::Column::OrganizationId.eq(organization_id))
80 .one(self.db_conn().read())
80 .one(self.access.database.read())
8181 .await?
8282 .ok_or(SpaceError::SpaceRoleNotFound)?;
8383 let row = space_join_rules::ActiveModel {
@@ -88,9 +88,9 @@
8888 enabled: Set(enabled),
8989 ..Default::default()
9090 }
91 .insert(self.db_conn().write())
91 .insert(self.access.database.write())
9292 .await?;
93 service::bump_version(self.db_conn().write(), organization_id).await?;
93 service::bump_version(self.access.database.write(), organization_id).await?;
9494 Ok(rule_summary(&row))
9595 }
9696
@@ -112,14 +112,14 @@
112112 .await?;
113113 let row = space_join_rules::Entity::find_by_id(rule_id)
114114 .filter(space_join_rules::Column::OrganizationId.eq(organization_id))
115 .one(self.db_conn().read())
115 .one(self.access.database.read())
116116 .await?
117117 .ok_or(SpaceError::SpaceNotFound)?;
118118 let mut active: space_join_rules::ActiveModel = row.into();
119119 if let Some(role_id) = role_id {
120120 let role = space_roles::Entity::find_by_id(role_id)
121121 .filter(space_roles::Column::OrganizationId.eq(organization_id))
122 .one(self.db_conn().read())
122 .one(self.access.database.read())
123123 .await?
124124 .ok_or(SpaceError::SpaceRoleNotFound)?;
125125 active.role_id = Set(role.id);
@@ -141,8 +141,8 @@
141141 if let Some(enabled) = enabled {
142142 active.enabled = Set(enabled);
143143 }
144 let updated = active.update(self.db_conn().write()).await?;
145 service::bump_version(self.db_conn().write(), organization_id).await?;
144 let updated = active.update(self.access.database.write()).await?;
145 service::bump_version(self.access.database.write(), organization_id).await?;
146146 Ok(rule_summary(&updated))
147147 }
148148
@@ -159,10 +159,10 @@
159159 let deleted = space_join_rules::Entity::delete_many()
160160 .filter(space_join_rules::Column::Id.eq(rule_id))
161161 .filter(space_join_rules::Column::OrganizationId.eq(organization_id))
162 .exec(self.db_conn().write())
162 .exec(self.access.database.write())
163163 .await?;
164164 if deleted.rows_affected > 0 {
165 service::bump_version(self.db_conn().write(), organization_id).await?;
165 service::bump_version(self.access.database.write(), organization_id).await?;
166166 }
167167 Ok(deleted.rows_affected > 0)
168168 }
@@ -179,7 +179,7 @@
179179 let rows = space_join_rules::Entity::find()
180180 .filter(space_join_rules::Column::OrganizationId.eq(organization_id))
181181 .order_by_asc(space_join_rules::Column::CreatedAt)
182 .all(self.db_conn().read())
182 .all(self.access.database.read())
183183 .await?;
184184 Ok(rows.iter().map(rule_summary).collect())
185185 }
@@ -197,7 +197,7 @@
197197 return Err(SpaceError::SpaceInactive);
198198 }
199199 if let Some(existing) =
200 member::find_any(self.db_conn().read(), organization_id, actor_user_id).await?
200 member::find_any(self.access.database.read(), organization_id, actor_user_id).await?
201201 {
202202 if existing.banned_at.is_some() {
203203 return Err(SpaceError::SpaceMemberBanned);
@@ -210,12 +210,12 @@
210210 .filter(space_join_requests::Column::OrganizationId.eq(organization_id))
211211 .filter(space_join_requests::Column::UserId.eq(actor_user_id))
212212 .filter(space_join_requests::Column::Status.eq(SpaceJoinRequestState::Pending))
213 .one(self.db_conn().read())
213 .one(self.access.database.read())
214214 .await?
215215 {
216216 let mut active: space_join_requests::ActiveModel = pending.into();
217217 active.note = Set(note);
218 let updated = active.update(self.db_conn().write()).await?;
218 let updated = active.update(self.access.database.write()).await?;
219219 return self.join_request_summary(&updated).await;
220220 }
221221 let row = space_join_requests::ActiveModel {
@@ -225,7 +225,7 @@
225225 note: Set(note),
226226 ..Default::default()
227227 }
228 .insert(self.db_conn().write())
228 .insert(self.access.database.write())
229229 .await?;
230230 self.join_request_summary(&row).await
231231 }
@@ -247,12 +247,12 @@
247247 if let Some(status) = status {
248248 select = select.filter(space_join_requests::Column::Status.eq(status));
249249 }
250 let total = select.clone().count(self.db_conn().read()).await?;
250 let total = select.clone().count(self.access.database.read()).await?;
251251 let rows = select
252252 .order_by_desc(space_join_requests::Column::CreatedAt)
253253 .limit(limit)
254254 .offset(offset)
255 .all(self.db_conn().read())
255 .all(self.access.database.read())
256256 .await?;
257257 let mut out = Vec::with_capacity(rows.len());
258258 for row in rows {
@@ -271,7 +271,7 @@
271271 ) -> SpaceResult<JoinRequestSummary> {
272272 let row = space_join_requests::Entity::find_by_id(join_request_id)
273273 .filter(space_join_requests::Column::OrganizationId.eq(organization_id))
274 .one(self.db_conn().read())
274 .one(self.access.database.read())
275275 .await?
276276 .ok_or(SpaceError::SpaceMemberNotFound)?;
277277 if row.user_id != actor_user_id {
@@ -282,7 +282,7 @@
282282 }
283283 let mut active: space_join_requests::ActiveModel = row.into();
284284 active.status = Set(SpaceJoinRequestState::Withdrawn);
285 let updated = active.update(self.db_conn().write()).await?;
285 let updated = active.update(self.access.database.write()).await?;
286286 self.join_request_summary(&updated).await
287287 }
288288
@@ -300,7 +300,7 @@
300300 .await?;
301301 let row = space_join_requests::Entity::find_by_id(join_request_id)
302302 .filter(space_join_requests::Column::OrganizationId.eq(organization_id))
303 .one(self.db_conn().read())
303 .one(self.access.database.read())
304304 .await?
305305 .ok_or(SpaceError::SpaceMemberNotFound)?;
306306 if row.status != SpaceJoinRequestState::Pending {
@@ -310,7 +310,7 @@
310310 let default_role = space_roles::Entity::find()
311311 .filter(space_roles::Column::OrganizationId.eq(organization_id))
312312 .filter(space_roles::Column::Key.eq(SYSTEM_ROLE_MEMBER))
313 .one(self.db_conn().read())
313 .one(self.access.database.read())
314314 .await?;
315315 self.admin_add_member(
316316 actor_user_id,
@@ -334,7 +334,7 @@
334334 if let Some(note) = note {
335335 active.note = Set(Some(note));
336336 }
337 let updated = active.update(self.db_conn().write()).await?;
337 let updated = active.update(self.access.database.write()).await?;
338338 self.join_request_summary(&updated).await
339339 }
340340
Modified lib/space/src/services/join_requests.rs View lib/space/src/services/join_requests.rs
@@ -5,8 +5,8 @@
55 AllowJoinLevel, MemberStatus, SpaceJoinRequestState, space_group_permissions,
66 space_member_groups, spaces,
77};
8use models::users::user_notifications::{self, NotificationType};
9use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
8use models::users::user_notifications::NotificationType;
9use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
1010use serde::{Deserialize, Serialize};
1111use time::{Duration, OffsetDateTime};
1212use utoipa::{IntoParams, ToSchema};
@@ -111,57 +111,12 @@
111111}
112112
113113impl SpaceService {
114 async fn assert_join_requirements(
115 &self,
116 space: &spaces::Model,
117 uid: i64,
118 ) -> Result<(), SpaceError> {
119 match AllowJoinLevel::from_i16(space.allow_join_level) {
120 AllowJoinLevel::None => {}
121 AllowJoinLevel::NeedMailActiveAndPhoneActive
122 | AllowJoinLevel::NeedMailActive
123 | AllowJoinLevel::NeedMailActiveAndLastOneMid
124 | AllowJoinLevel::NeedMailActiveAndLastOneHours => {
125 if !crate::authority::has_verified_email(&self.state, uid).await? {
126 return Err(SpaceError::JoinRequirementNotMet(
127 "an activated email address is required".to_string(),
128 ));
129 }
130 let now = OffsetDateTime::now_utc();
131 let created_at = crate::authority::account_created_at(&self.state, uid).await?;
132 match AllowJoinLevel::from_i16(space.allow_join_level) {
133 AllowJoinLevel::NeedMailActiveAndLastOneMid
134 if created_at.is_some_and(|created| created > now - Duration::days(1)) =>
135 {
136 return Err(SpaceError::JoinRequirementNotMet(
137 "the account must be at least one day old".to_string(),
138 ));
139 }
140 AllowJoinLevel::NeedMailActiveAndLastOneHours
141 if created_at.is_some_and(|created| created > now - Duration::hours(1)) =>
142 {
143 return Err(SpaceError::JoinRequirementNotMet(
144 "the account must be at least one hour old".to_string(),
145 ));
146 }
147 _ => {}
148 }
149 }
150 }
151 if space.member_must_enable_2fa && !crate::authority::mfa_enabled(&self.state, uid).await? {
152 return Err(SpaceError::JoinRequirementNotMet(
153 "two-factor authentication is required".to_string(),
154 ));
155 }
156 Ok(())
157 }
158
159114 async fn approver_uids(&self, space: &spaces::Model) -> Result<Vec<i64>, SpaceError> {
160115 let mut uids: HashSet<i64> = HashSet::new();
161116 uids.insert(space.owner_user_id);
162117 let member_groups = space_member_groups::Entity::find()
163118 .filter(space_member_groups::Column::SpaceId.eq(space.id))
164 .all(self.db().read())
119 .all(self.access.database.read())
165120 .await?;
166121 let group_ids: Vec<Uuid> = {
167122 let mut ids: Vec<Uuid> = member_groups.iter().map(|mg| mg.group_id).collect();
@@ -173,7 +128,7 @@
173128 let grants = space_group_permissions::Entity::find()
174129 .filter(space_group_permissions::Column::GroupId.is_in(group_ids))
175130 .filter(space_group_permissions::Column::Permission.eq(MANAGE_MEMBERS))
176 .all(self.db().read())
131 .all(self.access.database.read())
177132 .await?;
178133 let managing_groups: HashSet<Uuid> = grants.into_iter().map(|g| g.group_id).collect();
179134 let candidates: Vec<i64> = member_groups
@@ -183,14 +138,7 @@
183138 .collect();
184139 if !candidates.is_empty() {
185140 let candidate_set: HashSet<i64> = candidates.into_iter().collect();
186 let active = crate::authority::list_members(
187 &self.state,
188 space.owner_user_id,
189 space.id,
190 false,
191 false,
192 )
193 .await?;
141 let active = crate::authority::list_members(self, space.id, false, false).await?;
194142 uids.extend(
195143 active
196144 .into_iter()
@@ -208,20 +156,16 @@
208156 title: String,
209157 data: serde_json::Value,
210158 ) -> Result<(), SpaceError> {
211 let row = user_notifications::ActiveModel {
212 id: Set(Uuid::now_v7()),
213 user_id: Set(recipient),
214 notification_type: Set(NotificationType::System),
215 title: Set(title),
216 body: Set(None),
217 data_json: Set(Some(data)),
218 actor_uid: Set(None),
219 read_at: Set(None),
220 created_at: Set(OffsetDateTime::now_utc()),
221 }
222 .insert(self.db().write())
223 .await?;
224 export::notify::publish_notification(&self.state, recipient, row.to_event_value()).await;
159 self.notifications
160 .create(
161 recipient,
162 NotificationType::System,
163 title,
164 None,
165 Some(data),
166 None,
167 )
168 .await?;
225169 Ok(())
226170 }
227171
@@ -233,18 +177,16 @@
233177 let (limit, offset) = export::pagination::page_bounds(
234178 query.limit,
235179 query.offset,
236 self.config().space.default_pagination,
237 self.config().space.max_pagination,
180 self.policy.settings.space.default_pagination,
181 self.policy.settings.space.max_pagination,
238182 );
239183
240 let rows = crate::authority::list_public_spaces(
241 &self.state,
242 query.q.as_deref().unwrap_or_default(),
243 )
244 .await?;
184 let rows =
185 crate::authority::list_public_spaces(self, query.q.as_deref().unwrap_or_default())
186 .await?;
245187 let mut ranked = Vec::with_capacity(rows.len());
246188 for row in rows {
247 let count = crate::authority::member_count(&self.state, row.id).await?;
189 let count = crate::authority::member_count(self, row.id).await?;
248190 ranked.push((row, count));
249191 }
250192 if query.sort.as_deref() == Some("newest") {
@@ -268,7 +210,7 @@
268210 && !rows.is_empty()
269211 {
270212 let space_ids: Vec<Uuid> = rows.iter().map(|(space, _)| space.id).collect();
271 let joined: HashSet<Uuid> = crate::authority::list_user_spaces(&self.state, uid, uid)
213 let joined: HashSet<Uuid> = crate::authority::list_user_spaces(self, uid)
272214 .await?
273215 .into_iter()
274216 .filter(crate::authority::space_is_active)
@@ -283,17 +225,22 @@
283225 .filter(space_join_requests::Column::UserId.eq(uid))
284226 .filter(space_join_requests::Column::OrganizationId.is_in(space_ids))
285227 .order_by_desc(space_join_requests::Column::CreatedAt)
286 .all(self.db().read())
228 .all(self.access.database.read())
287229 .await?;
288230 for row in requests {
289231 latest_requests.entry(row.organization_id).or_insert(row);
290232 }
291233 }
292234
293 let cooldown_hours = self.config().space.join_request_cooldown_hours.max(0);
235 let cooldown_hours = self
236 .policy
237 .settings
238 .space
239 .join_request_cooldown_hours
240 .max(0);
294241 let mut items = Vec::with_capacity(rows.len());
295242 for (space, members_count) in rows {
296 let extras = crate::authority::extras(&self.state, space.id).await?;
243 let extras = crate::authority::extras(self.access.database.read(), space.id).await?;
297244 let viewer = viewer.map(|_| ExploreViewerDto {
298245 is_member: memberships.contains(&space.id),
299246 request: latest_requests
@@ -354,30 +301,15 @@
354301 space_name: &str,
355302 cmd: CreateJoinRequestCmd,
356303 ) -> Result<JoinRequestDto, SpaceError> {
357 if !self
358 .state
359 .redis
360 .quota_allow(
361 &format!("quota:space_join_request:{caller}"),
362 self.config().quota.space_join_request_per_day,
363 86_400,
364 )
365 .await
366 {
367 return Err(SpaceError::RateLimited(
368 "join request quota exceeded, retry tomorrow".to_string(),
369 ));
370 }
371 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
304 self.policy.allow_join_request(caller).await?;
305 let space = lookup::lookup_space_by_name(self, space_name).await?;
372306 if space.join_policy == models::space::SpaceJoinPolicy::Private || !space.allow_join {
373307 return Err(SpaceError::JoinNotAllowed(
374308 "this space does not accept join requests".to_string(),
375309 ));
376310 }
377311
378 if let Some(member) =
379 crate::authority::member(&self.state, caller, space.id, caller).await?
380 {
312 if let Some(member) = crate::authority::member(self, space.id, caller).await? {
381313 if member.status() == MemberStatus::Active {
382314 return Err(SpaceError::Conflict(
383315 "user is already a member of this space".to_string(),
@@ -394,7 +326,7 @@
394326 .filter(space_join_requests::Column::OrganizationId.eq(space.id))
395327 .filter(space_join_requests::Column::UserId.eq(caller))
396328 .filter(space_join_requests::Column::Status.eq(SpaceJoinRequestState::Pending))
397 .one(self.db().read())
329 .one(self.access.database.read())
398330 .await?;
399331 if pending.is_some() {
400332 return Err(SpaceError::Conflict(
@@ -407,10 +339,15 @@
407339 .filter(space_join_requests::Column::UserId.eq(caller))
408340 .filter(space_join_requests::Column::Status.eq(SpaceJoinRequestState::Rejected))
409341 .order_by_desc(space_join_requests::Column::CreatedAt)
410 .one(self.db().read())
342 .one(self.access.database.read())
411343 .await?
412344 {
413 let cooldown_hours = self.config().space.join_request_cooldown_hours.max(0);
345 let cooldown_hours = self
346 .policy
347 .settings
348 .space
349 .join_request_cooldown_hours
350 .max(0);
414351 let decided_at = authority::to_time(last.decided_at.unwrap_or(last.created_at));
415352 if OffsetDateTime::now_utc() < decided_at + Duration::hours(cooldown_hours) {
416353 return Err(SpaceError::JoinRequestCooldown(
@@ -419,7 +356,7 @@
419356 }
420357 }
421358
422 self.assert_join_requirements(&space, caller).await?;
359 self.policy.assert_join_requirements(&space, caller).await?;
423360
424361 let note = cmd
425362 .note
@@ -448,7 +385,7 @@
448385 if uid == caller {
449386 continue;
450387 }
451 let locale = self.recipient_locale(Some(uid)).await;
388 let locale = self.policy.recipient_locale(Some(uid)).await;
452389 let title =
453390 i18n::catalog::notification_join_request_submitted_title(locale, &space.name);
454391 if let Err(error) = self.notify_join_event(uid, title, data.clone()).await {
@@ -467,10 +404,10 @@
467404 space_name: &str,
468405 request_id: Uuid,
469406 ) -> Result<(), SpaceError> {
470 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
407 let space = lookup::lookup_space_by_name(self, space_name).await?;
471408 let request = JoinRequests::find_by_id(request_id)
472409 .filter(space_join_requests::Column::OrganizationId.eq(space.id))
473 .one(self.db().read())
410 .one(self.access.database.read())
474411 .await?
475412 .ok_or(SpaceError::NotFound)?;
476413 if request.user_id != caller {
@@ -492,14 +429,14 @@
492429 space_name: &str,
493430 query: ListJoinRequestsQuery,
494431 ) -> Result<JoinRequestPage, SpaceError> {
495 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
432 let space = lookup::lookup_space_by_name(self, space_name).await?;
496433 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
497434
498435 let (limit, offset) = export::pagination::page_bounds(
499436 query.limit,
500437 query.offset,
501 self.config().space.default_pagination,
502 self.config().space.max_pagination,
438 self.policy.settings.space.default_pagination,
439 self.policy.settings.space.max_pagination,
503440 );
504441
505442 let (rows, total) = self
@@ -528,12 +465,12 @@
528465 request_id: Uuid,
529466 approve: bool,
530467 ) -> Result<JoinRequestDto, SpaceError> {
531 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
468 let space = lookup::lookup_space_by_name(self, space_name).await?;
532469 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
533470
534471 let request = JoinRequests::find_by_id(request_id)
535472 .filter(space_join_requests::Column::OrganizationId.eq(space.id))
536 .one(self.db().read())
473 .one(self.access.database.read())
537474 .await?
538475 .ok_or(SpaceError::NotFound)?;
539476 if request.status != SpaceJoinRequestState::Pending {
@@ -543,9 +480,9 @@
543480 }
544481
545482 if approve {
546 assert_not_banned(&self.state, space.id, request.user_id).await?;
483 assert_not_banned(self, space.id, request.user_id).await?;
547484 if space.member_must_enable_2fa
548 && !crate::authority::mfa_enabled(&self.state, request.user_id).await?
485 && !crate::authority::mfa_enabled(&self.policy.accounts, request.user_id).await?
549486 {
550487 return Err(SpaceError::Forbidden);
551488 }
@@ -565,7 +502,7 @@
565502 .await;
566503 }
567504
568 let locale = self.recipient_locale(Some(applicant_id)).await;
505 let locale = self.policy.recipient_locale(Some(applicant_id)).await;
569506 let title = if approve {
570507 i18n::catalog::notification_join_request_approved_title(locale, &space.name)
571508 } else {
Modified lib/space/src/services/join_rules.rs View lib/space/src/services/join_rules.rs
@@ -76,7 +76,7 @@
7676/// Grants the configured role for every enabled, matching join rule of the space.
7777/// Idempotent: an already bound role is left untouched.
7878pub async fn apply_space_join_rules(
79 state: &export::AppState,
79 access: &crate::Access,
8080 space_id: Uuid,
8181 user_id: i64,
8282 trigger: SpaceJoinTrigger,
@@ -93,21 +93,22 @@
9393 .add(space_join_rules::Column::Matcher.eq(""))
9494 .add(space_join_rules::Column::Matcher.eq(matcher)),
9595 )
96 .all(state.database.read())
96 .all(access.database.read())
9797 .await?;
9898 if rules.is_empty() {
9999 return Ok(());
100100 }
101101
102 let Some(member) = crate::authority::member(state, user_id, space_id, user_id).await? else {
102 let Some(member) =
103 crate::services::member::find_any(access.database.read(), space_id, user_id).await?
104 else {
103105 return Ok(());
104106 };
105107 if member.status() != models::space::MemberStatus::Active {
106108 return Ok(());
107109 }
108110
109 let service = SpaceService::new(state.clone());
110 let txn = state.database.write().begin().await?;
111 let txn = access.database.write().begin().await?;
111112 let mut bound = false;
112113 for rule in &rules {
113114 let role = space_roles::Entity::find_by_id(rule.role_id)
@@ -133,7 +134,7 @@
133134 }
134135 txn.commit().await?;
135136 if bound {
136 service.invalidate_org(space_id).await?;
137 crate::services::permission::invalidate(&access.database, &access.cache, space_id).await?;
137138 }
138139 Ok(())
139140}
@@ -146,7 +147,7 @@
146147 space_name: &str,
147148 cmd: CreateJoinRuleCmd,
148149 ) -> Result<JoinRuleDto, SpaceError> {
149 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
150 let space = lookup::lookup_space_by_name(self, space_name).await?;
150151 require_perm(self, &space, caller, MANAGE_ROLES).await?;
151152 let matcher = normalize_matcher(&cmd.matcher)?;
152153
@@ -172,7 +173,7 @@
172173 rule_id: Uuid,
173174 cmd: UpdateJoinRuleCmd,
174175 ) -> Result<JoinRuleDto, SpaceError> {
175 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
176 let space = lookup::lookup_space_by_name(self, space_name).await?;
176177 require_perm(self, &space, caller, MANAGE_ROLES).await?;
177178 let matcher = match cmd.matcher {
178179 Some(raw) => Some(normalize_matcher(&raw)?),
@@ -200,7 +201,7 @@
200201 space_name: &str,
201202 rule_id: Uuid,
202203 ) -> Result<(), SpaceError> {
203 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
204 let space = lookup::lookup_space_by_name(self, space_name).await?;
204205 require_perm(self, &space, caller, MANAGE_ROLES).await?;
205206 if !self
206207 .admin_delete_join_rule(caller, space.id, rule_id, None)
@@ -216,7 +217,7 @@
216217 caller: i64,
217218 space_name: &str,
218219 ) -> Result<Vec<JoinRuleDto>, SpaceError> {
219 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
220 let space = lookup::lookup_space_by_name(self, space_name).await?;
220221 require_perm(self, &space, caller, MANAGE_ROLES).await?;
221222 let rows = self.admin_list_join_rules(caller, space.id, None).await?;
222223 Ok(rows.into_iter().map(JoinRuleDto::from).collect())
Modified lib/space/src/services/member.rs View lib/space/src/services/member.rs
@@ -150,7 +150,7 @@
150150 .order_by_asc(space_members::Column::JoinedAt)
151151 .limit(limit)
152152 .offset(offset)
153 .all(self.db_conn().read())
153 .all(self.access.database.read())
154154 .await?;
155155 let mut out = Vec::with_capacity(rows.len());
156156 for row in rows {
@@ -178,15 +178,19 @@
178178 self.require_permission(actor_user_id, organization_id, "role.assign", request_id)
179179 .await?;
180180 }
181 let role_keys =
182 role::load_keys(organization_id, &body.role_keys, self.db_conn().read()).await?;
181 let role_keys = role::load_keys(
182 organization_id,
183 &body.role_keys,
184 self.access.database.read(),
185 )
186 .await?;
183187 users::Entity::find_by_id(body.user_id)
184188 .filter(users::Column::DeletedAt.is_null())
185 .one(self.db_conn().read())
189 .one(self.access.database.read())
186190 .await?
187191 .ok_or(SpaceError::UserNotFound)?;
188192
189 let txn = self.db_conn().write().begin().await?;
193 let txn = self.access.database.write().begin().await?;
190194 let (row, created) = upsert_join(
191195 &txn,
192196 organization_id,
@@ -241,8 +245,8 @@
241245 .await?;
242246 let reason = normalize_reason(reason)?;
243247 let row = match member_id {
244 Some(id) => load_by_id(self.db_conn().read(), organization_id, id).await?,
245 None => find_any(self.db_conn().read(), organization_id, user_id)
248 Some(id) => load_by_id(self.access.database.read(), organization_id, id).await?,
249 None => find_any(self.access.database.read(), organization_id, user_id)
246250 .await?
247251 .ok_or(SpaceError::SpaceMemberNotFound)?,
248252 };
@@ -253,7 +257,7 @@
253257 return Ok(false);
254258 }
255259
256 let txn = self.db_conn().write().begin().await?;
260 let txn = self.access.database.write().begin().await?;
257261 space_members::Entity::update_many()
258262 .col_expr(space_members::Column::LeftAt, Expr::value(Utc::now()))
259263 .col_expr(
@@ -301,7 +305,7 @@
301305 organization_id: Uuid,
302306 request_id: Option<&str>,
303307 ) -> SpaceResult<bool> {
304 let row = find_any(self.db_conn().read(), organization_id, actor_user_id)
308 let row = find_any(self.access.database.read(), organization_id, actor_user_id)
305309 .await?
306310 .ok_or(SpaceError::SpaceMemberNotFound)?;
307311 if row.role == SpaceMemberRole::Owner {
@@ -311,7 +315,7 @@
311315 return Ok(false);
312316 }
313317
314 let txn = self.db_conn().write().begin().await?;
318 let txn = self.access.database.write().begin().await?;
315319 space_members::Entity::update_many()
316320 .col_expr(space_members::Column::LeftAt, Expr::value(Utc::now()))
317321 .col_expr(
@@ -366,7 +370,7 @@
366370 if body.role == SpaceMemberRole::Owner {
367371 return Err(SpaceError::Forbidden);
368372 }
369 let row = load_by_id(self.db_conn().read(), organization_id, member_id).await?;
373 let row = load_by_id(self.access.database.read(), organization_id, member_id).await?;
370374 if row.role == SpaceMemberRole::Owner {
371375 return Err(SpaceError::SpaceOwnerRequired);
372376 }
@@ -376,12 +380,14 @@
376380 });
377381 }
378382
379 let owner_role = role::find_by_key(organization_id, "owner", self.db_conn().read()).await?;
380 let admin_role = role::find_by_key(organization_id, "admin", self.db_conn().read()).await?;
383 let owner_role =
384 role::find_by_key(organization_id, "owner", self.access.database.read()).await?;
385 let admin_role =
386 role::find_by_key(organization_id, "admin", self.access.database.read()).await?;
381387 let member_role =
382 role::find_by_key(organization_id, "member", self.db_conn().read()).await?;
388 role::find_by_key(organization_id, "member", self.access.database.read()).await?;
383389
384 let txn = self.db_conn().write().begin().await?;
390 let txn = self.access.database.write().begin().await?;
385391 space_members::Entity::update_many()
386392 .col_expr(space_members::Column::Role, Expr::value(body.role))
387393 .col_expr(
@@ -445,7 +451,7 @@
445451 }
446452 Some(value) => Some(value.to_owned()),
447453 };
448 let row = load_by_id(self.db_conn().read(), organization_id, member_id).await?;
454 let row = load_by_id(self.access.database.read(), organization_id, member_id).await?;
449455 if row.role == SpaceMemberRole::Owner {
450456 return Err(SpaceError::SpaceOwnerRequired);
451457 }
@@ -453,7 +459,7 @@
453459 return self.member_summary(&row).await;
454460 }
455461
456 let txn = self.db_conn().write().begin().await?;
462 let txn = self.access.database.write().begin().await?;
457463 space_members::Entity::update_many()
458464 .col_expr(space_members::Column::BannedAt, Expr::value(Utc::now()))
459465 .col_expr(
@@ -507,12 +513,12 @@
507513 ) -> SpaceResult<MemberSummary> {
508514 self.require_permission(actor_user_id, organization_id, "member.ban", request_id)
509515 .await?;
510 let row = load_by_id(self.db_conn().read(), organization_id, member_id).await?;
516 let row = load_by_id(self.access.database.read(), organization_id, member_id).await?;
511517 if row.banned_at.is_none() {
512518 return self.member_summary(&row).await;
513519 }
514520
515 let txn = self.db_conn().write().begin().await?;
521 let txn = self.access.database.write().begin().await?;
516522 // 解封必须把封禁的全部附带状态一起清掉,否则残留的到期时间与四个 allow_* 位会继续生效。
517523 let unlocked = space_members::Entity::update_many()
518524 .col_expr(
@@ -583,8 +589,8 @@
583589 self.require_permission(actor_user_id, organization_id, "member.view", request_id)
584590 .await?;
585591 let row = match member_id {
586 Some(id) => load_by_id(self.db_conn().read(), organization_id, id).await?,
587 None => find_any(self.db_conn().read(), organization_id, user_id)
592 Some(id) => load_by_id(self.access.database.read(), organization_id, id).await?,
593 None => find_any(self.access.database.read(), organization_id, user_id)
588594 .await?
589595 .ok_or(SpaceError::SpaceMemberNotFound)?,
590596 };
@@ -628,7 +634,7 @@
628634 body: UpdateMemberBody,
629635 request_id: Option<&str>,
630636 ) -> SpaceResult<MemberSummary> {
631 let row = find_active(self.db_conn().read(), organization_id, actor_user_id)
637 let row = find_active(self.access.database.read(), organization_id, actor_user_id)
632638 .await?
633639 .ok_or(SpaceError::Forbidden)?;
634640 self.apply_member_update(
@@ -652,7 +658,7 @@
652658 request_id: Option<&str>,
653659 ) -> SpaceResult<MemberSummary> {
654660 // 读取、版本校验与写回必须在同一事务内,否则并发封禁/离组/改角色会被旧快照覆盖。
655 let txn = self.db_conn().write().begin().await?;
661 let txn = self.access.database.write().begin().await?;
656662 let row = load_by_id(&txn, organization_id, member_id).await?;
657663 if body.expected_version > 0 && row.version != body.expected_version {
658664 txn.rollback().await?;
@@ -778,7 +784,7 @@
778784 .order_by_desc(space_members::Column::BannedAt)
779785 .limit(limit)
780786 .offset(offset)
781 .all(self.db_conn().read())
787 .all(self.access.database.read())
782788 .await?;
783789 let mut out = Vec::with_capacity(rows.len());
784790 for row in rows {
@@ -797,7 +803,7 @@
797803 ) -> SpaceResult<MemberSummary> {
798804 self.require_permission(actor_user_id, organization_id, "member.ban", request_id)
799805 .await?;
800 let row = find_any(self.db_conn().read(), organization_id, user_id)
806 let row = find_any(self.access.database.read(), organization_id, user_id)
801807 .await?
802808 .ok_or(SpaceError::SpaceMemberNotFound)?;
803809 if row.banned_at.is_none() {
@@ -818,7 +824,7 @@
818824 ) -> SpaceResult<MemberSummary> {
819825 self.require_permission(actor_user_id, organization_id, "member.ban", request_id)
820826 .await?;
821 let row = find_any(self.db_conn().read(), organization_id, user_id)
827 let row = find_any(self.access.database.read(), organization_id, user_id)
822828 .await?
823829 .ok_or(SpaceError::SpaceMemberNotFound)?;
824830 if row.banned_at.is_none() {
@@ -842,7 +848,7 @@
842848 Some(value) => value,
843849 };
844850
845 let txn = self.db_conn().write().begin().await?;
851 let txn = self.access.database.write().begin().await?;
846852 let mut active: space_members::ActiveModel = row.clone().into();
847853 active.ban_reason = Set(reason.clone());
848854 active.ban_expires_at = Set(expires_at);
Modified lib/space/src/services/mod.rs View lib/space/src/services/mod.rs
@@ -15,3 +15,5 @@
1515pub mod types;
1616
1717pub use types::*;
18
19pub mod policy;
Modified lib/space/src/services/permission.rs View lib/space/src/services/permission.rs
@@ -132,23 +132,23 @@
132132}
133133
134134async fn cached_kernel(
135 svc: &SpaceService,
135 db: &db::DbConn,
136 cache: &cache::Cache,
136137 organization_id: Uuid,
137138 user_id: i64,
138139 version: i32,
139140 membership: &space_members::Model,
140141) -> SpaceResult<Kernel> {
141142 let key = service::perm_cache_prefix(organization_id, user_id);
142 if let Ok(Some(bytes)) = svc.cache().get_bytes(&key).await
143 if let Ok(Some(bytes)) = cache.get_bytes(&key).await
143144 && let Ok(kernel) = serde_json::from_slice::<Kernel>(&bytes)
144145 && kernel.version == version
145146 {
146147 return Ok(kernel);
147148 }
148 let kernel = load_kernel(svc.db().read(), version, membership).await?;
149 let kernel = load_kernel(db.read(), version, membership).await?;
149150 if let Ok(bytes) = serde_json::to_vec(&kernel)
150 && let Err(error) = svc
151 .cache()
151 && let Err(error) = cache
152152 .set_ex(&key, bytes, Duration::from_secs(PERMISSION_CACHE_TTL_SECS))
153153 .await
154154 {
@@ -201,21 +201,38 @@
201201}
202202
203203async fn gate(
204 svc: &SpaceService,
204 db: &db::DbConn,
205 cache: &cache::Cache,
205206 organization_id: Uuid,
206207 user_id: i64,
207208 _request_id: Option<&str>,
208209) -> SpaceResult<Gate> {
209 let organization = svc.load_organization(organization_id).await?;
210 let organization = models::space::spaces::Entity::find_by_id(organization_id)
211 .one(db.read())
212 .await?
213 .filter(|row| row.deleted_at.is_none())
214 .ok_or(SpaceError::NotFound)?;
210215 let version = organization.version;
211 if organization.status != models::space::SpaceStatus::Active {
216 if organization.status != models::space::SpaceStatus::Active
217 || organization.archived_at.is_some()
218 {
212219 return Ok(Gate::Denied {
213220 version,
214221 reason: "org_inactive",
215222 matched_by: "org_status",
216223 });
217224 }
218 let membership = match member::find_any(svc.db().read(), organization_id, user_id).await? {
225 if organization.owner_user_id == user_id {
226 return Ok(Gate::Ready {
227 version,
228 kernel: Kernel {
229 version,
230 is_owner: true,
231 ..Default::default()
232 },
233 });
234 }
235 let membership = match member::find_any(db.read(), organization_id, user_id).await? {
219236 Some(row) => row,
220237 None => {
221238 return Ok(Gate::Denied {
@@ -240,7 +257,7 @@
240257 });
241258 }
242259
243 let kernel = cached_kernel(svc, organization_id, user_id, version, &membership).await?;
260 let kernel = cached_kernel(db, cache, organization_id, user_id, version, &membership).await?;
244261 Ok(Gate::Ready { version, kernel })
245262}
246263
@@ -254,7 +271,14 @@
254271 permission: &str,
255272 request_id: Option<&str>,
256273) -> SpaceResult<Resolution> {
257 let gate = gate(svc, organization_id, user_id, request_id).await?;
274 let gate = gate(
275 &svc.access.database,
276 &svc.access.cache,
277 organization_id,
278 user_id,
279 request_id,
280 )
281 .await?;
258282 Ok(gate.resolution(permission))
259283}
260284
@@ -273,7 +297,14 @@
273297 if target != actor_user_id && !self.is_platform_admin(actor_user_id).await? {
274298 return Err(SpaceError::Forbidden);
275299 }
276 let gate = gate(self, organization_id, target, request_id).await?;
300 let gate = gate(
301 &self.access.database,
302 &self.access.cache,
303 organization_id,
304 target,
305 request_id,
306 )
307 .await?;
277308 let resolution = gate.resolution(permission.trim());
278309 Ok(PermissionCheck {
279310 allowed: resolution.allowed,
@@ -297,7 +328,14 @@
297328 if target != actor_user_id && !self.is_platform_admin(actor_user_id).await? {
298329 return Err(SpaceError::Forbidden);
299330 }
300 let gate = gate(self, organization_id, target, request_id).await?;
331 let gate = gate(
332 &self.access.database,
333 &self.access.cache,
334 organization_id,
335 target,
336 request_id,
337 )
338 .await?;
301339 let version = gate.version();
302340 Ok(permissions
303341 .iter()
@@ -322,3 +360,44 @@
322360 let kernel = load_kernel(db, 0, membership).await?;
323361 Ok(allowed_list(&kernel))
324362}
363
364pub(crate) async fn effective_for(
365 db: &db::DbConn,
366 cache: &cache::Cache,
367 space_id: Uuid,
368 user_id: i64,
369) -> SpaceResult<Vec<String>> {
370 match gate(db, cache, space_id, user_id, None).await? {
371 Gate::Ready { kernel, .. } => Ok(allowed_list(&kernel)),
372 Gate::Denied { .. } => Ok(Vec::new()),
373 }
374}
375
376pub(crate) async fn invalidate(
377 db: &db::DbConn,
378 cache: &cache::Cache,
379 organization_id: Uuid,
380) -> SpaceResult<()> {
381 let members = space_members::Entity::find()
382 .select_only()
383 .column(space_members::Column::UserId)
384 .filter(space_members::Column::OrganizationId.eq(organization_id))
385 .into_tuple::<i64>()
386 .all(db.read())
387 .await?;
388 let mut keys = vec![
389 format!("auth:org:{organization_id}"),
390 format!("auth:org:summary:{organization_id}"),
391 ];
392 keys.extend(
393 members
394 .into_iter()
395 .map(|user| service::perm_cache_prefix(organization_id, user)),
396 );
397 for key in keys {
398 if let Err(error) = cache.delete(&key).await {
399 tracing::warn!(%error, %organization_id, "failed to invalidate permission cache");
400 }
401 }
402 Ok(())
403}
Added lib/space/src/services/policy.rs View lib/space/src/services/policy.rs

Diff too large to display.

Modified lib/space/src/services/role.rs View lib/space/src/services/role.rs
@@ -249,7 +249,7 @@
249249 .filter(space_roles::Column::OrganizationId.eq(organization_id))
250250 .order_by_desc(space_roles::Column::Rank)
251251 .order_by_asc(space_roles::Column::Key)
252 .all(self.db_conn().read())
252 .all(self.access.database.read())
253253 .await?;
254254 let mut out = Vec::with_capacity(rows.len());
255255 for row in rows {
@@ -273,14 +273,14 @@
273273 let grants = Self::validate_grants(&body.permissions)?;
274274 self.require_grantable(actor_user_id, organization_id, &grants, request_id)
275275 .await?;
276 if find_by_key(organization_id, &key, self.db_conn().read())
276 if find_by_key(organization_id, &key, self.access.database.read())
277277 .await?
278278 .is_some()
279279 {
280280 return Err(SpaceError::SpaceRoleKeyTaken);
281281 }
282282
283 let txn = self.db_conn().write().begin().await?;
283 let txn = self.access.database.write().begin().await?;
284284 let role = space_roles::ActiveModel {
285285 organization_id: Set(organization_id),
286286 key: Set(key.clone()),
@@ -335,7 +335,7 @@
335335 request_id,
336336 )
337337 .await?;
338 let role = load_role(self.db_conn().read(), organization_id, role_id).await?;
338 let role = load_role(self.access.database.read(), organization_id, role_id).await?;
339339 if role.key == SYSTEM_ROLE_OWNER {
340340 return Err(SpaceError::SpaceRoleSystem);
341341 }
@@ -343,7 +343,7 @@
343343 self.require_grantable(actor_user_id, organization_id, &grants, request_id)
344344 .await?;
345345
346 let txn = self.db_conn().write().begin().await?;
346 let txn = self.access.database.write().begin().await?;
347347 replace_grants(&txn, role_id, &organization_id, &grants).await?;
348348 space_roles::Entity::update_many()
349349 .col_expr(
@@ -398,7 +398,7 @@
398398 ) -> SpaceResult<RoleSummary> {
399399 self.require_permission(actor_user_id, organization_id, "role.update", request_id)
400400 .await?;
401 let role = load_role(self.db_conn().read(), organization_id, role_id).await?;
401 let role = load_role(self.access.database.read(), organization_id, role_id).await?;
402402 let name = name.as_deref().map(normalize_role_name).transpose()?;
403403 if expected_version > 0 && role.version != expected_version {
404404 return Err(SpaceError::VersionMismatch {
@@ -415,7 +415,7 @@
415415 return Err(SpaceError::SpaceRoleSystem);
416416 }
417417
418 let txn = self.db_conn().write().begin().await?;
418 let txn = self.access.database.write().begin().await?;
419419 if let Some(true) = default_role {
420420 // 默认角色唯一:换默认之前先清掉旧的,否则新成员会被绑两个默认角色。
421421 space_roles::Entity::update_many()
@@ -481,19 +481,19 @@
481481 ) -> SpaceResult<bool> {
482482 self.require_permission(actor_user_id, organization_id, "role.delete", request_id)
483483 .await?;
484 let role = load_role(self.db_conn().read(), organization_id, role_id).await?;
484 let role = load_role(self.access.database.read(), organization_id, role_id).await?;
485485 if role.system {
486486 return Err(SpaceError::SpaceRoleSystem);
487487 }
488488 let bound = space_member_roles::Entity::find()
489489 .filter(space_member_roles::Column::RoleId.eq(role_id))
490 .count(self.db_conn().read())
490 .count(self.access.database.read())
491491 .await?;
492492 if bound > 0 {
493493 return Err(SpaceError::SpaceRoleInUse);
494494 }
495495
496 let txn = self.db_conn().write().begin().await?;
496 let txn = self.access.database.write().begin().await?;
497497 space_role_permissions::Entity::delete_many()
498498 .filter(space_role_permissions::Column::RoleId.eq(role_id))
499499 .exec(&txn)
@@ -532,15 +532,22 @@
532532 role_key: &str,
533533 request_id: Option<&str>,
534534 ) -> SpaceResult<Vec<String>> {
535 let member =
536 crate::services::member::load_by_id(self.db_conn().read(), organization_id, member_id)
537 .await?;
535 let member = crate::services::member::load_by_id(
536 self.access.database.read(),
537 organization_id,
538 member_id,
539 )
540 .await?;
538541 if member.banned_at.is_some() || member.left_at.is_some() {
539542 return Err(SpaceError::SpaceMemberNotFound);
540543 }
541 let role = find_by_key(organization_id, role_key.trim(), self.db_conn().read())
542 .await?
543 .ok_or(SpaceError::SpaceRoleNotFound)?;
544 let role = find_by_key(
545 organization_id,
546 role_key.trim(),
547 self.access.database.read(),
548 )
549 .await?
550 .ok_or(SpaceError::SpaceRoleNotFound)?;
544551 if role.key == SYSTEM_ROLE_OWNER {
545552 return Err(SpaceError::Forbidden);
546553 }
@@ -553,13 +560,13 @@
553560 .await?;
554561 // 系统角色等同于整套权限包:代绑时必须具备其中的每一条权限。
555562 if role.system {
556 let grants = grants_of(self.db_conn().read(), role.id).await?;
563 let grants = grants_of(self.access.database.read(), role.id).await?;
557564 self.require_grantable(actor_user_id, organization_id, &grants, request_id)
558565 .await?;
559566 }
560567 }
561568
562 let txn = self.db_conn().write().begin().await?;
569 let txn = self.access.database.write().begin().await?;
563570 bind_role(&txn, member_id, role.id, actor_user_id).await?;
564571 let version = bump_version(&txn, organization_id).await?;
565572 let keys = role_keys_of(&txn, member_id).await?;
@@ -592,15 +599,22 @@
592599 role_key: &str,
593600 request_id: Option<&str>,
594601 ) -> SpaceResult<Vec<String>> {
595 let role = find_by_key(organization_id, role_key.trim(), self.db_conn().read())
596 .await?
597 .ok_or(SpaceError::SpaceRoleNotFound)?;
602 let role = find_by_key(
603 organization_id,
604 role_key.trim(),
605 self.access.database.read(),
606 )
607 .await?
608 .ok_or(SpaceError::SpaceRoleNotFound)?;
598609 if role.key == SYSTEM_ROLE_OWNER {
599610 return Err(SpaceError::SpaceRoleSystem);
600611 }
601 let member =
602 crate::services::member::load_by_id(self.db_conn().read(), organization_id, member_id)
603 .await?;
612 let member = crate::services::member::load_by_id(
613 self.access.database.read(),
614 organization_id,
615 member_id,
616 )
617 .await?;
604618 if actor_user_id == member.user_id {
605619 if !role.self_assignable {
606620 return Err(SpaceError::Forbidden);
@@ -610,13 +624,13 @@
610624 self.require_permission(actor_user_id, organization_id, "role.assign", request_id)
611625 .await?;
612626 if role.system {
613 let grants = grants_of(self.db_conn().read(), role.id).await?;
627 let grants = grants_of(self.access.database.read(), role.id).await?;
614628 self.require_grantable(actor_user_id, organization_id, &grants, request_id)
615629 .await?;
616630 }
617631 }
618632
619 let txn = self.db_conn().write().begin().await?;
633 let txn = self.access.database.write().begin().await?;
620634 unbind_role(&txn, member_id, role.id).await?;
621635 let version = bump_version(&txn, organization_id).await?;
622636 let keys = role_keys_of(&txn, member_id).await?;
@@ -657,9 +671,12 @@
657671 request_id,
658672 )
659673 .await?;
660 let member =
661 crate::services::member::load_by_id(self.db_conn().read(), organization_id, member_id)
662 .await?;
674 let member = crate::services::member::load_by_id(
675 self.access.database.read(),
676 organization_id,
677 member_id,
678 )
679 .await?;
663680 if member.role == models::space::SpaceMemberRole::Owner {
664681 return Err(SpaceError::SpaceOwnerRequired);
665682 }
@@ -667,7 +684,7 @@
667684 self.require_grantable(actor_user_id, organization_id, &grants, request_id)
668685 .await?;
669686
670 let txn = self.db_conn().write().begin().await?;
687 let txn = self.access.database.write().begin().await?;
671688 models::space::space_member_permissions::Entity::delete_many()
672689 .filter(models::space::space_member_permissions::Column::MemberId.eq(member_id))
673690 .exec(&txn)
@@ -707,7 +724,7 @@
707724 let rows = models::space::space_member_permissions::Entity::find()
708725 .filter(models::space::space_member_permissions::Column::MemberId.eq(member_id))
709726 .order_by_asc(models::space::space_member_permissions::Column::Permission)
710 .all(self.db_conn().read())
727 .all(self.access.database.read())
711728 .await?;
712729 Ok(rows
713730 .into_iter()
@@ -743,7 +760,7 @@
743760 ) -> SpaceResult<RoleSummary> {
744761 self.require_permission(actor_user_id, organization_id, "role.view", request_id)
745762 .await?;
746 let row = load_role(self.db_conn().read(), organization_id, role_id).await?;
763 let row = load_role(self.access.database.read(), organization_id, role_id).await?;
747764 self.role_summary(&row).await
748765 }
749766
@@ -756,15 +773,15 @@
756773 role_id: Uuid,
757774 request_id: Option<&str>,
758775 ) -> SpaceResult<MemberSummary> {
759 let role = load_role(self.db_conn().read(), organization_id, role_id).await?;
776 let role = load_role(self.access.database.read(), organization_id, role_id).await?;
760777 if role.system || !role.self_assignable {
761778 return Err(SpaceError::Forbidden);
762779 }
763 let row = member::find_active(self.db_conn().read(), organization_id, actor_user_id)
780 let row = member::find_active(self.access.database.read(), organization_id, actor_user_id)
764781 .await?
765782 .ok_or(SpaceError::Forbidden)?;
766783
767 let txn = self.db_conn().write().begin().await?;
784 let txn = self.access.database.write().begin().await?;
768785 bind_role(&txn, row.id, role_id, actor_user_id).await?;
769786 let version = bump_version(&txn, organization_id).await?;
770787 let refreshed = member::load_by_id(&txn, organization_id, row.id).await?;
@@ -799,15 +816,15 @@
799816 role_id: Uuid,
800817 request_id: Option<&str>,
801818 ) -> SpaceResult<MemberSummary> {
802 let role = load_role(self.db_conn().read(), organization_id, role_id).await?;
819 let role = load_role(self.access.database.read(), organization_id, role_id).await?;
803820 if role.system || !role.self_assignable {
804821 return Err(SpaceError::Forbidden);
805822 }
806 let row = member::find_active(self.db_conn().read(), organization_id, actor_user_id)
823 let row = member::find_active(self.access.database.read(), organization_id, actor_user_id)
807824 .await?
808825 .ok_or(SpaceError::Forbidden)?;
809826
810 let txn = self.db_conn().write().begin().await?;
827 let txn = self.access.database.write().begin().await?;
811828 unbind_role(&txn, row.id, role_id).await?;
812829 let version = bump_version(&txn, organization_id).await?;
813830 let refreshed = member::load_by_id(&txn, organization_id, row.id).await?;
Modified lib/space/src/services/rooms.rs View lib/space/src/services/rooms.rs
@@ -1,76 +1,22 @@
1use models::room::room_members;
2use models::room::rooms::{self, RoomKind};
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, QuerySelect, Set,
5 TransactionTrait,
6};
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use utoipa::ToSchema;
1use sea_orm::TransactionTrait;
102use uuid::Uuid;
113
4use chat_room_store::SpaceContext;
5pub use chat_room_store::{CreateRoomCmd, RoomDto};
6
127use crate::authority;
138use crate::errors::SpaceError;
149use crate::services::service::SpaceService;
1510use crate::share::authz::{MANAGE_CHANNELS, require_perm};
1611use crate::share::lookup;
1712
18#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
19pub struct CreateRoomCmd {
20 pub name: String,
21 #[serde(default)]
22 pub kind: RoomKind,
23 #[serde(default)]
24 pub topic: Option<String>,
25 #[serde(default)]
26 pub is_private: bool,
27 #[serde(default)]
28 pub nsfw: bool,
29 #[serde(default)]
30 pub user_limit: Option<i32>,
31 #[serde(default)]
32 pub rate_limit_per_user: Option<i32>,
33 #[serde(default)]
34 pub parent_id: Option<Uuid>,
35}
36
37#[derive(Debug, Clone, Serialize, ToSchema)]
38pub struct RoomDto {
39 pub id: Uuid,
40 pub space_id: Option<Uuid>,
41 pub kind: RoomKind,
42 pub name: String,
43 pub topic: Option<String>,
44 pub position: i64,
45 pub parent_id: Option<Uuid>,
46 pub owner_id: i64,
47 pub is_private: bool,
48 pub nsfw: bool,
49 pub user_limit: Option<i32>,
50 pub rate_limit_per_user: Option<i32>,
51 pub is_active: bool,
52 pub created_at: OffsetDateTime,
53 pub updated_at: OffsetDateTime,
54}
55
56impl From<rooms::Model> for RoomDto {
57 fn from(r: rooms::Model) -> Self {
58 Self {
59 id: r.id,
60 space_id: r.space_id,
61 kind: r.kind,
62 name: r.name,
63 topic: r.topic,
64 position: r.position,
65 parent_id: r.parent_id,
66 owner_id: r.owner_id,
67 is_private: r.is_private,
68 nsfw: r.nsfw,
69 user_limit: r.user_limit,
70 rate_limit_per_user: r.rate_limit_per_user,
71 is_active: r.is_active,
72 created_at: r.created_at,
73 updated_at: r.updated_at,
13impl From<chat_room_store::Error> for SpaceError {
14 fn from(error: chat_room_store::Error) -> Self {
15 match error {
16 chat_room_store::Error::InvalidInput(message) => Self::BadRequest(message),
17 chat_room_store::Error::Conflict(message) => Self::Conflict(message),
18 chat_room_store::Error::NotFound => Self::NotFound,
19 chat_room_store::Error::Database(error) => Self::from(error),
7420 }
7521 }
7622}
@@ -83,263 +29,70 @@
8329 space_name: &str,
8430 cmd: CreateRoomCmd,
8531 ) -> Result<RoomDto, SpaceError> {
86 if !self
87 .state
88 .redis
89 .quota_allow(
90 &format!("quota:room_create:{caller}"),
91 self.config().quota.room_create_per_day,
92 86_400,
93 )
94 .await
95 {
96 return Err(SpaceError::RateLimited(
97 "room creation quota exceeded, retry tomorrow".to_string(),
98 ));
99 }
100 let space = lookup::lookup_space_by_name(self, caller, space_name).await?;
32 self.policy.allow_room_creation(caller).await?;
33 let space = lookup::lookup_space_by_name(self, space_name).await?;
10134 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
10235 if !authority::space_is_active(&space) {
10336 return Err(SpaceError::NotFound);
10437 }
105 if !matches!(
106 cmd.kind,
107 RoomKind::GuildRoom
108 | RoomKind::Text
109 | RoomKind::Category
110 | RoomKind::News
111 | RoomKind::Voice
112 | RoomKind::Stage
113 ) {
114 return Err(SpaceError::BadRequest(
115 "unsupported room kind: only guild_room, text, category, news, voice and stage can be created"
116 .to_string(),
117 ));
118 }
119
120 let name = cmd.name.trim();
121 if name.is_empty() || name.chars().count() > 100 {
122 return Err(SpaceError::BadRequest(
123 "room name is empty or too long".to_string(),
124 ));
125 }
126 let lower = name.to_lowercase();
127 if !{
128 let name = &lower;
129 !name.is_empty()
130 && name
131 .chars()
132 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
133 } {
134 return Err(SpaceError::BadRequest(
135 "invalid room name: must contain only lowercase letters, digits, and hyphens"
136 .to_string(),
137 ));
138 }
139
140 let existing = rooms::Entity::find()
141 .filter(rooms::Column::SpaceId.eq(space.id))
142 .filter(rooms::Column::LowerName.eq(&lower))
143 .one(self.db().read())
144 .await?;
145 if existing.is_some() {
146 return Err(SpaceError::Conflict(format!(
147 "room name {lower} already exists in this space"
148 )));
149 }
150
151 let now = OffsetDateTime::now_utc();
152 let room_id = Uuid::now_v7();
153
154 let max_pos: i64 = rooms::Entity::find()
155 .filter(rooms::Column::SpaceId.eq(space.id))
156 .select_only()
157 .column_as(
158 sea_orm::sea_query::Expr::col(rooms::Column::Position).max(),
159 "max_pos",
160 )
161 .into_tuple::<Option<i64>>()
162 .one(self.db().read())
163 .await?
164 .flatten()
165 .unwrap_or(-1);
166 let position = max_pos + 1;
167
168 let room_row = rooms::ActiveModel {
169 id: Set(room_id),
170 space_id: Set(Some(space.id)),
171 kind: Set(cmd.kind),
172 lower_name: Set(lower.clone()),
173 name: Set(name.to_string()),
174 topic: Set(cmd.topic.clone()),
175 position: Set(position),
176 parent_id: Set(cmd.parent_id),
177 owner_id: Set(caller),
178 is_private: Set(cmd.is_private),
179 nsfw: Set(cmd.nsfw),
180 user_limit: Set(cmd.user_limit),
181 rate_limit_per_user: Set(cmd.rate_limit_per_user),
182 bitrate: Set(None),
183 last_message_id: Set(None),
184 is_active: Set(true),
185 created_at: Set(now),
186 updated_at: Set(now),
187 };
188
189 let member_uids: Vec<i64> = if !cmd.is_private {
190 authority::list_members(&self.state, caller, space.id, false, false)
38 let members = if cmd.is_private {
39 Vec::new()
40 } else {
41 authority::list_members(self, space.id, false, false)
19142 .await?
19243 .into_iter()
19344 .map(|member| member.user_id)
19445 .collect()
195 } else {
196 vec![]
19746 };
198
199 let member_uids_clone = member_uids.clone();
200 let inserted = self
201 .db()
202 .primary()
203 .transaction::<_, rooms::Model, SpaceError>(|txn| {
47 let context = SpaceContext {
48 id: space.id,
49 lower_name: space.slug.clone(),
50 name: space.name.clone(),
51 avatar_url: authority::space_avatar(self, &space).await?,
52 };
53 let created = self
54 .access
55 .database
56 .write()
57 .transaction::<_, RoomDto, SpaceError>(move |tx| {
20458 Box::pin(async move {
205 let r = room_row.insert(txn).await?;
206 for &uid in &member_uids_clone {
207 let rm = room_members::ActiveModel {
208 role: Set(if uid == caller {
209 room_members::RoomMemberRole::Owner
210 } else {
211 room_members::RoomMemberRole::Member
212 }),
213 room_id: Set(room_id),
214 user_id: Set(uid),
215 nick: Set(None),
216 joined_at: Set(now),
217 left_at: Set(None),
218 created_at: Set(now),
219 updated_at: Set(Some(now)),
220 };
221 rm.insert(txn).await?;
222 }
223 Ok(r)
59 Ok(
60 chat_room_store::create_space_room(tx, &context, caller, &members, cmd)
61 .await?,
62 )
22463 })
22564 })
22665 .await?;
227
228 let actor = export::users::get_public_user(&self.state, caller)
229 .await
230 .ok()
231 .flatten()
232 .map(|user| {
233 serde_json::json!({
234 "id": user.user_id,
235 "username": user.username,
236 "display_name": user.displayname,
237 "avatar_url": export::resolve_user_avatar_url(user.user_id, &user.avatar_url),
238 })
239 });
240 let space_avatar = crate::authority::space_avatar(&self.state, &space).await?;
241 let request_id = export::middleware::request_id();
242 events::outbox::enqueue_chat_users(
243 self.db().write(),
244 "chat.membership.create",
245 &member_uids
246 .iter()
247 .copied()
248 .filter(|uid| *uid != caller)
249 .collect::<Vec<_>>(),
250 room_id,
251 &serde_json::json!({
252 "kind": "room_added",
253 "room_id": room_id,
254 "room": {
255 "id": inserted.id,
256 "space_id": inserted.space_id,
257 "kind": inserted.kind,
258 "name": &inserted.name,
259 "topic": &inserted.topic,
260 },
261 "space": {
262 "id": space.id,
263 "lower_name": &space.slug,
264 "name": &space.name,
265 "avatar_url": &space_avatar,
266 },
267 "actor": actor,
268 }),
269 request_id.as_deref(),
270 )
271 .await?;
272
273 Ok(RoomDto::from(inserted))
66 Ok(created)
27467 }
27568
27669 #[tracing::instrument(name = "space.delete_room", skip_all, fields(caller = %caller, room_id = %room_id))]
27770 pub async fn delete_room(&self, caller: i64, room_id: Uuid) -> Result<(), SpaceError> {
278 let room = rooms::Entity::find_by_id(room_id)
279 .one(self.db().read())
280 .await?
281 .ok_or(SpaceError::NotFound)?;
282
283 if !room.is_active {
284 return Err(SpaceError::NotFound);
285 }
286
287 let Some(space_id) = room.space_id else {
288 return Err(SpaceError::NotFound);
289 };
290 let space = authority::space_by_id(&self.state, caller, space_id).await?;
71 let room = chat_room_store::room(self.access.database.read(), room_id).await?;
72 let space_id = room.space_id.ok_or(SpaceError::NotFound)?;
73 let space = authority::space_by_id(self, space_id).await?;
29174 require_perm(self, &space, caller, MANAGE_CHANNELS).await?;
29275 if !authority::space_is_active(&space) {
29376 return Err(SpaceError::NotFound);
29477 }
295
296 let actor = export::users::get_public_user(&self.state, caller)
297 .await
298 .ok()
299 .flatten()
300 .map(|user| {
301 serde_json::json!({
302 "id": user.user_id,
303 "username": user.username,
304 "display_name": user.displayname,
305 "avatar_url": export::resolve_user_avatar_url(user.user_id, &user.avatar_url),
78 let context = SpaceContext {
79 id: space.id,
80 lower_name: space.slug.clone(),
81 name: space.name.clone(),
82 avatar_url: authority::space_avatar(self, &space).await?,
83 };
84 self.access
85 .database
86 .write()
87 .transaction::<_, (), SpaceError>(move |tx| {
88 Box::pin(async move {
89 Ok(
90 chat_room_store::deactivate_space_room(tx, &context, caller, room_id)
91 .await?,
92 )
30693 })
307 });
308 let room_detail = serde_json::json!({
309 "id": room.id,
310 "space_id": room.space_id,
311 "kind": room.kind,
312 "name": &room.name,
313 "topic": &room.topic,
314 });
315 let now = OffsetDateTime::now_utc();
316 let mut am: rooms::ActiveModel = room.into();
317 am.is_active = Set(false);
318 am.updated_at = Set(now);
319 am.update(self.db().write()).await?;
320
321 let space_avatar = crate::authority::space_avatar(&self.state, &space).await?;
322 let request_id = export::middleware::request_id();
323 events::outbox::enqueue_room(
324 self.db().write(),
325 "chat.room.update",
326 room_id,
327 serde_json::json!({
328 "room_id": room_id,
329 "deleted": true,
330 "room": room_detail,
331 "space": {
332 "id": space.id,
333 "lower_name": &space.slug,
334 "name": &space.name,
335 "avatar_url": &space_avatar,
336 },
337 "actor": actor,
338 }),
339 request_id.as_deref(),
340 )
341 .await?;
342
94 })
95 .await?;
34396 Ok(())
34497 }
34598}
Modified lib/space/src/services/service.rs View lib/space/src/services/service.rs
@@ -1,12 +1,8 @@
11use crate::errors::{SpaceError, SpaceResult};
22use crate::services::{error, member, permission, role};
3use cache::Cache;
43use chrono::Utc;
54use config::AppConfig;
6use config::auth::AuthConfig;
7use db::DbConn;
8use events::{EventHandler, Labels, label};
9use export::{AppState, Database};
5use events::{Labels, label};
106use models::users::users;
117use sea_orm::sea_query::{Expr, ExprTrait};
128use sea_orm::{
@@ -14,55 +10,29 @@
1410 QuerySelect, Set, TransactionTrait,
1511};
1612use serde::Serialize;
17use std::sync::Arc;
18use storage::BlobStore;
19use time::OffsetDateTime;
2013use uuid::Uuid;
2114
22async fn actor_details(
23 conn: &impl sea_orm::ConnectionTrait,
24 labels: &Labels,
25) -> Option<serde_json::Value> {
26 let uid = labels.get(label::USER_ID)?.parse::<i64>().ok()?;
27 let user = models::users::users::Entity::find_by_id(uid)
28 .one(conn)
29 .await
30 .ok()??;
31 Some(serde_json::json!({
32 "id": user.id,
33 "username": user.username,
34 "display_name": user.displayname,
35 "email": user.email,
36 "is_admin": user.is_admin,
37 "bot": user.bot,
38 }))
39}
40
4115#[derive(Clone)]
4216pub struct SpaceService {
43 pub state: AppState,
44 db_conn: DbConn,
17 pub(crate) access: crate::Access,
18 pub(crate) images: crate::services::images::SpaceImages,
19 pub(crate) policy: crate::services::policy::SpacePolicy,
20 pub(crate) notifications: export::notify::Notifications,
4521}
4622
4723impl SpaceService {
48 pub fn new(state: AppState) -> Self {
49 let db_conn = DbConn::new(
50 state.database.primary().clone(),
51 state.database.replicas().to_vec(),
52 );
53 Self { state, db_conn }
54 }
55
56 pub fn db_conn(&self) -> &DbConn {
57 &self.db_conn
58 }
59
60 pub fn cache(&self) -> &Cache {
61 &self.state.cache
62 }
63
64 pub fn events(&self) -> Option<&EventHandler> {
65 self.state.events.as_ref()
24 pub fn new(
25 access: crate::Access,
26 images: crate::services::images::SpaceImages,
27 policy: crate::services::policy::SpacePolicy,
28 notifications: export::notify::Notifications,
29 ) -> Self {
30 Self {
31 access,
32 images,
33 policy,
34 notifications,
35 }
6636 }
6737
6838 pub async fn emit_tx<T>(
@@ -76,101 +46,31 @@
7646 where
7747 T: Serialize,
7848 {
79 let trace_id = request_id
80 .map(str::to_owned)
81 .or_else(envelope::context::request_id)
82 .unwrap_or_default();
83 let mut payload = serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null);
84 if let Some(actor) = actor_details(conn, &labels).await {
85 payload["actor"] = actor;
86 }
87 events::outbox::enqueue_at(
49 events::emit(
8850 conn,
8951 kind,
90 labels,
91 &payload,
92 &trace_id,
93 chrono::Utc::now(),
94 request_id,
52 labels.with_opt(label::REQUEST_ID, request_id),
53 payload,
9554 )
9655 .await?;
9756 Ok(())
9857 }
99
100 pub fn config(&self) -> &AppConfig {
101 &self.state.config
102 }
103
104 pub fn auth(&self) -> &AuthConfig {
105 &self.state.config.auth
106 }
107
108 pub async fn require_session(
109 &self,
110 session: &session::Session,
111 _request_id: Option<&str>,
112 ) -> Result<i64, SpaceError> {
113 let user_id = session.user_id()?.ok_or(SpaceError::Unauthorized)?;
114 let version = session
115 .security_version()?
116 .ok_or(SpaceError::Unauthorized)?;
117 let id = session
118 .get::<String>("auth_session_id")?
119 .ok_or(SpaceError::Unauthorized)?;
120 let kenauth = self
121 .state
122 .kenauth
123 .as_deref()
124 .ok_or_else(|| SpaceError::Internal("kenoauth is not configured".to_owned()))?;
125 let _ = kenauth.get_session(user_id, version, &id).await?;
126 Ok(user_id)
127 }
128
129 pub fn db(&self) -> &Arc<dyn Database> {
130 &self.state.database
131 }
132
133 pub fn storage(&self) -> &dyn BlobStore {
134 self.state.storage.as_ref()
135 }
136
137 /// Language for one recipient's space mail.
138 pub(crate) async fn recipient_locale(&self, uid: Option<i64>) -> i18n::UiLocale {
139 let fallback = i18n::config_fallback(self.config().email.default_locale());
140 match uid {
141 Some(uid) => i18n::resolve_user_locale(self.db().read(), uid, fallback).await,
142 None => fallback,
143 }
144 }
14558}
14659
14760impl SpaceService {
14861 pub(crate) async fn grant_public_room_memberships(&self, space_id: Uuid, user_id: i64) {
149 let public_rooms = models::room::rooms::Entity::find()
150 .filter(models::room::rooms::Column::SpaceId.eq(space_id))
151 .filter(models::room::rooms::Column::IsPrivate.eq(false))
152 .filter(models::room::rooms::Column::IsActive.eq(true))
153 .all(self.db().read())
62 let result = self
63 .access
64 .database
65 .write()
66 .transaction::<_, (), SpaceError>(move |tx| {
67 Box::pin(async move {
68 Ok(chat_room_store::grant_public_memberships(tx, space_id, user_id).await?)
69 })
70 })
15471 .await;
155 let Ok(public_rooms) = public_rooms else {
156 tracing::warn!(%space_id, %user_id, "public room lookup failed");
157 return;
158 };
159 for room in &public_rooms {
160 let now = OffsetDateTime::now_utc();
161 let rm = models::room::room_members::ActiveModel {
162 role: Set(models::room::room_members::RoomMemberRole::Member),
163 room_id: Set(room.id),
164 user_id: Set(user_id),
165 nick: Set(None),
166 joined_at: Set(now),
167 left_at: Set(None),
168 created_at: Set(now),
169 updated_at: Set(Some(now)),
170 };
171 if let Err(e) = rm.insert(self.db().write()).await {
172 tracing::warn!(error = %e, room_id = %room.id, %user_id, "failed to create room_members record");
173 }
72 if let Err(error) = result {
73 tracing::warn!(%error, %space_id, %user_id, "public room membership grant failed");
17474 }
17575 }
17676
@@ -181,7 +81,7 @@
18181 std::collections::HashMap<i64, crate::services::dto::UserDto>,
18282 crate::errors::SpaceError,
18383 > {
184 Ok(export::users::load_user_briefs(&self.state, uids).await?)
84 Ok(auth::users::load_user_briefs(&self.policy.accounts, uids).await?)
18585 }
18686}
18787
@@ -205,14 +105,12 @@
205105 /// 组织写操作的统一收尾:推进 version,然后清掉本组织全部派生缓存。
206106 /// version 同时是权限缓存的版本键与乐观锁基准,必须与写操作同事务推进。
207107 pub(crate) async fn invalidate_org(&self, organization_id: Uuid) -> SpaceResult<()> {
208 let members = space_members::Entity::find()
209 .select_only()
210 .column(space_members::Column::UserId)
211 .filter(space_members::Column::OrganizationId.eq(organization_id))
212 .into_tuple::<i64>()
213 .all(self.db_conn().read())
214 .await?;
215 self.drop_org_cache(organization_id, members).await
108 crate::services::permission::invalidate(
109 &self.access.database,
110 &self.access.cache,
111 organization_id,
112 )
113 .await
216114 }
217115
218116 /// 成员行已随事务删除时用这一版:user_id 由调用方在事务内收集后传入。
@@ -221,7 +119,7 @@
221119 organization_id: Uuid,
222120 members: Vec<i64>,
223121 ) -> SpaceResult<()> {
224 let cache = self.cache();
122 let cache = &self.access.cache;
225123 for key in [
226124 format!("auth:org:{organization_id}"),
227125 format!("auth:org:summary:{organization_id}"),
@@ -246,7 +144,7 @@
246144 .select_only()
247145 .column(spaces::Column::Version)
248146 .into_tuple::<i32>()
249 .one(self.db_conn().read())
147 .one(self.access.database.read())
250148 .await?)
251149 }
252150
@@ -274,7 +172,7 @@
274172 .select_only()
275173 .column(users::Column::IsAdmin)
276174 .into_tuple::<bool>()
277 .one(self.db_conn().read())
175 .one(self.access.database.read())
278176 .await?
279177 .unwrap_or(false))
280178 }
@@ -345,12 +243,12 @@
345243
346244 users::Entity::find_by_id(actor_user_id)
347245 .filter(users::Column::DeletedAt.is_null())
348 .one(self.db_conn().read())
246 .one(self.access.database.read())
349247 .await?
350248 .ok_or(SpaceError::UserNotFound)?;
351249 if spaces::Entity::find()
352250 .filter(spaces::Column::Slug.eq(slug.clone()))
353 .one(self.db_conn().read())
251 .one(self.access.database.read())
354252 .await?
355253 .is_some()
356254 {
@@ -364,7 +262,7 @@
364262 };
365263 let allow_join_level = valid_join_level(body.allow_join_level.unwrap_or(0))?;
366264
367 let txn = self.db_conn().write().begin().await?;
265 let txn = self.access.database.write().begin().await?;
368266 let organization = spaces::ActiveModel {
369267 slug: Set(slug),
370268 name: Set(name),
@@ -428,14 +326,14 @@
428326 (Some(id), _) => {
429327 spaces::Entity::find_by_id(id)
430328 .filter(spaces::Column::DeletedAt.is_null())
431 .one(self.db_conn().read())
329 .one(self.access.database.read())
432330 .await?
433331 }
434332 (None, Some(slug)) => {
435333 spaces::Entity::find()
436334 .filter(spaces::Column::Slug.eq(slug.to_owned()))
437335 .filter(spaces::Column::DeletedAt.is_null())
438 .one(self.db_conn().read())
336 .one(self.access.database.read())
439337 .await?
440338 }
441339 (None, None) => None,
@@ -447,7 +345,9 @@
447345 .await?;
448346 // 非成员只看公开字段:my_role 为空即可,不额外补一层 DTO。
449347 let detail =
450 match member::find_any(self.db_conn().read(), organization.id, actor_user_id).await? {
348 match member::find_any(self.access.database.read(), organization.id, actor_user_id)
349 .await?
350 {
451351 Some(row) => Some(self.member_summary(&row).await?),
452352 None => None,
453353 };
@@ -475,12 +375,14 @@
475375 .filter(space_members::Column::OrganizationId.eq(organization.id))
476376 .filter(space_members::Column::LeftAt.is_null())
477377 .filter(space_members::Column::BannedAt.is_null())
478 .count(self.db_conn().read())
378 .count(self.access.database.read())
479379 .await? as i64;
480380 let my_role = match actor_user_id {
481 Some(user_id) => member::find_active(self.db_conn().read(), organization.id, user_id)
482 .await?
483 .map(|row| row.role),
381 Some(user_id) => {
382 member::find_active(self.access.database.read(), organization.id, user_id)
383 .await?
384 .map(|row| row.role)
385 }
484386 None => None,
485387 };
486388 Ok(OrganizationSummary {
@@ -530,7 +432,7 @@
530432 .filter(space_members::Column::LeftAt.is_null())
531433 .filter(space_members::Column::BannedAt.is_null())
532434 .into_tuple::<Uuid>()
533 .all(self.db_conn().read())
435 .all(self.access.database.read())
534436 .await?;
535437 if ids.is_empty() {
536438 return Ok(Vec::new());
@@ -546,7 +448,7 @@
546448 .order_by_desc(spaces::Column::UpdatedAt)
547449 .limit(limit)
548450 .offset(offset)
549 .all(self.db_conn().read())
451 .all(self.access.database.read())
550452 .await?;
551453
552454 let mut out = Vec::with_capacity(rows.len());
@@ -579,7 +481,7 @@
579481 .order_by_desc(spaces::Column::CreatedAt)
580482 .limit(limit)
581483 .offset(offset)
582 .all(self.db_conn().read())
484 .all(self.access.database.read())
583485 .await?;
584486 let mut out = Vec::with_capacity(rows.len());
585487 for row in rows {
@@ -611,7 +513,7 @@
611513 None => None,
612514 };
613515
614 let txn = self.db_conn().write().begin().await?;
516 let txn = self.access.database.write().begin().await?;
615517 let current = spaces::Entity::find_by_id(organization_id)
616518 .filter(spaces::Column::DeletedAt.is_null())
617519 .one(&txn)
@@ -699,7 +601,7 @@
699601 .await?;
700602 let reason = normalize_reason(reason)?;
701603
702 let txn = self.db_conn().write().begin().await?;
604 let txn = self.access.database.write().begin().await?;
703605 spaces::Entity::update_many()
704606 .col_expr(spaces::Column::Status, Expr::value(SpaceStatus::Archived))
705607 .col_expr(spaces::Column::ArchivedAt, Expr::value(Utc::now()))
@@ -749,7 +651,7 @@
749651 return Err(SpaceError::Forbidden);
750652 }
751653
752 let txn = self.db_conn().write().begin().await?;
654 let txn = self.access.database.write().begin().await?;
753655 spaces::Entity::update_many()
754656 .col_expr(spaces::Column::Status, Expr::value(SpaceStatus::Active))
755657 .col_expr(
@@ -810,14 +712,14 @@
810712 .filter(space_members::Column::OrganizationId.eq(organization_id))
811713 .filter(space_members::Column::LeftAt.is_null())
812714 .filter(space_members::Column::BannedAt.is_null())
813 .count(self.db_conn().read())
715 .count(self.access.database.read())
814716 .await?;
815717 if active > 0 {
816718 return Err(SpaceError::SpaceHasMembers);
817719 }
818720 }
819721
820 let txn = self.db_conn().write().begin().await?;
722 let txn = self.access.database.write().begin().await?;
821723 let member_ids = space_members::Entity::find()
822724 .select_only()
823725 .column(space_members::Column::Id)
@@ -920,19 +822,26 @@
920822 if target_user_id == organization.owner_user_id {
921823 return Err(SpaceError::SpaceMemberExists);
922824 }
923 let target = member::find_active(self.db_conn().read(), organization_id, target_user_id)
924 .await?
925 .ok_or(SpaceError::SpaceMemberNotFound)?;
926 let owner_role =
927 role::find_by_key(organization_id, SYSTEM_ROLE_OWNER, self.db_conn().read())
825 let target =
826 member::find_active(self.access.database.read(), organization_id, target_user_id)
928827 .await?
929 .ok_or_else(|| SpaceError::internal("owner role is missing"))?;
930 let admin_role =
931 role::find_by_key(organization_id, SYSTEM_ROLE_ADMIN, self.db_conn().read())
932 .await?
933 .ok_or_else(|| SpaceError::internal("admin role is missing"))?;
828 .ok_or(SpaceError::SpaceMemberNotFound)?;
829 let owner_role = role::find_by_key(
830 organization_id,
831 SYSTEM_ROLE_OWNER,
832 self.access.database.read(),
833 )
834 .await?
835 .ok_or_else(|| SpaceError::internal("owner role is missing"))?;
836 let admin_role = role::find_by_key(
837 organization_id,
838 SYSTEM_ROLE_ADMIN,
839 self.access.database.read(),
840 )
841 .await?
842 .ok_or_else(|| SpaceError::internal("admin role is missing"))?;
934843
935 let txn = self.db_conn().write().begin().await?;
844 let txn = self.access.database.write().begin().await?;
936845 // 唯一 OWNER 的不变式落在两处:spaces.owner_user_id 与成员行的粗粒度角色。
937846 // 两个改写放在同一事务里,读到中间态的可能性为零。
938847 if let Some(previous) =
@@ -984,14 +893,14 @@
984893 pub async fn load_organization(&self, organization_id: Uuid) -> SpaceResult<spaces::Model> {
985894 spaces::Entity::find_by_id(organization_id)
986895 .filter(spaces::Column::DeletedAt.is_null())
987 .one(self.db_conn().read())
896 .one(self.access.database.read())
988897 .await?
989898 .ok_or(SpaceError::SpaceNotFound)
990899 }
991900
992901 pub async fn member_summary(&self, row: &space_members::Model) -> SpaceResult<MemberSummary> {
993902 let user = users::Entity::find_by_id(row.user_id)
994 .one(self.db_conn().read())
903 .one(self.access.database.read())
995904 .await?;
996905 Ok(MemberSummary {
997906 id: row.id,
@@ -1003,8 +912,8 @@
1003912 .unwrap_or_default(),
1004913 displayname: user.and_then(|u| u.displayname),
1005914 role: row.role,
1006 role_keys: role::role_keys_of(self.db_conn().read(), row.id).await?,
1007 permissions: permission::effective(self.db_conn().read(), row).await?,
915 role_keys: role::role_keys_of(self.access.database.read(), row.id).await?,
916 permissions: permission::effective(self.access.database.read(), row).await?,
1008917 joined_at: row.joined_at,
1009918 left_at: row.left_at,
1010919 banned_at: row.banned_at,
@@ -1031,10 +940,10 @@
1031940 }
1032941
1033942 pub(crate) async fn role_summary(&self, row: &space_roles::Model) -> SpaceResult<RoleSummary> {
1034 let permissions = role::grants_of(self.db_conn().read(), row.id).await?;
943 let permissions = role::grants_of(self.access.database.read(), row.id).await?;
1035944 let member_count = space_member_roles::Entity::find()
1036945 .filter(space_member_roles::Column::RoleId.eq(row.id))
1037 .count(self.db_conn().read())
946 .count(self.access.database.read())
1038947 .await? as i64;
1039948 Ok(RoleSummary {
1040949 id: row.id,
@@ -1063,7 +972,7 @@
1063972 .filter(space_roles::Column::OrganizationId.eq(organization_id))
1064973 .filter(space_roles::Column::DefaultRole.eq(true))
1065974 .order_by_asc(space_roles::Column::Rank)
1066 .one(self.db_conn().read())
975 .one(self.access.database.read())
1067976 .await?
1068977 .map(|row| row.id))
1069978 }
@@ -1194,3 +1103,19 @@
11941103 .then_some(value)
11951104 .ok_or(SpaceError::MalformedRequest)
11961105}
1106
1107#[derive(Clone)]
1108pub struct SpaceSettings {
1109 pub space: config::space::SpaceConfig,
1110 pub quota: config::quota::QuotaConfig,
1111 pub email: config::email::EmailConfig,
1112}
1113impl SpaceSettings {
1114 pub fn from_config(config: &AppConfig) -> Self {
1115 Self {
1116 space: config.space.clone(),
1117 quota: config.quota.clone(),
1118 email: config.email.clone(),
1119 }
1120 }
1121}
Modified lib/space/src/share/authz.rs View lib/space/src/share/authz.rs
@@ -1,6 +1,5 @@
11use std::collections::HashSet;
22
3use export::AppState;
43use models::space::spaces;
54
65use crate::authority;
@@ -60,11 +59,11 @@
6059}
6160
6261pub async fn assert_not_banned(
63 state: &AppState,
62 state: &SpaceService,
6463 space_id: uuid::Uuid,
6564 user_id: i64,
6665) -> Result<(), SpaceError> {
67 if let Ok(Some(member)) = authority::member(state, user_id, space_id, user_id).await
66 if let Ok(Some(member)) = authority::member(state, space_id, user_id).await
6867 && member.status() == models::space::MemberStatus::Banned
6968 {
7069 return Err(SpaceError::Forbidden);
@@ -72,24 +71,21 @@
7271 Ok(())
7372}
7473
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.
7874pub async fn effective_permissions(
79 state: &AppState,
75 state: &SpaceService,
8076 space: &spaces::Model,
8177 user_id: i64,
8278) -> Result<Permissions, SpaceError> {
83 let names: Vec<String> = ALL_PERMISSIONS
84 .iter()
85 .map(|action| format!("space.{action}"))
86 .collect();
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();
79 let granted = crate::services::permission::effective_for(
80 &state.access.database,
81 &state.access.cache,
82 space.id,
83 user_id,
84 )
85 .await?
86 .into_iter()
87 .filter_map(|permission| permission.strip_prefix("space.").map(str::to_owned))
88 .collect::<Vec<_>>();
9389 if granted.is_empty() {
9490 return Err(SpaceError::Forbidden);
9591 }
@@ -102,7 +98,7 @@
10298 caller: i64,
10399 action: &str,
104100) -> Result<(), SpaceError> {
105 let perms = effective_permissions(&service.state, space, caller).await?;
101 let perms = effective_permissions(service, space, caller).await?;
106102 if !perms.has(action) {
107103 return Err(SpaceError::Forbidden);
108104 }
Modified lib/space/src/share/lookup.rs View lib/space/src/share/lookup.rs
@@ -5,15 +5,14 @@
55use crate::services::service::SpaceService;
66
77pub async fn find_active_space_by_name(
8 state: &export::AppState,
9 caller: i64,
8 state: &SpaceService,
109 space_name: &str,
1110) -> Result<spaces::Model, SpaceError> {
1211 let lower_name = space_name.trim().to_lowercase();
1312 if lower_name.is_empty() {
1413 return Err(SpaceError::NotFound);
1514 }
16 let space = authority::space_by_slug(state, caller, &lower_name).await?;
15 let space = authority::space_by_slug(state, &lower_name).await?;
1716 if !authority::space_is_active(&space) {
1817 return Err(SpaceError::NotFound);
1918 }
@@ -22,8 +21,7 @@
2221
2322pub async fn lookup_space_by_name(
2423 service: &SpaceService,
25 caller: i64,
2624 space_name: &str,
2725) -> Result<spaces::Model, SpaceError> {
28 find_active_space_by_name(&service.state, caller, space_name).await
26 find_active_space_by_name(service, space_name).await
2927}
Added lib/space/src/access.rs View lib/space/src/access.rs

Diff too large to display.

Modified lib/space/src/authority.rs View lib/space/src/authority.rs
@@ -1,27 +1,14 @@
1use std::collections::HashMap;
2
3use export::AppState;
41use models::space::space_extras::{self, Entity as SpaceExtras};
52use models::space::space_members::{self, Entity as Members};
63use models::space::spaces::{self, Entity as Spaces};
7use models::space::{AllowJoinLevel, SpaceJoinPolicy, SpaceStatus};
4use models::space::{SpaceJoinPolicy, SpaceStatus};
85use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set};
96use time::OffsetDateTime;
107use uuid::Uuid;
118
129use crate::errors::SpaceError;
13use crate::services::dto::{SpaceDto, UserDto, resolve_space_image_url};
1410use crate::services::service::SpaceService;
1511
16pub const RPC_KEY: &str = "kenoauth is not configured";
17
18pub fn client(state: &AppState) -> Result<&dyn export::AuthApi, SpaceError> {
19 state
20 .kenauth
21 .as_deref()
22 .ok_or_else(|| SpaceError::Internal(RPC_KEY.to_string()))
23}
24
2512pub fn to_time(value: chrono::DateTime<chrono::Utc>) -> OffsetDateTime {
2613 OffsetDateTime::from_unix_timestamp(value.timestamp())
2714 .map(|row| {
@@ -33,39 +20,6 @@
3320
3421pub fn to_time_opt(value: Option<chrono::DateTime<chrono::Utc>>) -> Option<OffsetDateTime> {
3522 value.map(to_time)
36}
37
38pub fn space_dto(
39 record: &spaces::Model,
40 extras: Option<&space_extras::Model>,
41 nums_members: i64,
42 owners: &HashMap<i64, UserDto>,
43) -> SpaceDto {
44 SpaceDto {
45 id: record.id,
46 lower_name: record.slug.clone(),
47 name: record.name.clone(),
48 description: record.description.clone(),
49 owner: owners.get(&record.owner_user_id).cloned(),
50 avatar_url: resolve_space_image_url(
51 &record.slug,
52 "avatar",
53 extras
54 .and_then(|row| row.avatar_url.clone())
55 .or_else(|| record.avatar_url.clone()),
56 ),
57 is_private: record.join_policy == SpaceJoinPolicy::Private,
58 is_active: record.status == SpaceStatus::Active && record.archived_at.is_none(),
59 allow_join: record.allow_join,
60 allow_invite: record.allow_invite,
61 members_count: nums_members,
62 welcome_message: record.welcome_message.clone(),
63 allow_join_level: AllowJoinLevel::from_i16(record.allow_join_level),
64 member_must_enable_2fa: record.member_must_enable_2fa,
65 welcome_object: extras.and_then(|row| row.welcome_object),
66 created_at: to_time(record.created_at),
67 updated_at: to_time(record.updated_at),
68 }
6923}
7024
7125pub fn space_is_active(space: &spaces::Model) -> bool {
@@ -73,88 +27,79 @@
7327}
7428
7529pub async fn extras(
76 state: &AppState,
30 db: &impl sea_orm::ConnectionTrait,
7731 space_id: Uuid,
7832) -> Result<Option<space_extras::Model>, SpaceError> {
79 Ok(SpaceExtras::find_by_id(space_id)
80 .one(state.database.read())
81 .await?)
33 Ok(SpaceExtras::find_by_id(space_id).one(db).await?)
8234}
8335
8436pub async fn space_avatar(
85 state: &AppState,
37 state: &SpaceService,
8638 space: &spaces::Model,
8739) -> Result<Option<String>, SpaceError> {
88 Ok(extras(state, space.id)
40 Ok(extras(state.access.database.read(), space.id)
8941 .await?
9042 .and_then(|row| row.avatar_url)
9143 .or_else(|| space.avatar_url.clone()))
9244}
9345
94pub async fn member_count(state: &AppState, space_id: Uuid) -> Result<i64, SpaceError> {
46pub async fn member_count(state: &SpaceService, space_id: Uuid) -> Result<i64, SpaceError> {
9547 use sea_orm::PaginatorTrait;
9648
9749 let count = Members::find()
9850 .filter(space_members::Column::OrganizationId.eq(space_id))
9951 .filter(space_members::Column::LeftAt.is_null())
10052 .filter(space_members::Column::BannedAt.is_null())
101 .count(state.database.read())
53 .count(state.access.database.read())
10254 .await?;
10355 Ok(count as i64)
10456}
10557
10658pub async fn space_by_id(
107 state: &AppState,
108 _actor: i64,
59 state: &SpaceService,
10960 space_id: Uuid,
11061) -> Result<spaces::Model, SpaceError> {
11162 Spaces::find_by_id(space_id)
11263 .filter(spaces::Column::DeletedAt.is_null())
113 .one(state.database.read())
64 .one(state.access.database.read())
11465 .await?
11566 .ok_or(SpaceError::NotFound)
11667}
11768
118pub async fn space_by_slug(
119 state: &AppState,
120 _actor: i64,
121 slug: &str,
122) -> Result<spaces::Model, SpaceError> {
69pub async fn space_by_slug(state: &SpaceService, slug: &str) -> Result<spaces::Model, SpaceError> {
12370 Spaces::find()
12471 .filter(spaces::Column::Slug.eq(slug.trim().to_lowercase()))
12572 .filter(spaces::Column::DeletedAt.is_null())
126 .one(state.database.read())
73 .one(state.access.database.read())
12774 .await?
12875 .ok_or(SpaceError::NotFound)
12976}
13077
13178pub async fn member(
132 state: &AppState,
133 _actor: i64,
79 state: &SpaceService,
13480 space_id: Uuid,
13581 user_id: i64,
13682) -> Result<Option<space_members::Model>, SpaceError> {
13783 Ok(Members::find()
13884 .filter(space_members::Column::OrganizationId.eq(space_id))
13985 .filter(space_members::Column::UserId.eq(user_id))
140 .one(state.database.read())
86 .one(state.access.database.read())
14187 .await?)
14288}
14389
14490pub async fn require_member(
145 state: &AppState,
91 state: &SpaceService,
14692 space_id: Uuid,
14793 user_id: i64,
14894) -> Result<space_members::Model, SpaceError> {
149 match member(state, user_id, space_id, user_id).await? {
95 match member(state, space_id, user_id).await? {
15096 Some(member) if member.status() == models::space::MemberStatus::Active => Ok(member),
15197 _ => Err(SpaceError::Forbidden),
15298 }
15399}
154100
155101pub async fn list_members(
156 state: &AppState,
157 _actor: i64,
102 state: &SpaceService,
158103 space_id: Uuid,
159104 include_inactive: bool,
160105 include_banned: bool,
@@ -166,12 +111,11 @@
166111 if !include_banned {
167112 query = query.filter(space_members::Column::BannedAt.is_null());
168113 }
169 Ok(query.all(state.database.read()).await?)
114 Ok(query.all(state.access.database.read()).await?)
170115}
171116
172117pub async fn list_user_spaces(
173 state: &AppState,
174 _actor: i64,
118 state: &SpaceService,
175119 user_id: i64,
176120) -> Result<Vec<spaces::Model>, SpaceError> {
177121 let space_ids: Vec<Uuid> = Members::find()
@@ -181,14 +125,14 @@
181125 .select_only()
182126 .column(space_members::Column::OrganizationId)
183127 .into_tuple()
184 .all(state.database.read())
128 .all(state.access.database.read())
185129 .await?;
186130 spaces_by_ids(state, space_ids).await
187131}
188132
189133/// Public directory (ACTIVE, joinable). Presentation fields come from the extras row.
190134pub async fn list_public_spaces(
191 state: &AppState,
135 state: &SpaceService,
192136 query: &str,
193137) -> Result<Vec<spaces::Model>, SpaceError> {
194138 use sea_orm::Condition;
@@ -207,25 +151,31 @@
207151 .add(spaces::Column::Slug.contains(needle)),
208152 );
209153 }
210 Ok(select.limit(1_000).all(state.database.read()).await?)
154 Ok(select
155 .limit(1_000)
156 .all(state.access.database.read())
157 .await?)
211158}
212159
213async fn spaces_by_ids(state: &AppState, ids: Vec<Uuid>) -> Result<Vec<spaces::Model>, SpaceError> {
160async fn spaces_by_ids(
161 state: &SpaceService,
162 ids: Vec<Uuid>,
163) -> Result<Vec<spaces::Model>, SpaceError> {
214164 if ids.is_empty() {
215165 return Ok(Vec::new());
216166 }
217167 Ok(Spaces::find()
218168 .filter(spaces::Column::Id.is_in(ids))
219169 .filter(spaces::Column::DeletedAt.is_null())
220 .all(state.database.read())
170 .all(state.access.database.read())
221171 .await?)
222172}
223173
224174pub async fn account_created_at(
225 state: &AppState,
175 accounts: &auth::Accounts,
226176 user_id: i64,
227177) -> Result<Option<OffsetDateTime>, SpaceError> {
228 let user = client(state)?.get_authority_user(user_id).await?;
178 let user = accounts.get_authority_user(user_id).await?;
229179 let Some(created) = user.created_at else {
230180 return Ok(None);
231181 };
@@ -237,31 +187,21 @@
237187 }))
238188}
239189
240pub async fn mfa_enabled(state: &AppState, user_id: i64) -> Result<bool, SpaceError> {
241 let status = client(state)?.get_user_status(user_id).await?;
190pub async fn mfa_enabled(accounts: &auth::Accounts, user_id: i64) -> Result<bool, SpaceError> {
191 let status = accounts.get_user_status(user_id).await?;
242192 Ok(status.active_mfa_factors > 0)
243193}
244194
245pub async fn has_verified_email(state: &AppState, user_id: i64) -> Result<bool, SpaceError> {
246 let status = client(state)?.get_user_status(user_id).await?;
195pub async fn has_verified_email(
196 accounts: &auth::Accounts,
197 user_id: i64,
198) -> Result<bool, SpaceError> {
199 let status = accounts.get_user_status(user_id).await?;
247200 Ok(status.email_verified_at.is_some())
248201}
249202
250pub async fn permissions(
251 state: &AppState,
252 space_id: Uuid,
253 user_id: i64,
254 actions: &[String],
255) -> Result<Vec<bool>, SpaceError> {
256 let service = SpaceService::new(state.clone());
257 let checks = service
258 .check_permissions(user_id, space_id, user_id, actions, None)
259 .await?;
260 Ok(checks.into_iter().map(|check| check.allowed).collect())
261}
262
263203pub async fn upsert_extras(
264 state: &AppState,
204 db: &db::DbConn,
265205 space_id: Uuid,
266206 avatar_url: Option<Option<String>>,
267207 welcome_object: Option<Option<Uuid>>,
@@ -277,7 +217,7 @@
277217 invite_sort_url_hash: Option<Option<String>>,
278218) -> Result<space_extras::Model, SpaceError> {
279219 let now = OffsetDateTime::now_utc();
280 let existing = extras(state, space_id).await?;
220 let existing = extras(db.read(), space_id).await?;
281221 let row = match existing {
282222 Some(row) => {
283223 let mut active: space_extras::ActiveModel = row.into();
@@ -318,7 +258,7 @@
318258 active.invite_sort_url_hash = Set(value);
319259 }
320260 active.updated_at = Set(now);
321 active.update(state.database.write()).await?
261 active.update(db.write()).await?
322262 }
323263 None => {
324264 space_extras::ActiveModel {
@@ -338,7 +278,7 @@
338278 created_at: Set(now),
339279 updated_at: Set(now),
340280 }
341 .insert(state.database.write())
281 .insert(db.write())
342282 .await?
343283 }
344284 };
Modified lib/space/src/errors.rs View lib/space/src/errors.rs
@@ -148,84 +148,22 @@
148148 NotFound(404, "resource not found"),
149149 Conflict(409, "resource conflict"),
150150 TooManyRequests(429, "rate limit exceeded"),
151 PreconditionFailed(412, "precondition failed"),
151152 Internal(500, "internal server error"),
152153 }
153154}
154155
155156impl From<SpaceError> for ApiError {
156157 fn from(error: SpaceError) -> Self {
157 match error {
158 SpaceError::BadRequest(msg) => Self::bad_request(msg),
159 SpaceError::Unauthorized => Self::unauthorized(),
160 SpaceError::Forbidden => Self::forbidden(),
161 SpaceError::JoinNotAllowed(msg) => Self::forbidden_with_code("join_not_allowed", msg),
162 SpaceError::JoinRequirementNotMet(msg) => {
163 Self::forbidden_with_code("join_requirement_not_met", msg)
164 }
165 SpaceError::JoinRequestCooldown(msg) => {
166 Self::forbidden_with_code("join_request_cooldown", msg)
167 }
168 SpaceError::NotFound => Self::not_found("space resource not found"),
169 SpaceError::Conflict(msg) => Self::conflict(msg),
170 SpaceError::RateLimited(msg) => Self::rate_limited(msg),
171 SpaceError::Internal(message) => Self::internal(message),
172 SpaceError::MalformedRequest => Self::bad_request("request payload is malformed"),
173 SpaceError::InvalidEmail => Self::bad_request("email is invalid"),
174 SpaceError::InvalidPermission { permission } => {
175 Self::bad_request(format!("permission `{permission}` is not recognized"))
176 }
177 SpaceError::SpaceNotFound => Self::not_found("space does not exist"),
178 SpaceError::SpaceMemberNotFound => Self::not_found("space member does not exist"),
179 SpaceError::SpaceRoleNotFound => Self::not_found("space role does not exist"),
180 SpaceError::SpaceInviteNotFound => Self::not_found("space invite does not exist"),
181 SpaceError::UserNotFound => Self::not_found("user does not exist"),
182 SpaceError::SpaceSlugTaken => Self::conflict("space slug is already taken"),
183 SpaceError::SpaceMemberExists => {
184 Self::conflict("user is already a member of this space")
185 }
186 SpaceError::SpaceRoleKeyTaken => Self::conflict("space role key is already used"),
187 SpaceError::SpaceHasMembers => Self::conflict("space still has active members"),
188 SpaceError::VersionMismatch { .. } => Self::build(
189 409,
190 Self::payload(409, "resource version mismatch").with_error_code("version_mismatch"),
191 ),
192 SpaceError::SpaceInactive => {
193 Self::forbidden_with_code("space_inactive", "space is not active".to_string())
194 }
195 SpaceError::SpaceMemberBanned => Self::forbidden_with_code(
196 "space_member_banned",
197 "space membership is banned".to_string(),
198 ),
199 SpaceError::ScopeInsufficient { required } => Self::forbidden_with_code(
200 "scope_insufficient",
201 format!("scope `{required}` is missing"),
202 ),
203 SpaceError::SpaceRoleInUse => Self::build(
204 412,
205 Self::payload(412, "space role is still assigned to members")
206 .with_error_code("space_role_in_use"),
207 ),
208 SpaceError::SpaceRoleSystem => Self::build(
209 412,
210 Self::payload(412, "space role is built in and cannot be modified")
211 .with_error_code("space_role_system"),
212 ),
213 SpaceError::SpaceInviteExhausted => Self::build(
214 412,
215 Self::payload(412, "space invite is exhausted or revoked")
216 .with_error_code("space_invite_exhausted"),
217 ),
218 SpaceError::SpaceInviteExpired => Self::build(
219 412,
220 Self::payload(412, "space invite has expired")
221 .with_error_code("space_invite_expired"),
222 ),
223 SpaceError::SpaceOwnerRequired => Self::build(
224 412,
225 Self::payload(412, "space must have an owner; transfer ownership first")
226 .with_error_code("space_owner_required"),
227 ),
158 let status = error.status_code().as_u16();
159 if error.is_internal() {
160 tracing::warn!(%error, "space handler failed");
228161 }
162 Self::build(
163 status,
164 export::rest::AppResponseData::err(i32::from(status), error.message())
165 .with_error_code(error.code()),
166 )
229167 }
230168}
231169
@@ -317,27 +255,6 @@
317255 self.to_string()
318256 }
319257 }
320
321 pub fn body(&self) -> ErrorBody {
322 ErrorBody {
323 code: self.code().to_string(),
324 message: self.message(),
325 }
326 }
327258}
328259
329#[derive(Debug, serde::Serialize)]
330pub struct ErrorBody {
331 pub code: String,
332 pub message: String,
333}
334
335impl actix_web::ResponseError for SpaceError {
336 fn status_code(&self) -> StatusCode {
337 self.status_code()
338 }
339
340 fn error_response(&self) -> actix_web::HttpResponse {
341 actix_web::HttpResponse::build(self.status_code()).json(self.body())
342 }
343}
260export::access_error_conversion!(SpaceError, BadRequest);
Modified lib/space/src/lib.rs View lib/space/src/lib.rs
@@ -1,7 +1,11 @@
1pub mod authority;
1mod access;
2pub use access::{Access, Capability, Member, Space, require};
3pub(crate) mod authority;
24pub mod errors;
35pub mod handlers;
46pub mod openapi;
57pub mod routes;
68pub mod services;
79pub mod share;
10
11pub use models::space::{MemberStatus, MessageNotifications, SpaceJoinTrigger};
Modified lib/space/Cargo.toml View lib/space/Cargo.toml
@@ -4,6 +4,8 @@
44edition.workspace = true
55
66[dependencies]
7auth = { workspace = true }
8chat-room-store = { path = "../chat-room-store" }
79async-trait = { workspace = true }
810base64 = { workspace = true }
911cache = { workspace = true }
Modified lib/storage/src/lib.rs View lib/storage/src/lib.rs
@@ -1,7 +1,5 @@
11pub mod error;
22pub mod s3;
3pub mod store;
43
54pub use error::StorageError;
65pub use s3::{MAX_PRESIGN_EXPIRY_SECS, ObjectInfo, ObjectPage, S3Client};
7pub use store::BlobStore;
Deleted lib/storage/src/store.rs

Diff too large to display.

Modified lib/user/src/handlers/delete_avatar.rs View lib/user/src/handlers/delete_avatar.rs
@@ -21,6 +21,6 @@
2121 session: Session,
2222) -> Result<AppResponseData<NoData>, ApiError> {
2323 let uid = session::require_uid(&session)?;
24 service.delete_avatar(uid).await?;
24 service.avatars.delete_avatar(uid).await?;
2525 Ok(AppResponseData::empty())
2626}
Modified lib/user/src/handlers/get_avatar.rs View lib/user/src/handlers/get_avatar.rs
@@ -23,6 +23,6 @@
2323 session: Session,
2424) -> Result<AppResponseData<Option<AvatarDto>>, ApiError> {
2525 let uid = session::require_uid(&session)?;
26 let avatar = service.current_avatar(uid).await?;
26 let avatar = service.avatars.current_avatar(uid).await?;
2727 Ok(AppResponseData::ok(avatar))
2828}
Modified lib/user/src/handlers/get_user_avatar.rs View lib/user/src/handlers/get_user_avatar.rs
@@ -39,6 +39,7 @@
3939 // deliberately *not* checked is who the caller is relative to the target.
4040 session::require_uid(&session).map_err(|_| ApiError::unauthorized())?;
4141 let url = service
42 .avatars
4243 .avatar_redirect_url(user_id, query.variant.unwrap_or(ImageVariant::Preview))
4344 .await?;
4445 Ok(Response::Found()
Modified lib/user/src/handlers/mod.rs View lib/user/src/handlers/mod.rs
@@ -14,6 +14,7 @@
1414pub mod push_config;
1515pub mod recent_mentions;
1616pub mod save_message;
17pub mod ssh_key;
1718pub mod unsave_message;
1819pub mod update_meme;
1920pub mod upload_avatar;
Modified lib/user/src/handlers/push_config.rs View lib/user/src/handlers/push_config.rs
@@ -3,7 +3,7 @@
33use serde::Serialize;
44use utoipa::ToSchema;
55
6use crate::services::service::UserService;
6use config::push::PushConfig;
77
88/// 前端注册 Service Worker 所需的公开参数。
99///
@@ -25,8 +25,7 @@
2525 (status = 200, description = "web push configuration", body = AppResponseData<PushConfigDto>)
2626 )
2727)]
28pub async fn push_config(State(service): State<UserService>) -> AppResponseData<PushConfigDto> {
29 let push = &service.config().push;
28pub async fn push_config(State(push): State<PushConfig>) -> AppResponseData<PushConfigDto> {
3029 AppResponseData::ok(PushConfigDto {
3130 enabled: push.enabled,
3231 public_key: if push.enabled {
Renamed lib/auth/src/handlers/identity/ssh_key.rs → lib/user/src/handlers/ssh_key.rs View lib/user/src/handlers/ssh_key.rs
@@ -1,14 +1,15 @@
1use crate::envelope::{Envelope, NoData};
2use crate::errors::AuthError;
1use crate::errors::ApiError;
32use crate::services::ssh_key::types::{SshKeyAddRequest, SshKeySummary};
4use crate::session::{RequestId, Session};
5use crate::share::types::ListQuery;
6use actix_web::web;
3use auth::share::types::ListQuery;
4use export::http::{Extension, Json, Path, Query, State};
5use export::middleware::RequestId;
6use export::rest::{AppResponseData, NoData};
7use session::Session;
78use uuid::Uuid;
89
910#[utoipa::path(
1011 get,
11 path = "/api/v1/auth/ssh-keys",
12 path = "/auth/ssh-keys",
1213 tag = "auth",
1314 operation_id = "auth_ssh_key_list",
1415 summary = "列示 SSH 公钥",
@@ -21,38 +22,37 @@
2122 (
2223 status = 200,
2324 description = "公钥列表",
24 body = Envelope<Vec<SshKeySummary>>,
25 body = AppResponseData<Vec<SshKeySummary>>,
2526 example = json!({"code":200,"message":"success","data":[{"id":"018f2b1c-4d5e-7f80-9a1b-2c3d4e5f6072","name":"thinkpad","description":"笔记本","algorithm":"ed25519","fingerprint":"SHA256:9pQ2mLk1v3sT6yZx8cV0bN4mR7wQ2eH5jK9aD1sF3gU","public_key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL9kQ2mXv7Tb1sYz8cV0bN4mR7wQ2eH5jK9aD1sF3gU thinkpad","created_at":"2026-01-02T03:04:05Z","expires_at":null,"last_used_at":null,"revoked_at":null}]})
2627 ),
2728 (
2829 status = 401,
2930 description = "会话未绑定账号(`unauthenticated`)",
30 body = Envelope<NoData>,
31 body = AppResponseData<NoData>,
3132 example = json!({"code": "unauthenticated", "message": "authentication required"})
3233 ),
3334 (
3435 status = 500,
3536 description = "服务端故障(`internal`、`database`、`session`、`serialization`)",
36 body = Envelope<NoData>,
37 body = AppResponseData<NoData>,
3738 example = json!({"code": "internal", "message": "internal server error"})
3839 )
3940 )
4041)]
4142pub async fn ssh_key_list(
42 svc: web::Data<crate::AuthService>,
43 State(svc): State<crate::services::service::UserService>,
4344 session: Session,
44 query: web::Query<ListQuery>,
45 request_id: RequestId,
46) -> Result<Envelope<Vec<SshKeySummary>>, AuthError> {
47 let keys = svc
48 .list_ssh_keys(session, query.into_inner(), request_id.0)
49 .await?;
50 Ok(Envelope::ok(keys))
45 Query(query): Query<ListQuery>,
46 Extension(request_id): Extension<RequestId>,
47) -> Result<AppResponseData<Vec<SshKeySummary>>, ApiError> {
48 let svc = crate::services::ssh_key::SshKeyService::new(svc.accounts.clone());
49 let keys = svc.list_ssh_keys(session, query, request_id.0).await?;
50 Ok(AppResponseData::ok(keys))
5151}
5252
5353#[utoipa::path(
5454 post,
55 path = "/api/v1/auth/ssh-keys",
55 path = "/auth/ssh-keys",
5656 tag = "auth",
5757 operation_id = "auth_ssh_key_add",
5858 summary = "添加 SSH 公钥",
@@ -62,41 +62,42 @@
6262 (
6363 status = 200,
6464 description = "公钥已登记",
65 body = Envelope<SshKeySummary>,
65 body = AppResponseData<SshKeySummary>,
6666 example = json!({"code":200,"message":"success","data":{"id":"018f2b1c-4d5e-7f80-9a1b-2c3d4e5f6072","name":"thinkpad","description":"笔记本","algorithm":"ed25519","fingerprint":"SHA256:9pQ2mLk1v3sT6yZx8cV0bN4mR7wQ2eH5jK9aD1sF3gU","public_key":"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL9kQ2mXv7Tb1sYz8cV0bN4mR7wQ2eH5jK9aD1sF3gU thinkpad","created_at":"2026-01-02T03:04:05Z","expires_at":null,"last_used_at":null,"revoked_at":null}})
6767 ),
6868 (
6969 status = 400,
7070 description = "名称、备注、公钥或过期时间不合法(`ssh_key_name_invalid`、`malformed_request`、`ssh_key_invalid`、`invalid_expiry`)",
71 body = Envelope<NoData>,
71 body = AppResponseData<NoData>,
7272 example = json!({"code": "ssh_key_invalid", "message": "public key is invalid"})
7373 ),
7474 (
7575 status = 401,
7676 description = "会话未绑定账号,或账号已删除 / 不允许使用(`unauthenticated`)",
77 body = Envelope<NoData>,
77 body = AppResponseData<NoData>,
7878 example = json!({"code": "unauthenticated", "message": "authentication required"})
7979 ),
8080 (
8181 status = 409,
8282 description = "同一把公钥已经登记过(`ssh_key_taken`)",
83 body = Envelope<NoData>,
83 body = AppResponseData<NoData>,
8484 example = json!({"code": "ssh_key_taken", "message": "public key is already registered"})
8585 ),
8686 (
8787 status = 500,
8888 description = "服务端故障(`internal`、`database`、`session`、`serialization`)",
89 body = Envelope<NoData>,
89 body = AppResponseData<NoData>,
9090 example = json!({"code": "internal", "message": "internal server error"})
9191 )
9292 )
9393)]
9494pub async fn ssh_key_add(
95 svc: web::Data<crate::AuthService>,
95 State(svc): State<crate::services::service::UserService>,
9696 session: Session,
97 body: web::Json<SshKeyAddRequest>,
98 request_id: RequestId,
99) -> Result<Envelope<SshKeySummary>, AuthError> {
97 body: Json<SshKeyAddRequest>,
98 Extension(request_id): Extension<RequestId>,
99) -> Result<AppResponseData<SshKeySummary>, ApiError> {
100 let svc = crate::services::ssh_key::SshKeyService::new(svc.accounts.clone());
100101 let user_agent = session.user_agent()?;
101102 let ip_addr = session.client_ip()?;
102103 let response = svc
@@ -108,13 +109,13 @@
108109 request_id.0,
109110 )
110111 .await?;
111 Ok(Envelope::ok(response))
112 Ok(AppResponseData::ok(response))
112113}
113114
114115/// 只动自己名下的记录,已在吊销状态时重复调用照样返回成功;吊销与递增账号 `security_version`
115116#[utoipa::path(
116117 delete,
117 path = "/api/v1/auth/ssh-keys/{key_id}",
118 path = "/auth/ssh-keys/{key_id}",
118119 tag = "auth",
119120 operation_id = "auth_ssh_key_delete",
120121 summary = "吊销 SSH 公钥",
@@ -126,44 +127,39 @@
126127 (
127128 status = 200,
128129 description = "已吊销;重复调用同样返回成功",
129 body = Envelope<NoData>,
130 body = AppResponseData<NoData>,
130131 example = json!({"code":200,"message":"success","data":null})
131132 ),
132133 (
133134 status = 401,
134135 description = "会话未绑定账号(`unauthenticated`)",
135 body = Envelope<NoData>,
136 body = AppResponseData<NoData>,
136137 example = json!({"code": "unauthenticated", "message": "authentication required"})
137138 ),
138139 (
139140 status = 404,
140141 description = "公钥不存在或不属于当前账号(`ssh_key_not_found`)",
141 body = Envelope<NoData>,
142 body = AppResponseData<NoData>,
142143 example = json!({"code": "ssh_key_not_found", "message": "ssh key does not exist"})
143144 ),
144145 (
145146 status = 500,
146147 description = "服务端故障(`internal`、`database`、`session`、`serialization`)",
147 body = Envelope<NoData>,
148 body = AppResponseData<NoData>,
148149 example = json!({"code": "internal", "message": "internal server error"})
149150 )
150151 )
151152)]
152153pub async fn ssh_key_delete(
153 svc: web::Data<crate::AuthService>,
154 State(svc): State<crate::services::service::UserService>,
154155 session: Session,
155 key_id: web::Path<Uuid>,
156 request_id: RequestId,
157) -> Result<Envelope<NoData>, AuthError> {
156 Path(key_id): Path<Uuid>,
157 Extension(request_id): Extension<RequestId>,
158) -> Result<AppResponseData<NoData>, ApiError> {
159 let svc = crate::services::ssh_key::SshKeyService::new(svc.accounts.clone());
158160 let user_agent = session.user_agent()?;
159161 let ip_addr = session.client_ip()?;
160 svc.revoke_ssh_key(
161 session,
162 key_id.into_inner(),
163 user_agent,
164 ip_addr,
165 request_id.0,
166 )
167 .await?;
168 Ok(Envelope::empty())
162 svc.revoke_ssh_key(session, key_id, user_agent, ip_addr, request_id.0)
163 .await?;
164 Ok(AppResponseData::empty())
169165}
Modified lib/user/src/handlers/upload_avatar.rs View lib/user/src/handlers/upload_avatar.rs
@@ -37,6 +37,7 @@
3737 .unwrap_or("application/octet-stream")
3838 .to_string();
3939 let dto = service
40 .avatars
4041 .upload_avatar_stream(uid, &content_type, &body)
4142 .await?;
4243 Ok(AppResponseData::ok(dto))
Added lib/user/src/services/ssh_key/mod.rs View lib/user/src/services/ssh_key/mod.rs

Diff too large to display.

Renamed lib/auth/src/services/ssh_key/service.rs → lib/user/src/services/ssh_key/service.rs View lib/user/src/services/ssh_key/service.rs
@@ -1,8 +1,8 @@
1use crate::events::{Labels, label};
2use crate::models::users::user_events::{self, UserEventType};
3use crate::models::users::user_ssh_keys::{self, PublicKeyType};
4use crate::models::users::users;
5use crate::session::Session;
1use auth::events::{Labels, label};
2use auth::models::users::user_events::{self, UserEventType};
3use auth::models::users::user_ssh_keys::{self, PublicKeyType};
4use auth::models::users::users;
5use auth::session::Session;
66use base64::Engine;
77use base64::engine::general_purpose::STANDARD_NO_PAD;
88use chrono::Utc;
@@ -14,14 +14,14 @@
1414};
1515use uuid::Uuid;
1616
17use crate::AuthService;
18use crate::errors::{AuthError, AuthResult};
17use crate::services::ssh_key::SshKeyService;
1918use crate::services::ssh_key::types::{
2019 MAX_SSH_KEY_DESCRIPTION_LEN, MAX_SSH_KEY_NAME_LEN, MAX_SSH_PUBLIC_KEY_LEN,
2120 MIN_SSH_PUBLIC_KEY_LEN, SshKeyAddRequest, SshKeyAlgorithm, SshKeySummary,
2221};
23use crate::share::crypto;
24use crate::share::types::ListQuery;
22use auth::errors::{AuthError, AuthResult};
23use auth::share::crypto;
24use auth::share::types::ListQuery;
2525
2626pub(crate) fn parse_public_key(raw: &str) -> AuthResult<(PublicKeyType, String)> {
2727 let key_line = raw.trim();
@@ -77,7 +77,7 @@
7777 }
7878}
7979
80impl AuthService {
80impl SshKeyService {
8181 #[tracing::instrument(skip_all, fields(request_id = %request_id, client_ip = ?session.client_ip().ok().flatten()))]
8282 pub async fn add_ssh_key(
8383 &self,
@@ -88,13 +88,13 @@
8888 request_id: String,
8989 ) -> AuthResult<SshKeySummary> {
9090 let user_id = self
91 .accounts
9192 .require_session(&session, Some(request_id.as_str()))
9293 .await?;
9394 self.add_ssh_key_for_user(user_id, request, user_agent, ip_addr, request_id)
9495 .await
9596 }
9697
97 /// 与 `add_ssh_key` 同逻辑,但调用方已按 RPC 密钥确认过用户身份。
9898 pub async fn add_ssh_key_for_user(
9999 &self,
100100 user_id: i64,
@@ -104,7 +104,7 @@
104104 request_id: String,
105105 ) -> AuthResult<SshKeySummary> {
106106 let user = users::Entity::find_by_id(user_id)
107 .one(self.db().read())
107 .one(self.accounts.db().read())
108108 .await?
109109 .ok_or(AuthError::Unauthenticated)?;
110110 if user.deleted_at.is_some() || !user.allow_use {
@@ -147,11 +147,11 @@
147147 expires_at: Set(expiry),
148148 ..Default::default()
149149 }
150 .insert(self.db().write())
150 .insert(self.accounts.db().write())
151151 .await
152152 .map_err(|error| match error.sql_err() {
153153 Some(SqlErr::UniqueConstraintViolation(_)) => AuthError::SshKeyTaken,
154 _ => AuthError::Database(crate::db::DbError::from(error)),
154 _ => AuthError::Database(auth::db::DbError::from(error)),
155155 })?;
156156
157157 user_events::ActiveModel {
@@ -169,7 +169,7 @@
169169 }))),
170170 ..Default::default()
171171 }
172 .insert(self.db().write())
172 .insert(self.accounts.db().write())
173173 .await?;
174174
175175 let response = summary(inserted);
@@ -193,6 +193,7 @@
193193 _request_id: String,
194194 ) -> AuthResult<Vec<SshKeySummary>> {
195195 let user_id = self
196 .accounts
196197 .require_session(&session, Some(_request_id.as_str()))
197198 .await?;
198199 let records = user_ssh_keys::Entity::find()
@@ -200,7 +201,7 @@
200201 .order_by_desc(user_ssh_keys::Column::CreatedAt)
201202 .limit(query.limit())
202203 .offset(query.offset())
203 .all(self.db().read())
204 .all(self.accounts.db().read())
204205 .await?;
205206
206207 Ok(records.into_iter().map(summary).collect())
@@ -217,18 +218,19 @@
217218 request_id: String,
218219 ) -> AuthResult<()> {
219220 let user_id = self
221 .accounts
220222 .require_session(&session, Some(request_id.as_str()))
221223 .await?;
222224 let record = user_ssh_keys::Entity::find_by_id(key_id)
223225 .filter(user_ssh_keys::Column::UserId.eq(user_id))
224 .one(self.db().read())
226 .one(self.accounts.db().read())
225227 .await?
226228 .ok_or(AuthError::SshKeyNotFound)?;
227229 if record.revoked_at.is_some() {
228230 return Ok(());
229231 }
230232
231 let transaction = self.db().write().begin().await?;
233 let transaction = self.accounts.db().write().begin().await?;
232234 let result: AuthResult<()> = async {
233235 user_ssh_keys::Entity::update_many()
234236 .col_expr(user_ssh_keys::Column::RevokedAt, Expr::value(Utc::now()))
@@ -292,3 +294,24 @@
292294 Ok(())
293295 }
294296}
297
298impl SshKeyService {
299 async fn emit_labeled(
300 &self,
301 kind: &str,
302 labels: Labels,
303 payload: impl serde::Serialize,
304 request_id: Option<&str>,
305 ) {
306 if let Err(error) = auth::events::emit(
307 self.accounts.db().write(),
308 kind,
309 labels.with_opt(label::REQUEST_ID, request_id),
310 payload,
311 )
312 .await
313 {
314 tracing::warn!(%error, kind, "SSH key event enqueue failed");
315 }
316 }
317}
Renamed lib/auth/src/services/ssh_key/types.rs → lib/user/src/services/ssh_key/types.rs View lib/user/src/services/ssh_key/types.rs
@@ -1,4 +1,4 @@
1use crate::models::users::user_ssh_keys::PublicKeyType;
1use auth::models::users::user_ssh_keys::PublicKeyType;
22use chrono::{DateTime, Utc};
33use serde::{Deserialize, Serialize};
44use utoipa::ToSchema;
Modified lib/user/src/services/avatar.rs View lib/user/src/services/avatar.rs
@@ -7,7 +7,9 @@
77use utoipa::ToSchema;
88
99use crate::errors::UserError;
10use crate::services::service::UserService;
10use export::{AppDatabase, AppRedis};
11use std::sync::Arc;
12use storage::S3Client;
1113
1214pub const MAX_AVATAR_BYTES: usize = 5 * 1024 * 1024;
1315pub const AVATAR_PREFIX: &str = "avatars";
@@ -92,7 +94,22 @@
9294 pub updated_at: OffsetDateTime,
9395}
9496
95impl UserService {
97#[derive(Clone)]
98pub struct AvatarService {
99 database: Arc<AppDatabase>,
100 storage: Arc<S3Client>,
101 redis: AppRedis,
102}
103
104impl AvatarService {
105 pub fn new(database: Arc<AppDatabase>, storage: Arc<S3Client>, redis: AppRedis) -> Self {
106 Self {
107 database,
108 storage,
109 redis,
110 }
111 }
112
96113 /// Redis key for the presigned URL of one representation. The default
97114 /// (`preview`) keeps the historical key so the negative cache entry left by a
98115 /// previously-skipped avatar cannot pin a stale URL for another variant.
@@ -114,7 +131,7 @@
114131 return Ok(None);
115132 };
116133 let cache_key = Self::avatar_url_cache_key(uid, variant);
117 match export::redis::get_string(&self.state.redis, &cache_key).await {
134 match export::redis::get_string(&self.redis, &cache_key).await {
118135 Ok(Some(url)) => return Ok(Some(url)),
119136 Ok(None) => {}
120137 Err(error) => {
@@ -127,12 +144,12 @@
127144 None => return Ok(Some(stored_url)),
128145 };
129146 let url = self
130 .storage()
147 .storage
148 .as_ref()
131149 .presign_get(&key, AVATAR_URL_TTL_SECS as u32)
132150 .map_err(UserError::from)?;
133151 if let Err(error) =
134 export::redis::set_string(&self.state.redis, &cache_key, &url, AVATAR_URL_TTL_SECS)
135 .await
152 export::redis::set_string(&self.redis, &cache_key, &url, AVATAR_URL_TTL_SECS).await
136153 {
137154 tracing::warn!(%error, "failed to cache avatar presigned url");
138155 }
@@ -150,7 +167,7 @@
150167 variant: ImageVariant,
151168 ) -> Result<Option<String>, UserError> {
152169 let user = user::Entity::find_by_id(uid)
153 .one(self.db().read())
170 .one(self.database.read())
154171 .await?
155172 .ok_or(UserError::NotFound)?;
156173 if user.local_avatar_url.is_empty() {
@@ -165,8 +182,8 @@
165182 let key_base = export::imaging::preview_key_base_of(&original_key);
166183 let recorded = self.recorded_avatar_preview(uid, &key_base).await;
167184 let state = export::imaging::ensure_preview(
168 self.storage(),
169 &self.state.redis,
185 self.storage.as_ref(),
186 &self.redis,
170187 &key_base,
171188 &original_key,
172189 {
@@ -193,13 +210,13 @@
193210 /// recognised once its derivative has been generated.
194211 async fn recorded_avatar_preview(&self, uid: i64, key_base: &str) -> Option<PreviewState> {
195212 let meta_key = avatar_meta_key(uid);
196 if let Ok(bytes) = self.storage().get(&meta_key).await
213 if let Ok(bytes) = self.storage.as_ref().get(&meta_key).await
197214 && let Ok(record) = serde_json::from_slice::<serde_json::Value>(&bytes)
198215 && let Some(state) = export::imaging::recorded_preview(&record)
199216 {
200217 return Some(state);
201218 }
202 export::imaging::find_stored_preview(self.storage(), key_base).await
219 export::imaging::find_stored_preview(self.storage.as_ref(), key_base).await
203220 }
204221
205222 /// Persist the sidecar next to the original, recording which derivative is
@@ -224,7 +241,8 @@
224241 match serde_json::to_vec(&meta) {
225242 Ok(meta_bytes) => {
226243 if let Err(error) = self
227 .storage()
244 .storage
245 .as_ref()
228246 .put_with_content_type(&avatar_meta_key(uid), &meta_bytes, "application/json")
229247 .await
230248 {
@@ -242,7 +260,7 @@
242260 async fn delete_avatar_previews(&self, original_key: &str) {
243261 let key_base = export::imaging::preview_key_base_of(original_key);
244262 for candidate in imaging::layout::preview_candidate_keys(&key_base) {
245 if let Err(error) = self.storage().delete(&candidate).await {
263 if let Err(error) = self.storage.as_ref().delete(&candidate).await {
246264 tracing::warn!(%error, key = %candidate, "failed to delete old avatar preview");
247265 }
248266 }
@@ -251,8 +269,7 @@
251269 async fn invalidate_avatar_cache(&self, uid: i64) {
252270 for variant in [ImageVariant::Preview, ImageVariant::Original] {
253271 if let Err(error) =
254 export::redis::delete(&self.state.redis, &Self::avatar_url_cache_key(uid, variant))
255 .await
272 export::redis::delete(&self.redis, &Self::avatar_url_cache_key(uid, variant)).await
256273 {
257274 tracing::warn!(%error, "failed to invalidate avatar url cache");
258275 }
@@ -281,7 +298,7 @@
281298 }
282299
283300 let previous = user::Entity::find_by_id(uid)
284 .one(self.db().read())
301 .one(self.database.read())
285302 .await?
286303 .ok_or(UserError::NotFound)?
287304 .local_avatar_url;
@@ -292,7 +309,8 @@
292309 }
293310
294311 let key = format!("{AVATAR_PREFIX}/{uid}.{}", fmt.extension());
295 self.storage()
312 self.storage
313 .as_ref()
296314 .put_with_content_type(&key, bytes, fmt.content_type())
297315 .await
298316 .map_err(UserError::from)?;
@@ -300,13 +318,13 @@
300318 let now = OffsetDateTime::now_utc();
301319 let stored_url = format!("storage://{AVATAR_PREFIX}/{uid}.{}", fmt.extension());
302320 let user = user::Entity::find_by_id(uid)
303 .one(self.db().read())
321 .one(self.database.read())
304322 .await?
305323 .ok_or(UserError::NotFound)?;
306324 let mut active: user::ActiveModel = user.into();
307325 active.local_avatar_url = Set(stored_url.clone());
308326 active.updated_at = Set(now);
309 active.update(self.db().write()).await?;
327 active.update(self.database.write()).await?;
310328
311329 // The preview is built immediately so the first list view after an upload
312330 // already has it; a failure here is not an upload failure.
@@ -315,8 +333,8 @@
315333 // clearing it first keeps exactly one candidate object alive.
316334 self.delete_avatar_previews(&key).await;
317335 let preview = export::imaging::ensure_preview(
318 self.storage(),
319 &self.state.redis,
336 self.storage.as_ref(),
337 &self.redis,
320338 &key_base,
321339 &key,
322340 fmt.content_type(),
@@ -352,7 +370,7 @@
352370 #[tracing::instrument(name = "user.delete_avatar", skip_all, fields(uid = %uid))]
353371 pub async fn delete_avatar(&self, uid: i64) -> Result<(), UserError> {
354372 let user = user::Entity::find_by_id(uid)
355 .one(self.db().read())
373 .one(self.database.read())
356374 .await?
357375 .ok_or(UserError::NotFound)?;
358376 let old_url = user.local_avatar_url.clone();
@@ -360,18 +378,18 @@
360378 let mut active: user::ActiveModel = user.into();
361379 active.local_avatar_url = Set(String::new());
362380 active.updated_at = Set(now);
363 active.update(self.db().write()).await?;
381 active.update(self.database.write()).await?;
364382
365383 self.invalidate_avatar_cache(uid).await;
366384
367385 if let Some(old_key) = avatar_object_key(&old_url)
368386 && old_key.starts_with(&format!("{AVATAR_PREFIX}/"))
369387 {
370 if let Err(error) = self.storage().delete(&old_key).await {
388 if let Err(error) = self.storage.as_ref().delete(&old_key).await {
371389 tracing::warn!(%error, "failed to delete old user avatar object");
372390 }
373391 self.delete_avatar_previews(&old_key).await;
374 if let Err(error) = self.storage().delete(&avatar_meta_key(uid)).await {
392 if let Err(error) = self.storage.as_ref().delete(&avatar_meta_key(uid)).await {
375393 tracing::warn!(%error, "failed to delete old user avatar meta json");
376394 }
377395 }
@@ -406,7 +424,7 @@
406424
407425 pub async fn current_avatar(&self, uid: i64) -> Result<Option<AvatarDto>, UserError> {
408426 let user = user::Entity::find_by_id(uid)
409 .one(self.db().read())
427 .one(self.database.read())
410428 .await?
411429 .ok_or(UserError::NotFound)?;
412430 if user.local_avatar_url.is_empty() {
Modified lib/user/src/services/mod.rs View lib/user/src/services/mod.rs
@@ -3,4 +3,6 @@
33pub mod profile;
44pub mod push_subscriptions;
55pub mod service;
6pub mod ssh_key;
7pub mod ssh_keys;
68pub mod suspensions;
Modified lib/user/src/services/notifications.rs View lib/user/src/services/notifications.rs
@@ -94,7 +94,7 @@
9494 .filter(user_recent_mentions::Column::UserId.eq(uid))
9595 .order_by_desc(user_recent_mentions::Column::CreatedAt)
9696 .limit(Some(lim))
97 .all(self.db().read())
97 .all(self.database.read())
9898 .await?;
9999 Ok(rows.into_iter().map(RecentMentionDto::from).collect())
100100 }
@@ -109,7 +109,7 @@
109109 .order_by_desc(user_reports::Column::CreatedAt)
110110 .limit(q.limit())
111111 .offset(q.offset())
112 .all(self.db().read())
112 .all(self.database.read())
113113 .await?;
114114 Ok(rows.into_iter().map(UserReportDto::from).collect())
115115 }
@@ -124,7 +124,7 @@
124124 .order_by_desc(user_saved_messages::Column::SavedAt)
125125 .limit(q.limit())
126126 .offset(q.offset())
127 .all(self.db().read())
127 .all(self.database.read())
128128 .await?;
129129 Ok(rows.into_iter().map(SavedMessageDto::from).collect())
130130 }
@@ -139,7 +139,7 @@
139139 .filter(user_saved_messages::Column::UserId.eq(uid))
140140 .filter(user_saved_messages::Column::ChannelId.eq(cmd.channel_id))
141141 .filter(user_saved_messages::Column::MessageId.eq(cmd.message_id))
142 .one(self.db().read())
142 .one(self.database.read())
143143 .await?;
144144 if let Some(row) = existing {
145145 return Ok(SavedMessageDto::from(row));
@@ -153,7 +153,7 @@
153153 message_id: Set(cmd.message_id),
154154 saved_at: Set(now),
155155 };
156 let saved = active.insert(self.db().write()).await?;
156 let saved = active.insert(self.database.write()).await?;
157157 Ok(SavedMessageDto::from(saved))
158158 }
159159
@@ -166,7 +166,7 @@
166166 user_saved_messages::Entity::delete_many()
167167 .filter(user_saved_messages::Column::UserId.eq(uid))
168168 .filter(user_saved_messages::Column::MessageId.eq(message_id))
169 .exec(self.db().write())
169 .exec(self.database.write())
170170 .await?;
171171 Ok(())
172172 }
Modified lib/user/src/services/profile.rs View lib/user/src/services/profile.rs
@@ -119,9 +119,8 @@
119119 pub async fn my_spaces(&self, uid: i64, q: PageQuery) -> Result<Vec<MySpaceDto>, UserError> {
120120 let limit = q.limit();
121121 let offset = q.offset();
122 let mut space_rows = space::authority::list_user_spaces(&self.state, uid, uid).await?;
123 space_rows.retain(space::authority::space_is_active);
124 space_rows.sort_by(|a, b| a.slug.cmp(&b.slug));
122 let mut space_rows = self.access.user_spaces(uid).await?;
123 space_rows.sort_by(|a, b| a.slug().cmp(b.slug()));
125124
126125 let mut out = Vec::new();
127126 for space in space_rows
@@ -129,24 +128,17 @@
129128 .skip(offset as usize)
130129 .take(limit as usize)
131130 {
132 let perms = space::share::authz::effective_permissions(&self.state, &space, uid)
133 .await?
134 .into_vec();
135
136 let avatar_url = space::services::dto::resolve_space_image_url(
137 &space.slug,
138 "avatar",
139 space::authority::space_avatar(&self.state, &space).await?,
140 );
141 let members_count = space::authority::member_count(&self.state, space.id).await?;
131 let perms = space.permissions(uid).await?;
132 let avatar_url = space.avatar_url().await?;
133 let members_count = space.member_count().await?;
142134 let space_dto = SpaceDto {
143 id: space.id,
144 name: space.name,
145 owner_uid: space.owner_user_id,
135 id: space.id(),
136 name: space.name().to_owned(),
137 owner_uid: space.owner(),
146138 avatar_url,
147 is_private: space.join_policy == models::space::SpaceJoinPolicy::Private,
139 is_private: space.is_private(),
148140 members_count,
149 created_at: space::authority::to_time(space.created_at),
141 created_at: space.created_at(),
150142 };
151143 out.push(MySpaceDto {
152144 space: space_dto,
@@ -166,7 +158,7 @@
166158 .filter(user_favorite_memes::Column::UserId.eq(uid))
167159 .order_by_desc(user_favorite_memes::Column::CreatedAt)
168160 .limit(Some(lim))
169 .all(self.db().read())
161 .all(self.database.read())
170162 .await?;
171163 Ok(rows.into_iter().map(MemeDto::from).collect())
172164 }
@@ -196,7 +188,7 @@
196188 meme_id: Uuid,
197189 ) -> Result<user_favorite_memes::Model, UserError> {
198190 let row = user_favorite_memes::Entity::find_by_id(meme_id)
199 .one(self.db().read())
191 .one(self.database.read())
200192 .await?
201193 .ok_or(UserError::NotFound)?;
202194 if row.user_id != uid {
@@ -213,7 +205,7 @@
213205 let existing = user_favorite_memes::Entity::find()
214206 .filter(user_favorite_memes::Column::UserId.eq(uid))
215207 .filter(user_favorite_memes::Column::Name.eq(name))
216 .one(self.db().read())
208 .one(self.database.read())
217209 .await?;
218210 Ok(existing)
219211 }
@@ -249,7 +241,7 @@
249241 am.size = Set(cmd.size);
250242 am.width = Set(cmd.width);
251243 am.height = Set(cmd.height);
252 let model = am.update(self.db().write()).await?;
244 let model = am.update(self.database.write()).await?;
253245 Ok(MemeDto::from(model))
254246 }
255247
@@ -269,7 +261,7 @@
269261 active.size = Set(cmd.size);
270262 active.width = Set(cmd.width);
271263 active.height = Set(cmd.height);
272 active.update(self.db().write()).await?
264 active.update(self.database.write()).await?
273265 }
274266 None => {
275267 let active = user_favorite_memes::ActiveModel {
@@ -283,7 +275,7 @@
283275 height: Set(cmd.height),
284276 created_at: Set(now),
285277 };
286 active.insert(self.db().write()).await?
278 active.insert(self.database.write()).await?
287279 }
288280 };
289281
@@ -298,7 +290,7 @@
298290 pub async fn delete_meme(&self, uid: i64, meme_id: Uuid) -> Result<(), UserError> {
299291 self.owned_meme(uid, meme_id).await?;
300292 user_favorite_memes::Entity::delete_by_id(meme_id)
301 .exec(self.db().write())
293 .exec(self.database.write())
302294 .await?;
303295 Ok(())
304296 }
@@ -309,16 +301,13 @@
309301 target_uid: i64,
310302 ) -> Result<ProfileDetailDto, UserError> {
311303 let local = user::Entity::find_by_id(target_uid)
312 .one(self.db().read())
304 .one(self.database.read())
313305 .await?;
314 let public = export::users::get_public_user(&self.state, target_uid)
306 let public = auth::users::get_public_user(&self.accounts, target_uid)
315307 .await?
316308 .ok_or(UserError::NotFound)?;
317309 let status = self
318 .state
319 .kenauth
320 .as_ref()
321 .ok_or_else(|| UserError::Internal("kenoauth is not configured".to_string()))?
310 .accounts
322311 .get_user_status(target_uid)
323312 .await
324313 .map_err(|error| UserError::Internal(error.to_string()))?;
@@ -346,7 +335,7 @@
346335 }
347336
348337 let profile = user_profiles::Entity::find_by_id(target_uid)
349 .one(self.db().read())
338 .one(self.database.read())
350339 .await?;
351340
352341 let display_name = if public.displayname.trim().is_empty() {
@@ -426,17 +415,14 @@
426415 viewer: i64,
427416 target: i64,
428417) -> Result<bool, UserError> {
429 let viewer_spaces = space::authority::list_user_spaces(&service.state, viewer, viewer).await?;
418 let viewer_spaces = service.access.user_spaces(viewer).await?;
430419 if viewer_spaces.is_empty() {
431420 return Ok(false);
432421 }
433 let target_spaces = space::authority::list_user_spaces(&service.state, target, target).await?;
434 let target_ids: std::collections::HashSet<Uuid> = target_spaces
435 .into_iter()
436 .filter(|space| space::authority::space_is_active(space))
437 .map(|space| space.id)
438 .collect();
422 let target_spaces = service.access.user_spaces(target).await?;
423 let target_ids: std::collections::HashSet<Uuid> =
424 target_spaces.into_iter().map(|space| space.id()).collect();
439425 Ok(viewer_spaces
440426 .iter()
441 .any(|space| space::authority::space_is_active(space) && target_ids.contains(&space.id)))
427 .any(|space| target_ids.contains(&space.id())))
442428}
Modified lib/user/src/services/push_subscriptions.rs View lib/user/src/services/push_subscriptions.rs
@@ -54,7 +54,7 @@
5454 .order_by_desc(user_push_subscriptions::Column::CreatedAt)
5555 .limit(q.limit())
5656 .offset(q.offset())
57 .all(self.db().read())
57 .all(self.database.read())
5858 .await?;
5959 Ok(rows.into_iter().map(PushSubscriptionDto::from).collect())
6060 }
@@ -71,7 +71,7 @@
7171 let existing = user_push_subscriptions::Entity::find()
7272 .filter(user_push_subscriptions::Column::UserId.eq(uid))
7373 .filter(user_push_subscriptions::Column::Endpoint.eq(&cmd.endpoint))
74 .one(self.db().read())
74 .one(self.database.read())
7575 .await?;
7676
7777 let now = OffsetDateTime::now_utc();
@@ -83,7 +83,7 @@
8383 am.platform = Set(cmd.platform);
8484 am.user_agent = Set(cmd.user_agent);
8585 am.updated_at = Set(Some(now));
86 am.update(self.db().write()).await?
86 am.update(self.database.write()).await?
8787 }
8888 None => {
8989 let active = user_push_subscriptions::ActiveModel {
@@ -97,7 +97,7 @@
9797 created_at: Set(now),
9898 updated_at: Set(Some(now)),
9999 };
100 active.insert(self.db().write()).await?
100 active.insert(self.database.write()).await?
101101 }
102102 };
103103
@@ -111,14 +111,14 @@
111111 )]
112112 pub async fn delete_push_subscription(&self, uid: i64, push_id: Uuid) -> Result<(), UserError> {
113113 let row = user_push_subscriptions::Entity::find_by_id(push_id)
114 .one(self.db().read())
114 .one(self.database.read())
115115 .await?
116116 .ok_or(UserError::NotFound)?;
117117 if row.user_id != uid {
118118 return Err(UserError::Forbidden);
119119 }
120120 user_push_subscriptions::Entity::delete_by_id(push_id)
121 .exec(self.db().write())
121 .exec(self.database.write())
122122 .await?;
123123 Ok(())
124124 }
Modified lib/user/src/services/service.rs View lib/user/src/services/service.rs
@@ -1,28 +1,26 @@
1use crate::services::avatar::AvatarService;
2use export::AppDatabase;
13use std::sync::Arc;
24
3use config::AppConfig;
4use export::{AppState, Database};
5use storage::BlobStore;
6
75#[derive(Clone)]
86pub struct UserService {
9 pub state: AppState,
7 pub(crate) database: Arc<AppDatabase>,
8 pub(crate) accounts: auth::Accounts,
9 pub(crate) access: space::Access,
10 pub(crate) avatars: AvatarService,
1011}
11
1212impl UserService {
13 pub fn new(state: AppState) -> Self {
14 Self { state }
15 }
16
17 pub fn config(&self) -> &AppConfig {
18 &self.state.config
19 }
20
21 pub fn db(&self) -> &Arc<dyn Database> {
22 &self.state.database
23 }
24
25 pub fn storage(&self) -> &dyn BlobStore {
26 self.state.storage.as_ref()
13 pub fn new(
14 database: Arc<AppDatabase>,
15 accounts: auth::Accounts,
16 access: space::Access,
17 avatars: AvatarService,
18 ) -> Self {
19 Self {
20 database,
21 accounts,
22 access,
23 avatars,
24 }
2725 }
2826}
Renamed lib/auth/src/api/ssh_key.rs → lib/user/src/services/ssh_keys.rs View lib/user/src/services/ssh_keys.rs
@@ -1,36 +1,25 @@
1use crate::models::users::user_events::UserEventType;
2use crate::models::users::{user_ssh_keys, users};
3use crate::protos::auth::common::v1::{AuthMethod, PageResponse};
4use crate::protos::auth::v1::ssh_key_service_server::{SshKeyService, SshKeyServiceServer};
5use crate::protos::auth::v1::*;
1use auth::models::users::user_events::UserEventType;
2use auth::models::users::{user_ssh_keys, users};
3use auth::protos::auth::common::v1::{AuthMethod, PageResponse};
4use auth::protos::auth::v1::*;
5use export::KenAuthError;
66use sea_orm::sea_query::{Expr, ExprTrait};
77use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, TransactionTrait};
8use tonic::{Request, Response, Status};
98use uuid::Uuid;
109
11use crate::AuthService;
12use crate::api::{context, convert, error, store};
13use crate::errors::AuthError;
10use crate::services::ssh_key::SshKeyService;
11use auth::errors::AuthError;
12use auth::share::{auth_convert as convert, auth_error as error, auth_store as store};
1413
1514#[derive(Clone)]
16pub struct SshKeyApi {
17 svc: AuthService,
15pub struct SshKeys {
16 svc: SshKeyService,
1817}
1918
20impl SshKeyApi {
21 pub fn new(svc: AuthService) -> Self {
19impl SshKeys {
20 pub fn new(svc: SshKeyService) -> Self {
2221 Self { svc }
2322 }
24
25 pub fn server(
26 self,
27 ) -> tonic::service::interceptor::InterceptedService<
28 SshKeyServiceServer<Self>,
29 context::Interceptor,
30 > {
31 let gate = context::Interceptor::new(self.svc.auth().rpc_key.clone());
32 SshKeyServiceServer::with_interceptor(self, gate)
33 }
3423}
3524
3625fn not_found() -> LookupSshKeyResponse {
@@ -40,67 +29,13 @@
4029 }
4130}
4231
43#[tonic::async_trait]
44impl SshKeyService for SshKeyApi {
45 async fn lookup_ssh_key(
46 &self,
47 request: Request<LookupSshKeyRequest>,
48 ) -> Result<Response<LookupSshKeyResponse>, Status> {
49 let meta = context::meta(&request).await?;
50 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.SshKeyService");
51 let result = self
52 .lookup_ssh_key_inner(meta.request_id(), request.into_inner())
53 .await;
54 timer.finish("LookupSshKey", &result);
55 result
56 }
57
58 async fn add_ssh_key(
59 &self,
60 request: Request<AddSshKeyRequest>,
61 ) -> Result<Response<AddSshKeyResponse>, Status> {
62 let meta = context::meta(&request).await?;
63 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.SshKeyService");
64 let result = self
65 .add_ssh_key_inner(meta.request_id(), request.into_inner())
66 .await;
67 timer.finish("AddSshKey", &result);
68 result
69 }
70
71 async fn list_user_ssh_keys(
72 &self,
73 request: Request<ListUserSshKeysRequest>,
74 ) -> Result<Response<ListUserSshKeysResponse>, Status> {
75 let meta = context::meta(&request).await?;
76 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.SshKeyService");
77 let result = self
78 .list_user_ssh_keys_inner(meta.request_id(), request.into_inner())
79 .await;
80 timer.finish("ListUserSshKeys", &result);
81 result
82 }
83
84 async fn revoke_ssh_key(
85 &self,
86 request: Request<RevokeSshKeyRequest>,
87 ) -> Result<Response<RevokeSshKeyResponse>, Status> {
88 let meta = context::meta(&request).await?;
89 let timer = crate::oltp::metrics::ActiveRpcGuard::new("auth.v1.SshKeyService");
90 let result = self
91 .revoke_ssh_key_inner(meta.request_id(), request.into_inner())
92 .await;
93 timer.finish("RevokeSshKey", &result);
94 result
95 }
96}
97impl SshKeyApi {
32impl SshKeys {
9833 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
99 async fn lookup_ssh_key_inner(
34 pub async fn lookup_ssh_key_inner(
10035 &self,
10136 request_id: Option<&str>,
10237 request: LookupSshKeyRequest,
103 ) -> Result<Response<LookupSshKeyResponse>, Status> {
38 ) -> Result<LookupSshKeyResponse, KenAuthError> {
10439 let fingerprint = if request.fingerprint.trim().is_empty() {
10540 let public_key = request.public_key.trim();
10641 if public_key.is_empty() {
@@ -118,11 +53,11 @@
11853
11954 let row = user_ssh_keys::Entity::find()
12055 .filter(user_ssh_keys::Column::Fingerprint.eq(fingerprint))
121 .one(self.svc.db().read())
56 .one(self.svc.accounts.db().read())
12257 .await
12358 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
12459 let Some(row) = row else {
125 return Ok(Response::new(not_found()));
60 return Ok(not_found());
12661 };
12762 if request.require_active
12863 && (row.revoked_at.is_some()
@@ -130,38 +65,38 @@
13065 .expires_at
13166 .is_some_and(|expires| expires <= store::now()))
13267 {
133 return Ok(Response::new(not_found()));
68 return Ok(not_found());
13469 }
13570
13671 let user = if request.require_active {
137 match store::load_active_user(self.svc.db(), row.user_id).await {
72 match store::load_active_user(self.svc.accounts.db(), row.user_id).await {
13873 Ok(user) => user,
13974 Err(
14075 AuthError::AccountUnavailable { .. }
14176 | AuthError::LoginForbidden { .. }
14277 | AuthError::UserNotFound,
143 ) => return Ok(Response::new(not_found())),
78 ) => return Ok(not_found()),
14479 Err(err) => return Err(error::status(&err, request_id)),
14580 }
14681 } else {
147 store::load_user(self.svc.db(), row.user_id)
82 store::load_user(self.svc.accounts.db(), row.user_id)
14883 .await
14984 .map_err(|err| error::status(&err, request_id))?
15085 };
15186
152 Ok(Response::new(LookupSshKeyResponse {
87 Ok(LookupSshKeyResponse {
15388 found: true,
154 key: Some(convert::ssh_key_info(&row)),
89 key: Some(crate::share::ssh_keys::ssh_key_info(&row)),
15590 principal: Some(store::principal(&user, AuthMethod::SshKey)),
15691 current_security_version: user.security_version,
157 }))
92 })
15893 }
15994 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
160 async fn add_ssh_key_inner(
95 pub async fn add_ssh_key_inner(
16196 &self,
16297 request_id: Option<&str>,
16398 request: AddSshKeyRequest,
164 ) -> Result<Response<AddSshKeyResponse>, Status> {
99 ) -> Result<AddSshKeyResponse, KenAuthError> {
165100 let created = self
166101 .svc
167102 .add_ssh_key_for_user(
@@ -180,21 +115,21 @@
180115 .map_err(|err| error::status(&err, request_id))?;
181116
182117 let row = user_ssh_keys::Entity::find_by_id(created.id)
183 .one(self.svc.db().read())
118 .one(self.svc.accounts.db().read())
184119 .await
185120 .map_err(|err| error::status(&AuthError::from(err), request_id))?
186121 .ok_or_else(|| error::unavailable("ssh key vanished after insert", request_id))?;
187122
188 Ok(Response::new(AddSshKeyResponse {
189 key: Some(convert::ssh_key_info(&row)),
190 }))
123 Ok(AddSshKeyResponse {
124 key: Some(crate::share::ssh_keys::ssh_key_info(&row)),
125 })
191126 }
192127 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
193 async fn list_user_ssh_keys_inner(
128 pub async fn list_user_ssh_keys_inner(
194129 &self,
195130 request_id: Option<&str>,
196131 request: ListUserSshKeysRequest,
197 ) -> Result<Response<ListUserSshKeysResponse>, Status> {
132 ) -> Result<ListUserSshKeysResponse, KenAuthError> {
198133 let page = request.page.unwrap_or_default();
199134 let offset = match store::parse_page_token(&page.page_token) {
200135 Some(offset) => offset,
@@ -216,7 +151,7 @@
216151 .order_by_desc(user_ssh_keys::Column::CreatedAt)
217152 .limit(page_size as u64)
218153 .offset(offset)
219 .all(self.svc.db().read())
154 .all(self.svc.accounts.db().read())
220155 .await
221156 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
222157
@@ -225,50 +160,54 @@
225160 } else {
226161 String::new()
227162 };
228 let keys = rows.iter().map(convert::ssh_key_info).collect();
163 let keys = rows
164 .iter()
165 .map(crate::share::ssh_keys::ssh_key_info)
166 .collect();
229167
230 Ok(Response::new(ListUserSshKeysResponse {
168 Ok(ListUserSshKeysResponse {
231169 keys,
232170 page: Some(PageResponse { next_page_token }),
233 }))
171 })
234172 }
235173 #[tracing::instrument(skip_all, fields(request_id = %request_id.unwrap_or_default()))]
236 async fn revoke_ssh_key_inner(
174 pub async fn revoke_ssh_key_inner(
237175 &self,
238176 request_id: Option<&str>,
239177 request: RevokeSshKeyRequest,
240 ) -> Result<Response<RevokeSshKeyResponse>, Status> {
178 ) -> Result<RevokeSshKeyResponse, KenAuthError> {
241179 let key_id = Uuid::parse_str(request.key_id.trim())
242180 .map_err(|_| error::invalid_argument("key_id is not a valid uuid", request_id))?;
243181
244182 let row = user_ssh_keys::Entity::find_by_id(key_id)
245183 .filter(user_ssh_keys::Column::UserId.eq(request.user_id))
246 .one(self.svc.db().write())
184 .one(self.svc.accounts.db().write())
247185 .await
248186 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
249187 let Some(row) = row else {
250 let user = store::load_user(self.svc.db(), request.user_id)
188 let user = store::load_user(self.svc.accounts.db(), request.user_id)
251189 .await
252190 .map_err(|err| error::status(&err, request_id))?;
253 return Ok(Response::new(RevokeSshKeyResponse {
191 return Ok(RevokeSshKeyResponse {
254192 revoked: false,
255193 security_version: user.security_version,
256 }));
194 });
257195 };
258196 if row.revoked_at.is_some() {
259 let user = store::load_user(self.svc.db(), request.user_id)
197 let user = store::load_user(self.svc.accounts.db(), request.user_id)
260198 .await
261199 .map_err(|err| error::status(&err, request_id))?;
262 return Ok(Response::new(RevokeSshKeyResponse {
200 return Ok(RevokeSshKeyResponse {
263201 revoked: false,
264202 security_version: user.security_version,
265 }));
203 });
266204 }
267205
268206 let revoked_at = store::now();
269207 // 撤销与版本提升必须在同一事务:分身只提交一半会让已撤销的 key 继续可用。
270208 let txn = self
271209 .svc
210 .accounts
272211 .db()
273212 .write()
274213 .begin()
@@ -287,13 +226,13 @@
287226 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
288227 if updated.rows_affected != 1 {
289228 let _ = txn.rollback().await;
290 let user = store::load_user(self.svc.db(), request.user_id)
229 let user = store::load_user(self.svc.accounts.db(), request.user_id)
291230 .await
292231 .map_err(|err| error::status(&err, request_id))?;
293 return Ok(Response::new(RevokeSshKeyResponse {
232 return Ok(RevokeSshKeyResponse {
294233 revoked: false,
295234 security_version: user.security_version,
296 }));
235 });
297236 }
298237
299238 if request.bump_security_version {
@@ -313,11 +252,12 @@
313252 .map_err(|err| error::status(&AuthError::from(err), request_id))?;
314253
315254 if request.bump_security_version {
316 crate::services::me::invalidate_me_cache(self.svc.cache(), request.user_id).await;
255 auth::services::me::invalidate_me_cache(self.svc.accounts.cache(), request.user_id)
256 .await;
317257 }
318258
319259 if let Err(err) = store::audit(
320 self.svc.db(),
260 self.svc.accounts.db(),
321261 request.user_id,
322262 None,
323263 UserEventType::PublicKeyRemoved,
@@ -338,13 +278,13 @@
338278 tracing::warn!(%err, ssh_key_id = %key_id, "failed to record ssh key revocation");
339279 }
340280
341 let user = store::load_user(self.svc.db(), request.user_id)
281 let user = store::load_user(self.svc.accounts.db(), request.user_id)
342282 .await
343283 .map_err(|err| error::status(&err, request_id))?;
344284
345 Ok(Response::new(RevokeSshKeyResponse {
285 Ok(RevokeSshKeyResponse {
346286 revoked: true,
347287 security_version: user.security_version,
348 }))
288 })
349289 }
350290}
Modified lib/user/src/services/suspensions.rs View lib/user/src/services/suspensions.rs
@@ -61,7 +61,7 @@
6161 );
6262 }
6363
64 let rows = sel.all(self.db().read()).await?;
64 let rows = sel.all(self.database.read()).await?;
6565 Ok(rows.into_iter().map(SuspensionDto::from).collect())
6666 }
6767}
Modified lib/user/src/share/mod.rs View lib/user/src/share/mod.rs
@@ -1,3 +1,5 @@
11pub mod guard;
22pub mod page;
33pub mod types;
4
5pub mod ssh_keys;
Added lib/user/src/share/ssh_keys.rs View lib/user/src/share/ssh_keys.rs

Diff too large to display.

Modified lib/user/src/errors.rs View lib/user/src/errors.rs
@@ -25,18 +25,7 @@
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}
28export::access_error_conversion!(UserError, BadRequest);
4029
4130export::db_error_conversions!(UserError);
4231export::storage_error_conversion!(UserError);
@@ -52,6 +41,9 @@
5241 Forbidden(403, "operation not allowed"),
5342 NotFound(404, "resource not found"),
5443 Conflict(409, "resource conflict"),
44 RateLimited(429, "rate limit exceeded"),
45 PreconditionFailed(412, "precondition failed"),
46 Unavailable(503, "service unavailable"),
5547 Internal(500, "internal server error"),
5648 }
5749}
@@ -68,3 +60,18 @@
6860 }
6961 }
7062}
63
64impl From<auth::AuthError> for ApiError {
65 fn from(error: auth::AuthError) -> Self {
66 let status = error.status_code().as_u16();
67 if error.is_internal() {
68 tracing::warn!(%error, "user SSH key handler failed");
69 }
70 let mut response = export::rest::AppResponseData::err(i32::from(status), error.message())
71 .with_error_code(error.code());
72 if let auth::AuthError::RateLimited { retry_after_secs } = &error {
73 response = response.with_retry_after(*retry_after_secs);
74 }
75 Self::build(status, response)
76 }
77}
Modified lib/user/src/openapi.rs View lib/user/src/openapi.rs
@@ -20,6 +20,9 @@
2020#[openapi(
2121 info(title = "gitdata user api", version = "1.0"),
2222 paths(
23 handlers::ssh_key::ssh_key_list,
24 handlers::ssh_key::ssh_key_add,
25 handlers::ssh_key::ssh_key_delete,
2326 handlers::get_avatar::get_avatar,
2427 handlers::get_user_avatar::get_user_avatar,
2528 handlers::upload_avatar::upload_avatar,
Modified lib/user/src/routes.rs View lib/user/src/routes.rs
@@ -30,6 +30,14 @@
3030 );
3131
3232 let protected = Router::<UserService>::new()
33 .route(
34 "/auth/ssh-keys",
35 get(crate::handlers::ssh_key::ssh_key_list).post(crate::handlers::ssh_key::ssh_key_add),
36 )
37 .route(
38 "/auth/ssh-keys/{key_id}",
39 delete(crate::handlers::ssh_key::ssh_key_delete),
40 )
3341 .route("/users/{user_id}/avatar", get(get_user_avatar))
3442 .route(
3543 "/me/avatar",
Modified lib/user/Cargo.toml View lib/user/Cargo.toml
@@ -4,6 +4,9 @@
44edition.workspace = true
55
66[dependencies]
7auth = { workspace = true }
8chrono = { version = "0.4.45", features = ["serde"] }
9
710async-trait = { workspace = true }
811config = { workspace = true }
912export = { workspace = true }
Renamed lib/auth/src/share/session/cookie.rs → lib/webapi/src/middleware/cookie.rs View lib/webapi/src/middleware/cookie.rs

No content changes.

Renamed lib/auth/src/share/session/csrf.rs → lib/webapi/src/middleware/csrf.rs View lib/webapi/src/middleware/csrf.rs

No content changes.

Renamed lib/auth/src/share/session/mod.rs → lib/webapi/src/middleware/mod.rs View lib/webapi/src/middleware/mod.rs
@@ -1,3 +1,3 @@
1pub mod cookie;
1mod cookie;
22pub mod csrf;
33pub mod revalidate;
Renamed lib/auth/src/share/session/revalidate.rs → lib/webapi/src/middleware/revalidate.rs View lib/webapi/src/middleware/revalidate.rs
@@ -7,7 +7,7 @@
77use ::session::Session;
88use export::AppState;
99
10use crate::share::session::cookie::append_cookie;
10use crate::middleware::cookie::append_cookie;
1111
1212const SKIP_PREFIXES: [&str; 8] = [
1313 "/healthz",
@@ -51,7 +51,10 @@
5151 let Some(session_id) = session.session_id() else {
5252 return Ok(next.call(request).await?.map_into_boxed_body());
5353 };
54 let Some(kenauth) = state.kenauth.as_ref().cloned() else {
54 let Some(accounts) = request
55 .app_data::<Data<auth::Accounts>>()
56 .map(|value| value.get_ref().clone())
57 else {
5558 return Ok(next.call(request).await?.map_into_boxed_body());
5659 };
5760
@@ -65,7 +68,7 @@
6568 return Ok(next.call(request).await?.map_into_boxed_body());
6669 }
6770
68 match kenauth.get_session(user_id, version, &session_id).await {
71 match accounts.get_session(user_id, version, &session_id).await {
6972 Ok(_) => {
7073 let _ = export::redis::set_string(
7174 &state.redis,
@@ -76,8 +79,7 @@
7679 .await;
7780 Ok(next.call(request).await?.map_into_boxed_body())
7881 }
79 Err(crate::errors::KenAuthError::Unauthenticated)
80 | Err(crate::errors::KenAuthError::NotFound) => {
82 Err(export::KenAuthError::Unauthenticated) | Err(export::KenAuthError::NotFound) => {
8183 let mut response = HttpResponse::Unauthorized().json(serde_json::json!({
8284 "code": 401,
8385 "message": "session_revoked",
Renamed lib/auth/src/handlers/account/account_deletion.rs → lib/webapi/src/account_deletion.rs View lib/webapi/src/account_deletion.rs
@@ -1,13 +1,13 @@
11use ::session::Session;
22use ::session::share::session;
3use export::http::State;
3use export::http::{Extension, State};
44use export::rest::{AppResponseData, NoData};
55use serde::Serialize;
66use time::OffsetDateTime;
77use utoipa::ToSchema;
88
9use crate::AuthService;
10use crate::errors::{ApiError, AuthBusinessError};
9use auth::errors::{ApiError, AuthBusinessError};
10use export::AppState;
1111
1212#[derive(Debug, Clone, Serialize, ToSchema)]
1313pub struct AccountDeletionDto {
@@ -39,11 +39,11 @@
3939 )
4040)]
4141pub async fn account_deletion_status(
42 State(service): State<AuthService>,
42 State(state): State<AppState>,
4343 session: Session,
4444) -> Result<AppResponseData<Option<AccountDeletionDto>>, ApiError> {
4545 let uid = session::require_uid(&session)?;
46 let status = deletion::account_deletion_status(&service.state, uid)
46 let status = deletion::account_deletion_status(&state, uid)
4747 .await
4848 .map_err(AuthBusinessError::from)?;
4949 Ok(AppResponseData::ok(status.map(AccountDeletionDto::from)))
@@ -61,14 +61,19 @@
6161 )
6262)]
6363pub async fn request_account_deletion(
64 State(service): State<AuthService>,
64 State(state): State<AppState>,
65 Extension(accounts): Extension<auth::Accounts>,
6566 session: Session,
6667) -> Result<AppResponseData<AccountDeletionDto>, ApiError> {
6768 let uid = session::require_uid(&session)?;
68 let status =
69 deletion::request_account_deletion(&service.state, uid, "user requested account deletion")
70 .await
71 .map_err(AuthBusinessError::from)?;
69 let status = deletion::request_account_deletion(
70 &state,
71 &accounts,
72 uid,
73 "user requested account deletion",
74 )
75 .await
76 .map_err(AuthBusinessError::from)?;
7277 Ok(AppResponseData::ok(status.into()))
7378}
7479
@@ -84,11 +89,12 @@
8489 )
8590)]
8691pub async fn cancel_account_deletion(
87 State(service): State<AuthService>,
92 State(state): State<AppState>,
93 Extension(accounts): Extension<auth::Accounts>,
8894 session: Session,
8995) -> Result<AppResponseData<NoData>, ApiError> {
9096 let uid = session::require_uid(&session)?;
91 deletion::cancel_account_deletion(&service.state, uid)
97 deletion::cancel_account_deletion(&state, &accounts, uid)
9298 .await
9399 .map_err(AuthBusinessError::from)?;
94100 Ok(AppResponseData::empty())
Renamed lib/auth/src/assets.rs → lib/webapi/src/assets.rs View lib/webapi/src/assets.rs
@@ -70,8 +70,8 @@
7070}
7171
7272fn api_not_found() -> HttpResponse {
73 HttpResponse::NotFound().json(crate::errors::ErrorBody {
74 code: "not_found".to_owned(),
75 message: "unknown api route".to_owned(),
76 })
73 HttpResponse::NotFound().json(
74 export::rest::AppResponseData::<export::rest::NoData>::err(404, "unknown api route")
75 .with_error_code("not_found"),
76 )
7777}
Modified lib/webapi/src/cleanup.rs View lib/webapi/src/cleanup.rs
@@ -45,6 +45,7 @@
4545
4646#[derive(Clone)]
4747pub struct CleanupState {
48 pub database: std::sync::Arc<export::AppDatabase>,
4849 pub space: SpaceService,
4950 pub issues: IssueService,
5051 pub git: GitService,
@@ -78,8 +79,18 @@
7879 pub archived: bool,
7980}
8081
81pub fn router(space: SpaceService, issues: IssueService, git: GitService) -> Router {
82 let state = CleanupState { space, issues, git };
82pub fn router(
83 database: std::sync::Arc<export::AppDatabase>,
84 space: SpaceService,
85 issues: IssueService,
86 git: GitService,
87) -> Router {
88 let state = CleanupState {
89 database,
90 space,
91 issues,
92 git,
93 };
8394 Router::<()>::new()
8495 .route("/spaces/{space_name}/cleanup", get(list_cleanup_objects))
8596 .route(
@@ -146,7 +157,7 @@
146157 space_name: &str,
147158 uid: i64,
148159) -> Result<models::space::spaces::Model, ApiError> {
149 let space = lookup::lookup_space_by_name(&state.space, uid, space_name)
160 let space = lookup::lookup_space_by_name(&state.space, space_name)
150161 .await
151162 .map_err(map_space)?;
152163 if space.owner_user_id != uid {
@@ -195,7 +206,7 @@
195206}
196207
197208async fn repo_row_by_dir(
198 db: &sea_orm::DatabaseConnection,
209 db: &impl sea_orm::ConnectionTrait,
199210 dir_id: Uuid,
200211) -> Result<repos::Model, ApiError> {
201212 repos::Entity::find()
@@ -223,7 +234,7 @@
223234) -> Result<AppResponseData<Vec<CleanupObjectDto>>, ApiError> {
224235 let uid = session::require_uid(&session)?;
225236 let space = require_owner(&state, &space_name, uid).await?;
226 let db = state.space.db();
237 let db = &state.database;
227238
228239 let nodes = dirs::Entity::find()
229240 .filter(dirs::Column::SpaceId.eq(space.id))
@@ -389,7 +400,7 @@
389400) -> Result<AppResponseData<NoData>, ApiError> {
390401 let uid = session::require_uid(&session)?;
391402 let space = require_owner(&state, &space_name, uid).await?;
392 let db = state.space.db();
403 let db = &state.database;
393404
394405 match kind.as_str() {
395406 "repo" => {
@@ -454,7 +465,7 @@
454465 if kind != "repo" {
455466 return Err(ApiError::bad_request("only repositories support archiving"));
456467 }
457 let repo = repo_row_by_dir(state.space.db().read(), object_id).await?;
468 let repo = repo_row_by_dir(state.database.read(), object_id).await?;
458469 let ctx = state
459470 .git
460471 .context(space_name.clone(), repo.name.clone())
@@ -487,7 +498,7 @@
487498) -> Result<AppResponseData<NoData>, ApiError> {
488499 let uid = session::require_uid(&session)?;
489500 let space = require_owner(&state, &space_name, uid).await?;
490 let db = state.space.db();
501 let db = &state.database;
491502
492503 match kind.as_str() {
493504 "repo" => {
@@ -614,7 +625,7 @@
614625 "visibility overwrites are stored on directory nodes only",
615626 ));
616627 }
617 let db = state.space.db();
628 let db = &state.database;
618629 dirs::Entity::find_by_id(object_id)
619630 .filter(dirs::Column::SpaceId.eq(space.id))
620631 .one(db.read())
Modified lib/webapi/src/lib.rs View lib/webapi/src/lib.rs
@@ -1,4 +1,7 @@
1pub mod account_deletion;
2pub mod assets;
13pub mod cleanup;
4pub mod middleware;
25pub mod openapi;
36
47use actix_cors::Cors;
@@ -32,25 +35,113 @@
3235}
3336
3437pub fn scope(state: AppState) -> Scope {
35 let auth = ::auth::AuthService::new(state.clone());
36 let chat = chat::services::service::ChatService::new(state.clone());
37 let issues = issues::services::service::IssueService::new(state.clone());
38 let space = space::services::service::SpaceService::new(state.clone());
39 let user = user::services::service::UserService::new(state.clone());
40 let git = git::service::GitService::new(state.clone());
41 let pulls = pulls::services::service::PullService::new(state.clone());
38 let accounts = auth::Accounts::new(state.database.connection().clone(), state.cache.clone());
39 let access = space::Access::new(state.database.connection().clone(), state.cache.clone());
40 let notifications =
41 export::notify::Notifications::new(state.database.clone(), state.redis.clone());
42 let auth = auth::AuthService::new(
43 accounts.clone(),
44 auth::services::AuthSettings::from_config(&state.config),
45 state.mail.clone(),
46 );
47 let chat = build_chat_service(&state, accounts.clone());
48 let issue_audience = issues::services::audience::Audience::new(
49 accounts.clone(),
50 notifications,
51 state.mail.clone(),
52 i18n::config_fallback(state.config.email.default_locale()),
53 );
54 let issues = issues::services::service::IssueService::new(
55 state.database.clone(),
56 state.storage.clone(),
57 issue_audience,
58 issues::services::service::IssueSettings::from_config(&state.config),
59 issues::services::admission::Admission::new(
60 access.clone(),
61 state.redis.clone(),
62 state.config.quota.clone(),
63 ),
64 );
65 let space = space::services::service::SpaceService::new(
66 access.clone(),
67 space::services::images::SpaceImages::new(
68 access.clone(),
69 state.storage.clone(),
70 state.redis.clone(),
71 ),
72 space::services::policy::SpacePolicy::new(
73 space::services::service::SpaceSettings::from_config(&state.config),
74 accounts.clone(),
75 state.redis.clone(),
76 ),
77 export::notify::Notifications::new(state.database.clone(), state.redis.clone()),
78 );
79 let user = user::services::service::UserService::new(
80 state.database.clone(),
81 accounts.clone(),
82 access.clone(),
83 user::services::avatar::AvatarService::new(
84 state.database.clone(),
85 state.storage.clone(),
86 state.redis.clone(),
87 ),
88 );
89 let git_config = state.config.git.clone();
90 let ks = proto::KenShellClient::connect_with_key(
91 &git_config.kenshell_endpoint,
92 &git_config.kenshell_rpc_key,
93 std::time::Duration::from_secs(git_config.kenshell_timeout_secs.max(1)),
94 )
95 .unwrap_or_else(|error| {
96 panic!(
97 "invalid git.kenshell_endpoint `{}`: {error}",
98 git_config.kenshell_endpoint
99 )
100 });
101 let git = git::service::GitService::new(
102 state.database.clone(),
103 accounts.clone(),
104 access,
105 git::service::GitBackend::new(state.cache.clone(), git_config, std::sync::Arc::new(ks)),
106 );
107 let pulls = pulls::services::service::PullService::new(
108 state.database.clone(),
109 git.clone(),
110 issues.clone(),
111 pulls::services::actors::Actors::new(
112 accounts.clone(),
113 state.redis.clone(),
114 state.config.quota.clone(),
115 ),
116 );
42117 let internal_service_key = state.config.internal.service_key.clone();
43118
44 let cleanup_router = cleanup::router(space.clone(), issues.clone(), git.clone());
119 let cleanup_router = cleanup::router(
120 state.database.clone(),
121 space.clone(),
122 issues.clone(),
123 git.clone(),
124 );
45125
46 let auth_domain = auth.clone();
126 let account_deletion = Router::<AppState>::new()
127 .route(
128 "/me/deletion",
129 export::http::get(account_deletion::account_deletion_status)
130 .post(account_deletion::request_account_deletion)
131 .delete(account_deletion::cancel_account_deletion),
132 )
133 .wrap(Guard::Session)
134 .with_state(state.clone());
135 let accounts_for_http = accounts.clone();
136 let push_config = state.config.push.clone();
47137 let public = Router::<()>::new()
48138 .setup(move |scope| {
49139 scope
50 .app_data(web::Data::new(auth_domain))
51 .configure(::auth::routes::configure_api)
140 .app_data(web::Data::new(accounts_for_http))
141 .app_data(web::Data::new(push_config))
52142 })
53 .merge(::auth::routes_account::router(auth.clone()).with_state(auth.clone()))
143 .merge(account_deletion)
144 .merge(::auth::routes::router(auth.clone()).with_state(auth.clone()))
54145 .merge(chat::routes::router(chat.clone(), space.clone()).with_state(chat.clone()))
55146 .merge(issues::routes::router(issues.clone()).with_state(issues))
56147 .merge(space::routes::router(space.clone()).with_state(space))
@@ -67,8 +158,39 @@
67158 Router::<()>::new()
68159 .nest("/api/v1/internal", internal)
69160 .nest("/api/v1", public)
70 .nest("/pages", ::auth::routes_account::pages_router(auth.clone()))
161 .nest("/pages", ::auth::routes::pages_router(auth.clone()))
71162 .merge(export::health::router(state))
72163 .into_scope(web::scope(""))
73 .default_service(web::to(::auth::assets::serve))
164 .default_service(web::to(assets::serve))
165}
166
167pub fn build_chat_service(
168 state: &AppState,
169 accounts: auth::Accounts,
170) -> chat::services::service::ChatService {
171 let audience = chat::services::audience::Audience::new(
172 accounts,
173 export::notify::Notifications::new(state.database.clone(), state.redis.clone()),
174 state.mail.clone(),
175 chat::share::realtime::Realtime::new(state.database.clone()),
176 i18n::config_fallback(state.config.email.default_locale()),
177 );
178 let participants = chat::services::presence::Participants::new(
179 state.database.clone(),
180 state.redis.clone(),
181 space::Access::new(state.database.connection().clone(), state.cache.clone()),
182 audience.clone(),
183 chat::services::presence::ParticipantSettings::from_config(&state.config),
184 );
185 chat::services::service::ChatService::new(
186 state.database.clone(),
187 chat::services::media::Media::new(
188 state.storage.clone(),
189 state.redis.clone(),
190 state.database.clone(),
191 ),
192 audience,
193 chat::services::service::ChatSettings::from_config(&state.config),
194 participants,
195 )
74196}
Modified lib/webapi/src/openapi.rs View lib/webapi/src/openapi.rs
@@ -1,7 +1,15 @@
11use utoipa::OpenApi;
22
33#[derive(OpenApi)]
4#[openapi(info(title = "gitdata api", version = "1.0"))]
4#[openapi(
5 info(title = "gitdata api", version = "1.0"),
6 paths(
7 crate::account_deletion::account_deletion_status,
8 crate::account_deletion::request_account_deletion,
9 crate::account_deletion::cancel_account_deletion,
10 ),
11 components(schemas(crate::account_deletion::AccountDeletionDto))
12)]
513pub struct WebApiDoc;
614
715const CONVENTIONS: &str = r#"
@@ -102,7 +110,7 @@
102110 }};
103111 }
104112 m!("auth", auth::openapi::ApiDoc::openapi());
105 m!("auth-account", auth::openapi_account::AuthApiDoc::openapi());
113 m!("auth-account", auth::openapi::AuthApiDoc::openapi());
106114 m!("chat", chat::openapi::ChatApiDoc::openapi());
107115 m!("issues", issues::openapi::IssuesApiDoc::openapi());
108116 m!("space", space::openapi::SpaceApiDoc::openapi());
Renamed lib/auth/build.rs → lib/webapi/build.rs View lib/webapi/build.rs

No content changes.

Modified lib/webapi/Cargo.toml View lib/webapi/Cargo.toml
@@ -4,6 +4,7 @@
44edition.workspace = true
55
66[dependencies]
7proto = { workspace = true }
78config = { path = "../config" }
89auth = { workspace = true }
910chat = { workspace = true }
Modified Cargo.lock View Cargo.lock
@@ -773,7 +773,6 @@
773773 "config 1.0.0",
774774 "crypt",
775775 "db",
776 "deletion",
777776 "email",
778777 "envelope",
779778 "events",
@@ -810,15 +809,12 @@
810809 "session",
811810 "sha1 0.10.7",
812811 "sha2 0.10.9",
813 "space",
814812 "sqlparser",
815813 "thiserror 2.0.21",
816814 "time 0.3.55",
817815 "tokio",
818816 "tokio-stream",
819817 "tokio-util",
820 "tonic",
821 "tonic-prost",
822818 "tower",
823819 "tracing",
824820 "tracing-opentelemetry",
@@ -1258,9 +1254,12 @@
12581254dependencies = [
12591255 "actix-web",
12601256 "async-trait",
1257 "auth",
12611258 "base64 0.23.1",
12621259 "bytes",
1260 "chat-room-store",
12631261 "config 1.0.0",
1262 "email",
12641263 "encoding_rs",
12651264 "events",
12661265 "export",
@@ -1295,6 +1294,21 @@
12951294]
12961295
12971296[[package]]
1297name = "chat-room-store"
1298version = "1.0.0"
1299dependencies = [
1300 "events",
1301 "models",
1302 "sea-orm",
1303 "serde",
1304 "serde_json",
1305 "thiserror 2.0.21",
1306 "time 0.3.55",
1307 "utoipa 5.5.0",
1308 "uuid",
1309]
1310
1311[[package]]
12981312name = "cheetah-string"
12991313version = "1.2.0"
13001314source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1861,6 +1875,7 @@
18611875name = "deletion"
18621876version = "1.0.0"
18631877dependencies = [
1878 "auth",
18641879 "config 1.0.0",
18651880 "export",
18661881 "models",
@@ -2357,7 +2372,6 @@
23572372 "thiserror 2.0.21",
23582373 "time 0.3.55",
23592374 "tokio",
2360 "tonic",
23612375 "tracing",
23622376 "utoipa 5.5.0",
23632377 "uuid",
@@ -2713,6 +2727,7 @@
27132727dependencies = [
27142728 "actix-web",
27152729 "async-trait",
2730 "auth",
27162731 "base64 0.23.1",
27172732 "bytes",
27182733 "cache",
@@ -3570,8 +3585,10 @@
35703585dependencies = [
35713586 "actix-web",
35723587 "async-trait",
3588 "auth",
35733589 "bytes",
35743590 "config 1.0.0",
3591 "email",
35753592 "events",
35763593 "export",
35773594 "futures",
@@ -3584,6 +3601,7 @@
35843601 "serde_json",
35853602 "session",
35863603 "space",
3604 "storage",
35873605 "time 0.3.55",
35883606 "tracing",
35893607 "utoipa 5.5.0",
@@ -4353,7 +4371,6 @@
43534371version = "1.0.0"
43544372dependencies = [
43554373 "chrono",
4356 "proto",
43574374 "sea-orm",
43584375 "serde",
43594376 "serde_json",
@@ -5676,6 +5693,7 @@
56765693version = "1.0.0"
56775694dependencies = [
56785695 "actix-web",
5696 "auth",
56795697 "config 1.0.0",
56805698 "events",
56815699 "export",
@@ -5699,6 +5717,7 @@
56995717name = "push"
57005718version = "1.0.0"
57015719dependencies = [
5720 "auth",
57025721 "base64 0.23.1",
57035722 "config 1.0.0",
57045723 "export",
@@ -7180,8 +7199,10 @@
71807199dependencies = [
71817200 "actix-web",
71827201 "async-trait",
7202 "auth",
71837203 "base64 0.23.1",
71847204 "cache",
7205 "chat-room-store",
71857206 "chrono",
71867207 "config 1.0.0",
71877208 "db",
@@ -8334,7 +8355,9 @@
83348355dependencies = [
83358356 "actix-web",
83368357 "async-trait",
8358 "auth",
83378359 "base64 0.23.1",
8360 "chrono",
83388361 "config 1.0.0",
83398362 "export",
83408363 "futures",
@@ -8701,6 +8724,7 @@
87018724 "issues",
87028725 "maud",
87038726 "models",
8727 "proto",
87048728 "pulldown-cmark",
87058729 "pulls",
87068730 "redis",
Modified Cargo.toml View Cargo.toml
@@ -88,7 +88,7 @@
8888url = "2"
8989percent-encoding = "2"
9090encoding_rs = "0.8"
91tonic = { version = "0.14", default-features = false, features = ["channel", "codegen", "router", "server"] }
91tonic = { version = "0.14", default-features = false, features = ["channel", "codegen", "router", "server", "tls-ring"] }
9292tonic-prost = "0.14"
9393prost = "0.14"
9494prost-types = "0.14"
Added PROJECT-CHECKPOINT.md View PROJECT-CHECKPOINT.md
@@ -0,0 +1,18 @@
1# Domain convergence checkpoint
2
3Goal: remove auth RPC, reorganize auth following 8fefadc, move SSH keys to user, unify identity HTTP, narrow domain capabilities, consolidate space access and cross-domain APIs, and centralize outbound delivery.
4
5Constraints: Git RPC implementation must not change. User authorized unlimited compilation and parallel agents. Preserve existing staged changes. No tests requested.
6
7| ID | Requirement | Priority | Status | Evidence |
8|---|---|---|---|---|
9| A | auth organization, RPC removal, SSH migration | P0 | COMPLETE | RPC implementations removed; Accounts replaces AuthApi; SSH lives in user; auth has no tonic/RPC references |
10| B | Single identity HTTP boundary | P0 | COMPLETE | Shared Router/State/ApiError; assets and middleware in webapi; Retry-After preserved through shared Envelope metadata |
11| C | Narrow services, concrete adapters, one read router | P1 | COMPLETE | No AppState in seven domain crates; Database/BlobStore/AuthApi traits removed; AppDatabase delegates DbConn; capabilities extracted |
12| D | Deep space policy, opaque handle | P1 | COMPLETE | Access/Space + Capability; shared permission kernel/cache; no domain access to space tables/errors |
13| E | Owner APIs across domains | P1 | COMPLETE | Issue label read API; repo ownership in Access; UserDto from export; chat-room-store owns room writes; Git facade uses concrete backend |
14| F | Deep outbound delivery | P1 | COMPLETE | events::emit; transactional Notifications; email helper; database-only Realtime |
15
16Latest check: `cargo check --workspace --target-dir target/auth-refactor` passed with 0 warnings. Targeted rustfmt check passed for auth/user/chat/issues/pulls/space/git/webapi/export/models. `git diff --check` passed. Git RPC files remain unchanged. No tests were run.
17
18Next: optional focused runtime/smoke verification in an environment with its dependencies.