feat: refactor user handling and remove deprecated user email functionality
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Deleted lib/auth/src/handlers/access_keys.rs lib/auth/src/handlers/access_keys.rs +0 −223
@@ -1,223 +0,0 @@
1use axum::Json;
2use axum::extract::{Path, Query, State};
3use axum::http::HeaderMap;
4use export::pagination::{Page, PageQuery, paginate};
5use export::rest::{AppResponseData, NoData};
6use jwt::Session;
7use jwt::share::session::require_uid;
8use kenauth::proto::common::v1::{AccessKeyInfo, PageRequest, TokenScope};
9use models::users::access_scope::AccessKeyScope;
10use serde::{Deserialize, Serialize};
11use time::OffsetDateTime;
12use utoipa::ToSchema;
13use uuid::Uuid;
14
15use crate::errors::ApiError;
16use crate::services::AuthService;
17use crate::share::rpc;
18
19const SCOPE_PAGE_SIZE: i32 = 100;
20
21#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
22pub struct CreateAccessKeyCmd {
23 pub name: String,
24 pub scope: AccessKeyScope,
25 #[serde(
26 default,
27 deserialize_with = "export::serde_datetime::deserialize_opt_datetime"
28 )]
29 pub expires_at: Option<OffsetDateTime>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
33pub struct AccessKeyDto {
34 pub id: Uuid,
35 pub name: String,
36 pub scope: AccessKeyScope,
37 pub created_at: OffsetDateTime,
38 pub expires_at: Option<OffsetDateTime>,
39 pub last_used_at: Option<OffsetDateTime>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
43pub struct CreateAccessKeyResp {
44 pub key: AccessKeyDto,
45 pub token: String,
46}
47
48fn scopes_of(scope: AccessKeyScope) -> Vec<i32> {
49 match scope {
50 AccessKeyScope::Read => vec![TokenScope::RepoRead as i32],
51 AccessKeyScope::Write => vec![TokenScope::RepoRead as i32, TokenScope::RepoWrite as i32],
52 }
53}
54
55fn scope_of(info: &AccessKeyInfo) -> AccessKeyScope {
56 if info.scopes.contains(&(TokenScope::RepoWrite as i32)) {
57 AccessKeyScope::Write
58 } else {
59 AccessKeyScope::Read
60 }
61}
62
63fn key_dto(info: AccessKeyInfo) -> Result<AccessKeyDto, ApiError> {
64 let created_at = info
65 .created_at
66 .and_then(|ts| rpc::datetime(ts.seconds, ts.nanos))
67 .ok_or_else(|| ApiError::internal("kenoauth returned a key without created_at"))?;
68 Ok(AccessKeyDto {
69 id: Uuid::parse_str(&info.access_key_id)
70 .map_err(|_| ApiError::internal("kenoauth returned a malformed key id"))?,
71 scope: scope_of(&info),
72 name: info.name,
73 created_at,
74 expires_at: info
75 .expires_at
76 .and_then(|ts| rpc::datetime(ts.seconds, ts.nanos)),
77 last_used_at: info
78 .last_used_at
79 .and_then(|ts| rpc::datetime(ts.seconds, ts.nanos)),
80 })
81}
82
83#[utoipa::path(
84 get,
85 path = "/auth/access-keys",
86 tag = "security",
87 description = "列出当前账号的访问令牌(不含令牌明文)。",
88 params(PageQuery),
89 responses(
90 (status = 200, description = "access keys", body = AppResponseData<Page<AccessKeyDto>>),
91 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
92 )
93)]
94pub async fn list_access_keys(
95 State(service): State<AuthService>,
96 session: Session,
97 Query(query): Query<PageQuery>,
98) -> Result<AppResponseData<Page<AccessKeyDto>>, ApiError> {
99 let uid = require_uid(&session)?;
100 let client = rpc::client(&service.state)?;
101 let listed = client
102 .list_access_keys(
103 uid,
104 false,
105 PageRequest {
106 page_size: SCOPE_PAGE_SIZE,
107 page_token: String::new(),
108 },
109 )
110 .await
111 .map_err(|error| ApiError::internal(error.to_string()))?;
112 let mut keys = Vec::with_capacity(listed.keys.len());
113 for info in listed.keys {
114 keys.push(key_dto(info)?);
115 }
116 Ok(AppResponseData::ok(paginate(keys, &query)))
117}
118
119#[utoipa::path(
120 post,
121 path = "/auth/access-keys",
122 tag = "security",
123 description = "为当前账号签发新的访问令牌,令牌明文只在本响应出现一次。",
124 request_body = CreateAccessKeyCmd,
125 responses(
126 (status = 200, description = "access key created", body = AppResponseData<CreateAccessKeyResp>),
127 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
128 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
129 )
130)]
131pub async fn create_access_key(
132 State(service): State<AuthService>,
133 session: Session,
134 headers: HeaderMap,
135 Json(cmd): Json<CreateAccessKeyCmd>,
136) -> Result<AppResponseData<CreateAccessKeyResp>, ApiError> {
137 let uid = require_uid(&session)?;
138 let client = rpc::client(&service.state)?;
139 let expires_in_secs = match cmd.expires_at {
140 Some(expires_at) => (expires_at - OffsetDateTime::now_utc()).whole_seconds(),
141 None => 0,
142 };
143 let created = client
144 .create_access_key(
145 uid,
146 cmd.name.trim(),
147 "",
148 scopes_of(cmd.scope),
149 expires_in_secs,
150 &rpc::client_ip(&headers),
151 )
152 .await
153 .map_err(|error| ApiError::bad_request(error.to_string()))?;
154 let key = created
155 .key
156 .ok_or_else(|| ApiError::internal("kenoauth returned no key"))?;
157 Ok(AppResponseData::ok(CreateAccessKeyResp {
158 key: key_dto(key)?,
159 token: created.token,
160 }))
161}
162
163#[utoipa::path(
164 delete,
165 path = "/auth/access-keys/{key_id}",
166 tag = "security",
167 description = "吊销指定访问令牌;令牌不存在或不属于当前账号时返回 404。",
168 params(("key_id" = Uuid, Path)),
169 responses(
170 (status = 200, description = "access key revoked", body = AppResponseData<NoData>),
171 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
172 (status = 404, description = "not found", body = AppResponseData<NoData>)
173 )
174)]
175pub async fn delete_access_key(
176 State(service): State<AuthService>,
177 session: Session,
178 Path(key_id): Path<Uuid>,
179) -> Result<AppResponseData<NoData>, ApiError> {
180 let uid = require_uid(&session)?;
181 let client = rpc::client(&service.state)?;
182 let revoked = client
183 .revoke_access_key(uid, &key_id.to_string(), "user_revoked")
184 .await
185 .map_err(|error| ApiError::internal(error.to_string()))?;
186 if !revoked.revoked && !revoked.already_revoked {
187 return Err(ApiError::not_found("access key not found"));
188 }
189 Ok(AppResponseData::empty())
190}
191
192#[utoipa::path(
193 post,
194 path = "/auth/access-keys/{key_id}/rotate",
195 tag = "auth",
196 description = "为指定访问令牌换发新明文,保留其编号、名称与范围。",
197 params(("key_id" = Uuid, Path)),
198 responses(
199 (status = 200, description = "access key rotated", body = AppResponseData<CreateAccessKeyResp>),
200 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
201 (status = 404, description = "not found", body = AppResponseData<NoData>)
202 )
203)]
204pub async fn rotate_access_key(
205 State(service): State<AuthService>,
206 session: Session,
207 headers: HeaderMap,
208 Path(key_id): Path<Uuid>,
209) -> Result<AppResponseData<CreateAccessKeyResp>, ApiError> {
210 let uid = require_uid(&session)?;
211 let client = rpc::client(&service.state)?;
212 let rotated = client
213 .rotate_access_key(uid, &key_id.to_string(), &rpc::client_ip(&headers))
214 .await
215 .map_err(|_error| ApiError::not_found("access key not found"))?;
216 let key = rotated
217 .key
218 .ok_or_else(|| ApiError::internal("kenoauth returned no key"))?;
219 Ok(AppResponseData::ok(CreateAccessKeyResp {
220 key: key_dto(key)?,
221 token: rotated.token,
222 }))
223}
No newline at end of file
Modified lib/auth/src/handlers/mod.rs lib/auth/src/handlers/mod.rs +0 −2 View lib/auth/src/handlers/mod.rs
@@ -1,5 +1,4 @@
11pub mod accept_agreement;
2pub mod access_keys;
32pub mod agreement_page;
43pub mod create_report;
54pub mod delete_notification;
@@ -12,7 +11,6 @@
1211pub mod list_notifications;
1312pub mod mark_notifications_read;
1413pub mod sessions;
15pub mod ssh_keys;
1614pub mod me;
1715pub mod unread_notifications_count;
1816pub mod update_preferences;
Deleted lib/auth/src/handlers/ssh_keys.rs lib/auth/src/handlers/ssh_keys.rs +0 −165
@@ -1,165 +0,0 @@
1use axum::Json;
2use axum::extract::{Path, Query, State};
3use axum::http::HeaderMap;
4use export::pagination::{Page, PageQuery, paginate};
5use export::rest::{AppResponseData, NoData};
6use jwt::Session;
7use jwt::share::session::require_uid;
8use kenauth::proto::common::v1::{PageRequest, SshKeyAlgorithm, SshKeyInfo};
9use serde::{Deserialize, Serialize};
10use time::OffsetDateTime;
11use utoipa::ToSchema;
12use uuid::Uuid;
13
14use crate::errors::ApiError;
15use crate::services::AuthService;
16use crate::share::rpc;
17
18const KEY_PAGE_SIZE: i32 = 100;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
21#[serde(rename_all = "snake_case")]
22pub enum SshKeyType {
23 SshRsa,
24 SshEd25519,
25 EcdsaSha2Nistp256,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
29pub struct SshKeyDto {
30 pub id: Uuid,
31 pub name: String,
32 pub key_type: SshKeyType,
33 pub fingerprint: String,
34 pub created_at: OffsetDateTime,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
38pub struct AddSshKeyCmd {
39 pub name: String,
40 pub content: String,
41}
42
43fn key_type_of(algorithm: i32) -> SshKeyType {
44 match SshKeyAlgorithm::try_from(algorithm) {
45 Ok(SshKeyAlgorithm::Rsa) => SshKeyType::SshRsa,
46 Ok(SshKeyAlgorithm::Ecdsa) => SshKeyType::EcdsaSha2Nistp256,
47 _ => SshKeyType::SshEd25519,
48 }
49}
50
51fn key_dto(info: SshKeyInfo) -> Result<SshKeyDto, ApiError> {
52 let created_at = info
53 .created_at
54 .and_then(|ts| rpc::datetime(ts.seconds, ts.nanos))
55 .ok_or_else(|| ApiError::internal("kenoauth returned a key without created_at"))?;
56 Ok(SshKeyDto {
57 id: Uuid::parse_str(&info.key_id)
58 .map_err(|_| ApiError::internal("kenoauth returned a malformed key id"))?,
59 key_type: key_type_of(info.algorithm),
60 name: info.name,
61 fingerprint: info.fingerprint,
62 created_at,
63 })
64}
65
66#[utoipa::path(
67 get,
68 path = "/auth/ssh-keys",
69 tag = "security",
70 description = "列出当前账号已登记的 SSH 公钥。",
71 params(PageQuery),
72 responses(
73 (status = 200, description = "ssh public keys", body = AppResponseData<Page<SshKeyDto>>),
74 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
75 )
76)]
77pub async fn list_ssh_keys(
78 State(service): State<AuthService>,
79 session: Session,
80 Query(query): Query<PageQuery>,
81) -> Result<AppResponseData<Page<SshKeyDto>>, ApiError> {
82 let uid = require_uid(&session)?;
83 let client = rpc::client(&service.state)?;
84 let listed = client
85 .list_user_ssh_keys(
86 uid,
87 false,
88 PageRequest {
89 page_size: KEY_PAGE_SIZE,
90 page_token: String::new(),
91 },
92 )
93 .await
94 .map_err(|error| ApiError::internal(error.to_string()))?;
95 let mut keys = Vec::with_capacity(listed.keys.len());
96 for info in listed.keys {
97 keys.push(key_dto(info)?);
98 }
99 Ok(AppResponseData::ok(paginate(keys, &query)))
100}
101
102#[utoipa::path(
103 post,
104 path = "/auth/ssh-keys",
105 tag = "security",
106 description = "登记一把新的 SSH 公钥,用于 git over SSH。",
107 request_body = AddSshKeyCmd,
108 responses(
109 (status = 200, description = "ssh public key added", body = AppResponseData<SshKeyDto>),
110 (status = 400, description = "request failed validation", body = AppResponseData<NoData>),
111 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
112 )
113)]
114pub async fn add_ssh_key(
115 State(service): State<AuthService>,
116 session: Session,
117 headers: HeaderMap,
118 Json(cmd): Json<AddSshKeyCmd>,
119) -> Result<AppResponseData<SshKeyDto>, ApiError> {
120 let uid = require_uid(&session)?;
121 let client = rpc::client(&service.state)?;
122 let added = client
123 .add_ssh_key(
124 uid,
125 cmd.name.trim(),
126 cmd.content.trim(),
127 "",
128 &rpc::client_ip(&headers),
129 )
130 .await
131 .map_err(|error| ApiError::bad_request(error.to_string()))?;
132 let key = added
133 .key
134 .ok_or_else(|| ApiError::internal("kenoauth returned no key"))?;
135 Ok(AppResponseData::ok(key_dto(key)?))
136}
137
138#[utoipa::path(
139 delete,
140 path = "/auth/ssh-keys/{key_id}",
141 tag = "security",
142 description = "吊销指定 SSH 公钥;密钥不存在或不属于当前账号时返回 404。",
143 params(("key_id" = Uuid, Path)),
144 responses(
145 (status = 200, description = "ssh public key revoked", body = AppResponseData<NoData>),
146 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
147 (status = 404, description = "not found", body = AppResponseData<NoData>)
148 )
149)]
150pub async fn delete_ssh_key(
151 State(service): State<AuthService>,
152 session: Session,
153 Path(key_id): Path<Uuid>,
154) -> Result<AppResponseData<NoData>, ApiError> {
155 let uid = require_uid(&session)?;
156 let client = rpc::client(&service.state)?;
157 let revoked = client
158 .revoke_ssh_key(uid, &key_id.to_string(), "user_revoked", false)
159 .await
160 .map_err(|error| ApiError::internal(error.to_string()))?;
161 if !revoked.revoked {
162 return Err(ApiError::not_found("ssh key not found"));
163 }
164 Ok(AppResponseData::empty())
165}
No newline at end of file
Modified lib/auth/src/handlers/users.rs lib/auth/src/handlers/users.rs +2 −3 View lib/auth/src/handlers/users.rs
@@ -1,11 +1,10 @@
11use axum::Json;
22use axum::extract::{Path, Query, State};
33use export::rest::{AppResponseData, NoData};
4use serde::Deserialize;
54use jwt::Session;
65use jwt::share::session::require_uid;
6use serde::Deserialize;
77use utoipa::IntoParams;
8use uuid::Uuid;
98
109use crate::errors::ApiError;
1110use crate::services::AuthService;
@@ -13,7 +12,7 @@
1312
1413#[derive(Debug, Deserialize, IntoParams)]
1514pub struct UsersBatchQuery {
16 pub ids: Vec<Uuid>,
15 pub ids: Vec<i64>,
1716}
1817
1918#[utoipa::path(
Modified lib/auth/src/oauth/project.rs lib/auth/src/oauth/project.rs +25 −91 View lib/auth/src/oauth/project.rs
@@ -1,110 +1,44 @@
11use models::users::user::{self, ProfileVisibility};
2use models::users::user_emails;
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
2use sea_orm::{ActiveModelTrait, EntityTrait, Set};
43use time::OffsetDateTime;
54
65use crate::errors::AuthError;
76use crate::services::AuthService;
87
8/// Ensure the local settings row for an authenticated account exists.
9///
10/// Identity fields are not copied here: KenApi reads username, display name,
11/// email, account state and avatar from KenOAuth on demand. This row only
12/// anchors local preferences and foreign keys.
913pub async fn upsert(
1014 service: &AuthService,
1115 public: &kenauth::proto::common::v1::UserPublic,
1216) -> Result<user::Model, AuthError> {
1317 let db = service.db().write();
1418 let now = OffsetDateTime::now_utc();
15 let lower = public.username.to_lowercase();
16 let display_name = if public.displayname.trim().is_empty() {
17 public.username.clone()
18 } else {
19 public.displayname.clone()
20 };
21 let avatar_url = public.avatar_url.clone();
2219
23 let existing = user::Entity::find_by_id(public.user_id)
24 .one(db)
25 .await?
26 .or_else(|| None);
27
28 let row = match existing {
20 let existing = user::Entity::find_by_id(public.user_id).one(db).await?;
21 match existing {
2922 Some(row) => {
3023 let mut am: user::ActiveModel = row.into();
31 am.username = Set(public.username.clone());
32 am.lower_name = Set(lower);
33 am.display_name = Set(display_name);
34 am.avatar_url = Set(avatar_url);
35 am.is_active = Set(true);
36 am.prohibit_login = Set(false);
24 am.last_login_at = Set(Some(now));
3725 am.updated_at = Set(now);
38 am.update(db).await?
26 Ok(am.update(db).await?)
3927 }
40 None => {
41 user::ActiveModel {
42 uid: Set(public.user_id),
43 username: Set(public.username.clone()),
44 lower_name: Set(lower),
45 display_name: Set(display_name),
46 avatar_url: Set(avatar_url),
47 is_active: Set(true),
48 is_restricted: Set(false),
49 prohibit_login: Set(false),
50 visibility: Set(ProfileVisibility::Public),
51 bot: Set(false),
52 system: Set(false),
53 last_login_at: Set(None),
54 last_login_ip_hash: Set(None),
55 version: Set(1),
56 passwd_hash: Set(None),
57 password_last_changed_at: Set(None),
58 passwd_version: Set(None),
59 totp_encrypted_secret: Set(None),
60 totp_enabled: Set(false),
61 totp_last_used_at: Set(None),
62 totp_last_counter: Set(None),
63 timezone: Set(None),
64 locale: Set(None),
65 language: Set(None),
66 created_at: Set(now),
67 updated_at: Set(now),
68 }
69 .insert(db)
70 .await?
28 None => Ok(user::ActiveModel {
29 uid: Set(public.user_id),
30 is_restricted: Set(false),
31 visibility: Set(ProfileVisibility::Public),
32 last_login_at: Set(Some(now)),
33 last_login_ip_hash: Set(None),
34 local_avatar_url: Set(String::new()),
35 timezone: Set(None),
36 locale: Set(None),
37 language: Set(None),
38 version: Set(1),
39 updated_at: Set(now),
7140 }
72 };
73
74 if !public.email.trim().is_empty() {
75 let lower_email = public.email.to_lowercase();
76 let existing_email = user_emails::Entity::find()
77 .filter(user_emails::Column::UserId.eq(public.user_id))
78 .filter(user_emails::Column::IsPrimary.eq(true))
79 .one(db)
80 .await?;
81 match existing_email {
82 Some(email_row) => {
83 let mut am: user_emails::ActiveModel = email_row.into();
84 am.email = Set(public.email.clone());
85 am.lower_email = Set(lower_email);
86 am.is_activated = Set(public.email_verified);
87 am.updated_at = Set(now);
88 am.update(db).await?;
89 }
90 None => {
91 user_emails::ActiveModel {
92 id: Set(uuid::Uuid::now_v7()),
93 user_id: Set(public.user_id),
94 email: Set(public.email.clone()),
95 lower_email: Set(lower_email),
96 is_activated: Set(public.email_verified),
97 is_primary: Set(true),
98 created_at: Set(now),
99 updated_at: Set(now),
100 activated_at: Set(public.email_verified.then_some(now)),
101 deleted_at: Set(None),
102 }
103 .insert(db)
104 .await?;
105 }
106 }
41 .insert(db)
42 .await?),
10743 }
108
109 Ok(row)
110}
No newline at end of file
44}
Modified lib/auth/src/services/profile.rs lib/auth/src/services/profile.rs +74 −86 View lib/auth/src/services/profile.rs
@@ -68,58 +68,61 @@
6868
6969impl AuthService {
7070 pub async fn me(&self, uid: i64) -> Result<(UserMeDto, Option<ProfileDto>), AuthError> {
71 export::users::ensure_local_user(&self.state, uid).await?;
7172 let db = self.db();
72 let Some(mut user) = user::Entity::find_by_id(uid).one(db.read()).await? else {
73 let Some(user) = user::Entity::find_by_id(uid).one(db.read()).await? else {
7374 return Err(AuthError::NotFound);
7475 };
75 // Identity fields are owned by KenOAuth. Refresh the local projection on
76 // every /me so changes made in KenAdmin or another app show up here
77 // without waiting for the next login callback. Fall back to the local
78 // projection if the authority plane is briefly unavailable.
79 if let Some(client) = self.state.kenauth.as_ref() {
80 match client.get_user_public(uid).await {
81 Ok(public) => {
82 let display_name = if public.displayname.trim().is_empty() {
83 public.username.clone()
84 } else {
85 public.displayname.clone()
86 };
87 if user.username != public.username || user.display_name != display_name {
88 let now = OffsetDateTime::now_utc();
89 let mut active = user.clone().into_active_model();
90 active.username = Set(public.username.clone());
91 active.lower_name = Set(public.username.to_lowercase());
92 active.display_name = Set(display_name);
93 active.updated_at = Set(now);
94 user = active.update(db.write()).await?;
95 }
96 }
97 Err(error) => {
98 tracing::warn!(%error, %uid, "authority profile refresh failed; using local projection");
99 }
100 }
101 }
76 let client = self
77 .state
78 .kenauth
79 .as_ref()
80 .ok_or_else(|| AuthError::Internal("kenoauth is not configured".to_string()))?;
81 let authority = client.get_authority_user(uid).await.map_err(map_kenauth)?;
82 let public = client.get_user_public(uid).await.map_err(map_kenauth)?;
83 let status = client.get_user_status(uid).await.map_err(map_kenauth)?;
84
85 let display_name = if public.displayname.trim().is_empty() {
86 public.username.clone()
87 } else {
88 public.displayname.clone()
89 };
90 let avatar_raw = if user.local_avatar_url.is_empty() {
91 public.avatar_url.clone()
92 } else {
93 user.local_avatar_url.clone()
94 };
95 let created_at = authority
96 .created_at
97 .as_ref()
98 .and_then(|value| {
99 OffsetDateTime::from_unix_timestamp(value.seconds)
100 .ok()
101 .and_then(|dt| {
102 dt.replace_nanosecond(value.nanos.clamp(0, 999_999_999) as u32)
103 .ok()
104 })
105 })
106 .unwrap_or_else(OffsetDateTime::now_utc);
107
102108 let profile = user_profiles::Entity::find_by_id(uid)
103109 .one(db.read())
104110 .await?;
105111 let me = UserMeDto {
106 user_id: user.uid,
107 username: user.username,
108 display_name: user.display_name,
109 avatar_url: space::services::spaces::resolve_user_avatar_url(
110 user.uid,
111 &user.avatar_url,
112 ),
113 is_active: user.is_active,
112 user_id: uid,
113 username: public.username,
114 display_name,
115 avatar_url: space::services::spaces::resolve_user_avatar_url(uid, &avatar_raw),
116 is_active: status.allow_use && status.deleted_at.is_none(),
114117 is_restricted: user.is_restricted,
115 prohibit_login: user.prohibit_login,
118 prohibit_login: authority.forbid_login,
116119 visibility: user.visibility,
117 bot: user.bot,
118 system: user.system,
120 bot: public.bot,
121 system: public.system,
119122 timezone: user.timezone,
120123 locale: user.locale,
121124 language: user.language,
122 created_at: user.created_at,
125 created_at,
123126 };
124127 let profile = profile.map(|row| ProfileDto {
125128 real_name: row.real_name,
@@ -139,51 +142,41 @@
139142 }
140143
141144 pub async fn update_profile(&self, uid: i64, cmd: UpdateProfileCmd) -> Result<(), AuthError> {
145 export::users::ensure_local_user(&self.state, uid).await?;
142146 let db = self.db();
143147 let Some(user) = user::Entity::find_by_id(uid).one(db.write()).await? else {
144148 return Err(AuthError::NotFound);
145149 };
146150 let now = OffsetDateTime::now_utc();
147151 let mut user_active = user.into_active_model();
148 let authoritative_display_name = match cmd
152 if let Some(display_name) = cmd
149153 .display_name
150154 .as_deref()
151155 .map(str::trim)
152156 .filter(|name| !name.is_empty())
153157 {
154 Some(display_name) => {
155 if display_name.chars().count() > 64 {
156 return Err(AuthError::BadRequest(
157 "display_name must be at most 64 characters".to_string(),
158 ));
159 }
160 let client =
161 self.state.kenauth.as_ref().ok_or_else(|| {
162 AuthError::Internal("kenoauth is not configured".to_string())
163 })?;
164 let current = client.get_authority_user(uid).await.map_err(map_kenauth)?;
165 let updated = client
166 .update_authority_user(
167 uid,
168 None,
169 Some(display_name.to_string()),
170 false,
171 None,
172 current.version,
173 )
174 .await
175 .map_err(map_kenauth)?;
176 Some(
177 updated
178 .displayname
179 .filter(|value| !value.trim().is_empty())
180 .unwrap_or(updated.username),
158 if display_name.chars().count() > 64 {
159 return Err(AuthError::BadRequest(
160 "display_name must be at most 64 characters".to_string(),
161 ));
162 }
163 let client = self
164 .state
165 .kenauth
166 .as_ref()
167 .ok_or_else(|| AuthError::Internal("kenoauth is not configured".to_string()))?;
168 let current = client.get_authority_user(uid).await.map_err(map_kenauth)?;
169 client
170 .update_authority_user(
171 uid,
172 None,
173 Some(display_name.to_string()),
174 false,
175 None,
176 current.version,
181177 )
182 }
183 None => None,
184 };
185 if let Some(display_name) = authoritative_display_name {
186 user_active.display_name = Set(display_name);
178 .await
179 .map_err(map_kenauth)?;
187180 }
188181 if let Some(locale) = cmd.locale {
189182 user_active.locale = Set(Some(locale));
@@ -287,11 +280,7 @@
287280 ) -> Result<UserPublic, AuthError> {
288281 let username = crate::share::validate::validate_username(new_username.trim())?;
289282 let db = self.db();
290 let user = user::Entity::find_by_id(uid)
291 .one(db.read())
292 .await?
293 .ok_or(AuthError::NotFound)?;
294 let avatar_url = space::services::spaces::resolve_user_avatar_url(uid, &user.avatar_url);
283 let local = user::Entity::find_by_id(uid).one(db.read()).await?;
295284
296285 let client = self
297286 .state
@@ -314,24 +303,23 @@
314303 } else {
315304 current
316305 };
306 let public = client.get_user_public(uid).await.map_err(map_kenauth)?;
317307
318308 let display_name = updated
319309 .displayname
320310 .clone()
321311 .filter(|value| !value.trim().is_empty())
322312 .unwrap_or_else(|| updated.username.clone());
323 let mut active = user.into_active_model();
324 active.username = Set(updated.username.clone());
325 active.lower_name = Set(updated.lowername.clone());
326 active.display_name = Set(display_name.clone());
327 active.updated_at = Set(OffsetDateTime::now_utc());
328 active.update(db.write()).await?;
329
313 let avatar_raw = local
314 .as_ref()
315 .map(|row| row.local_avatar_url.as_str())
316 .filter(|value| !value.is_empty())
317 .unwrap_or(public.avatar_url.as_str());
330318 Ok(UserPublic {
331319 user_id: uid,
332320 username: updated.username,
333321 display_name,
334 avatar_url,
322 avatar_url: space::services::spaces::resolve_user_avatar_url(uid, avatar_raw),
335323 })
336324 }
337325}
Modified lib/auth/src/services/users.rs lib/auth/src/services/users.rs +100 −32 View lib/auth/src/services/users.rs
@@ -1,7 +1,8 @@
1use models::users::user;
2use models::users::user::ProfileVisibility;
1use std::collections::HashMap;
2
3use kenauth::proto::common::v1 as auth_public;
4use models::users::user::{self, ProfileVisibility};
35use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
4use uuid::Uuid;
56
67use super::AuthService;
78use crate::errors::AuthError;
@@ -10,35 +11,58 @@
1011const MAX_BATCH_USERS: usize = 100;
1112
1213impl AuthService {
13 fn filter_public_user(&self, caller: i64, user: user::Model) -> Result<UserPublic, AuthError> {
14 if !user.is_active || user.system {
14 fn filter_public_user(
15 &self,
16 caller: i64,
17 local: Option<&user::Model>,
18 public: &auth_public::UserPublic,
19 allow_use: bool,
20 ) -> Result<UserPublic, AuthError> {
21 if !allow_use || public.system {
1522 return Err(AuthError::NotFound);
1623 }
17 if user.visibility == ProfileVisibility::Private && caller != user.uid {
24 let visibility = local
25 .map(|row| row.visibility)
26 .unwrap_or(ProfileVisibility::Public);
27 if visibility == ProfileVisibility::Private && caller != public.user_id {
1828 return Err(AuthError::NotFound);
1929 }
30 let display_name = if public.displayname.trim().is_empty() {
31 public.username.clone()
32 } else {
33 public.displayname.clone()
34 };
35 let avatar_raw = local
36 .map(|row| row.local_avatar_url.as_str())
37 .filter(|value| !value.is_empty())
38 .unwrap_or(public.avatar_url.as_str());
2039 Ok(UserPublic {
21 user_id: user.uid,
22 username: user.username,
23 display_name: user.display_name,
40 user_id: public.user_id,
41 username: public.username.clone(),
42 display_name,
2443 avatar_url: space::services::spaces::resolve_user_avatar_url(
25 user.uid,
26 &user.avatar_url,
44 public.user_id,
45 avatar_raw,
2746 ),
2847 })
2948 }
3049
3150 pub async fn public_user(&self, caller: i64, uid: i64) -> Result<UserPublic, AuthError> {
32 let Some(user) = user::Entity::find_by_id(uid).one(self.db().read()).await? else {
33 return Err(AuthError::NotFound);
34 };
35 self.filter_public_user(caller, user)
51 let client = self
52 .state
53 .kenauth
54 .as_ref()
55 .ok_or_else(|| AuthError::Internal("kenoauth is not configured".to_string()))?;
56 let public = client.get_user_public(uid).await.map_err(map_kenauth)?;
57 let authority = client.get_authority_user(uid).await.map_err(map_kenauth)?;
58 let local = user::Entity::find_by_id(uid).one(self.db().read()).await?;
59 self.filter_public_user(caller, local.as_ref(), &public, authority.allow_use)
3660 }
3761
3862 pub async fn public_users(
3963 &self,
4064 caller: i64,
41 ids: Vec<Uuid>,
65 ids: Vec<i64>,
4266 ) -> Result<Vec<UserPublic>, AuthError> {
4367 if ids.len() > MAX_BATCH_USERS {
4468 return Err(AuthError::BadRequest(format!(
@@ -48,14 +72,44 @@
4872 if ids.is_empty() {
4973 return Ok(Vec::new());
5074 }
51 let rows = user::Entity::find()
52 .filter(user::Column::Uid.is_in(ids))
75 let mut unique: Vec<i64> = ids;
76 unique.sort_unstable();
77 unique.dedup();
78
79 let client = self
80 .state
81 .kenauth
82 .as_ref()
83 .ok_or_else(|| AuthError::Internal("kenoauth is not configured".to_string()))?;
84 let publics = client
85 .batch_get_user_public(unique.clone())
86 .await
87 .map_err(map_kenauth)?;
88 let authorities = client
89 .batch_get_authority_users(unique.clone())
90 .await
91 .map_err(map_kenauth)?;
92 let locals: HashMap<i64, user::Model> = user::Entity::find()
93 .filter(user::Column::Uid.is_in(unique))
5394 .all(self.db().read())
54 .await?;
55 Ok(rows
95 .await?
5696 .into_iter()
57 .filter_map(|u| self.filter_public_user(caller, u).ok())
58 .collect())
97 .map(|row| (row.uid, row))
98 .collect();
99 let allow: HashMap<i64, bool> = authorities
100 .into_iter()
101 .map(|row| (row.id, row.allow_use))
102 .collect();
103
104 let mut out = Vec::with_capacity(publics.len());
105 for public in publics {
106 let local = locals.get(&public.user_id);
107 let allow_use = allow.get(&public.user_id).copied().unwrap_or(false);
108 if let Ok(user) = self.filter_public_user(caller, local, &public, allow_use) {
109 out.push(user);
110 }
111 }
112 Ok(out)
59113 }
60114
61115 pub async fn public_user_by_name(
@@ -63,17 +117,31 @@
63117 caller: i64,
64118 username: &str,
65119 ) -> Result<UserPublic, AuthError> {
66 let lower = username.trim().to_lowercase();
67 if lower.is_empty() {
120 let name = username.trim();
121 if name.is_empty() {
68122 return Err(AuthError::NotFound);
69123 }
70 let Some(user) = user::Entity::find()
71 .filter(user::Column::LowerName.eq(&lower))
72 .one(self.db().read())
73 .await?
74 else {
75 return Err(AuthError::NotFound);
76 };
77 self.filter_public_user(caller, user)
124 let client = self
125 .state
126 .kenauth
127 .as_ref()
128 .ok_or_else(|| AuthError::Internal("kenoauth is not configured".to_string()))?;
129 let public = client
130 .get_user_public_by_ref(auth_public::user_ref::Key::Username(name.to_string()))
131 .await
132 .map_err(map_kenauth)?;
133 self.public_user(caller, public.user_id).await
134 }
135}
136
137fn map_kenauth(error: kenauth::KenAuthError) -> AuthError {
138 use kenauth::KenAuthError;
139 match error {
140 KenAuthError::NotFound => AuthError::NotFound,
141 KenAuthError::Unauthenticated => AuthError::Unauthorized,
142 KenAuthError::Rejected(message) => AuthError::BadRequest(message),
143 KenAuthError::Unavailable(message)
144 | KenAuthError::Config(message)
145 | KenAuthError::Internal(message) => AuthError::Internal(message),
78146 }
79147}
Modified lib/auth/src/openapi.rs lib/auth/src/openapi.rs +0 −15 View lib/auth/src/openapi.rs
@@ -40,13 +40,6 @@
4040 handlers::two_factor::totp_disable,
4141 handlers::two_factor::recovery_codes,
4242 handlers::two_factor::regenerate_recovery_codes,
43 handlers::access_keys::list_access_keys,
44 handlers::access_keys::create_access_key,
45 handlers::access_keys::delete_access_key,
46 handlers::access_keys::rotate_access_key,
47 handlers::ssh_keys::list_ssh_keys,
48 handlers::ssh_keys::add_ssh_key,
49 handlers::ssh_keys::delete_ssh_key,
5043 handlers::sessions::list_sessions,
5144 handlers::sessions::revoke_all_sessions,
5245 handlers::sessions::revoke_session,
@@ -82,15 +75,7 @@
8275 handlers::two_factor::TotpCodeCmd,
8376 handlers::two_factor::RegenerateRecoveryCodesCmd,
8477 handlers::two_factor::RecoveryCodesResp,
85 handlers::access_keys::AccessKeyDto,
86 handlers::access_keys::CreateAccessKeyCmd,
87 handlers::access_keys::CreateAccessKeyResp,
8878 models::users::access_scope::AccessKeyScope,
89 export::pagination::Page<handlers::access_keys::AccessKeyDto>,
90 handlers::ssh_keys::SshKeyDto,
91 handlers::ssh_keys::SshKeyType,
92 handlers::ssh_keys::AddSshKeyCmd,
93 export::pagination::Page<handlers::ssh_keys::SshKeyDto>,
9479 handlers::sessions::SessionDto,
9580 handlers::sessions::SessionKind,
9681 handlers::sessions::CountResp,
Modified lib/auth/src/routes.rs lib/auth/src/routes.rs +0 −22 View lib/auth/src/routes.rs
@@ -71,28 +71,6 @@
7171 .put(crate::handlers::two_factor::regenerate_recovery_codes),
7272 )
7373 .route(
74 "/auth/access-keys",
75 get(crate::handlers::access_keys::list_access_keys)
76 .post(crate::handlers::access_keys::create_access_key),
77 )
78 .route(
79 "/auth/access-keys/{key_id}",
80 delete(crate::handlers::access_keys::delete_access_key),
81 )
82 .route(
83 "/auth/access-keys/{key_id}/rotate",
84 post(crate::handlers::access_keys::rotate_access_key),
85 )
86 .route(
87 "/auth/ssh-keys",
88 get(crate::handlers::ssh_keys::list_ssh_keys)
89 .post(crate::handlers::ssh_keys::add_ssh_key),
90 )
91 .route(
92 "/auth/ssh-keys/{key_id}",
93 delete(crate::handlers::ssh_keys::delete_ssh_key),
94 )
95 .route(
9674 "/me/sessions",
9775 get(crate::handlers::sessions::list_sessions)
9876 .delete(crate::handlers::sessions::revoke_all_sessions),
Modified lib/chat/src/services/dms.rs lib/chat/src/services/dms.rs +36 −12 View lib/chat/src/services/dms.rs
@@ -105,13 +105,23 @@
105105
106106 async fn open_direct_dm(&self, caller: i64, peer_user_id: i64) -> Result<DmDto, ChatError> {
107107 self.check_dm_quota(caller).await?;
108 let peer = user::Entity::find_by_id(peer_user_id)
109 .one(self.db().read())
108 let client = self
109 .state
110 .kenauth
111 .as_ref()
112 .ok_or_else(|| ChatError::Internal("kenoauth is not configured".to_string()))?;
113 let peer = export::users::get_public_user(&self.state, peer_user_id)
110114 .await?
111 .filter(|u| u.is_active)
112115 .ok_or(ChatError::NotFound)?;
116 let peer_status = client
117 .get_user_status(peer_user_id)
118 .await
119 .map_err(|error| ChatError::Internal(error.to_string()))?;
120 if !peer_status.allow_use {
121 return Err(ChatError::NotFound);
122 }
113123
114 let pair = dm_pair_key(caller, peer.uid);
124 let pair = dm_pair_key(caller, peer.user_id);
115125 let existing = rooms::Entity::find()
116126 .filter(rooms::Column::LowerName.eq(&pair))
117127 .filter(rooms::Column::Kind.eq(RoomKind::DirectMessage))
@@ -149,10 +159,11 @@
149159 }
150160 };
151161
152 self.ensure_dm_members(room.id, caller, &[caller, peer.uid])
162 self.ensure_dm_members(room.id, caller, &[caller, peer.user_id])
153163 .await?;
154164
155 self.build_dm_dto(caller, room, Some(peer.into()), Vec::new())
165 let peer_dto = UserDto::from_public(&peer);
166 self.build_dm_dto(caller, room, Some(peer_dto), Vec::new())
156167 .await
157168 }
158169
@@ -163,13 +174,26 @@
163174 "group DM supports at most {MAX_GROUP_DM_MEMBERS} members"
164175 )));
165176 }
166 let users = user::Entity::find()
167 .filter(user::Column::Uid.is_in(peers.clone()))
168 .all(self.db().read())
169 .await?;
170 if users.len() != peers.len() || users.iter().any(|u| !u.is_active) {
177 let client = self
178 .state
179 .kenauth
180 .as_ref()
181 .ok_or_else(|| ChatError::Internal("kenoauth is not configured".to_string()))?;
182 let publics = export::users::load_public_users(&self.state, &peers).await?;
183 if publics.len() != peers.len() {
184 return Err(ChatError::NotFound);
185 }
186 let authorities = client
187 .batch_get_authority_users(peers.clone())
188 .await
189 .map_err(|error| ChatError::Internal(error.to_string()))?;
190 if authorities.iter().any(|user| !user.allow_use) {
171191 return Err(ChatError::NotFound);
172192 }
193 let users: Vec<_> = peers
194 .iter()
195 .filter_map(|uid| publics.get(uid).cloned())
196 .collect();
173197
174198 let mut member_uids = peers.clone();
175199 member_uids.push(caller);
@@ -223,7 +247,7 @@
223247 self.ensure_dm_members(room.id, caller, &member_uids)
224248 .await?;
225249
226 let members = users.into_iter().map(UserDto::from).collect::<Vec<_>>();
250 let members = users.iter().map(UserDto::from_public).collect::<Vec<_>>();
227251 self.build_dm_dto(caller, room, None, members).await
228252 }
229253
Modified lib/chat/src/services/service.rs lib/chat/src/services/service.rs +10 −11 View lib/chat/src/services/service.rs
@@ -43,15 +43,11 @@
4343 let mut set: Vec<i64> = uids.to_vec();
4444 set.sort();
4545 set.dedup();
46 let users = user::Entity::find()
47 .filter(user::Column::Uid.is_in(set))
48 .all(self.db().read())
49 .await?;
50 let map = users
46 let users = export::users::load_public_users(&self.state, &set).await?;
47 Ok(users
5148 .into_iter()
52 .map(|u| (u.uid, UserDto::from(u)))
53 .collect();
54 Ok(map)
49 .map(|(uid, public)| (uid, UserDto::from_public(&public)))
50 .collect())
5551 }
5652
5753 /// Language for one recipient's notification/e-mail text.
@@ -71,9 +67,12 @@
7167 if uids.is_empty() {
7268 return Ok(HashMap::new());
7369 }
74 models::users::queries::primary_emails(self.db().read(), uids)
75 .await
76 .map_err(ChatError::from)
70 let users = export::users::load_public_users(&self.state, uids).await?;
71 Ok(users
72 .into_iter()
73 .filter(|(_, user)| user.email_verified && !user.email.trim().is_empty())
74 .map(|(uid, user)| (uid, user.email.to_lowercase()))
75 .collect())
7776 }
7877
7978 pub(crate) async fn send_email(
Modified lib/chat/src/share/mention.rs lib/chat/src/share/mention.rs +15 −18 View lib/chat/src/share/mention.rs
@@ -76,24 +76,21 @@
7676) -> Result<Resolved, ChatError> {
7777 // Targeted lookup by username/name, then verify the resolved user is
7878 // actually visible in this room's context (avoids full member scans).
79 let candidates = user::Entity::find()
80 .filter(
81 Condition::any()
82 .add(user::Column::LowerName.eq(lower))
83 .add(user::Column::Username.eq(lower)),
84 )
85 .all(service.db().read())
86 .await?;
79 let candidate = export::users::find_by_username(&service.state, lower).await?;
8780 if let Some(space) = &ctx.space {
88 for candidate in candidates {
89 if space.owner_id == candidate.uid {
90 return Ok(Resolved::User(candidate.uid));
81 if let Some(candidate) = &candidate {
82 if space.owner_id == candidate.user_id {
83 return Ok(Resolved::User(candidate.user_id));
9184 }
92 let member =
93 space::authority::member(&service.state, space.owner_id, space.id, candidate.uid)
94 .await?;
85 let member = space::authority::member(
86 &service.state,
87 space.owner_id,
88 space.id,
89 candidate.user_id,
90 )
91 .await?;
9592 if member.is_some_and(|m| m.status == MemberStatus::Active) {
96 return Ok(Resolved::User(candidate.uid));
93 return Ok(Resolved::User(candidate.user_id));
9794 }
9895 }
9996 let group = space_groups::Entity::find()
@@ -107,12 +104,12 @@
107104 return Ok(Resolved::None);
108105 }
109106
110 for candidate in candidates {
111 let member = room_members::Entity::find_by_id((ctx.room.id, candidate.uid))
107 if let Some(candidate) = candidate {
108 let member = room_members::Entity::find_by_id((ctx.room.id, candidate.user_id))
112109 .one(service.db().read())
113110 .await?;
114111 if member.is_some_and(|m| m.left_at.is_none()) {
115 return Ok(Resolved::User(candidate.uid));
112 return Ok(Resolved::User(candidate.user_id));
116113 }
117114 }
118115 Ok(Resolved::None)
Modified lib/export/src/lib.rs lib/export/src/lib.rs +1 −0 View lib/export/src/lib.rs
@@ -17,6 +17,7 @@
1717pub mod storage;
1818pub mod telemetry;
1919pub mod urls;
20pub mod users;
2021
2122use std::sync::Arc;
2223
Added lib/export/src/users.rs lib/export/src/users.rs +113 −0 View lib/export/src/users.rs
@@ -0,0 +1,113 @@
1use std::collections::HashMap;
2
3use kenauth::proto::common::v1::UserPublic;
4
5use crate::AppState;
6use crate::error::ExportError;
7
8/// Bulk identity lookup. KenApi keeps no local copy of usernames, display
9/// names or avatars: every caller goes through KenOAuth.
10pub async fn load_public_users(
11 state: &AppState,
12 uids: &[i64],
13) -> Result<HashMap<i64, UserPublic>, ExportError> {
14 if uids.is_empty() {
15 return Ok(HashMap::new());
16 }
17 let mut ids: Vec<i64> = uids.to_vec();
18 ids.sort_unstable();
19 ids.dedup();
20 let client = state
21 .kenauth
22 .as_ref()
23 .ok_or_else(|| ExportError::InvalidConfig("kenoauth is not configured".to_string()))?;
24 let users = client
25 .batch_get_user_public(ids)
26 .await
27 .map_err(|error| ExportError::Connection(error.to_string()))?;
28 Ok(users.into_iter().map(|user| (user.user_id, user)).collect())
29}
30
31pub async fn get_public_user(
32 state: &AppState,
33 uid: i64,
34) -> Result<Option<UserPublic>, ExportError> {
35 let client = state
36 .kenauth
37 .as_ref()
38 .ok_or_else(|| ExportError::InvalidConfig("kenoauth is not configured".to_string()))?;
39 match client.get_user_public(uid).await {
40 Ok(user) => Ok(Some(user)),
41 Err(kenauth::KenAuthError::NotFound) => Ok(None),
42 Err(error) => Err(ExportError::Connection(error.to_string())),
43 }
44}
45
46pub async fn find_by_email(
47 state: &AppState,
48 email: &str,
49) -> Result<Option<UserPublic>, ExportError> {
50 let client = state
51 .kenauth
52 .as_ref()
53 .ok_or_else(|| ExportError::InvalidConfig("kenoauth is not configured".to_string()))?;
54 match client
55 .get_user_public_by_ref(kenauth::proto::common::v1::user_ref::Key::Email(
56 email.to_string(),
57 ))
58 .await
59 {
60 Ok(user) => Ok(Some(user)),
61 Err(kenauth::KenAuthError::NotFound) => Ok(None),
62 Err(error) => Err(ExportError::Connection(error.to_string())),
63 }
64}
65
66pub async fn find_by_username(
67 state: &AppState,
68 username: &str,
69) -> Result<Option<UserPublic>, ExportError> {
70 let client = state
71 .kenauth
72 .as_ref()
73 .ok_or_else(|| ExportError::InvalidConfig("kenoauth is not configured".to_string()))?;
74 match client
75 .get_user_public_by_ref(kenauth::proto::common::v1::user_ref::Key::Username(
76 username.to_string(),
77 ))
78 .await
79 {
80 Ok(user) => Ok(Some(user)),
81 Err(kenauth::KenAuthError::NotFound) => Ok(None),
82 Err(error) => Err(ExportError::Connection(error.to_string())),
83 }
84}
85
86/// Create the local settings/anchor row on demand. It contains no identity
87/// projection; it exists so local preferences and foreign keys have a stable
88/// row even when the user never went through KenApi's login callback.
89pub async fn ensure_local_user(state: &AppState, uid: i64) -> Result<(), ExportError> {
90 use models::users::user::{self, ProfileVisibility};
91 use sea_orm::{ActiveModelTrait, EntityTrait, Set};
92
93 let db = state.database.write();
94 if user::Entity::find_by_id(uid).one(db).await?.is_some() {
95 return Ok(());
96 }
97 user::ActiveModel {
98 uid: Set(uid),
99 is_restricted: Set(false),
100 visibility: Set(ProfileVisibility::Public),
101 last_login_at: Set(None),
102 last_login_ip_hash: Set(None),
103 local_avatar_url: Set(String::new()),
104 timezone: Set(None),
105 locale: Set(None),
106 language: Set(None),
107 version: Set(1),
108 updated_at: Set(time::OffsetDateTime::now_utc()),
109 }
110 .insert(db)
111 .await?;
112 Ok(())
113}
Modified lib/git/src/lfs/lock.rs lib/git/src/lfs/lock.rs +3 −3 View lib/git/src/lfs/lock.rs
@@ -188,9 +188,9 @@
188188}
189189
190190async fn owner_name(svc: &GitService, uid: i64) -> String {
191 match user::Entity::find_by_id(uid).one(svc.db_read()).await {
192 Ok(Some(u)) if !u.display_name.trim().is_empty() => u.display_name,
193 Ok(Some(u)) => u.username,
191 match export::users::get_public_user(&svc.state, uid).await {
192 Ok(Some(user)) if !user.displayname.trim().is_empty() => user.displayname,
193 Ok(Some(user)) => user.username,
194194 _ => uid.to_string(),
195195 }
196196}
Modified lib/git/src/service/commits.rs lib/git/src/service/commits.rs +5 −8 View lib/git/src/service/commits.rs
@@ -4,7 +4,6 @@
44use models::git::git_commit_status;
55use models::git::git_commit_status::CommitStatusState;
66use models::git::git_commits;
7use models::users::user_emails;
87use sea_orm::{
98 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
109 Unchanged,
@@ -150,12 +149,10 @@
150149 }
151150}
152151
153async fn uid_for_email(db: &DatabaseConnection, email: &str) -> Result<Option<i64>, GitError> {
154 let row = user_emails::Entity::find()
155 .filter(user_emails::Column::LowerEmail.eq(email.to_lowercase()))
156 .one(db)
157 .await?;
158 Ok(row.map(|r| r.user_id))
152async fn uid_for_email(state: &export::AppState, email: &str) -> Result<Option<i64>, GitError> {
153 Ok(export::users::find_by_email(state, email)
154 .await?
155 .map(|user| user.user_id))
159156}
160157
161158impl GitService {
@@ -341,7 +338,7 @@
341338 let existing = git_commits::Entity::find_by_id((ctx.model.id, sha.clone()))
342339 .one(self.db_read())
343340 .await?;
344 let author_id = uid_for_email(self.db_read(), &info.author.email).await?;
341 let author_id = uid_for_email(&self.state, &info.author.email).await?;
345342 let now = OffsetDateTime::now_utc();
346343 let row = match existing {
347344 Some(cur) => {
Modified lib/git/src/service/content.rs lib/git/src/service/content.rs +1 −1 View lib/git/src/service/content.rs
@@ -37,7 +37,7 @@
3737 caller: i64,
3838 when: OffsetDateTime,
3939 ) -> Result<SignatureDto, GitError> {
40 let ident = load_identity(&self.state.database, caller).await?;
40 let ident = load_identity(&self.state, caller).await?;
4141 Ok(SignatureDto {
4242 name: ident.name,
4343 email: ident.email,
Modified lib/git/src/service/releases.rs lib/git/src/service/releases.rs +12 −6 View lib/git/src/service/releases.rs
@@ -156,8 +156,7 @@
156156 .map_err(crate::share::errors::map_baby)?
157157 .to_string(),
158158 };
159 let publisher_row = models::users::user::Entity::find_by_id(publisher)
160 .one(self.db_read())
159 let publisher_row = export::users::get_public_user(&self.state, publisher)
161160 .await?
162161 .ok_or(GitError::Unauthorized)?;
163162 let now = OffsetDateTime::now_utc();
@@ -165,7 +164,11 @@
165164 id: Set(Uuid::now_v7()),
166165 repo_id: Set(ctx.model.id),
167166 publisher_id: Set(publisher),
168 publisher_name: Set(publisher_row.display_name.clone()),
167 publisher_name: Set(if publisher_row.displayname.trim().is_empty() {
168 publisher_row.username.clone()
169 } else {
170 publisher_row.displayname.clone()
171 }),
169172 tag_name: Set(tag.to_string()),
170173 lower_tag_name: Set(tag.to_lowercase()),
171174 target: Set(target.clone()),
@@ -212,8 +215,7 @@
212215 if let Some(content) = cmd.content.as_deref() {
213216 validate_len(content, MAX_CONTENT_LEN, "release content")?;
214217 }
215 let actor_row = models::users::user::Entity::find_by_id(actor)
216 .one(self.db_read())
218 let actor_row = export::users::get_public_user(&self.state, actor)
217219 .await?
218220 .ok_or(GitError::Unauthorized)?;
219221 let mut model: repo_release::ActiveModel = row.into();
@@ -228,7 +230,11 @@
228230 // 首次发布时记录实际发布人。
229231 if !is_draft {
230232 model.publisher_id = Set(actor);
231 model.publisher_name = Set(actor_row.display_name.clone());
233 model.publisher_name = Set(if actor_row.displayname.trim().is_empty() {
234 actor_row.username.clone()
235 } else {
236 actor_row.displayname.clone()
237 });
232238 }
233239 }
234240 if let Some(is_prerelease) = cmd.is_prerelease {
Modified lib/git/src/service/stars.rs lib/git/src/service/stars.rs +8 −9 View lib/git/src/service/stars.rs
@@ -133,20 +133,19 @@
133133 return Ok(Vec::new());
134134 }
135135 let user_ids: Vec<i64> = rows.iter().map(|r| r.user_id).collect();
136 let users = models::users::user::Entity::find()
137 .filter(models::users::user::Column::Uid.is_in(user_ids.iter().copied()))
138 .all(self.db_read())
139 .await?;
140 let by_id: std::collections::HashMap<i64, models::users::user::Model> =
141 users.into_iter().map(|u| (u.uid, u)).collect();
136 let by_id = export::users::load_public_users(&self.state, &user_ids).await?;
142137 Ok(rows
143138 .into_iter()
144139 .filter_map(|row| {
145140 by_id.get(&row.user_id).map(|u| StargazerDto {
146 user_id: u.uid,
141 user_id: u.user_id,
147142 username: u.username.clone(),
148 display_name: u.display_name.clone(),
149 avatar_url: export::resolve_user_avatar_url(u.uid, &u.avatar_url),
143 display_name: if u.displayname.trim().is_empty() {
144 u.username.clone()
145 } else {
146 u.displayname.clone()
147 },
148 avatar_url: export::resolve_user_avatar_url(u.user_id, &u.avatar_url),
150149 starred_at: row.created_at,
151150 })
152151 })
Modified lib/git/src/service/watches.rs lib/git/src/service/watches.rs +8 −9 View lib/git/src/service/watches.rs
@@ -159,20 +159,19 @@
159159 return Ok(Vec::new());
160160 }
161161 let user_ids: Vec<i64> = rows.iter().map(|r| r.user_id).collect();
162 let users = models::users::user::Entity::find()
163 .filter(models::users::user::Column::Uid.is_in(user_ids.iter().copied()))
164 .all(self.db_read())
165 .await?;
166 let by_id: std::collections::HashMap<i64, models::users::user::Model> =
167 users.into_iter().map(|u| (u.uid, u)).collect();
162 let by_id = export::users::load_public_users(&self.state, &user_ids).await?;
168163 Ok(rows
169164 .into_iter()
170165 .filter_map(|row| {
171166 by_id.get(&row.user_id).map(|u| WatcherDto {
172 user_id: u.uid,
167 user_id: u.user_id,
173168 username: u.username.clone(),
174 display_name: u.display_name.clone(),
175 avatar_url: export::resolve_user_avatar_url(u.uid, &u.avatar_url),
169 display_name: if u.displayname.trim().is_empty() {
170 u.username.clone()
171 } else {
172 u.displayname.clone()
173 },
174 avatar_url: export::resolve_user_avatar_url(u.user_id, &u.avatar_url),
176175 mode: row.mode,
177176 watching_since: row.created_at,
178177 })
Modified lib/git/src/service/whitelists.rs lib/git/src/service/whitelists.rs +3 −6 View lib/git/src/service/whitelists.rs
@@ -89,12 +89,9 @@
8989 if uids.is_empty() {
9090 return Ok(map);
9191 }
92 let rows = user::Entity::find()
93 .filter(user::Column::Uid.is_in(uids.to_vec()))
94 .all(self.db_read())
95 .await?;
96 for u in rows {
97 map.insert(u.uid, space::services::spaces::UserDto::from(u));
92 let rows = export::users::load_public_users(&self.state, uids).await?;
93 for (uid, user) in rows {
94 map.insert(uid, space::services::spaces::UserDto::from_public(&user));
9895 }
9996 Ok(map)
10097 }
Modified lib/git/src/share/errors.rs lib/git/src/share/errors.rs +9 −0 View lib/git/src/share/errors.rs
@@ -22,6 +22,15 @@
2222 Internal(String),
2323}
2424
25impl From<export::ExportError> for GitError {
26 fn from(error: export::ExportError) -> Self {
27 match error {
28 export::ExportError::Database(err) => Self::from(err),
29 other => Self::Internal(other.to_string()),
30 }
31 }
32}
33
2534impl From<space::errors::SpaceError> for GitError {
2635 fn from(error: space::errors::SpaceError) -> Self {
2736 match error {
Modified lib/git/src/share/identity.rs lib/git/src/share/identity.rs +11 −20 View lib/git/src/share/identity.rs
@@ -1,7 +1,3 @@
1use models::users::user;
2use models::users::user_emails;
3use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
4
51use crate::share::errors::GitError;
62
73#[derive(Clone, Debug)]
@@ -10,27 +6,22 @@
106 pub email: String,
117}
128
13pub async fn load_identity(db: &export::AppDatabase, uid: i64) -> Result<GitIdentity, GitError> {
14 let u = user::Entity::find_by_id(uid)
15 .one(db.read())
9pub async fn load_identity(state: &export::AppState, uid: i64) -> Result<GitIdentity, GitError> {
10 let user = export::users::get_public_user(state, uid)
1611 .await?
1712 .ok_or(GitError::NotFound)?;
18 let email = user_emails::Entity::find()
19 .filter(user_emails::Column::UserId.eq(uid))
20 .filter(user_emails::Column::IsPrimary.eq(true))
21 .filter(user_emails::Column::IsActivated.eq(true))
22 .filter(user_emails::Column::DeletedAt.is_null())
23 .one(db.read())
24 .await?
25 .ok_or_else(|| GitError::InvalidArgument("no active primary email".to_string()))?;
26
27 let name = if u.display_name.trim().is_empty() {
28 u.username.clone()
13 if user.email.trim().is_empty() || !user.email_verified {
14 return Err(GitError::InvalidArgument(
15 "no active primary email".to_string(),
16 ));
17 }
18 let name = if user.displayname.trim().is_empty() {
19 user.username.clone()
2920 } else {
30 u.display_name.clone()
21 user.displayname.clone()
3122 };
3223 Ok(GitIdentity {
3324 name,
34 email: email.lower_email,
25 email: user.email,
3526 })
3627}
Modified lib/issues/src/services/service.rs lib/issues/src/services/service.rs +14 −19 View lib/issues/src/services/service.rs
@@ -65,15 +65,11 @@
6565 let mut set: Vec<i64> = uids.to_vec();
6666 set.sort();
6767 set.dedup();
68 let users = user::Entity::find()
69 .filter(user::Column::Uid.is_in(set))
70 .all(self.db().read())
71 .await?;
72 let map = users
68 let users = export::users::load_public_users(&self.state, &set).await?;
69 Ok(users
7370 .into_iter()
74 .map(|u| (u.uid, UserDto::from(u)))
75 .collect();
76 Ok(map)
71 .map(|(uid, public)| (uid, UserDto::from_public(&public)))
72 .collect())
7773 }
7874
7975 #[allow(clippy::too_many_arguments)]
@@ -202,16 +198,12 @@
202198 return Ok(Vec::new());
203199 }
204200
205 let users = user::Entity::find()
206 .filter(user::Column::Uid.is_in(member_uids))
207 .all(self.db().read())
208 .await?;
201 let users = export::users::load_public_users(&self.state, &member_uids).await?;
209202
210203 let mut name_to_uid: HashMap<String, i64> = HashMap::new();
211 for u in users {
212 name_to_uid.insert(u.username.to_lowercase(), u.uid);
213 name_to_uid.insert(u.lower_name.to_lowercase(), u.uid);
214 name_to_uid.insert(u.uid.to_string(), u.uid);
204 for (uid, user) in users {
205 name_to_uid.insert(user.username.to_lowercase(), uid);
206 name_to_uid.insert(uid.to_string(), uid);
215207 }
216208
217209 let mut result_set: HashSet<i64> = HashSet::new();
@@ -260,9 +252,12 @@
260252 if uids.is_empty() {
261253 return Ok(HashMap::new());
262254 }
263 models::users::queries::primary_emails(self.db().read(), uids)
264 .await
265 .map_err(IssueError::from)
255 let users = export::users::load_public_users(&self.state, uids).await?;
256 Ok(users
257 .into_iter()
258 .filter(|(_, user)| user.email_verified && !user.email.trim().is_empty())
259 .map(|(uid, user)| (uid, user.email.to_lowercase()))
260 .collect())
266261 }
267262
268263 pub(crate) async fn send_email(
Modified lib/kenauth/src/client.rs lib/kenauth/src/client.rs +58 −0 View lib/kenauth/src/client.rs
@@ -558,6 +558,50 @@
558558 .map(|response| response.into_inner().access.unwrap_or_default())
559559 }
560560
561 pub async fn batch_get_authority_users(
562 &self,
563 user_ids: Vec<i64>,
564 ) -> Result<Vec<proto::internal::v1::AuthorityUser>, KenAuthError> {
565 let mut client = self.authority();
566 client
567 .batch_get_user(
568 self.read_request(proto::internal::v1::BatchGetUserRequest { user_ids }),
569 )
570 .await
571 .map_err(KenAuthError::from_status)
572 .map(|response| response.into_inner().users)
573 }
574
575 pub async fn get_user_status(
576 &self,
577 user_id: i64,
578 ) -> Result<proto::common::v1::UserStatus, KenAuthError> {
579 let mut client = self.identity();
580 let user = Some(proto::common::v1::UserRef {
581 key: Some(proto::common::v1::user_ref::Key::UserId(user_id)),
582 });
583 client
584 .get_user_status(self.read_request(proto::v1::GetUserStatusRequest { user }))
585 .await
586 .map_err(KenAuthError::from_status)
587 .map(|response| response.into_inner().status.unwrap_or_default())
588 }
589
590 pub async fn get_user_public_by_ref(
591 &self,
592 key: proto::common::v1::user_ref::Key,
593 ) -> Result<proto::common::v1::UserPublic, KenAuthError> {
594 let mut client = self.identity();
595 let user = Some(proto::common::v1::UserRef { key: Some(key) });
596 client
597 .get_user_public(self.read_request(proto::v1::GetUserPublicRequest { user }))
598 .await
599 .map_err(KenAuthError::from_status)?
600 .into_inner()
601 .user
602 .ok_or(KenAuthError::NotFound)
603 }
604
561605 pub async fn get_authority_user(
562606 &self,
563607 user_id: i64,
@@ -603,6 +647,20 @@
603647 .ok_or(KenAuthError::NotFound)
604648 }
605649
650 pub async fn batch_get_user_public(
651 &self,
652 user_ids: Vec<i64>,
653 ) -> Result<Vec<proto::common::v1::UserPublic>, KenAuthError> {
654 let mut client = self.identity();
655 client
656 .batch_get_user_public(
657 self.read_request(proto::v1::BatchGetUserPublicRequest { user_ids }),
658 )
659 .await
660 .map_err(KenAuthError::from_status)
661 .map(|response| response.into_inner().users)
662 }
663
606664 pub async fn get_user_public(
607665 &self,
608666 user_id: i64,
Modified lib/models/src/users/mod.rs lib/models/src/users/mod.rs +0 −2 View lib/models/src/users/mod.rs
@@ -1,7 +1,6 @@
11pub mod user;
22pub mod user_agreement_acceptances;
33pub mod user_blocks;
4pub mod user_emails;
54pub mod user_external_identity;
65pub mod access_scope;
76pub mod user_favorite_memes;
@@ -17,4 +16,3 @@
1716
1817pub use user::*;
1918
20pub mod queries;
Deleted lib/models/src/users/queries.rs lib/models/src/users/queries.rs +0 −27
@@ -1,27 +0,0 @@
1use std::collections::HashMap;
2
3use sea_orm::{ColumnTrait, ConnectionTrait, DbErr, EntityTrait, QueryFilter};
4
5
6use super::user_emails;
7
8/// Maps each user id to its activated primary email (lower-cased form).
9pub async fn primary_emails<C: ConnectionTrait>(
10 db: &C,
11 uids: &[i64],
12) -> Result<HashMap<i64, String>, DbErr> {
13 if uids.is_empty() {
14 return Ok(HashMap::new());
15 }
16 let rows = user_emails::Entity::find()
17 .filter(user_emails::Column::UserId.is_in(uids.to_vec()))
18 .filter(user_emails::Column::IsPrimary.eq(true))
19 .filter(user_emails::Column::IsActivated.eq(true))
20 .filter(user_emails::Column::DeletedAt.is_null())
21 .all(db)
22 .await?;
23 Ok(rows
24 .into_iter()
25 .map(|r| (r.user_id, r.lower_email))
26 .collect())
27}
Modified lib/models/src/users/user.rs lib/models/src/users/user.rs +12 −33 View lib/models/src/users/user.rs
@@ -1,5 +1,4 @@
11use sea_orm::entity::prelude::*;
2use sea_orm::{EntityTrait, QueryFilter, Select};
32use serde::{Deserialize, Serialize};
43use time::OffsetDateTime;
54
@@ -53,46 +52,32 @@
5352 Nobody,
5453}
5554
55/// Local per-user settings only.
56///
57/// Identity and account state (username, display name, email, password, TOTP,
58/// active/prohibit flags, bot/system, created_at) live **exclusively** in
59/// KenOAuth and are fetched over gRPC. This row exists as a stable local anchor
60/// for foreign keys plus data KenApi itself owns.
5661#[sea_orm::model]
5762#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
5863#[sea_orm(table_name = "users")]
5964pub struct Model {
6065 #[sea_orm(primary_key, auto_increment = false)]
6166 pub uid: i64,
62 pub username: String,
63 #[sea_orm(unique)]
64 pub lower_name: String,
65 pub display_name: String,
66 pub avatar_url: String,
67 pub is_active: bool,
67 /// KenApi-only moderation flag (not an OAuth account state).
6868 pub is_restricted: bool,
69 pub prohibit_login: bool,
7069 pub visibility: ProfileVisibility,
71 #[sea_orm(default_value = "false")]
72 pub bot: bool,
73 #[sea_orm(default_value = "false")]
74 pub system: bool,
7570 pub last_login_at: Option<OffsetDateTime>,
7671 pub last_login_ip_hash: Option<String>,
77 pub version: i32,
78
79 pub passwd_hash: Option<String>,
80 pub password_last_changed_at: Option<OffsetDateTime>,
81 pub passwd_version: Option<i64>,
82 pub totp_encrypted_secret: Option<String>,
83 #[sea_orm(default_value = "false")]
84 pub totp_enabled: bool,
85 pub totp_last_used_at: Option<OffsetDateTime>,
86 pub totp_last_counter: Option<i64>,
87
72 /// Avatar uploaded into KenApi's own object storage. Empty = fall back to
73 /// KenOAuth's resolved avatar.
74 pub local_avatar_url: String,
8875 pub timezone: Option<String>,
8976 pub locale: Option<String>,
9077 pub language: Option<String>,
91
92 pub created_at: OffsetDateTime,
78 /// Local settings optimistic-lock version (never the OAuth account version).
79 pub version: i32,
9380 pub updated_at: OffsetDateTime,
94 #[sea_orm(has_many)]
95 pub emails: HasMany<super::user_emails::Entity>,
9681 #[sea_orm(has_many, relation_enum = "User")]
9782 pub notifications: HasMany<super::user_notifications::Entity>,
9883 #[sea_orm(has_one)]
@@ -119,12 +104,6 @@
119104 pub blocked_by: HasMany<Entity>,
120105 #[sea_orm(self_ref, has_many, via = "user_blocks", reverse)]
121106 pub blocking: HasMany<Entity>,
122}
123
124impl Entity {
125 pub fn active() -> Select<Self> {
126 Self::find().filter(COLUMN.is_active.eq(true))
127 }
128107}
129108
130109impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/users/user_emails.rs lib/models/src/users/user_emails.rs +0 −25
@@ -1,25 +0,0 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "user_email")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 pub user_id: i64,
12 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
13 pub user: BelongsTo<super::user::Entity>,
14 pub email: String,
15 #[sea_orm(unique_key = "lower_email")]
16 pub lower_email: String,
17 pub is_activated: bool,
18 pub is_primary: bool,
19 pub created_at: OffsetDateTime,
20 pub updated_at: OffsetDateTime,
21 pub activated_at: Option<OffsetDateTime>,
22 pub deleted_at: Option<OffsetDateTime>,
23}
24
25impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/schema_extras.sql lib/models/schema_extras.sql +42 −0 View lib/models/schema_extras.sql
@@ -315,3 +315,45 @@
315315 DROP TABLE IF EXISTS spaces CASCADE;
316316 END IF;
317317END $$;
318
319-- ---------------------------------------------------------------------------
320-- 2026-10-31: retire the local identity projection.
321-- KenOAuth owns username / display name / email / account state / password /
322-- TOTP. Only local settings and a local avatar asset remain in `users`.
323-- ---------------------------------------------------------------------------
324DO $$
325BEGIN
326 IF to_regclass('users') IS NOT NULL THEN
327 IF EXISTS (
328 SELECT 1 FROM information_schema.columns
329 WHERE table_schema = current_schema()
330 AND table_name = 'users'
331 AND column_name = 'avatar_url'
332 ) THEN
333 UPDATE users
334 SET local_avatar_url = avatar_url
335 WHERE local_avatar_url = ''
336 AND avatar_url LIKE 'storage://%';
337 END IF;
338
339 ALTER TABLE users
340 DROP COLUMN IF EXISTS username,
341 DROP COLUMN IF EXISTS lower_name,
342 DROP COLUMN IF EXISTS display_name,
343 DROP COLUMN IF EXISTS avatar_url,
344 DROP COLUMN IF EXISTS is_active,
345 DROP COLUMN IF EXISTS prohibit_login,
346 DROP COLUMN IF EXISTS bot,
347 DROP COLUMN IF EXISTS system,
348 DROP COLUMN IF EXISTS passwd_hash,
349 DROP COLUMN IF EXISTS password_last_changed_at,
350 DROP COLUMN IF EXISTS passwd_version,
351 DROP COLUMN IF EXISTS totp_encrypted_secret,
352 DROP COLUMN IF EXISTS totp_enabled,
353 DROP COLUMN IF EXISTS totp_last_used_at,
354 DROP COLUMN IF EXISTS totp_last_counter,
355 DROP COLUMN IF EXISTS created_at;
356 END IF;
357
358 DROP TABLE IF EXISTS user_emails CASCADE;
359END $$;
Modified lib/models/schema_pre_sync.sql lib/models/schema_pre_sync.sql +5 −0 View lib/models/schema_pre_sync.sql
@@ -160,3 +160,8 @@
160160 ADD COLUMN IF NOT EXISTS required_status_contexts VARCHAR NOT NULL DEFAULT '';
161161 END IF;
162162END $$;
163
164-- 2026-10-31: local users table keeps only KenApi-owned settings. This column
165-- must exist before SeaORM sync adds it as NOT NULL on an existing table.
166ALTER TABLE IF EXISTS users
167 ADD COLUMN IF NOT EXISTS local_avatar_url text NOT NULL DEFAULT '';
Modified lib/pulls/src/services/events.rs lib/pulls/src/services/events.rs +3 −6 View lib/pulls/src/services/events.rs
@@ -180,13 +180,10 @@
180180 if ids.is_empty() {
181181 return Ok(HashMap::new());
182182 }
183 let rows = user::Entity::find()
184 .filter(user::Column::Uid.is_in(ids.iter().copied()))
185 .all(self.db_read())
186 .await?;
187 Ok(rows
183 let users = export::users::load_public_users(&self.state, ids).await?;
184 Ok(users
188185 .into_iter()
189 .map(|row| (row.uid, UserDto::from(row)))
186 .map(|(uid, user)| (uid, UserDto::from_public(&user)))
190187 .collect())
191188 }
192189}
Modified lib/pulls/src/services/pulls.rs lib/pulls/src/services/pulls.rs +4 −9 View lib/pulls/src/services/pulls.rs
@@ -599,13 +599,10 @@
599599 if ids.is_empty() {
600600 return Ok(HashMap::new());
601601 }
602 let users = user::Entity::find()
603 .filter(user::Column::Uid.is_in(ids.iter().copied()))
604 .all(self.db_read())
605 .await?;
602 let users = export::users::load_public_users(&self.state, &ids).await?;
606603 Ok(users
607604 .into_iter()
608 .map(|row| (row.uid, UserDto::from(row)))
605 .map(|(uid, user)| (uid, UserDto::from_public(&user)))
609606 .collect())
610607 }
611608
@@ -614,12 +611,10 @@
614611 if let Ok(uid) = reference.parse::<i64>() {
615612 return Ok(uid);
616613 }
617 let row = user::Entity::find()
618 .filter(user::Column::Username.eq(reference))
619 .one(self.db_read())
614 let user = export::users::find_by_username(&self.state, reference)
620615 .await?
621616 .ok_or_else(|| PullError::BadRequest(format!("user `{reference}` not found")))?;
622 Ok(row.uid)
617 Ok(user.user_id)
623618 }
624619
625620 /// (additions, deletions, files_changed)
Modified lib/push/src/worker.rs lib/push/src/worker.rs +12 −6 View lib/push/src/worker.rs
@@ -226,17 +226,23 @@
226226 async fn build_payload(&self, job: &PushJob) -> Result<NotificationPayload, PushError> {
227227 let (title, icon) = match job.actor_id {
228228 Some(actor_id) => {
229 let actor = user::Entity::find_by_id(actor_id)
230 .one(self.state.database.read())
229 let actor = export::users::get_public_user(&self.state, actor_id)
231230 .await
232231 .map_err(|error| {
233232 PushError::Internal(format!("actor lookup failed: {error}"))
234233 })?;
235234 match actor {
236 Some(actor) => (
237 format!("{} · {}", actor.display_name, job.title),
238 self.avatar_url(actor_id, &actor.avatar_url).await,
239 ),
235 Some(actor) => {
236 let name = if actor.displayname.trim().is_empty() {
237 actor.username.clone()
238 } else {
239 actor.displayname.clone()
240 };
241 (
242 format!("{name} · {}", job.title),
243 self.avatar_url(actor_id, &actor.avatar_url).await,
244 )
245 }
240246 None => (job.title.clone(), None),
241247 }
242248 }
Modified lib/space/src/services/invites.rs lib/space/src/services/invites.rs +24 −52 View lib/space/src/services/invites.rs
@@ -8,7 +8,6 @@
88use models::space::space_members::{self, MemberStatus, MessageNotifications};
99use models::space::spaces;
1010use models::users::user;
11use models::users::user_emails;
1211use models::users::user_notifications;
1312use models::users::user_notifications::NotificationType;
1413use sea_orm::sea_query::Expr;
@@ -123,35 +122,21 @@
123122 let mut target_uid = cmd.user_id;
124123
125124 if let Some(uid) = target_uid {
126 user::Entity::find_by_id(uid)
127 .one(self.db().read())
125 let public = export::users::get_public_user(&self.state, uid)
128126 .await?
129127 .ok_or(SpaceError::NotFound)?;
130
131 if let Some(ref em) = email_norm {
132 let match_email = user_emails::Entity::find()
133 .filter(user_emails::Column::UserId.eq(uid))
134 .filter(user_emails::Column::LowerEmail.eq(em))
135 .filter(user_emails::Column::IsActivated.eq(true))
136 .filter(user_emails::Column::DeletedAt.is_null())
137 .one(self.db().read())
138 .await?;
139 if match_email.is_none() {
140 return Err(SpaceError::BadRequest(
141 "email does not match user's activated email".to_string(),
142 ));
143 }
128 if let Some(ref em) = email_norm
129 && (!public.email_verified || !public.email.eq_ignore_ascii_case(em))
130 {
131 return Err(SpaceError::BadRequest(
132 "email does not match user's activated email".to_string(),
133 ));
144134 }
145 } else if let Some(ref em) = email_norm {
146 let email_row = user_emails::Entity::find()
147 .filter(user_emails::Column::LowerEmail.eq(em))
148 .filter(user_emails::Column::IsActivated.eq(true))
149 .filter(user_emails::Column::DeletedAt.is_null())
150 .one(self.db().read())
151 .await?;
152 if let Some(er) = email_row {
153 target_uid = Some(er.user_id);
154 }
135 } else if let Some(ref em) = email_norm
136 && let Some(public) = export::users::find_by_email(&self.state, em).await?
137 && public.email_verified
138 {
139 target_uid = Some(public.user_id);
155140 }
156141
157142 let group_count = space_groups::Entity::find()
@@ -299,17 +284,11 @@
299284 let mut recipient_email: Option<String> = email_norm;
300285 if recipient_email.is_none()
301286 && let Some(uid) = target_uid
287 && let Some(public) = export::users::get_public_user(&self.state, uid).await?
288 && public.email_verified
289 && !public.email.trim().is_empty()
302290 {
303 let primary = user_emails::Entity::find()
304 .filter(user_emails::Column::UserId.eq(uid))
305 .filter(user_emails::Column::IsPrimary.eq(true))
306 .filter(user_emails::Column::IsActivated.eq(true))
307 .filter(user_emails::Column::DeletedAt.is_null())
308 .one(self.db().read())
309 .await?;
310 if let Some(p) = primary {
311 recipient_email = Some(p.lower_email);
312 }
291 recipient_email = Some(public.email.to_lowercase());
313292 }
314293
315294 if let Some(recipient) = recipient_email {
@@ -470,14 +449,11 @@
470449
471450 let is_assigned_user = invite.user_id == Some(caller);
472451 let is_assigned_email = if !invite.email.is_empty() {
473 user_emails::Entity::find()
474 .filter(user_emails::Column::UserId.eq(caller))
475 .filter(user_emails::Column::LowerEmail.eq(&invite.email))
476 .filter(user_emails::Column::IsActivated.eq(true))
477 .filter(user_emails::Column::DeletedAt.is_null())
478 .one(self.db().read())
452 export::users::get_public_user(&self.state, caller)
479453 .await?
480 .is_some()
454 .is_some_and(|user| {
455 user.email_verified && user.email.eq_ignore_ascii_case(&invite.email)
456 })
481457 } else {
482458 false
483459 };
@@ -527,14 +503,10 @@
527503
528504 assert_not_banned(&self.state, space.id, caller).await?;
529505
530 if space.member_must_enable_2fa {
531 let caller_user = user::Entity::find_by_id(caller)
532 .one(self.db().read())
533 .await?
534 .ok_or(SpaceError::NotFound)?;
535 if !caller_user.totp_enabled {
536 return Err(SpaceError::Forbidden);
537 }
506 if space.member_must_enable_2fa
507 && !crate::authority::mfa_enabled(&self.state, caller).await?
508 {
509 return Err(SpaceError::Forbidden);
538510 }
539511
540512 let client = self
Modified lib/space/src/services/join_requests.rs lib/space/src/services/join_requests.rs +9 −28 View lib/space/src/services/join_requests.rs
@@ -6,7 +6,6 @@
66use models::space::space_members::{self, MemberStatus};
77use models::space::spaces::{self, AllowJoinLevel};
88use models::users::user;
9use models::users::user_emails;
109use models::users::user_notifications::{self, NotificationType};
1110use sea_orm::sea_query::Expr;
1211use sea_orm::{
@@ -118,21 +117,10 @@
118117}
119118
120119impl SpaceService {
121 async fn has_activated_email(&self, uid: i64) -> Result<bool, SpaceError> {
122 let count = user_emails::Entity::find()
123 .filter(user_emails::Column::UserId.eq(uid))
124 .filter(user_emails::Column::IsActivated.eq(true))
125 .filter(user_emails::Column::DeletedAt.is_null())
126 .count(self.db().read())
127 .await?;
128 Ok(count > 0)
129 }
130
131120 async fn assert_join_requirements(
132121 &self,
133122 space: &spaces::Model,
134123 uid: i64,
135 applicant: &user::Model,
136124 ) -> Result<(), SpaceError> {
137125 match space.allow_join_level {
138126 AllowJoinLevel::None => {}
@@ -140,22 +128,23 @@
140128 | AllowJoinLevel::NeedMailActive
141129 | AllowJoinLevel::NeedMailActiveAndLastOneMid
142130 | AllowJoinLevel::NeedMailActiveAndLastOneHours => {
143 if !self.has_activated_email(uid).await? {
131 if !crate::authority::has_verified_email(&self.state, uid).await? {
144132 return Err(SpaceError::JoinRequirementNotMet(
145133 "an activated email address is required".to_string(),
146134 ));
147135 }
148136 let now = OffsetDateTime::now_utc();
137 let created_at = crate::authority::account_created_at(&self.state, uid).await?;
149138 match space.allow_join_level {
150139 AllowJoinLevel::NeedMailActiveAndLastOneMid
151 if applicant.created_at > now - Duration::days(1) =>
140 if created_at.is_some_and(|created| created > now - Duration::days(1)) =>
152141 {
153142 return Err(SpaceError::JoinRequirementNotMet(
154143 "the account must be at least one day old".to_string(),
155144 ));
156145 }
157146 AllowJoinLevel::NeedMailActiveAndLastOneHours
158 if applicant.created_at > now - Duration::hours(1) =>
147 if created_at.is_some_and(|created| created > now - Duration::hours(1)) =>
159148 {
160149 return Err(SpaceError::JoinRequirementNotMet(
161150 "the account must be at least one hour old".to_string(),
@@ -165,7 +154,7 @@
165154 }
166155 }
167156 }
168 if space.member_must_enable_2fa && !applicant.totp_enabled {
157 if space.member_must_enable_2fa && !crate::authority::mfa_enabled(&self.state, uid).await? {
169158 return Err(SpaceError::JoinRequirementNotMet(
170159 "two-factor authentication is required".to_string(),
171160 ));
@@ -382,11 +371,6 @@
382371 ));
383372 }
384373
385 let applicant = user::Entity::find_by_id(caller)
386 .one(self.db().read())
387 .await?
388 .ok_or(SpaceError::NotFound)?;
389
390374 if let Some(member) =
391375 crate::authority::member(&self.state, caller, space.id, caller).await?
392376 {
@@ -431,8 +415,7 @@
431415 }
432416 }
433417
434 self.assert_join_requirements(&space, caller, &applicant)
435 .await?;
418 self.assert_join_requirements(&space, caller).await?;
436419
437420 let note = cmd
438421 .note
@@ -632,11 +615,9 @@
632615
633616 if approve {
634617 assert_not_banned(&self.state, space.id, request.user_id).await?;
635 let applicant = user::Entity::find_by_id(request.user_id)
636 .one(self.db().read())
637 .await?
638 .ok_or(SpaceError::NotFound)?;
639 if space.member_must_enable_2fa && !applicant.totp_enabled {
618 if space.member_must_enable_2fa
619 && !crate::authority::mfa_enabled(&self.state, request.user_id).await?
620 {
640621 return Err(SpaceError::Forbidden);
641622 }
642623 }
Modified lib/space/src/services/members.rs lib/space/src/services/members.rs +2 −6 View lib/space/src/services/members.rs
@@ -183,14 +183,10 @@
183183 let space = lookup_space_by_name(self, caller, space_name).await?;
184184 require_perm(self, &space, caller, MANAGE_MEMBERS).await?;
185185
186 let target_user = user::Entity::find_by_id(cmd.user_id)
187 .one(self.db().read())
188 .await?
189 .ok_or(SpaceError::NotFound)?;
190
191186 assert_not_banned(&self.state, space.id, cmd.user_id).await?;
192187
193 if space.member_must_enable_2fa && !target_user.totp_enabled {
188 if space.member_must_enable_2fa && !authority::mfa_enabled(&self.state, cmd.user_id).await?
189 {
194190 return Err(SpaceError::Forbidden);
195191 }
196192
Modified lib/space/src/services/spaces.rs lib/space/src/services/spaces.rs +14 −14 View lib/space/src/services/spaces.rs
@@ -40,13 +40,17 @@
4040 pub avatar_url: String,
4141}
4242
43impl From<user::Model> for UserDto {
44 fn from(u: user::Model) -> Self {
43impl UserDto {
44 pub fn from_public(public: &kenauth::proto::common::v1::UserPublic) -> Self {
4545 Self {
46 user_id: u.uid,
47 username: u.username,
48 display_name: u.display_name,
49 avatar_url: resolve_user_avatar_url(u.uid, &u.avatar_url),
46 user_id: public.user_id,
47 username: public.username.clone(),
48 display_name: if public.displayname.trim().is_empty() {
49 public.username.clone()
50 } else {
51 public.displayname.clone()
52 },
53 avatar_url: resolve_user_avatar_url(public.user_id, &public.avatar_url),
5054 }
5155 }
5256}
@@ -165,15 +169,11 @@
165169 set.dedup();
166170 set
167171 };
168 let users = user::Entity::find()
169 .filter(user::Column::Uid.is_in(unique_uids))
170 .all(self.db().read())
171 .await?;
172 let map = users
172 let users = export::users::load_public_users(&self.state, &unique_uids).await?;
173 Ok(users
173174 .into_iter()
174 .map(|u| (u.uid, UserDto::from(u)))
175 .collect();
176 Ok(map)
175 .map(|(uid, public)| (uid, UserDto::from_public(&public)))
176 .collect())
177177 }
178178
179179 #[tracing::instrument(name = "space.create_space", skip_all, fields(caller = %caller))]
Modified lib/space/src/authority.rs lib/space/src/authority.rs +18 −0 View lib/space/src/authority.rs
@@ -373,6 +373,24 @@
373373 .collect())
374374}
375375
376pub async fn account_created_at(
377 state: &AppState,
378 user_id: i64,
379) -> Result<Option<OffsetDateTime>, SpaceError> {
380 let user = client(state)?.get_authority_user(user_id).await?;
381 Ok(timestamp(&user.created_at))
382}
383
384pub async fn mfa_enabled(state: &AppState, user_id: i64) -> Result<bool, SpaceError> {
385 let status = client(state)?.get_user_status(user_id).await?;
386 Ok(status.active_mfa_factors > 0)
387}
388
389pub async fn has_verified_email(state: &AppState, user_id: i64) -> Result<bool, SpaceError> {
390 let status = client(state)?.get_user_status(user_id).await?;
391 Ok(status.email_verified_at.is_some())
392}
393
376394pub async fn permissions(
377395 state: &AppState,
378396 space_id: Uuid,
Modified lib/user/src/services/avatar.rs lib/user/src/services/avatar.rs +11 −11 View lib/user/src/services/avatar.rs
@@ -166,14 +166,14 @@
166166 .one(self.db().read())
167167 .await?
168168 .ok_or(UserError::NotFound)?;
169 if user.avatar_url.is_empty() {
169 if user.local_avatar_url.is_empty() {
170170 return Ok(None);
171171 }
172172 if variant == ImageVariant::Original {
173 return Ok(Some(user.avatar_url));
173 return Ok(Some(user.local_avatar_url));
174174 }
175 let Some(original_key) = avatar_object_key(&user.avatar_url) else {
176 return Ok(Some(user.avatar_url));
175 let Some(original_key) = avatar_object_key(&user.local_avatar_url) else {
176 return Ok(Some(user.local_avatar_url));
177177 };
178178 let key_base = export::imaging::preview_key_base_of(&original_key);
179179 let recorded = self.recorded_avatar_preview(uid, &key_base).await;
@@ -189,7 +189,7 @@
189189 .await;
190190 Ok(Some(match state {
191191 Some(PreviewState::Ready(ready)) => format!("storage://{}", ready.key),
192 Some(PreviewState::Skipped(_)) | None => user.avatar_url,
192 Some(PreviewState::Skipped(_)) | None => user.local_avatar_url,
193193 }))
194194 }
195195
@@ -290,7 +290,7 @@
290290 .one(self.db().read())
291291 .await?
292292 .ok_or(UserError::NotFound)?
293 .avatar_url;
293 .local_avatar_url;
294294 if let Some(old_key) = avatar_object_key(&previous)
295295 && old_key != format!("{AVATAR_PREFIX}/{uid}.{}", fmt.extension())
296296 {
@@ -311,7 +311,7 @@
311311 .await?
312312 .ok_or(UserError::NotFound)?;
313313 let mut active: user::ActiveModel = user.into();
314 active.avatar_url = Set(stored_url.clone());
314 active.local_avatar_url = Set(stored_url.clone());
315315 active.updated_at = Set(now);
316316 active.update(self.db().write()).await?;
317317
@@ -362,10 +362,10 @@
362362 .one(self.db().read())
363363 .await?
364364 .ok_or(UserError::NotFound)?;
365 let old_url = user.avatar_url.clone();
365 let old_url = user.local_avatar_url.clone();
366366 let now = OffsetDateTime::now_utc();
367367 let mut active: user::ActiveModel = user.into();
368 active.avatar_url = Set(String::new());
368 active.local_avatar_url = Set(String::new());
369369 active.updated_at = Set(now);
370370 active.update(self.db().write()).await?;
371371
@@ -416,10 +416,10 @@
416416 .one(self.db().read())
417417 .await?
418418 .ok_or(UserError::NotFound)?;
419 if user.avatar_url.is_empty() {
419 if user.local_avatar_url.is_empty() {
420420 return Ok(None);
421421 }
422 let ext = user.avatar_url.rsplit('.').next().unwrap_or_default();
422 let ext = user.local_avatar_url.rsplit('.').next().unwrap_or_default();
423423 let fmt = AvatarFormat::from_extension(ext).unwrap_or(AvatarFormat::Jpg);
424424 let url = self
425425 .avatar_presigned_url(uid, ImageVariant::Preview)
Modified lib/user/src/services/profile.rs lib/user/src/services/profile.rs +40 −16 View lib/user/src/services/profile.rs
@@ -308,16 +308,30 @@
308308 viewer: Option<i64>,
309309 target_uid: i64,
310310 ) -> Result<ProfileDetailDto, UserError> {
311 let user = user::Entity::find_by_id(target_uid)
311 let local = user::Entity::find_by_id(target_uid)
312312 .one(self.db().read())
313 .await?;
314 let public = export::users::get_public_user(&self.state, target_uid)
313315 .await?
314316 .ok_or(UserError::NotFound)?;
317 let status = self
318 .state
319 .kenauth
320 .as_ref()
321 .ok_or_else(|| UserError::Internal("kenoauth is not configured".to_string()))?
322 .get_user_status(target_uid)
323 .await
324 .map_err(|error| UserError::Internal(error.to_string()))?;
315325
316326 let is_owner = viewer == Some(target_uid);
317 if !user.is_active && !is_owner {
327 if !status.allow_use && !is_owner {
318328 return Err(UserError::NotFound);
319329 }
320 let can_view = match user.visibility {
330 let visibility = local
331 .as_ref()
332 .map(|row| row.visibility)
333 .unwrap_or(ProfileVisibility::Public);
334 let can_view = match visibility {
321335 ProfileVisibility::Public => true,
322336 ProfileVisibility::RegisteredOnly => viewer.is_some(),
323337 ProfileVisibility::MembersOnly => matches!(
@@ -335,19 +349,26 @@
335349 .one(self.db().read())
336350 .await?;
337351
352 let display_name = if public.displayname.trim().is_empty() {
353 public.username.clone()
354 } else {
355 public.displayname.clone()
356 };
357 let local_avatar = local
358 .as_ref()
359 .map(|row| row.local_avatar_url.as_str())
360 .unwrap_or_default();
361 let avatar_raw = if local_avatar.is_empty() {
362 public.avatar_url.clone()
363 } else {
364 local_avatar.to_string()
365 };
338366 let mut dto = ProfileDetailDto {
339 user_id: user.uid,
340 username: user.username,
341 display_name: user.display_name,
342 avatar_url: space::services::spaces::resolve_user_avatar_url(
343 user.uid,
344 &user.avatar_url,
345 ),
346 visibility: if is_owner {
347 Some(user.visibility)
348 } else {
349 None
350 },
367 user_id: target_uid,
368 username: public.username.clone(),
369 display_name,
370 avatar_url: space::services::spaces::resolve_user_avatar_url(target_uid, &avatar_raw),
371 visibility: if is_owner { Some(visibility) } else { None },
351372 real_name: None,
352373 bio: None,
353374 pronouns: None,
@@ -360,7 +381,10 @@
360381 show_location: if is_owner { Some(false) } else { None },
361382 show_real_name: if is_owner { Some(false) } else { None },
362383 show_activity: if is_owner { Some(false) } else { None },
363 updated_at: user.updated_at,
384 updated_at: local
385 .as_ref()
386 .map(|row| row.updated_at)
387 .unwrap_or_else(OffsetDateTime::now_utc),
364388 };
365389 if let Some(p) = profile {
366390 let gate = |flag: bool, v: Option<String>| -> Option<String> {
Modified openapi.json openapi.json View openapi.json
Diff too large to display.