Skip to content
Clone

feat: refactor user handling and remove deprecated user email functionality

ZhenYi committed
parent b4afb4b commit acbf7ea Browse files

42 files changed

+704 −996

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

File tree 42
Deleted lib/auth/src/handlers/access_keys.rs
@@ -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 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
@@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
@@ -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 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
@@ -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 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 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 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 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 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 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 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 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 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 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 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 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 View openapi.json

Diff too large to display.