Skip to content
Clone

feat(redis): refactor Redis operations and remove deprecated functions

ZhenYi committed
parent 028baf2 commit c0fae98 Browse files

38 files changed

+830 −1680

File tree 38
Modified app/apihost/src/main.rs View app/apihost/src/main.rs
@@ -10,7 +10,7 @@
1010
1111#[tokio::main]
1212async fn main() -> anyhow::Result<()> {
13 init_tracing();
13 export::telemetry::init_tracing();
1414
1515 let cfg = ApihostConfig::load().context("loading configuration")?;
1616 if !cfg.apihost.enabled() {
@@ -49,7 +49,7 @@
4949 info!(%addr, "apihost listening");
5050
5151 axum::serve(listener, api_router.into_make_service())
52 .with_graceful_shutdown(shutdown_signal())
52 .with_graceful_shutdown(export::telemetry::shutdown_signal())
5353 .await
5454 .context("axum server error")?;
5555
@@ -61,37 +61,4 @@
6161 Ok(())
6262}
6363
64fn init_tracing() {
65 use tracing_subscriber::EnvFilter;
66 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
67 tracing_subscriber::fmt().with_env_filter(filter).init();
68}
6964
70async fn shutdown_signal() {
71 let ctrl_c = async {
72 if let Err(err) = tokio::signal::ctrl_c().await {
73 warn!(error = %err, "failed to install ctrl_c handler");
74 }
75 };
76
77 #[cfg(unix)]
78 let terminate = async {
79 use tokio::signal::unix::{SignalKind, signal};
80 match signal(SignalKind::terminate()) {
81 Ok(mut s) => {
82 s.recv().await;
83 }
84 Err(err) => {
85 warn!(error = %err, "failed to install SIGTERM handler");
86 }
87 }
88 };
89
90 #[cfg(not(unix))]
91 let terminate = std::future::pending::<()>();
92
93 tokio::select! {
94 _ = ctrl_c => info!("received SIGINT"),
95 _ = terminate => info!("received SIGTERM"),
96 }
97}
Modified app/githost/src/main.rs View app/githost/src/main.rs
@@ -8,7 +8,7 @@
88
99#[tokio::main]
1010async fn main() -> anyhow::Result<()> {
11 init_tracing();
11 export::telemetry::init_tracing();
1212
1313 let cfg = GithostConfig::load().context("loading configuration")?;
1414 if !cfg.githost.enabled() {
@@ -71,7 +71,7 @@
7171 });
7272
7373 let ssh_crashed = tokio::select! {
74 _ = shutdown_signal() => false,
74 _ = export::telemetry::shutdown_signal() => false,
7575 res = &mut ssh_task => {
7676 match res {
7777 Err(e) => warn!(error = %e, "ssh task panicked"),
@@ -96,37 +96,4 @@
9696 Ok(())
9797}
9898
99fn init_tracing() {
100 use tracing_subscriber::EnvFilter;
101 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
102 tracing_subscriber::fmt().with_env_filter(filter).init();
103}
10499
105async fn shutdown_signal() {
106 let ctrl_c = async {
107 if let Err(err) = tokio::signal::ctrl_c().await {
108 warn!(error = %err, "failed to install ctrl_c handler");
109 }
110 };
111
112 #[cfg(unix)]
113 let terminate = async {
114 use tokio::signal::unix::{SignalKind, signal};
115 match signal(SignalKind::terminate()) {
116 Ok(mut s) => {
117 s.recv().await;
118 }
119 Err(err) => {
120 warn!(error = %err, "failed to install SIGTERM handler");
121 }
122 }
123 };
124
125 #[cfg(not(unix))]
126 let terminate = std::future::pending::<()>();
127
128 tokio::select! {
129 _ = ctrl_c => info!("received SIGINT"),
130 _ = terminate => info!("received SIGTERM"),
131 }
132}
Modified app/synchost/src/main.rs View app/synchost/src/main.rs
@@ -6,11 +6,11 @@
66use config::scoped::{ScopedConfig, SynchostConfig};
77use git::sync::{NoopWebhookTx, SyncQueue, SyncWorker, WebhookTx};
88use tokio_util::sync::CancellationToken;
9use tracing::{info, warn};
9use tracing::info;
1010
1111#[tokio::main]
1212async fn main() -> anyhow::Result<()> {
13 init_tracing();
13 export::telemetry::init_tracing();
1414
1515 let cfg = SynchostConfig::load().context("loading configuration")?;
1616
@@ -56,7 +56,7 @@
5656 "synchost running"
5757 );
5858
59 wait_for_shutdown().await;
59 export::telemetry::shutdown_signal().await;
6060 info!("synchost shutting down");
6161
6262 cancel.cancel();
@@ -69,37 +69,4 @@
6969 Ok(())
7070}
7171
72fn init_tracing() {
73 use tracing_subscriber::EnvFilter;
74 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
75 tracing_subscriber::fmt().with_env_filter(filter).init();
76}
77
78async fn wait_for_shutdown() {
79 let ctrl_c = async {
80 if let Err(err) = tokio::signal::ctrl_c().await {
81 warn!(error = %err, "failed to install ctrl_c handler");
82 }
83 };
8472
85 #[cfg(unix)]
86 let terminate = async {
87 use tokio::signal::unix::{SignalKind, signal};
88 match signal(SignalKind::terminate()) {
89 Ok(mut s) => {
90 s.recv().await;
91 }
92 Err(err) => {
93 warn!(error = %err, "failed to install SIGTERM handler");
94 }
95 }
96 };
97
98 #[cfg(not(unix))]
99 let terminate = std::future::pending::<()>();
100
101 tokio::select! {
102 _ = ctrl_c => info!("received SIGINT"),
103 _ = terminate => info!("received SIGTERM"),
104 }
105}
Modified lib/auth/src/services/email.rs View lib/auth/src/services/email.rs
@@ -119,7 +119,7 @@
119119 let code = generate_numeric_code(config.verify_code_len);
120120 let temp_id = Uuid::now_v7();
121121 let code_key = format!("auth:code:{purpose_name}:{temp_id}");
122 crate::share::redis::set_string(
122 export::redis::set_string(
123123 self.redis(),
124124 &code_key,
125125 &code,
@@ -153,7 +153,7 @@
153153 let config = &self.config().auth;
154154 let purpose_name = purpose_str(cmd.purpose);
155155 let code_key = format!("auth:code:{purpose_name}:{}", cmd.temp_id);
156 let Some(stored) = crate::share::redis::get_string(self.redis(), &code_key).await? else {
156 let Some(stored) = export::redis::get_string(self.redis(), &code_key).await? else {
157157 return Err(AuthError::BadRequest(
158158 "verification code expired or not found".to_string(),
159159 ));
@@ -167,7 +167,7 @@
167167 )
168168 .await?
169169 {
170 crate::share::redis::delete(self.redis(), &code_key).await?;
170 export::redis::delete(self.redis(), &code_key).await?;
171171 return Err(AuthError::RateLimited);
172172 }
173173 let provided = cmd.code.trim().to_string();
@@ -176,7 +176,7 @@
176176 "invalid verification code".to_string(),
177177 ));
178178 }
179 crate::share::redis::delete(self.redis(), &code_key).await?;
179 export::redis::delete(self.redis(), &code_key).await?;
180180 attempts_reset(self.redis(), &attempts_key).await?;
181181
182182 let db = self.db();
Modified lib/auth/src/services/mod.rs View lib/auth/src/services/mod.rs
@@ -3,9 +3,6 @@
33use async_trait::async_trait;
44use config::AppConfig;
55use export::{AppDatabase, AppRedis, AppState};
6use models::users::user;
7use models::users::user_sessions;
8use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
96use session::SessionError;
107use session::share::guard::{SessionGuard, SessionWithUser};
118
@@ -74,21 +71,6 @@
7471 &self,
7572 token_hash: &str,
7673 ) -> Result<Option<SessionWithUser>, SessionError> {
77 let outcome = user_sessions::Entity::find()
78 .filter(user_sessions::Column::TokenHash.eq(token_hash))
79 .find_also_related(user::Entity)
80 .one(self.db().read())
81 .await?;
82 Ok(outcome.and_then(|(row, current)| {
83 current.map(|c| SessionWithUser {
84 user_id: row.user_id,
85 is_revoked: row.is_revoked,
86 expires_at: row.expires_at,
87 is_active: c.is_active,
88 prohibit_login: c.prohibit_login,
89 is_restricted: c.is_restricted,
90 passwd_version: c.passwd_version,
91 })
92 }))
74 session::lookup_session_with_user(self.db().read(), token_hash).await
9375 }
9476}
Modified lib/auth/src/services/password.rs View lib/auth/src/services/password.rs
@@ -77,7 +77,7 @@
7777 token_hash: sha256_b64url(token.as_bytes()),
7878 email: lower_email.clone(),
7979 };
80 crate::share::redis::set_string(
80 export::redis::set_string(
8181 self.redis(),
8282 &reset_key,
8383 &serde_json::to_string(&stored)
@@ -115,7 +115,7 @@
115115 validate_password(&cmd.new_password, &config.password_policy)?;
116116
117117 let reset_key = format!("auth:reset:{}", cmd.temp_id);
118 let Some(raw) = crate::share::redis::getdel_string(self.redis(), &reset_key).await? else {
118 let Some(raw) = export::redis::getdel_string(self.redis(), &reset_key).await? else {
119119 return Err(AuthError::BadRequest(
120120 "reset token expired or not found".to_string(),
121121 ));
Modified lib/auth/src/services/two_factor.rs View lib/auth/src/services/two_factor.rs
@@ -48,6 +48,36 @@
4848 pub codes: Vec<String>,
4949}
5050
51fn generate_recovery_codes() -> Vec<String> {
52 (0..RECOVERY_CODE_COUNT)
53 .map(|_| recovery_code())
54 .collect()
55}
56
57async fn insert_recovery_codes(
58 txn: &(impl sea_orm::ConnectionTrait + Sync),
59 uid: Uuid,
60 codes: &[String],
61 now: OffsetDateTime,
62) -> Result<(), sea_orm::DbErr> {
63 user_recovery_codes::Entity::delete_many()
64 .filter(user_recovery_codes::Column::UserId.eq(uid))
65 .exec(txn)
66 .await?;
67 for code in codes {
68 let row = user_recovery_codes::ActiveModel {
69 id: Set(Uuid::now_v7()),
70 user_id: Set(uid),
71 code_hash: Set(sha256_b64url(code.as_bytes())),
72 used_at: Set(None),
73 expires_at: Set(None),
74 created_at: Set(now),
75 };
76 row.insert(txn).await?;
77 }
78 Ok(())
79}
80
5181fn recovery_code() -> String {
5282 let segment = || {
5383 let bytes = crate::share::crypto::random_bytes(3);
@@ -128,10 +158,7 @@
128158 let (_, counter) = verify_totp_code(&secret, &cmd.code, None)?;
129159 let now = OffsetDateTime::now_utc();
130160
131 let mut generated = Vec::with_capacity(RECOVERY_CODE_COUNT);
132 for _ in 0..RECOVERY_CODE_COUNT {
133 generated.push(recovery_code());
134 }
161 let generated = generate_recovery_codes();
135162 let codes = generated.clone();
136163
137164 TransactionTrait::transaction(db.primary(), |txn| {
@@ -143,22 +170,7 @@
143170 active.updated_at = Set(now);
144171 active.update(txn).await?;
145172
146 user_recovery_codes::Entity::delete_many()
147 .filter(user_recovery_codes::Column::UserId.eq(uid))
148 .exec(txn)
149 .await?;
150 for code in &generated {
151 let row = user_recovery_codes::ActiveModel {
152 id: Set(Uuid::now_v7()),
153 user_id: Set(uid),
154 code_hash: Set(sha256_b64url(code.as_bytes())),
155 used_at: Set(None),
156 expires_at: Set(None),
157 created_at: Set(now),
158 };
159 row.insert(txn).await?;
160 }
161 Ok::<(), sea_orm::DbErr>(())
173 insert_recovery_codes(txn, uid, &generated, now).await
162174 })
163175 })
164176 .await?;
@@ -259,29 +271,11 @@
259271 return Err(AuthError::InvalidCredentials);
260272 }
261273 let now = OffsetDateTime::now_utc();
262 let mut generated = Vec::with_capacity(RECOVERY_CODE_COUNT);
263 for _ in 0..RECOVERY_CODE_COUNT {
264 generated.push(recovery_code());
265 }
274 let generated = generate_recovery_codes();
266275 let codes = generated.clone();
267276 TransactionTrait::transaction(db.primary(), |txn| {
268277 Box::pin(async move {
269 user_recovery_codes::Entity::delete_many()
270 .filter(user_recovery_codes::Column::UserId.eq(uid))
271 .exec(txn)
272 .await?;
273 for code in &generated {
274 let row = user_recovery_codes::ActiveModel {
275 id: Set(Uuid::now_v7()),
276 user_id: Set(uid),
277 code_hash: Set(sha256_b64url(code.as_bytes())),
278 used_at: Set(None),
279 expires_at: Set(None),
280 created_at: Set(now),
281 };
282 row.insert(txn).await?;
283 }
284 Ok::<(), sea_orm::DbErr>(())
278 insert_recovery_codes(txn, uid, &generated, now).await
285279 })
286280 })
287281 .await?;
Modified lib/auth/src/share/mod.rs View lib/auth/src/share/mod.rs
@@ -1,7 +1,6 @@
11pub mod crypto;
22pub mod guard;
33pub mod rate;
4pub mod redis;
54pub mod totp;
65pub mod types;
76pub mod validate;
Modified lib/auth/src/share/rate.rs View lib/auth/src/share/rate.rs
@@ -7,7 +7,7 @@
77 key: &str,
88 window_secs: u64,
99) -> Result<(), AuthError> {
10 let is_new = super::redis::set_nx_ex(redis, key, "1", window_secs).await?;
10 let is_new = export::redis::set_nx_ex(redis, key, "1", window_secs).await?;
1111 if !is_new {
1212 return Err(AuthError::RateLimited);
1313 }
@@ -20,11 +20,11 @@
2020 max: u64,
2121 window_secs: u64,
2222) -> Result<bool, AuthError> {
23 let count = super::redis::incr_expire(redis, key, window_secs).await?;
23 let count = export::redis::incr_expire(redis, key, window_secs).await?;
2424 Ok(count <= max as i64)
2525}
2626
2727pub async fn attempts_reset(redis: &AppRedis, key: &str) -> Result<(), AuthError> {
28 super::redis::delete(redis, key).await?;
28 export::redis::delete(redis, key).await?;
2929 Ok(())
3030}
Deleted lib/auth/src/share/redis.rs
@@ -1,87 +0,0 @@
1use export::{AppRedis, RedisConnection};
2use redis::RedisError;
3
4macro_rules! dispatch {
5 ($conn:expr, |$c:ident| $body:expr) => {
6 match &mut $conn {
7 RedisConnection::Standalone($c) => $body,
8 RedisConnection::Cluster($c) => $body,
9 }
10 };
11}
12
13pub async fn get_string(redis: &AppRedis, key: &str) -> Result<Option<String>, RedisError> {
14 let mut conn = redis.connection_clone();
15 dispatch!(conn, |c| redis::cmd("GET").arg(key).query_async(c).await)
16}
17
18pub async fn set_string(
19 redis: &AppRedis,
20 key: &str,
21 value: &str,
22 ttl_secs: u64,
23) -> Result<(), RedisError> {
24 let mut conn = redis.connection_clone();
25 dispatch!(conn, |c| {
26 redis::cmd("SET")
27 .arg(key)
28 .arg(value)
29 .arg("EX")
30 .arg(ttl_secs)
31 .exec_async(c)
32 .await
33 })
34}
35
36pub async fn set_nx_ex(
37 redis: &AppRedis,
38 key: &str,
39 value: &str,
40 ttl_secs: u64,
41) -> Result<bool, RedisError> {
42 let mut conn = redis.connection_clone();
43 dispatch!(conn, |c| {
44 redis::cmd("SET")
45 .arg(key)
46 .arg(value)
47 .arg("NX")
48 .arg("EX")
49 .arg(ttl_secs)
50 .query_async(c)
51 .await
52 })
53}
54
55pub async fn delete(redis: &AppRedis, key: &str) -> Result<(), RedisError> {
56 let mut conn = redis.connection_clone();
57 dispatch!(conn, |c| redis::cmd("DEL").arg(key).exec_async(c).await)
58}
59
60pub async fn exists(redis: &AppRedis, key: &str) -> Result<bool, RedisError> {
61 let mut conn = redis.connection_clone();
62 dispatch!(conn, |c| redis::cmd("EXISTS").arg(key).query_async(c).await)
63}
64
65pub async fn getdel_string(redis: &AppRedis, key: &str) -> Result<Option<String>, RedisError> {
66 let mut conn = redis.connection_clone();
67 dispatch!(conn, |c| redis::cmd("GETDEL").arg(key).query_async(c).await)
68}
69
70const INCR_EXPIRE_SCRIPT: &str = r#"local c = redis.call('INCR', KEYS[1])
71if c == 1 then
72 redis.call('EXPIRE', KEYS[1], ARGV[1])
73end
74return c"#;
75
76pub async fn incr_expire(redis: &AppRedis, key: &str, ttl_secs: u64) -> Result<i64, RedisError> {
77 let mut conn = redis.connection_clone();
78 dispatch!(conn, |c| {
79 redis::cmd("EVAL")
80 .arg(INCR_EXPIRE_SCRIPT)
81 .arg(1)
82 .arg(key)
83 .arg(ttl_secs)
84 .query_async(c)
85 .await
86 })
87}
Modified lib/auth/src/errors.rs View lib/auth/src/errors.rs
@@ -1,12 +1,5 @@
11use std::fmt;
22
3use axum::Json;
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use export::rest::{AppResponseData, NoData};
8use utoipa::IntoResponses;
9
103use crate::pow::PowError;
114
125#[derive(Debug)]
@@ -40,6 +33,11 @@
4033
4134impl std::error::Error for AuthError {}
4235
36export::db_error_conversions!(AuthError);
37export::redis_error_conversion!(AuthError);
38export::export_error_conversion!(AuthError);
39export::json_payload_conversion!(AuthError);
40
4341impl From<PowError> for AuthError {
4442 fn from(error: PowError) -> Self {
4543 match error {
@@ -56,187 +54,37 @@
5654 }
5755}
5856
59impl From<sea_orm::DbErr> for AuthError {
60 fn from(error: sea_orm::DbErr) -> Self {
61 if is_unique_violation(&error) {
62 return Self::Conflict("resource already exists".to_string());
63 }
64 Self::Internal(format!("database error: {error}"))
65 }
66}
67
68impl From<sea_orm::TransactionError<sea_orm::DbErr>> for AuthError {
69 fn from(error: sea_orm::TransactionError<sea_orm::DbErr>) -> Self {
70 match &error {
71 sea_orm::TransactionError::Transaction(db_err) if is_unique_violation(db_err) => {
72 Self::Conflict("resource already exists".to_string())
73 }
74 _ => Self::Internal(format!("database transaction error: {error}")),
75 }
76 }
77}
78
79fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
80 let msg = err.to_string().to_lowercase();
81 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
82}
83
84impl From<redis::RedisError> for AuthError {
85 fn from(error: redis::RedisError) -> Self {
86 Self::Internal(format!("redis error: {error}"))
87 }
88}
89
90impl From<export::ExportError> for AuthError {
91 fn from(error: export::ExportError) -> Self {
92 Self::Internal(format!("infrastructure error: {error}"))
93 }
94}
95
96impl From<serde_json::Error> for AuthError {
97 fn from(error: serde_json::Error) -> Self {
98 Self::BadRequest(format!("invalid payload: {error}"))
99 }
100}
101
102#[derive(Debug, IntoResponses)]
103pub enum ApiError {
104 #[response(status = 400, description = "request failed validation")]
105 BadRequest(#[to_schema] AppResponseData<NoData>),
106 #[response(status = 400, description = "credentials rejected")]
107 InvalidCredentials(#[to_schema] AppResponseData<NoData>),
108 #[response(status = 401, description = "authentication required")]
109 Unauthorized(#[to_schema] AppResponseData<NoData>),
110 #[response(status = 403, description = "operation not allowed")]
111 Forbidden(#[to_schema] AppResponseData<NoData>),
112 #[response(status = 404, description = "resource not found")]
113 NotFound(#[to_schema] AppResponseData<NoData>),
114 #[response(status = 409, description = "resource conflict")]
115 Conflict(#[to_schema] AppResponseData<NoData>),
116 #[response(status = 429, description = "rate limit exceeded")]
117 RateLimited(#[to_schema] AppResponseData<NoData>),
118 #[response(status = 500, description = "internal server error")]
119 Internal(#[to_schema] AppResponseData<NoData>),
120}
121
122impl ApiError {
123 fn payload(code: i32, message: &'static str) -> AppResponseData<NoData> {
124 AppResponseData::err(code, message)
125 }
126
127 fn decompose(self) -> (u16, AppResponseData<NoData>) {
128 match self {
129 Self::BadRequest(envelope) => (400, envelope),
130 Self::InvalidCredentials(envelope) => (400, envelope),
131 Self::Unauthorized(envelope) => (401, envelope),
132 Self::Forbidden(envelope) => (403, envelope),
133 Self::NotFound(envelope) => (404, envelope),
134 Self::Conflict(envelope) => (409, envelope),
135 Self::RateLimited(envelope) => (429, envelope),
136 Self::Internal(envelope) => (500, envelope),
137 }
57export::api_error! {
58 log: "auth",
59 pub enum ApiError {
60 BadRequest(400, "request failed validation"),
61 InvalidCredentials(400, "credentials rejected"),
62 Unauthorized(401, "authentication required"),
63 Forbidden(403, "operation not allowed"),
64 NotFound(404, "resource not found"),
65 Conflict(409, "resource conflict"),
66 RateLimited(429, "rate limit exceeded"),
67 Internal(500, "internal server error"),
13868 }
13969}
14070
141impl IntoResponse for ApiError {
142 fn into_response(self) -> Response {
143 let (code, envelope) = self.decompose();
144 let status =
145 StatusCode::from_u16(code).unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR);
146 (status, Json(envelope)).into_response()
147 }
148}
149
15071impl From<AuthError> for ApiError {
15172 fn from(error: AuthError) -> Self {
15273 match error {
153 AuthError::BadRequest(msg) => {
154 Self::BadRequest(AppResponseData::err(400, msg).with_error_code("bad_request"))
155 }
156 AuthError::PowFailed => Self::BadRequest(
157 Self::payload(400, "proof of work failed").with_error_code("pow_failed"),
158 ),
74 AuthError::BadRequest(msg) => Self::bad_request(msg),
15975 AuthError::InvalidCredentials => {
16076 Self::InvalidCredentials(Self::payload(400, "invalid credentials"))
16177 }
162 AuthError::Unauthorized => Self::Unauthorized(
163 Self::payload(401, "unauthorized").with_error_code("unauthorized"),
164 ),
165 AuthError::Forbidden => {
166 Self::Forbidden(Self::payload(403, "forbidden").with_error_code("forbidden"))
167 }
168 AuthError::NotFound => Self::NotFound(
169 Self::payload(404, "auth resource not found").with_error_code("not_found"),
170 ),
171 AuthError::Conflict(msg) => {
172 Self::Conflict(AppResponseData::err(409, msg).with_error_code("conflict"))
173 }
174 AuthError::RateLimited => Self::RateLimited(
175 Self::payload(429, "rate limit exceeded").with_error_code("rate_limited"),
78 AuthError::Unauthorized => Self::unauthorized(),
79 AuthError::Forbidden => Self::forbidden(),
80 AuthError::NotFound => Self::not_found("auth resource not found"),
81 AuthError::Conflict(msg) => Self::conflict(msg),
82 AuthError::RateLimited => Self::rate_limited_exceeded(),
83 AuthError::PowFailed => Self::build(
84 400,
85 Self::payload(400, "proof of work failed").with_error_code("pow_failed"),
17686 ),
177 AuthError::Internal(message) => {
178 tracing::warn!(error = %message, "auth handler failed");
179 Self::Internal(Self::payload(500, "internal server error"))
180 }
181 }
182 }
183}
184
185impl From<Box<ApiError>> for ApiError {
186 fn from(error: Box<ApiError>) -> Self {
187 *error
188 }
189}
190
191impl From<session::SessionError> for ApiError {
192 fn from(error: session::SessionError) -> Self {
193 match error {
194 session::SessionError::Unauthorized => {
195 Self::Unauthorized(Self::payload(401, "unauthorized"))
196 }
197 session::SessionError::Database(message) => {
198 tracing::warn!(error = %message, "auth handler failed");
199 Self::Internal(Self::payload(500, "internal server error"))
200 }
87 AuthError::Internal(message) => Self::internal(message),
20188 }
202 }
203}
204
205impl From<AppResponseData<()>> for ApiError {
206 fn from(_error: AppResponseData<()>) -> Self {
207 Self::BadRequest(Self::payload(400, "bad request"))
208 }
209}
210
211impl From<PathRejection> for ApiError {
212 fn from(error: PathRejection) -> Self {
213 Self::BadRequest(
214 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
215 )
216 }
217}
218
219impl From<JsonRejection> for ApiError {
220 fn from(error: JsonRejection) -> Self {
221 Self::BadRequest(
222 AppResponseData::err(400, "invalid request body")
223 .with_error(error.body_text())
224 .with_error_code("invalid_request_body"),
225 )
226 }
227}
228
229impl From<QueryRejection> for ApiError {
230 fn from(error: QueryRejection) -> Self {
231 Self::BadRequest(
232 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
233 )
234 }
235}
236
237impl From<(StatusCode, &'static str)> for ApiError {
238 fn from((status, message): (StatusCode, &'static str)) -> Self {
239 tracing::error!(%status, %message, "unhandled (status, message) converted to 500");
240 Self::Internal(Self::payload(500, "internal server error"))
24189 }
24290}
Modified lib/chat/src/services/service.rs View lib/chat/src/services/service.rs
@@ -7,8 +7,6 @@
77use export::database::AppDatabase;
88use export::storage::AppStorage;
99use models::users::user;
10use models::users::user_emails;
11use models::users::user_sessions;
1210use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
1311use session::SessionError;
1412use session::share::guard::{SessionGuard, SessionWithUser};
@@ -77,18 +75,9 @@
7775 if uids.is_empty() {
7876 return Ok(HashMap::new());
7977 }
80 let rows = user_emails::Entity::find()
81 .filter(user_emails::Column::UserId.is_in(uids.to_vec()))
82 .filter(user_emails::Column::IsPrimary.eq(true))
83 .filter(user_emails::Column::IsActivated.eq(true))
84 .filter(user_emails::Column::DeletedAt.is_null())
85 .all(self.db().read())
86 .await?;
87 let map = rows
88 .into_iter()
89 .map(|r| (r.user_id, r.lower_email))
90 .collect();
91 Ok(map)
78 models::users::queries::primary_emails(self.db().read(), uids)
79 .await
80 .map_err(ChatError::from)
9281 }
9382
9483 pub(crate) async fn send_email(
@@ -112,21 +101,6 @@
112101 &self,
113102 token_hash: &str,
114103 ) -> Result<Option<SessionWithUser>, SessionError> {
115 let outcome = user_sessions::Entity::find()
116 .filter(user_sessions::Column::TokenHash.eq(token_hash))
117 .find_also_related(user::Entity)
118 .one(self.db().read())
119 .await?;
120 Ok(outcome.and_then(|(row, current)| {
121 current.map(|c| SessionWithUser {
122 user_id: row.user_id,
123 is_revoked: row.is_revoked,
124 expires_at: row.expires_at,
125 is_active: c.is_active,
126 prohibit_login: c.prohibit_login,
127 is_restricted: c.is_restricted,
128 passwd_version: c.passwd_version,
129 })
130 }))
104 session::lookup_session_with_user(self.db().read(), token_hash).await
131105 }
132106}
Modified lib/chat/src/errors.rs View lib/chat/src/errors.rs
@@ -1,11 +1,6 @@
11use std::fmt;
22
3use axum::Json;
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use export::rest::{AppResponseData, NoData};
8use utoipa::IntoResponses;
3pub use export::is_unique_violation;
94
105#[derive(Debug)]
116pub enum ChatError {
@@ -34,202 +29,36 @@
3429
3530impl std::error::Error for ChatError {}
3631
37impl From<sea_orm::DbErr> for ChatError {
38 fn from(error: sea_orm::DbErr) -> Self {
39 if is_unique_violation(&error) {
40 return Self::Conflict("resource already exists".to_string());
41 }
42 Self::Internal(format!("database error: {error}"))
43 }
44}
45
46impl From<sea_orm::TransactionError<sea_orm::DbErr>> for ChatError {
47 fn from(error: sea_orm::TransactionError<sea_orm::DbErr>) -> Self {
48 match &error {
49 sea_orm::TransactionError::Transaction(db_err) if is_unique_violation(db_err) => {
50 Self::Conflict("resource already exists".to_string())
51 }
52 _ => Self::Internal(format!("database transaction error: {error}")),
53 }
54 }
55}
56
57impl From<sea_orm::TransactionError<ChatError>> for ChatError {
58 fn from(error: sea_orm::TransactionError<ChatError>) -> Self {
59 match error {
60 sea_orm::TransactionError::Connection(db_err) => Self::from(db_err),
61 sea_orm::TransactionError::Transaction(chat_err) => chat_err,
62 }
63 }
64}
65
66pub(crate) fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
67 let msg = err.to_string().to_lowercase();
68 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
69}
70
71impl From<space::errors::SpaceError> for ChatError {
72 fn from(error: space::errors::SpaceError) -> Self {
73 match error {
74 space::errors::SpaceError::BadRequest(msg) => Self::BadRequest(msg),
75 space::errors::SpaceError::Unauthorized => Self::Unauthorized,
76 space::errors::SpaceError::Forbidden => Self::Forbidden,
77 space::errors::SpaceError::NotFound => Self::NotFound,
78 space::errors::SpaceError::Conflict(msg) => Self::Conflict(msg),
79 space::errors::SpaceError::RateLimited(msg) => Self::RateLimited(msg),
80 space::errors::SpaceError::Internal(msg) => Self::Internal(msg),
81 }
82 }
83}
84
85impl From<export::ExportError> for ChatError {
86 fn from(error: export::ExportError) -> Self {
87 Self::Internal(format!("infrastructure error: {error}"))
88 }
89}
90
91impl From<storage::StorageError> for ChatError {
92 fn from(error: storage::StorageError) -> Self {
93 Self::Internal(format!("storage error: {error}"))
94 }
95}
96
97impl From<redis::RedisError> for ChatError {
98 fn from(error: redis::RedisError) -> Self {
99 Self::Internal(format!("redis error: {error}"))
100 }
101}
102
103impl From<serde_json::Error> for ChatError {
104 fn from(error: serde_json::Error) -> Self {
105 Self::BadRequest(format!("invalid payload: {error}"))
106 }
107}
108
109#[derive(Debug, IntoResponses)]
110pub enum ApiError {
111 #[response(status = 400, description = "request failed validation")]
112 BadRequest(#[to_schema] AppResponseData<NoData>),
113 #[response(status = 401, description = "authentication required")]
114 Unauthorized(#[to_schema] AppResponseData<NoData>),
115 #[response(status = 403, description = "operation not allowed")]
116 Forbidden(#[to_schema] AppResponseData<NoData>),
117 #[response(status = 404, description = "resource not found")]
118 NotFound(#[to_schema] AppResponseData<NoData>),
119 #[response(status = 409, description = "resource conflict")]
120 Conflict(#[to_schema] AppResponseData<NoData>),
121 #[response(status = 429, description = "rate limit exceeded")]
122 TooManyRequests(#[to_schema] AppResponseData<NoData>),
123 #[response(status = 500, description = "internal server error")]
124 Internal(#[to_schema] AppResponseData<NoData>),
125}
126
127impl ApiError {
128 fn payload(code: i32, message: &'static str) -> AppResponseData<NoData> {
129 AppResponseData::err(code, message)
130 }
131
132 fn decompose(self) -> (u16, AppResponseData<NoData>) {
133 match self {
134 Self::BadRequest(envelope) => (400, envelope),
135 Self::Unauthorized(envelope) => (401, envelope),
136 Self::Forbidden(envelope) => (403, envelope),
137 Self::NotFound(envelope) => (404, envelope),
138 Self::Conflict(envelope) => (409, envelope),
139 Self::TooManyRequests(envelope) => (429, envelope),
140 Self::Internal(envelope) => (500, envelope),
141 }
142 }
143}
32export::db_error_conversions!(ChatError);
33export::storage_error_conversion!(ChatError);
34export::redis_error_conversion!(ChatError);
35export::export_error_conversion!(ChatError);
36export::json_payload_conversion!(ChatError);
37export::domain_error_passthrough!(ChatError from space::errors::SpaceError);
14438
145impl IntoResponse for ApiError {
146 fn into_response(self) -> Response {
147 let (code, envelope) = self.decompose();
148 let status = match StatusCode::from_u16(code) {
149 Ok(status) => status,
150 Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
151 };
152 (status, Json(envelope)).into_response()
39export::api_error! {
40 log: "chat",
41 pub enum ApiError {
42 BadRequest(400, "request failed validation"),
43 Unauthorized(401, "authentication required"),
44 Forbidden(403, "operation not allowed"),
45 NotFound(404, "resource not found"),
46 Conflict(409, "resource conflict"),
47 TooManyRequests(429, "rate limit exceeded"),
48 Internal(500, "internal server error"),
15349 }
15450}
15551
15652impl From<ChatError> for ApiError {
15753 fn from(error: ChatError) -> Self {
15854 match error {
159 ChatError::BadRequest(msg) => {
160 Self::BadRequest(AppResponseData::err(400, msg).with_error_code("bad_request"))
161 }
162 ChatError::Unauthorized => Self::Unauthorized(
163 Self::payload(401, "unauthorized").with_error_code("unauthorized"),
164 ),
165 ChatError::Forbidden => {
166 Self::Forbidden(Self::payload(403, "forbidden").with_error_code("forbidden"))
167 }
168 ChatError::NotFound => Self::NotFound(
169 Self::payload(404, "chat resource not found").with_error_code("not_found"),
170 ),
171 ChatError::Conflict(msg) => {
172 Self::Conflict(AppResponseData::err(409, msg).with_error_code("conflict"))
173 }
174 ChatError::RateLimited(msg) => Self::TooManyRequests(
175 AppResponseData::err(429, msg).with_error_code("rate_limited"),
176 ),
177 ChatError::Internal(message) => {
178 tracing::warn!(error = %message, "chat handler failed");
179 Self::Internal(Self::payload(500, "internal server error"))
180 }
181 }
182 }
183}
184
185impl From<Box<ApiError>> for ApiError {
186 fn from(error: Box<ApiError>) -> Self {
187 *error
188 }
189}
190
191impl From<session::SessionError> for ApiError {
192 fn from(error: session::SessionError) -> Self {
193 match error {
194 session::SessionError::Unauthorized => {
195 Self::Unauthorized(Self::payload(401, "unauthorized"))
196 }
197 session::SessionError::Database(message) => {
198 tracing::warn!(error = %message, "chat handler failed");
199 Self::Internal(Self::payload(500, "internal server error"))
200 }
55 ChatError::BadRequest(msg) => Self::bad_request(msg),
56 ChatError::Unauthorized => Self::unauthorized(),
57 ChatError::Forbidden => Self::forbidden(),
58 ChatError::NotFound => Self::not_found("chat resource not found"),
59 ChatError::Conflict(msg) => Self::conflict(msg),
60 ChatError::RateLimited(msg) => Self::rate_limited(msg),
61 ChatError::Internal(message) => Self::internal(message),
20162 }
202 }
203}
204
205impl From<PathRejection> for ApiError {
206 fn from(error: PathRejection) -> Self {
207 Self::BadRequest(
208 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
209 )
210 }
211}
212
213impl From<JsonRejection> for ApiError {
214 fn from(error: JsonRejection) -> Self {
215 Self::BadRequest(
216 AppResponseData::err(400, "invalid request body")
217 .with_error(error.body_text())
218 .with_error_code("invalid_request_body"),
219 )
220 }
221}
222
223impl From<serde_json::Error> for ApiError {
224 fn from(_error: serde_json::Error) -> Self {
225 Self::BadRequest(Self::payload(400, "bad request"))
226 }
227}
228
229impl From<QueryRejection> for ApiError {
230 fn from(error: QueryRejection) -> Self {
231 Self::BadRequest(
232 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
233 )
23463 }
23564}
Added lib/export/src/api_error.rs View lib/export/src/api_error.rs
@@ -0,0 +1,309 @@
1use sea_orm::DbErr;
2
3pub fn is_unique_violation(err: &DbErr) -> bool {
4 let msg = err.to_string().to_lowercase();
5 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
6}
7
8/// Generates the `From` conversions shared by all domain error enums:
9/// `DbErr`, `TransactionError<DbErr>` and `TransactionError<Self>`.
10/// The target enum must have `Conflict(String)` and `Internal(String)` variants.
11#[macro_export]
12macro_rules! db_error_conversions {
13 ($name:ident) => {
14 impl From<::sea_orm::DbErr> for $name {
15 fn from(error: ::sea_orm::DbErr) -> Self {
16 if $crate::is_unique_violation(&error) {
17 return Self::Conflict("resource already exists".to_string());
18 }
19 Self::Internal(format!("database error: {error}"))
20 }
21 }
22
23 impl From<::sea_orm::TransactionError<::sea_orm::DbErr>> for $name {
24 fn from(error: ::sea_orm::TransactionError<::sea_orm::DbErr>) -> Self {
25 match &error {
26 ::sea_orm::TransactionError::Transaction(db_err)
27 if $crate::is_unique_violation(db_err) =>
28 {
29 Self::Conflict("resource already exists".to_string())
30 }
31 _ => Self::Internal(format!("database transaction error: {error}")),
32 }
33 }
34 }
35
36 impl From<::sea_orm::TransactionError<$name>> for $name {
37 fn from(error: ::sea_orm::TransactionError<$name>) -> Self {
38 match error {
39 ::sea_orm::TransactionError::Connection(db_err) => Self::from(db_err),
40 ::sea_orm::TransactionError::Transaction(err) => err,
41 }
42 }
43 }
44 };
45}
46
47#[macro_export]
48macro_rules! redis_error_conversion {
49 ($name:ident) => {
50 impl From<::redis::RedisError> for $name {
51 fn from(error: ::redis::RedisError) -> Self {
52 Self::Internal(format!("redis error: {error}"))
53 }
54 }
55 };
56}
57
58#[macro_export]
59macro_rules! storage_error_conversion {
60 ($name:ident) => {
61 impl From<::storage::StorageError> for $name {
62 fn from(error: ::storage::StorageError) -> Self {
63 Self::Internal(format!("storage error: {error}"))
64 }
65 }
66 };
67}
68
69#[macro_export]
70macro_rules! export_error_conversion {
71 ($name:ident) => {
72 impl From<$crate::ExportError> for $name {
73 fn from(error: $crate::ExportError) -> Self {
74 Self::Internal(format!("infrastructure error: {error}"))
75 }
76 }
77 };
78}
79
80#[macro_export]
81macro_rules! json_payload_conversion {
82 ($name:ident) => {
83 impl From<::serde_json::Error> for $name {
84 fn from(error: ::serde_json::Error) -> Self {
85 Self::BadRequest(format!("invalid payload: {error}"))
86 }
87 }
88 };
89}
90
91/// Standard variant-to-variant mapping between two domain error enums sharing
92/// the `BadRequest/Unauthorized/Forbidden/NotFound/Conflict/RateLimited/Internal`
93/// shape; non-matching source variants fall through to `Internal`.
94#[macro_export]
95macro_rules! domain_error_passthrough {
96 ($to:ident from $($src:tt)*) => {
97 impl From<$($src)*> for $to {
98 fn from(error: $($src)*) -> Self {
99 #[allow(unreachable_patterns)]
100 match error {
101 $($src)*::BadRequest(msg) => Self::BadRequest(msg),
102 $($src)*::Unauthorized => Self::Unauthorized,
103 $($src)*::Forbidden => Self::Forbidden,
104 $($src)*::NotFound => Self::NotFound,
105 $($src)*::Conflict(msg) => Self::Conflict(msg),
106 $($src)*::RateLimited(msg) => Self::RateLimited(msg),
107 $($src)*::Internal(msg) => Self::Internal(msg),
108 other => Self::Internal(other.to_string()),
109 }
110 }
111 }
112 };
113}
114
115/// Generates a crate's `ApiError` response enum plus its boilerplate:
116/// `IntoResponses` derive, envelope constructors, `IntoResponse`, and the
117/// `From` impls shared by every crate (boxed self, `AppResponseData<()>`,
118/// request rejections, `serde_json::Error`, `(StatusCode, &str)` and
119/// `session::SessionError`).
120///
121/// The enum must contain an `Internal` variant; `log` labels server-side
122/// warnings as "`<log> handler failed`".
123#[macro_export]
124macro_rules! api_error {
125 (
126 log: $log:literal,
127 $(#[$meta:meta])*
128 $vis:vis enum $name:ident {
129 $( $variant:ident ( $status:literal, $desc:literal ) ),* $(,)?
130 }
131 ) => {
132 $(#[$meta])*
133 #[derive(Debug, ::utoipa::IntoResponses)]
134 $vis enum $name {
135 $(
136 #[response(status = $status, description = $desc)]
137 $variant(#[to_schema] $crate::rest::AppResponseData<$crate::rest::NoData>),
138 )*
139 }
140
141 #[allow(dead_code)]
142 impl $name {
143 fn payload(
144 code: i32,
145 message: &'static str,
146 ) -> $crate::rest::AppResponseData<$crate::rest::NoData> {
147 $crate::rest::AppResponseData::err(code, message)
148 }
149
150 pub fn build(
151 status: u16,
152 envelope: $crate::rest::AppResponseData<$crate::rest::NoData>,
153 ) -> Self {
154 #[allow(unreachable_patterns)]
155 match status {
156 $( $status => Self::$variant(envelope), )*
157 _ => Self::Internal(envelope),
158 }
159 }
160
161 pub fn bad_request(message: impl ::std::convert::AsRef<str>) -> Self {
162 let message = message.as_ref();
163 Self::build(
164 400,
165 $crate::rest::AppResponseData::err(400, message)
166 .with_error_code("bad_request"),
167 )
168 }
169
170 pub fn unauthorized() -> Self {
171 Self::build(
172 401,
173 Self::payload(401, "unauthorized").with_error_code("unauthorized"),
174 )
175 }
176
177 pub fn forbidden() -> Self {
178 Self::build(
179 403,
180 Self::payload(403, "forbidden").with_error_code("forbidden"),
181 )
182 }
183
184 pub fn not_found(message: &'static str) -> Self {
185 Self::build(
186 404,
187 Self::payload(404, message).with_error_code("not_found"),
188 )
189 }
190
191 pub fn conflict(message: impl ::std::convert::AsRef<str>) -> Self {
192 let message = message.as_ref();
193 Self::build(
194 409,
195 $crate::rest::AppResponseData::err(409, message).with_error_code("conflict"),
196 )
197 }
198
199 pub fn conflict_with_code(
200 error_code: impl ::std::convert::Into<String>,
201 message: impl ::std::convert::Into<String>,
202 ) -> Self {
203 Self::build(
204 409,
205 $crate::rest::AppResponseData::err(409, message)
206 .with_error_code(error_code),
207 )
208 }
209
210 pub fn rate_limited(message: impl ::std::convert::Into<String>) -> Self {
211 Self::build(
212 429,
213 $crate::rest::AppResponseData::err(429, message)
214 .with_error_code("rate_limited"),
215 )
216 }
217
218 pub fn rate_limited_exceeded() -> Self {
219 Self::build(
220 429,
221 Self::payload(429, "rate limit exceeded").with_error_code("rate_limited"),
222 )
223 }
224
225 pub fn internal(message: impl ::std::fmt::Display) -> Self {
226 ::tracing::warn!(error = %message, concat!($log, " handler failed"));
227 Self::build(500, Self::payload(500, "internal server error"))
228 }
229 }
230
231 impl ::axum::response::IntoResponse for $name {
232 fn into_response(self) -> ::axum::response::Response {
233 let (status, envelope) = match self {
234 $( Self::$variant(envelope) => ($status, envelope), )*
235 };
236 let status = ::axum::http::StatusCode::from_u16(status)
237 .unwrap_or(::axum::http::StatusCode::INTERNAL_SERVER_ERROR);
238 (status, ::axum::Json(envelope)).into_response()
239 }
240 }
241
242 impl From<Box<$name>> for $name {
243 fn from(error: Box<$name>) -> Self {
244 *error
245 }
246 }
247
248 impl From<$crate::rest::AppResponseData<()>> for $name {
249 fn from(_error: $crate::rest::AppResponseData<()>) -> Self {
250 Self::BadRequest(Self::payload(400, "bad request"))
251 }
252 }
253
254 impl From<::axum::extract::rejection::PathRejection> for $name {
255 fn from(error: ::axum::extract::rejection::PathRejection) -> Self {
256 Self::BadRequest(
257 $crate::rest::AppResponseData::err(400, "invalid request path")
258 .with_error(error.body_text()),
259 )
260 }
261 }
262
263 impl From<::axum::extract::rejection::JsonRejection> for $name {
264 fn from(error: ::axum::extract::rejection::JsonRejection) -> Self {
265 Self::BadRequest(
266 $crate::rest::AppResponseData::err(400, "invalid request body")
267 .with_error(error.body_text())
268 .with_error_code("invalid_request_body"),
269 )
270 }
271 }
272
273 impl From<::axum::extract::rejection::QueryRejection> for $name {
274 fn from(error: ::axum::extract::rejection::QueryRejection) -> Self {
275 Self::BadRequest(
276 $crate::rest::AppResponseData::err(400, "invalid query parameters")
277 .with_error(error.body_text()),
278 )
279 }
280 }
281
282 impl From<::serde_json::Error> for $name {
283 fn from(_error: ::serde_json::Error) -> Self {
284 Self::BadRequest(Self::payload(400, "bad request"))
285 }
286 }
287
288 impl From<(::axum::http::StatusCode, &'static str)> for $name {
289 fn from((status, message): (::axum::http::StatusCode, &'static str)) -> Self {
290 ::tracing::error!(%status, %message, "unhandled (status, message) converted to 500");
291 Self::build(500, Self::payload(500, "internal server error"))
292 }
293 }
294
295 impl From<::session::SessionError> for $name {
296 fn from(error: ::session::SessionError) -> Self {
297 match error {
298 ::session::SessionError::Unauthorized => {
299 Self::build(401, Self::payload(401, "unauthorized"))
300 }
301 ::session::SessionError::Database(message) => {
302 ::tracing::warn!(error = %message, concat!($log, " handler failed"));
303 Self::build(500, Self::payload(500, "internal server error"))
304 }
305 }
306 }
307 }
308 };
309}
Modified lib/export/src/lib.rs View lib/export/src/lib.rs
@@ -1,3 +1,4 @@
1mod api_error;
12pub mod database;
23pub mod error;
34pub mod health;
@@ -7,10 +8,12 @@
78pub mod redis;
89pub mod rest;
910pub mod storage;
11pub mod telemetry;
1012
1113use std::sync::Arc;
1214
1315use config::AppConfig;
16pub use api_error::is_unique_violation;
1417pub use database::{AppDatabase, DatabaseRole};
1518pub use error::ExportError;
1619pub use redis::{AppRedis, RedisConnection};
Modified lib/export/src/redis.rs View lib/export/src/redis.rs
@@ -319,3 +319,95 @@
319319
320320 Ok(redis::Client::open(first)?.get_connection_info().clone())
321321}
322
323macro_rules! dispatch {
324 ($conn:expr, |$c:ident| $body:expr) => {
325 match &mut $conn {
326 RedisConnection::Standalone($c) => $body,
327 RedisConnection::Cluster($c) => $body,
328 }
329 };
330}
331
332pub async fn get_string(redis: &AppRedis, key: &str) -> Result<Option<String>, redis::RedisError> {
333 let mut conn = redis.connection_clone();
334 dispatch!(conn, |c| redis::cmd("GET").arg(key).query_async(c).await)
335}
336
337pub async fn set_string(
338 redis: &AppRedis,
339 key: &str,
340 value: &str,
341 ttl_secs: u64,
342) -> Result<(), redis::RedisError> {
343 let mut conn = redis.connection_clone();
344 dispatch!(conn, |c| {
345 redis::cmd("SET")
346 .arg(key)
347 .arg(value)
348 .arg("EX")
349 .arg(ttl_secs)
350 .exec_async(c)
351 .await
352 })
353}
354
355pub async fn set_nx_ex(
356 redis: &AppRedis,
357 key: &str,
358 value: &str,
359 ttl_secs: u64,
360) -> Result<bool, redis::RedisError> {
361 let mut conn = redis.connection_clone();
362 dispatch!(conn, |c| {
363 redis::cmd("SET")
364 .arg(key)
365 .arg(value)
366 .arg("NX")
367 .arg("EX")
368 .arg(ttl_secs)
369 .query_async(c)
370 .await
371 })
372}
373
374pub async fn delete(redis: &AppRedis, key: &str) -> Result<(), redis::RedisError> {
375 let mut conn = redis.connection_clone();
376 dispatch!(conn, |c| redis::cmd("DEL").arg(key).exec_async(c).await)
377}
378
379pub async fn exists(redis: &AppRedis, key: &str) -> Result<bool, redis::RedisError> {
380 let mut conn = redis.connection_clone();
381 dispatch!(conn, |c| redis::cmd("EXISTS").arg(key).query_async(c).await)
382}
383
384pub async fn getdel_string(
385 redis: &AppRedis,
386 key: &str,
387) -> Result<Option<String>, redis::RedisError> {
388 let mut conn = redis.connection_clone();
389 dispatch!(conn, |c| redis::cmd("GETDEL").arg(key).query_async(c).await)
390}
391
392const INCR_EXPIRE_SCRIPT: &str = r#"local c = redis.call('INCR', KEYS[1])
393if c == 1 then
394 redis.call('EXPIRE', KEYS[1], ARGV[1])
395end
396return c"#;
397
398pub async fn incr_expire(
399 redis: &AppRedis,
400 key: &str,
401 ttl_secs: u64,
402) -> Result<i64, redis::RedisError> {
403 let mut conn = redis.connection_clone();
404 dispatch!(conn, |c| {
405 redis::cmd("EVAL")
406 .arg(INCR_EXPIRE_SCRIPT)
407 .arg(1)
408 .arg(key)
409 .arg(ttl_secs)
410 .query_async(c)
411 .await
412 })
413}
Added lib/export/src/telemetry.rs View lib/export/src/telemetry.rs
@@ -0,0 +1,36 @@
1use tracing::{info, warn};
2use tracing_subscriber::EnvFilter;
3
4pub fn init_tracing() {
5 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
6 tracing_subscriber::fmt().with_env_filter(filter).init();
7}
8
9pub async fn shutdown_signal() {
10 let ctrl_c = async {
11 if let Err(err) = tokio::signal::ctrl_c().await {
12 warn!(error = %err, "failed to install ctrl_c handler");
13 }
14 };
15
16 #[cfg(unix)]
17 let terminate = async {
18 use tokio::signal::unix::{SignalKind, signal};
19 match signal(SignalKind::terminate()) {
20 Ok(mut s) => {
21 s.recv().await;
22 }
23 Err(err) => {
24 warn!(error = %err, "failed to install SIGTERM handler");
25 }
26 }
27 };
28
29 #[cfg(not(unix))]
30 let terminate = std::future::pending::<()>();
31
32 tokio::select! {
33 _ = ctrl_c => info!("received SIGINT"),
34 _ = terminate => info!("received SIGTERM"),
35 }
36}
Modified lib/export/Cargo.toml View lib/export/Cargo.toml
@@ -8,11 +8,12 @@
88storage = { workspace = true }
99sea-orm = { workspace = true }
1010redis = { workspace = true }
11tokio = { workspace = true }
11tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] }
1212email = { workspace = true }
1313axum = { workspace = true }
1414serde = { workspace = true, features = ["derive"] }
1515tracing = { workspace = true }
16tracing-subscriber = { workspace = true }
1617utoipa = { workspace = true }
1718rand.workspace = true
1819tower = { workspace = true }
Modified lib/issues/src/services/labels.rs View lib/issues/src/services/labels.rs
@@ -108,6 +108,27 @@
108108}
109109
110110impl IssueService {
111 async fn lookup_managed_label(
112 &self,
113 caller: Uuid,
114 space_name: &str,
115 board_id: Uuid,
116 issue_id: Uuid,
117 label_id: Uuid,
118 ) -> Result<issue_label::Model, IssueError> {
119 let space = lookup_space_by_name(self, space_name).await?;
120 require_member(self, &space, caller).await?;
121 require_board_manage(self, &space, caller).await?;
122 let board = lookup_board(self, &space, board_id, caller).await?;
123 let _ = lookup_issue(self, &board, issue_id).await?;
124 let label = issue_label::Entity::find_by_id(label_id)
125 .filter(issue_label::Column::DirId.eq(board.dir_id))
126 .one(self.db().read())
127 .await?
128 .ok_or(IssueError::NotFound)?;
129 Ok(label)
130 }
131
111132 pub async fn create_label(
112133 &self,
113134 caller: Uuid,
@@ -365,17 +386,9 @@
365386 issue_id: Uuid,
366387 label_id: Uuid,
367388 ) -> Result<(), IssueError> {
368 let space = lookup_space_by_name(self, space_name).await?;
369 require_member(self, &space, caller).await?;
370 require_board_manage(self, &space, caller).await?;
371 let board = lookup_board(self, &space, board_id, caller).await?;
372 let _ = lookup_issue(self, &board, issue_id).await?;
373
374 let label = issue_label::Entity::find_by_id(label_id)
375 .filter(issue_label::Column::DirId.eq(board.dir_id))
376 .one(self.db().read())
377 .await?
378 .ok_or(IssueError::NotFound)?;
389 let label = self
390 .lookup_managed_label(caller, space_name, board_id, issue_id, label_id)
391 .await?;
379392
380393 let existing = issue_relation_label::Entity::find()
381394 .filter(issue_relation_label::Column::IssueId.eq(issue_id))
@@ -434,17 +447,9 @@
434447 issue_id: Uuid,
435448 label_id: Uuid,
436449 ) -> Result<(), IssueError> {
437 let space = lookup_space_by_name(self, space_name).await?;
438 require_member(self, &space, caller).await?;
439 require_board_manage(self, &space, caller).await?;
440 let board = lookup_board(self, &space, board_id, caller).await?;
441 let _ = lookup_issue(self, &board, issue_id).await?;
442
443 let label = issue_label::Entity::find_by_id(label_id)
444 .filter(issue_label::Column::DirId.eq(board.dir_id))
445 .one(self.db().read())
446 .await?
447 .ok_or(IssueError::NotFound)?;
450 let label = self
451 .lookup_managed_label(caller, space_name, board_id, issue_id, label_id)
452 .await?;
448453
449454 let rel = issue_relation_label::Entity::find()
450455 .filter(issue_relation_label::Column::IssueId.eq(issue_id))
Modified lib/issues/src/services/reactions.rs View lib/issues/src/services/reactions.rs
@@ -65,6 +65,25 @@
6565 pub comment_id: Uuid,
6666}
6767
68fn aggregate_reactions(rows: Vec<issue_reactions::Model>) -> Vec<ReactionAggDto> {
69 let mut groups: HashMap<String, Vec<Uuid>> = HashMap::new();
70 for r in rows {
71 groups.entry(r.emoji).or_default().push(r.user_id);
72 }
73 let mut result = Vec::new();
74 for (emoji, mut uids) in groups {
75 uids.sort();
76 uids.dedup();
77 result.push(ReactionAggDto {
78 count: uids.len() as i64,
79 emoji,
80 user_ids: uids,
81 });
82 }
83 result.sort_by(|a, b| a.emoji.cmp(&b.emoji));
84 result
85}
86
6887impl IssueService {
6988 fn validate_emoji(&self, emoji: &str) -> Result<String, IssueError> {
7089 let clean = emoji.trim();
@@ -224,24 +243,7 @@
224243 .all(self.db().read())
225244 .await?;
226245
227 let mut groups: HashMap<String, Vec<Uuid>> = HashMap::new();
228 for r in rows {
229 groups.entry(r.emoji).or_default().push(r.user_id);
230 }
231
232 let mut result = Vec::new();
233 for (emoji, mut uids) in groups {
234 uids.sort();
235 uids.dedup();
236 result.push(ReactionAggDto {
237 count: uids.len() as i64,
238 emoji,
239 user_ids: uids,
240 });
241 }
242 result.sort_by(|a, b| a.emoji.cmp(&b.emoji));
243
244 Ok(result)
246 Ok(aggregate_reactions(rows))
245247 }
246248
247249 pub async fn list_comment_reactions(
@@ -268,24 +270,7 @@
268270 .all(self.db().read())
269271 .await?;
270272
271 let mut groups: HashMap<String, Vec<Uuid>> = HashMap::new();
272 for r in rows {
273 groups.entry(r.emoji).or_default().push(r.user_id);
274 }
275
276 let mut result = Vec::new();
277 for (emoji, mut uids) in groups {
278 uids.sort();
279 uids.dedup();
280 result.push(ReactionAggDto {
281 count: uids.len() as i64,
282 emoji,
283 user_ids: uids,
284 });
285 }
286 result.sort_by(|a, b| a.emoji.cmp(&b.emoji));
287
288 Ok(result)
273 Ok(aggregate_reactions(rows))
289274 }
290275
291276 pub async fn remove_reaction_by_emoji(
Modified lib/issues/src/services/service.rs View lib/issues/src/services/service.rs
@@ -10,9 +10,7 @@
1010use models::space::space_members::{self, MemberStatus};
1111use models::space::spaces;
1212use models::users::user;
13use models::users::user_emails;
1413use models::users::user_notifications::{self, NotificationType};
15use models::users::user_sessions;
1614use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set};
1715use session::SessionError;
1816use session::share::guard::{SessionGuard, SessionWithUser};
@@ -211,19 +209,10 @@
211209 if uids.is_empty() {
212210 return Ok(HashMap::new());
213211 }
214 let rows = user_emails::Entity::find()
215 .filter(user_emails::Column::UserId.is_in(uids.to_vec()))
216 .filter(user_emails::Column::IsPrimary.eq(true))
217 .filter(user_emails::Column::IsActivated.eq(true))
218 .filter(user_emails::Column::DeletedAt.is_null())
219 .all(self.db().read())
220 .await?;
212 models::users::queries::primary_emails(self.db().read(), uids)
213 .await
214 .map_err(IssueError::from)
221215
222 let map = rows
223 .into_iter()
224 .map(|r| (r.user_id, r.lower_email))
225 .collect();
226 Ok(map)
227216 }
228217
229218 pub(crate) async fn send_email(
@@ -247,21 +236,6 @@
247236 &self,
248237 token_hash: &str,
249238 ) -> Result<Option<SessionWithUser>, SessionError> {
250 let outcome = user_sessions::Entity::find()
251 .filter(user_sessions::Column::TokenHash.eq(token_hash))
252 .find_also_related(user::Entity)
253 .one(self.db().read())
254 .await?;
255 Ok(outcome.and_then(|(row, current)| {
256 current.map(|c| SessionWithUser {
257 user_id: row.user_id,
258 is_revoked: row.is_revoked,
259 expires_at: row.expires_at,
260 is_active: c.is_active,
261 prohibit_login: c.prohibit_login,
262 is_restricted: c.is_restricted,
263 passwd_version: c.passwd_version,
264 })
265 }))
239 session::lookup_session_with_user(self.db().read(), token_hash).await
266240 }
267241}
Modified lib/issues/src/errors.rs View lib/issues/src/errors.rs
@@ -1,12 +1,5 @@
11use std::fmt;
22
3use axum::Json;
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use export::rest::{AppResponseData, NoData};
8use utoipa::IntoResponses;
9
103#[derive(Debug)]
114pub enum IssueError {
125 BadRequest(String),
@@ -34,197 +27,34 @@
3427
3528impl std::error::Error for IssueError {}
3629
37impl From<sea_orm::DbErr> for IssueError {
38 fn from(error: sea_orm::DbErr) -> Self {
39 if is_unique_violation(&error) {
40 return Self::Conflict("resource already exists".to_string());
41 }
42 Self::Internal(format!("database error: {error}"))
43 }
44}
45
46impl From<sea_orm::TransactionError<sea_orm::DbErr>> for IssueError {
47 fn from(error: sea_orm::TransactionError<sea_orm::DbErr>) -> Self {
48 match &error {
49 sea_orm::TransactionError::Transaction(db_err) if is_unique_violation(db_err) => {
50 Self::Conflict("resource already exists".to_string())
51 }
52 _ => Self::Internal(format!("database transaction error: {error}")),
53 }
54 }
55}
56
57impl From<sea_orm::TransactionError<IssueError>> for IssueError {
58 fn from(error: sea_orm::TransactionError<IssueError>) -> Self {
59 match error {
60 sea_orm::TransactionError::Connection(db_err) => Self::from(db_err),
61 sea_orm::TransactionError::Transaction(issue_err) => issue_err,
62 }
63 }
64}
30export::db_error_conversions!(IssueError);
31export::export_error_conversion!(IssueError);
32export::json_payload_conversion!(IssueError);
33export::domain_error_passthrough!(IssueError from space::errors::SpaceError);
6534
66fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
67 let msg = err.to_string().to_lowercase();
68 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
69}
70
71impl From<space::errors::SpaceError> for IssueError {
72 fn from(error: space::errors::SpaceError) -> Self {
73 match error {
74 space::errors::SpaceError::BadRequest(msg) => Self::BadRequest(msg),
75 space::errors::SpaceError::Unauthorized => Self::Unauthorized,
76 space::errors::SpaceError::Forbidden => Self::Forbidden,
77 space::errors::SpaceError::NotFound => Self::NotFound,
78 space::errors::SpaceError::Conflict(msg) => Self::Conflict(msg),
79 space::errors::SpaceError::RateLimited(msg) => Self::RateLimited(msg),
80 space::errors::SpaceError::Internal(msg) => Self::Internal(msg),
81 }
82 }
83}
84
85impl From<export::ExportError> for IssueError {
86 fn from(error: export::ExportError) -> Self {
87 Self::Internal(format!("infrastructure error: {error}"))
88 }
89}
90
91impl From<serde_json::Error> for IssueError {
92 fn from(error: serde_json::Error) -> Self {
93 Self::BadRequest(format!("invalid payload: {error}"))
94 }
95}
96
97#[derive(Debug, IntoResponses)]
98pub enum ApiError {
99 #[response(status = 400, description = "request failed validation")]
100 BadRequest(#[to_schema] AppResponseData<NoData>),
101 #[response(status = 401, description = "authentication required")]
102 Unauthorized(#[to_schema] AppResponseData<NoData>),
103 #[response(status = 403, description = "operation not allowed")]
104 Forbidden(#[to_schema] AppResponseData<NoData>),
105 #[response(status = 404, description = "resource not found")]
106 NotFound(#[to_schema] AppResponseData<NoData>),
107 #[response(status = 409, description = "resource conflict")]
108 Conflict(#[to_schema] AppResponseData<NoData>),
109 #[response(status = 429, description = "rate limit exceeded")]
110 TooManyRequests(#[to_schema] AppResponseData<NoData>),
111 #[response(status = 500, description = "internal server error")]
112 Internal(#[to_schema] AppResponseData<NoData>),
113}
114
115impl ApiError {
116 fn payload(code: i32, message: &'static str) -> AppResponseData<NoData> {
117 AppResponseData::err(code, message)
118 }
119
120 fn decompose(self) -> (u16, AppResponseData<NoData>) {
121 match self {
122 Self::BadRequest(envelope) => (400, envelope),
123 Self::Unauthorized(envelope) => (401, envelope),
124 Self::Forbidden(envelope) => (403, envelope),
125 Self::NotFound(envelope) => (404, envelope),
126 Self::Conflict(envelope) => (409, envelope),
127 Self::TooManyRequests(envelope) => (429, envelope),
128 Self::Internal(envelope) => (500, envelope),
129 }
35export::api_error! {
36 log: "issues",
37 pub enum ApiError {
38 BadRequest(400, "request failed validation"),
39 Unauthorized(401, "authentication required"),
40 Forbidden(403, "operation not allowed"),
41 NotFound(404, "resource not found"),
42 Conflict(409, "resource conflict"),
43 TooManyRequests(429, "rate limit exceeded"),
44 Internal(500, "internal server error"),
13045 }
13146}
13247
133impl IntoResponse for ApiError {
134 fn into_response(self) -> Response {
135 let (code, envelope) = self.decompose();
136 let status = match StatusCode::from_u16(code) {
137 Ok(status) => status,
138 Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
139 };
140 (status, Json(envelope)).into_response()
141 }
142}
143
14448impl From<IssueError> for ApiError {
14549 fn from(error: IssueError) -> Self {
14650 match error {
147 IssueError::BadRequest(msg) => {
148 Self::BadRequest(AppResponseData::err(400, msg).with_error_code("bad_request"))
149 }
150 IssueError::Unauthorized => Self::Unauthorized(
151 Self::payload(401, "unauthorized").with_error_code("unauthorized"),
152 ),
153 IssueError::Forbidden => {
154 Self::Forbidden(Self::payload(403, "forbidden").with_error_code("forbidden"))
155 }
156 IssueError::NotFound => Self::NotFound(
157 Self::payload(404, "issue resource not found").with_error_code("not_found"),
158 ),
159 IssueError::Conflict(msg) => {
160 Self::Conflict(AppResponseData::err(409, msg).with_error_code("conflict"))
161 }
162 IssueError::RateLimited(msg) => Self::TooManyRequests(
163 AppResponseData::err(429, msg).with_error_code("rate_limited"),
164 ),
165 IssueError::Internal(message) => {
166 tracing::warn!(error = %message, "issues handler failed");
167 Self::Internal(Self::payload(500, "internal server error"))
168 }
169 }
170 }
171}
172
173impl From<Box<ApiError>> for ApiError {
174 fn from(error: Box<ApiError>) -> Self {
175 *error
176 }
177}
178
179impl From<session::SessionError> for ApiError {
180 fn from(error: session::SessionError) -> Self {
181 match error {
182 session::SessionError::Unauthorized => {
183 Self::Unauthorized(Self::payload(401, "unauthorized"))
184 }
185 session::SessionError::Database(message) => {
186 tracing::warn!(error = %message, "issues handler failed");
187 Self::Internal(Self::payload(500, "internal server error"))
188 }
51 IssueError::BadRequest(msg) => Self::bad_request(msg),
52 IssueError::Unauthorized => Self::unauthorized(),
53 IssueError::Forbidden => Self::forbidden(),
54 IssueError::NotFound => Self::not_found("issue resource not found"),
55 IssueError::Conflict(msg) => Self::conflict(msg),
56 IssueError::RateLimited(msg) => Self::rate_limited(msg),
57 IssueError::Internal(message) => Self::internal(message),
18958 }
190 }
191}
192
193impl From<AppResponseData<()>> for ApiError {
194 fn from(_error: AppResponseData<()>) -> Self {
195 Self::BadRequest(Self::payload(400, "bad request"))
196 }
197}
198
199impl From<PathRejection> for ApiError {
200 fn from(error: PathRejection) -> Self {
201 Self::BadRequest(
202 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
203 )
204 }
205}
206
207impl From<JsonRejection> for ApiError {
208 fn from(error: JsonRejection) -> Self {
209 Self::BadRequest(
210 AppResponseData::err(400, "invalid request body")
211 .with_error(error.body_text())
212 .with_error_code("invalid_request_body"),
213 )
214 }
215}
216
217impl From<QueryRejection> for ApiError {
218 fn from(error: QueryRejection) -> Self {
219 Self::BadRequest(
220 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
221 )
222 }
223}
224
225impl From<(StatusCode, &'static str)> for ApiError {
226 fn from((status, message): (StatusCode, &'static str)) -> Self {
227 tracing::error!(%status, %message, "unhandled (status, message) converted to 500");
228 Self::Internal(Self::payload(500, "internal server error"))
22959 }
23060}
Modified lib/models/src/users/mod.rs View lib/models/src/users/mod.rs
@@ -22,3 +22,5 @@
2222pub mod user_suspensions;
2323
2424pub use user::*;
25
26pub mod queries;
Added lib/models/src/users/queries.rs View lib/models/src/users/queries.rs
@@ -0,0 +1,24 @@
1use std::collections::HashMap;
2
3use sea_orm::{ColumnTrait, ConnectionTrait, DbErr, EntityTrait, QueryFilter};
4use uuid::Uuid;
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: &[Uuid],
12) -> Result<HashMap<Uuid, 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.into_iter().map(|r| (r.user_id, r.lower_email)).collect())
24}
Modified lib/pulls/src/errors.rs View lib/pulls/src/errors.rs
@@ -1,12 +1,5 @@
11use std::fmt;
22
3use axum::Json;
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use export::rest::{AppResponseData, NoData};
8use utoipa::IntoResponses;
9
103#[derive(Debug)]
114pub enum PullError {
125 BadRequest(String),
@@ -34,40 +27,11 @@
3427
3528impl std::error::Error for PullError {}
3629
37fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
38 let msg = err.to_string().to_lowercase();
39 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
40}
41
42impl From<sea_orm::DbErr> for PullError {
43 fn from(error: sea_orm::DbErr) -> Self {
44 if is_unique_violation(&error) {
45 return Self::Conflict("resource already exists".to_string());
46 }
47 Self::Internal(format!("database error: {error}"))
48 }
49}
50
51impl From<sea_orm::TransactionError<sea_orm::DbErr>> for PullError {
52 fn from(error: sea_orm::TransactionError<sea_orm::DbErr>) -> Self {
53 match &error {
54 sea_orm::TransactionError::Transaction(db_err) if is_unique_violation(db_err) => {
55 Self::Conflict("resource already exists".to_string())
56 }
57 _ => Self::Internal(format!("database transaction error: {error}")),
58 }
59 }
60}
30export::db_error_conversions!(PullError);
31export::export_error_conversion!(PullError);
32export::json_payload_conversion!(PullError);
33export::domain_error_passthrough!(PullError from issues::errors::IssueError);
6134
62impl From<sea_orm::TransactionError<PullError>> for PullError {
63 fn from(error: sea_orm::TransactionError<PullError>) -> Self {
64 match error {
65 sea_orm::TransactionError::Connection(db_err) => Self::from(db_err),
66 sea_orm::TransactionError::Transaction(pull_err) => pull_err,
67 }
68 }
69}
70
7135impl From<git::share::errors::GitError> for PullError {
7236 fn from(error: git::share::errors::GitError) -> Self {
7337 use git::share::errors::GitError as G;
@@ -82,128 +46,29 @@
8246 }
8347}
8448
85impl From<issues::errors::IssueError> for PullError {
86 fn from(error: issues::errors::IssueError) -> Self {
87 match error {
88 issues::errors::IssueError::BadRequest(msg) => Self::BadRequest(msg),
89 issues::errors::IssueError::Unauthorized => Self::Unauthorized,
90 issues::errors::IssueError::Forbidden => Self::Forbidden,
91 issues::errors::IssueError::NotFound => Self::NotFound,
92 issues::errors::IssueError::Conflict(msg) => Self::Conflict(msg),
93 issues::errors::IssueError::RateLimited(msg) => Self::RateLimited(msg),
94 issues::errors::IssueError::Internal(msg) => Self::Internal(msg),
95 }
96 }
97}
98
99impl From<export::ExportError> for PullError {
100 fn from(error: export::ExportError) -> Self {
101 Self::Internal(format!("infrastructure error: {error}"))
102 }
103}
104
105impl From<serde_json::Error> for PullError {
106 fn from(error: serde_json::Error) -> Self {
107 Self::BadRequest(format!("invalid payload: {error}"))
108 }
109}
110
111#[derive(Debug, IntoResponses)]
112pub enum ApiError {
113 #[response(status = 400, description = "request failed validation")]
114 BadRequest(#[to_schema] AppResponseData<NoData>),
115 #[response(status = 401, description = "authentication required")]
116 Unauthorized(#[to_schema] AppResponseData<NoData>),
117 #[response(status = 403, description = "operation not allowed")]
118 Forbidden(#[to_schema] AppResponseData<NoData>),
119 #[response(status = 404, description = "resource not found")]
120 NotFound(#[to_schema] AppResponseData<NoData>),
121 #[response(status = 409, description = "resource conflict")]
122 Conflict(#[to_schema] AppResponseData<NoData>),
123 #[response(status = 429, description = "rate limit exceeded")]
124 TooManyRequests(#[to_schema] AppResponseData<NoData>),
125 #[response(status = 500, description = "internal server error")]
126 Internal(#[to_schema] AppResponseData<NoData>),
127}
128
129impl ApiError {
130 fn payload(code: i32, message: &'static str) -> AppResponseData<NoData> {
131 AppResponseData::err(code, message)
132 }
133
134 fn decompose(self) -> (u16, AppResponseData<NoData>) {
135 match self {
136 Self::BadRequest(envelope) => (400, envelope),
137 Self::Unauthorized(envelope) => (401, envelope),
138 Self::Forbidden(envelope) => (403, envelope),
139 Self::NotFound(envelope) => (404, envelope),
140 Self::Conflict(envelope) => (409, envelope),
141 Self::TooManyRequests(envelope) => (429, envelope),
142 Self::Internal(envelope) => (500, envelope),
143 }
144 }
145}
146
147impl IntoResponse for ApiError {
148 fn into_response(self) -> Response {
149 let (code, envelope) = self.decompose();
150 let status =
151 StatusCode::from_u16(code).unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR);
152 (status, Json(envelope)).into_response()
49export::api_error! {
50 log: "pulls",
51 pub enum ApiError {
52 BadRequest(400, "request failed validation"),
53 Unauthorized(401, "authentication required"),
54 Forbidden(403, "operation not allowed"),
55 NotFound(404, "resource not found"),
56 Conflict(409, "resource conflict"),
57 TooManyRequests(429, "rate limit exceeded"),
58 Internal(500, "internal server error"),
15359 }
15460}
15561
15662impl From<PullError> for ApiError {
15763 fn from(error: PullError) -> Self {
15864 match error {
159 PullError::BadRequest(msg) => {
160 Self::BadRequest(AppResponseData::err(400, msg).with_error_code("bad_request"))
161 }
162 PullError::Unauthorized => Self::Unauthorized(
163 Self::payload(401, "unauthorized").with_error_code("unauthorized"),
164 ),
165 PullError::Forbidden => {
166 Self::Forbidden(Self::payload(403, "forbidden").with_error_code("forbidden"))
167 }
168 PullError::NotFound => Self::NotFound(
169 Self::payload(404, "pull request not found").with_error_code("not_found"),
170 ),
171 PullError::Conflict(msg) => {
172 Self::Conflict(AppResponseData::err(409, msg).with_error_code("merge_blocked"))
173 }
174 PullError::RateLimited(msg) => Self::TooManyRequests(
175 AppResponseData::err(429, msg).with_error_code("rate_limited"),
176 ),
177 PullError::Internal(message) => {
178 tracing::warn!(error = %message, "pulls handler failed");
179 Self::Internal(Self::payload(500, "internal server error"))
180 }
65 PullError::BadRequest(msg) => Self::bad_request(msg),
66 PullError::Unauthorized => Self::unauthorized(),
67 PullError::Forbidden => Self::forbidden(),
68 PullError::NotFound => Self::not_found("pull request not found"),
69 PullError::Conflict(msg) => Self::conflict_with_code("merge_blocked", msg),
70 PullError::RateLimited(msg) => Self::rate_limited(msg),
71 PullError::Internal(message) => Self::internal(message),
18172 }
182 }
183}
184
185impl From<PathRejection> for ApiError {
186 fn from(error: PathRejection) -> Self {
187 Self::BadRequest(
188 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
189 )
190 }
191}
192
193impl From<JsonRejection> for ApiError {
194 fn from(error: JsonRejection) -> Self {
195 Self::BadRequest(
196 AppResponseData::err(400, "invalid request body")
197 .with_error(error.body_text())
198 .with_error_code("invalid_request_body"),
199 )
200 }
201}
202
203impl From<QueryRejection> for ApiError {
204 fn from(error: QueryRejection) -> Self {
205 Self::BadRequest(
206 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
207 )
20873 }
20974}
Modified lib/session/src/share/guard.rs View lib/session/src/share/guard.rs
@@ -27,6 +27,32 @@
2727 ) -> Result<Option<SessionWithUser>, SessionError>;
2828}
2929
30/// Default `SessionGuard::lookup_session_with_user` body shared by all services.
31pub async fn lookup_session_with_user(
32 db: &sea_orm::DatabaseConnection,
33 token_hash: &str,
34) -> Result<Option<SessionWithUser>, SessionError> {
35 use models::users::{user, user_sessions};
36 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
37
38 let outcome = user_sessions::Entity::find()
39 .filter(user_sessions::Column::TokenHash.eq(token_hash))
40 .find_also_related(user::Entity)
41 .one(db)
42 .await?;
43 Ok(outcome.and_then(|(row, current)| {
44 current.map(|c| SessionWithUser {
45 user_id: row.user_id,
46 is_revoked: row.is_revoked,
47 expires_at: row.expires_at,
48 is_active: c.is_active,
49 prohibit_login: c.prohibit_login,
50 is_restricted: c.is_restricted,
51 passwd_version: c.passwd_version,
52 })
53 }))
54}
55
3056pub async fn session_guard<S>(
3157 State(service): State<S>,
3258 session: Session,
Modified lib/session/src/lib.rs View lib/session/src/lib.rs
@@ -6,7 +6,7 @@
66
77pub use config::build_session_config;
88pub use error::SessionError;
9pub use share::guard::{SessionGuard, SessionWithUser, session_guard};
9pub use share::guard::{SessionGuard, SessionWithUser, lookup_session_with_user, session_guard};
1010pub use share::session::{
1111 PWD_VERSION_KEY, apply_login, pwd_version_matches, require_uid, session_token_key,
1212 validate_session_pwd_version,
Modified lib/session/Cargo.toml View lib/session/Cargo.toml
@@ -5,6 +5,7 @@
55
66[dependencies]
77async-trait = { workspace = true }
8models = { workspace = true }
89axum = { workspace = true }
910base64 = { workspace = true }
1011chrono = { workspace = true }
Modified lib/space/src/services/service.rs View lib/space/src/services/service.rs
@@ -4,9 +4,6 @@
44use config::AppConfig;
55use email::EmailService;
66use export::{AppDatabase, AppState, AppStorage};
7use models::users::user;
8use models::users::user_sessions;
9use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
107use session::SessionError;
118use session::share::guard::{SessionGuard, SessionWithUser};
129
@@ -59,21 +56,6 @@
5956 &self,
6057 token_hash: &str,
6158 ) -> Result<Option<SessionWithUser>, SessionError> {
62 let outcome = user_sessions::Entity::find()
63 .filter(user_sessions::Column::TokenHash.eq(token_hash))
64 .find_also_related(user::Entity)
65 .one(self.db().read())
66 .await?;
67 Ok(outcome.and_then(|(row, current)| {
68 current.map(|c| SessionWithUser {
69 user_id: row.user_id,
70 is_revoked: row.is_revoked,
71 expires_at: row.expires_at,
72 is_active: c.is_active,
73 prohibit_login: c.prohibit_login,
74 is_restricted: c.is_restricted,
75 passwd_version: c.passwd_version,
76 })
77 }))
59 session::lookup_session_with_user(self.db().read(), token_hash).await
7860 }
7961}
Modified lib/space/src/errors.rs View lib/space/src/errors.rs
@@ -1,11 +1,6 @@
11use std::fmt;
22
3use axum::Json;
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use export::rest::{AppResponseData, NoData};
8use utoipa::IntoResponses;
3pub use export::is_unique_violation;
94
105#[derive(Debug)]
116pub enum SpaceError {
@@ -34,186 +29,34 @@
3429
3530impl std::error::Error for SpaceError {}
3631
37impl From<sea_orm::DbErr> for SpaceError {
38 fn from(error: sea_orm::DbErr) -> Self {
39 if is_unique_violation(&error) {
40 return Self::Conflict("resource already exists".to_string());
41 }
42 Self::Internal(format!("database error: {error}"))
43 }
44}
45
46impl From<sea_orm::TransactionError<sea_orm::DbErr>> for SpaceError {
47 fn from(error: sea_orm::TransactionError<sea_orm::DbErr>) -> Self {
48 match &error {
49 sea_orm::TransactionError::Transaction(db_err) if is_unique_violation(db_err) => {
50 Self::Conflict("resource already exists".to_string())
51 }
52 _ => Self::Internal(format!("database transaction error: {error}")),
53 }
54 }
55}
56
57impl From<sea_orm::TransactionError<SpaceError>> for SpaceError {
58 fn from(error: sea_orm::TransactionError<SpaceError>) -> Self {
59 match error {
60 sea_orm::TransactionError::Connection(db_err) => Self::from(db_err),
61 sea_orm::TransactionError::Transaction(space_err) => space_err,
62 }
63 }
64}
65
66pub(crate) fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
67 let msg = err.to_string().to_lowercase();
68 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
69}
70
71impl From<storage::StorageError> for SpaceError {
72 fn from(error: storage::StorageError) -> Self {
73 Self::Internal(format!("storage error: {error}"))
74 }
75}
76
77impl From<export::ExportError> for SpaceError {
78 fn from(error: export::ExportError) -> Self {
79 Self::Internal(format!("infrastructure error: {error}"))
80 }
81}
82
83impl From<serde_json::Error> for SpaceError {
84 fn from(error: serde_json::Error) -> Self {
85 Self::BadRequest(format!("invalid payload: {error}"))
86 }
87}
88
89#[derive(Debug, IntoResponses)]
90pub enum ApiError {
91 #[response(status = 400, description = "request failed validation")]
92 BadRequest(#[to_schema] AppResponseData<NoData>),
93 #[response(status = 401, description = "authentication required")]
94 Unauthorized(#[to_schema] AppResponseData<NoData>),
95 #[response(status = 403, description = "operation not allowed")]
96 Forbidden(#[to_schema] AppResponseData<NoData>),
97 #[response(status = 404, description = "resource not found")]
98 NotFound(#[to_schema] AppResponseData<NoData>),
99 #[response(status = 409, description = "resource conflict")]
100 Conflict(#[to_schema] AppResponseData<NoData>),
101 #[response(status = 429, description = "rate limit exceeded")]
102 TooManyRequests(#[to_schema] AppResponseData<NoData>),
103 #[response(status = 500, description = "internal server error")]
104 Internal(#[to_schema] AppResponseData<NoData>),
105}
32export::db_error_conversions!(SpaceError);
33export::storage_error_conversion!(SpaceError);
34export::export_error_conversion!(SpaceError);
35export::json_payload_conversion!(SpaceError);
10636
107impl ApiError {
108 fn payload(code: i32, message: &'static str) -> AppResponseData<NoData> {
109 AppResponseData::err(code, message)
110 }
111
112 fn decompose(self) -> (u16, AppResponseData<NoData>) {
113 match self {
114 Self::BadRequest(envelope) => (400, envelope),
115 Self::Unauthorized(envelope) => (401, envelope),
116 Self::Forbidden(envelope) => (403, envelope),
117 Self::NotFound(envelope) => (404, envelope),
118 Self::Conflict(envelope) => (409, envelope),
119 Self::TooManyRequests(envelope) => (429, envelope),
120 Self::Internal(envelope) => (500, envelope),
121 }
122 }
123}
124
125impl IntoResponse for ApiError {
126 fn into_response(self) -> Response {
127 let (code, envelope) = self.decompose();
128 let status = StatusCode::from_u16(code).unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR);
129 (status, Json(envelope)).into_response()
37export::api_error! {
38 log: "space",
39 pub enum ApiError {
40 BadRequest(400, "request failed validation"),
41 Unauthorized(401, "authentication required"),
42 Forbidden(403, "operation not allowed"),
43 NotFound(404, "resource not found"),
44 Conflict(409, "resource conflict"),
45 TooManyRequests(429, "rate limit exceeded"),
46 Internal(500, "internal server error"),
13047 }
13148}
13249
13350impl From<SpaceError> for ApiError {
13451 fn from(error: SpaceError) -> Self {
13552 match error {
136 SpaceError::BadRequest(msg) => {
137 Self::BadRequest(AppResponseData::err(400, msg).with_error_code("bad_request"))
138 }
139 SpaceError::Unauthorized => Self::Unauthorized(
140 Self::payload(401, "unauthorized").with_error_code("unauthorized"),
141 ),
142 SpaceError::Forbidden => {
143 Self::Forbidden(Self::payload(403, "forbidden").with_error_code("forbidden"))
144 }
145 SpaceError::NotFound => Self::NotFound(
146 Self::payload(404, "space resource not found").with_error_code("not_found"),
147 ),
148 SpaceError::Conflict(msg) => {
149 Self::Conflict(AppResponseData::err(409, msg).with_error_code("conflict"))
150 }
151 SpaceError::RateLimited(msg) => Self::TooManyRequests(
152 AppResponseData::err(429, msg).with_error_code("rate_limited"),
153 ),
154 SpaceError::Internal(message) => {
155 tracing::warn!(error = %message, "space handler failed");
156 Self::Internal(Self::payload(500, "internal server error"))
157 }
158 }
159 }
160}
161
162impl From<Box<ApiError>> for ApiError {
163 fn from(error: Box<ApiError>) -> Self {
164 *error
165 }
166}
167
168impl From<session::SessionError> for ApiError {
169 fn from(error: session::SessionError) -> Self {
170 match error {
171 session::SessionError::Unauthorized => {
172 Self::Unauthorized(Self::payload(401, "unauthorized"))
173 }
174 session::SessionError::Database(message) => {
175 tracing::warn!(error = %message, "space handler failed");
176 Self::Internal(Self::payload(500, "internal server error"))
177 }
53 SpaceError::BadRequest(msg) => Self::bad_request(msg),
54 SpaceError::Unauthorized => Self::unauthorized(),
55 SpaceError::Forbidden => Self::forbidden(),
56 SpaceError::NotFound => Self::not_found("space resource not found"),
57 SpaceError::Conflict(msg) => Self::conflict(msg),
58 SpaceError::RateLimited(msg) => Self::rate_limited(msg),
59 SpaceError::Internal(message) => Self::internal(message),
17860 }
179 }
180}
181
182impl From<AppResponseData<()>> for ApiError {
183 fn from(_error: AppResponseData<()>) -> Self {
184 Self::BadRequest(Self::payload(400, "bad request"))
185 }
186}
187
188impl From<PathRejection> for ApiError {
189 fn from(error: PathRejection) -> Self {
190 Self::BadRequest(
191 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
192 )
193 }
194}
195
196impl From<JsonRejection> for ApiError {
197 fn from(error: JsonRejection) -> Self {
198 Self::BadRequest(
199 AppResponseData::err(400, "invalid request body")
200 .with_error(error.body_text())
201 .with_error_code("invalid_request_body"),
202 )
203 }
204}
205
206impl From<QueryRejection> for ApiError {
207 fn from(error: QueryRejection) -> Self {
208 Self::BadRequest(
209 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
210 )
211 }
212}
213
214impl From<(StatusCode, &'static str)> for ApiError {
215 fn from((status, message): (StatusCode, &'static str)) -> Self {
216 tracing::error!(%status, %message, "unhandled (status, message) converted to 500");
217 Self::Internal(Self::payload(500, "internal server error"))
21861 }
21962}
Modified lib/user/src/services/avatar.rs View lib/user/src/services/avatar.rs
@@ -86,7 +86,7 @@
8686impl UserService {
8787 async fn presign_avatar_url(&self, uid: Uuid, stored_url: &str) -> Result<String, UserError> {
8888 let cache_key = avatar_url_cache_key(uid);
89 match crate::share::redis::get_string(&self.state.redis, &cache_key).await {
89 match export::redis::get_string(&self.state.redis, &cache_key).await {
9090 Ok(Some(url)) => return Ok(url),
9191 Ok(None) => {}
9292 Err(error) => {
@@ -99,7 +99,7 @@
9999 .client()
100100 .presign_get(&key, AVATAR_URL_TTL_SECS as u32)
101101 .map_err(UserError::from)?;
102 if let Err(error) = crate::share::redis::set_string(
102 if let Err(error) = export::redis::set_string(
103103 &self.state.redis,
104104 &cache_key,
105105 &url,
@@ -162,7 +162,7 @@
162162 active.update(self.db().write()).await?;
163163
164164 if let Err(error) =
165 crate::share::redis::del(&self.state.redis, &avatar_url_cache_key(uid)).await
165 export::redis::delete(&self.state.redis, &avatar_url_cache_key(uid)).await
166166 {
167167 tracing::warn!(%error, "failed to invalidate avatar url cache");
168168 }
@@ -213,7 +213,7 @@
213213 active.update(self.db().write()).await?;
214214
215215 if let Err(error) =
216 crate::share::redis::del(&self.state.redis, &avatar_url_cache_key(uid)).await
216 export::redis::delete(&self.state.redis, &avatar_url_cache_key(uid)).await
217217 {
218218 tracing::warn!(%error, "failed to invalidate avatar url cache");
219219 }
Modified lib/user/src/services/emails.rs View lib/user/src/services/emails.rs
@@ -94,7 +94,7 @@
9494 }
9595
9696 let rate_key = format!("user:email_code_rate:add_email:{email}");
97 let acquired = crate::share::redis::set_nx_ex(
97 let acquired = export::redis::set_nx_ex(
9898 &self.state.redis,
9999 &rate_key,
100100 "1",
@@ -183,7 +183,7 @@
183183 }
184184
185185 let attempts_key = format!("user:email_verify_attempts:{lower}");
186 let attempts = crate::share::redis::incr_expire(
186 let attempts = export::redis::incr_expire(
187187 &self.state.redis,
188188 &attempts_key,
189189 config.email_verify_code_ttl_secs as u64,
Modified lib/user/src/services/profile.rs View lib/user/src/services/profile.rs
@@ -217,13 +217,37 @@
217217 Ok(())
218218 }
219219
220 pub async fn create_meme(&self, uid: Uuid, cmd: UpsertMemeCmd) -> Result<MemeDto, UserError> {
221 Self::validate_meme_cmd(&cmd)?;
220 async fn owned_meme(
221 &self,
222 uid: Uuid,
223 meme_id: Uuid,
224 ) -> Result<user_favorite_memes::Model, UserError> {
225 let row = user_favorite_memes::Entity::find_by_id(meme_id)
226 .one(self.db().read())
227 .await?
228 .ok_or(UserError::NotFound)?;
229 if row.user_id != uid {
230 return Err(UserError::Forbidden);
231 }
232 Ok(row)
233 }
234
235 async fn find_meme_by_name(
236 &self,
237 uid: Uuid,
238 name: &str,
239 ) -> Result<Option<user_favorite_memes::Model>, UserError> {
222240 let existing = user_favorite_memes::Entity::find()
223241 .filter(user_favorite_memes::Column::UserId.eq(uid))
224 .filter(user_favorite_memes::Column::Name.eq(&cmd.name))
242 .filter(user_favorite_memes::Column::Name.eq(name))
225243 .one(self.db().read())
226244 .await?;
245 Ok(existing)
246 }
247
248 pub async fn create_meme(&self, uid: Uuid, cmd: UpsertMemeCmd) -> Result<MemeDto, UserError> {
249 Self::validate_meme_cmd(&cmd)?;
250 let existing = self.find_meme_by_name(uid, &cmd.name).await?;
227251 if existing.is_some() {
228252 return Err(UserError::Conflict("meme name already exists".to_string()));
229253 }
@@ -238,13 +262,7 @@
238262 cmd: UpsertMemeCmd,
239263 ) -> Result<MemeDto, UserError> {
240264 Self::validate_meme_cmd(&cmd)?;
241 let row = user_favorite_memes::Entity::find_by_id(meme_id)
242 .one(self.db().read())
243 .await?
244 .ok_or(UserError::NotFound)?;
245 if row.user_id != uid {
246 return Err(UserError::Forbidden);
247 }
265 let row = self.owned_meme(uid, meme_id).await?;
248266 let mut am: user_favorite_memes::ActiveModel = row.into();
249267 am.name = Set(cmd.name);
250268 am.content_type = Set(cmd.content_type);
@@ -261,11 +279,7 @@
261279 uid: Uuid,
262280 cmd: UpsertMemeCmd,
263281 ) -> Result<user_favorite_memes::Model, UserError> {
264 let existing = user_favorite_memes::Entity::find()
265 .filter(user_favorite_memes::Column::UserId.eq(uid))
266 .filter(user_favorite_memes::Column::Name.eq(&cmd.name))
267 .one(self.db().read())
268 .await?;
282 let existing = self.find_meme_by_name(uid, &cmd.name).await?;
269283
270284 let now = OffsetDateTime::now_utc();
271285 let model = match existing {
@@ -298,13 +312,7 @@
298312 }
299313
300314 pub async fn delete_meme(&self, uid: Uuid, meme_id: Uuid) -> Result<(), UserError> {
301 let row = user_favorite_memes::Entity::find_by_id(meme_id)
302 .one(self.db().read())
303 .await?
304 .ok_or(UserError::NotFound)?;
305 if row.user_id != uid {
306 return Err(UserError::Forbidden);
307 }
315 self.owned_meme(uid, meme_id).await?;
308316 user_favorite_memes::Entity::delete_by_id(meme_id)
309317 .exec(self.db().write())
310318 .await?;
@@ -346,79 +354,65 @@
346354 .one(self.db().read())
347355 .await?;
348356
357 let mut dto = ProfileDetailDto {
358 user_id: user.uid,
359 username: user.username,
360 display_name: user.display_name,
361 avatar_url: space::services::spaces::resolve_user_avatar_url(
362 user.uid,
363 &user.avatar_url,
364 ),
365 visibility: if is_owner {
366 Some(user.visibility)
367 } else {
368 None
369 },
370 real_name: None,
371 bio: None,
372 pronouns: None,
373 website: None,
374 location: None,
375 company: None,
376 banner_url: None,
377 accent_color: None,
378 show_email: if is_owner { Some(false) } else { None },
379 show_location: if is_owner { Some(false) } else { None },
380 show_real_name: if is_owner { Some(false) } else { None },
381 show_activity: if is_owner { Some(false) } else { None },
382 updated_at: user.updated_at,
383 };
349384 if let Some(p) = profile {
350385 let gate = |flag: bool, v: Option<String>| -> Option<String> {
351386 if is_owner || flag { v } else { None }
352387 };
353388 let emit_activity = is_owner || p.show_activity;
354 Ok(ProfileDetailDto {
355 user_id: user.uid,
356 username: user.username,
357 display_name: user.display_name,
358 avatar_url: space::services::spaces::resolve_user_avatar_url(
359 user.uid,
360 &user.avatar_url,
361 ),
362 visibility: if is_owner {
363 Some(user.visibility)
364 } else {
365 None
366 },
367 real_name: gate(p.show_real_name, p.real_name),
368 bio: gate(emit_activity, p.bio),
369 pronouns: gate(emit_activity, p.pronouns),
370 website: gate(emit_activity, p.website),
371 location: gate(p.show_location, p.location),
372 company: gate(emit_activity, p.company),
373 banner_url: gate(emit_activity, p.banner_url),
374 accent_color: gate(emit_activity, p.accent_color),
375 show_email: if is_owner { Some(p.show_email) } else { None },
376 show_location: if is_owner {
377 Some(p.show_location)
378 } else {
379 None
380 },
381 show_real_name: if is_owner {
382 Some(p.show_real_name)
383 } else {
384 None
385 },
386 show_activity: if is_owner {
387 Some(p.show_activity)
388 } else {
389 None
390 },
391 updated_at: p.updated_at,
392 })
393 } else {
394 Ok(ProfileDetailDto {
395 user_id: user.uid,
396 username: user.username,
397 display_name: user.display_name,
398 avatar_url: space::services::spaces::resolve_user_avatar_url(
399 user.uid,
400 &user.avatar_url,
401 ),
402 visibility: if is_owner {
403 Some(user.visibility)
404 } else {
405 None
406 },
407 real_name: None,
408 bio: None,
409 pronouns: None,
410 website: None,
411 location: None,
412 company: None,
413 banner_url: None,
414 accent_color: None,
415 show_email: if is_owner { Some(false) } else { None },
416 show_location: if is_owner { Some(false) } else { None },
417 show_real_name: if is_owner { Some(false) } else { None },
418 show_activity: if is_owner { Some(false) } else { None },
419 updated_at: user.updated_at,
420 })
389 dto.real_name = gate(p.show_real_name, p.real_name);
390 dto.bio = gate(emit_activity, p.bio);
391 dto.pronouns = gate(emit_activity, p.pronouns);
392 dto.website = gate(emit_activity, p.website);
393 dto.location = gate(p.show_location, p.location);
394 dto.company = gate(emit_activity, p.company);
395 dto.banner_url = gate(emit_activity, p.banner_url);
396 dto.accent_color = gate(emit_activity, p.accent_color);
397 dto.show_email = if is_owner { Some(p.show_email) } else { None };
398 dto.show_location = if is_owner {
399 Some(p.show_location)
400 } else {
401 None
402 };
403 dto.show_real_name = if is_owner {
404 Some(p.show_real_name)
405 } else {
406 None
407 };
408 dto.show_activity = if is_owner {
409 Some(p.show_activity)
410 } else {
411 None
412 };
413 dto.updated_at = p.updated_at;
421414 }
415 Ok(dto)
422416 }
423417}
424418
Modified lib/user/src/services/service.rs View lib/user/src/services/service.rs
@@ -3,9 +3,6 @@
33use async_trait::async_trait;
44use config::AppConfig;
55use export::{AppDatabase, AppState, AppStorage};
6use models::users::user;
7use models::users::user_sessions;
8use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
96use session::SessionError;
107use session::share::guard::{SessionGuard, SessionWithUser};
118
@@ -38,21 +35,6 @@
3835 &self,
3936 token_hash: &str,
4037 ) -> Result<Option<SessionWithUser>, SessionError> {
41 let outcome = user_sessions::Entity::find()
42 .filter(user_sessions::Column::TokenHash.eq(token_hash))
43 .find_also_related(user::Entity)
44 .one(self.db().read())
45 .await?;
46 Ok(outcome.and_then(|(row, current)| {
47 current.map(|c| SessionWithUser {
48 user_id: row.user_id,
49 is_revoked: row.is_revoked,
50 expires_at: row.expires_at,
51 is_active: c.is_active,
52 prohibit_login: c.prohibit_login,
53 is_restricted: c.is_restricted,
54 passwd_version: c.passwd_version,
55 })
56 }))
38 session::lookup_session_with_user(self.db().read(), token_hash).await
5739 }
5840}
Modified lib/user/src/share/mod.rs View lib/user/src/share/mod.rs
@@ -1,5 +1,4 @@
11pub mod crypto;
22pub mod guard;
33pub mod page;
4pub mod redis;
54pub mod types;
Deleted lib/user/src/share/redis.rs
@@ -1,82 +0,0 @@
1use export::{AppRedis, RedisConnection};
2use redis::RedisError;
3
4macro_rules! dispatch {
5 ($conn:expr, |$c:ident| $body:expr) => {
6 match &mut $conn {
7 RedisConnection::Standalone($c) => $body,
8 RedisConnection::Cluster($c) => $body,
9 }
10 };
11}
12
13pub async fn get_string(redis: &AppRedis, key: &str) -> Result<Option<String>, RedisError> {
14 let mut conn = redis.connection_clone();
15 dispatch!(conn, |c| redis::cmd("GET").arg(key).query_async(c).await)
16}
17
18pub async fn set_string(
19 redis: &AppRedis,
20 key: &str,
21 value: &str,
22 ttl_secs: u64,
23) -> Result<(), RedisError> {
24 let mut conn = redis.connection_clone();
25 dispatch!(conn, |c| {
26 redis::cmd("SET")
27 .arg(key)
28 .arg(value)
29 .arg("EX")
30 .arg(ttl_secs)
31 .exec_async(c)
32 .await
33 })
34}
35
36pub async fn set_nx_ex(
37 redis: &AppRedis,
38 key: &str,
39 value: &str,
40 ttl_secs: u64,
41) -> Result<bool, RedisError> {
42 let mut conn = redis.connection_clone();
43 dispatch!(conn, |c| {
44 redis::cmd("SET")
45 .arg(key)
46 .arg(value)
47 .arg("NX")
48 .arg("EX")
49 .arg(ttl_secs)
50 .query_async(c)
51 .await
52 })
53}
54
55pub async fn del(redis: &AppRedis, key: &str) -> Result<(), RedisError> {
56 let mut conn = redis.connection_clone();
57 dispatch!(conn, |c| redis::cmd("DEL").arg(key).exec_async(c).await)
58}
59
60pub async fn getdel_string(redis: &AppRedis, key: &str) -> Result<Option<String>, RedisError> {
61 let mut conn = redis.connection_clone();
62 dispatch!(conn, |c| redis::cmd("GETDEL").arg(key).query_async(c).await)
63}
64
65const INCR_EXPIRE_SCRIPT: &str = r#"local c = redis.call('INCR', KEYS[1])
66if c == 1 then
67 redis.call('EXPIRE', KEYS[1], ARGV[1])
68end
69return c"#;
70
71pub async fn incr_expire(redis: &AppRedis, key: &str, ttl_secs: u64) -> Result<i64, RedisError> {
72 let mut conn = redis.connection_clone();
73 dispatch!(conn, |c| {
74 redis::cmd("EVAL")
75 .arg(INCR_EXPIRE_SCRIPT)
76 .arg(1)
77 .arg(key)
78 .arg(ttl_secs)
79 .query_async(c)
80 .await
81 })
82}
Modified lib/user/src/errors.rs View lib/user/src/errors.rs
@@ -1,12 +1,5 @@
11use std::fmt;
22
3use axum::Json;
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
5use axum::http::StatusCode;
6use axum::response::{IntoResponse, Response};
7use export::rest::{AppResponseData, NoData};
8use utoipa::IntoResponses;
9
103#[derive(Debug)]
114pub enum UserError {
125 BadRequest(String),
@@ -32,189 +25,33 @@
3225
3326impl std::error::Error for UserError {}
3427
35impl From<sea_orm::DbErr> for UserError {
36 fn from(error: sea_orm::DbErr) -> Self {
37 if is_unique_violation(&error) {
38 return Self::Conflict("resource already exists".to_string());
39 }
40 Self::Internal(format!("database error: {error}"))
41 }
42}
43
44impl From<sea_orm::TransactionError<sea_orm::DbErr>> for UserError {
45 fn from(error: sea_orm::TransactionError<sea_orm::DbErr>) -> Self {
46 match &error {
47 sea_orm::TransactionError::Transaction(db_err) if is_unique_violation(db_err) => {
48 Self::Conflict("resource already exists".to_string())
49 }
50 _ => Self::Internal(format!("database transaction error: {error}")),
51 }
52 }
53}
54
55impl From<sea_orm::TransactionError<UserError>> for UserError {
56 fn from(error: sea_orm::TransactionError<UserError>) -> Self {
57 match error {
58 sea_orm::TransactionError::Connection(db_err) => Self::from(db_err),
59 sea_orm::TransactionError::Transaction(user_err) => user_err,
60 }
61 }
62}
63
64fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
65 let msg = err.to_string().to_lowercase();
66 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
67}
68
69impl From<storage::StorageError> for UserError {
70 fn from(error: storage::StorageError) -> Self {
71 Self::Internal(format!("storage error: {error}"))
72 }
73}
74
75impl From<redis::RedisError> for UserError {
76 fn from(error: redis::RedisError) -> Self {
77 Self::Internal(format!("redis error: {error}"))
78 }
79}
80
81impl From<export::ExportError> for UserError {
82 fn from(error: export::ExportError) -> Self {
83 Self::Internal(format!("infrastructure error: {error}"))
84 }
85}
86
87impl From<serde_json::Error> for UserError {
88 fn from(error: serde_json::Error) -> Self {
89 Self::BadRequest(format!("invalid payload: {error}"))
90 }
91}
92
93#[derive(Debug, IntoResponses)]
94pub enum ApiError {
95 #[response(status = 400, description = "request failed validation")]
96 BadRequest(#[to_schema] AppResponseData<NoData>),
97 #[response(status = 401, description = "authentication required")]
98 Unauthorized(#[to_schema] AppResponseData<NoData>),
99 #[response(status = 403, description = "operation not allowed")]
100 Forbidden(#[to_schema] AppResponseData<NoData>),
101 #[response(status = 404, description = "resource not found")]
102 NotFound(#[to_schema] AppResponseData<NoData>),
103 #[response(status = 409, description = "resource conflict")]
104 Conflict(#[to_schema] AppResponseData<NoData>),
105 #[response(status = 500, description = "internal server error")]
106 Internal(#[to_schema] AppResponseData<NoData>),
107}
108
109impl ApiError {
110 fn payload(code: i32, message: &'static str) -> AppResponseData<NoData> {
111 AppResponseData::err(code, message)
112 }
113
114 fn decompose(self) -> (u16, AppResponseData<NoData>) {
115 match self {
116 Self::BadRequest(envelope) => (400, envelope),
117 Self::Unauthorized(envelope) => (401, envelope),
118 Self::Forbidden(envelope) => (403, envelope),
119 Self::NotFound(envelope) => (404, envelope),
120 Self::Conflict(envelope) => (409, envelope),
121 Self::Internal(envelope) => (500, envelope),
122 }
123 }
124}
28export::db_error_conversions!(UserError);
29export::storage_error_conversion!(UserError);
30export::redis_error_conversion!(UserError);
31export::export_error_conversion!(UserError);
32export::json_payload_conversion!(UserError);
12533
126impl IntoResponse for ApiError {
127 fn into_response(self) -> Response {
128 let (code, envelope) = self.decompose();
129 let status = match StatusCode::from_u16(code) {
130 Ok(status) => status,
131 Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
132 };
133 (status, Json(envelope)).into_response()
34export::api_error! {
35 log: "user",
36 pub enum ApiError {
37 BadRequest(400, "request failed validation"),
38 Unauthorized(401, "authentication required"),
39 Forbidden(403, "operation not allowed"),
40 NotFound(404, "resource not found"),
41 Conflict(409, "resource conflict"),
42 Internal(500, "internal server error"),
13443 }
13544}
13645
13746impl From<UserError> for ApiError {
13847 fn from(error: UserError) -> Self {
13948 match error {
140 UserError::BadRequest(msg) => {
141 Self::BadRequest(AppResponseData::err(400, msg).with_error_code("bad_request"))
142 }
143 UserError::Unauthorized => Self::Unauthorized(
144 Self::payload(401, "unauthorized").with_error_code("unauthorized"),
145 ),
146 UserError::Forbidden => {
147 Self::Forbidden(Self::payload(403, "forbidden").with_error_code("forbidden"))
148 }
149 UserError::NotFound => Self::NotFound(
150 Self::payload(404, "user resource not found").with_error_code("not_found"),
151 ),
152 UserError::Conflict(msg) => {
153 Self::Conflict(AppResponseData::err(409, msg).with_error_code("conflict"))
154 }
155 UserError::Internal(message) => {
156 tracing::warn!(error = %message, "user handler failed");
157 Self::Internal(Self::payload(500, "internal server error"))
158 }
159 }
160 }
161}
162
163impl From<Box<ApiError>> for ApiError {
164 fn from(error: Box<ApiError>) -> Self {
165 *error
166 }
167}
168
169impl From<session::SessionError> for ApiError {
170 fn from(error: session::SessionError) -> Self {
171 match error {
172 session::SessionError::Unauthorized => {
173 Self::Unauthorized(Self::payload(401, "unauthorized"))
174 }
175 session::SessionError::Database(message) => {
176 tracing::warn!(error = %message, "user handler failed");
177 Self::Internal(Self::payload(500, "internal server error"))
178 }
49 UserError::BadRequest(msg) => Self::bad_request(msg),
50 UserError::Unauthorized => Self::unauthorized(),
51 UserError::Forbidden => Self::forbidden(),
52 UserError::NotFound => Self::not_found("user resource not found"),
53 UserError::Conflict(msg) => Self::conflict(msg),
54 UserError::Internal(message) => Self::internal(message),
17955 }
180 }
181}
182
183impl From<AppResponseData<()>> for ApiError {
184 fn from(_error: AppResponseData<()>) -> Self {
185 Self::BadRequest(Self::payload(400, "bad request"))
186 }
187}
188
189impl From<PathRejection> for ApiError {
190 fn from(error: PathRejection) -> Self {
191 Self::BadRequest(
192 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
193 )
194 }
195}
196
197impl From<JsonRejection> for ApiError {
198 fn from(error: JsonRejection) -> Self {
199 Self::BadRequest(
200 AppResponseData::err(400, "invalid request body")
201 .with_error(error.body_text())
202 .with_error_code("invalid_request_body"),
203 )
204 }
205}
206
207impl From<QueryRejection> for ApiError {
208 fn from(error: QueryRejection) -> Self {
209 Self::BadRequest(
210 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
211 )
212 }
213}
214
215impl From<(StatusCode, &'static str)> for ApiError {
216 fn from((status, message): (StatusCode, &'static str)) -> Self {
217 tracing::error!(%status, %message, "unhandled (status, message) converted to 500");
218 Self::Internal(Self::payload(500, "internal server error"))
21956 }
22057}
Modified Cargo.lock View Cargo.lock
@@ -2074,6 +2074,7 @@
20742074 "tokio",
20752075 "tower",
20762076 "tracing",
2077 "tracing-subscriber",
20772078 "utoipa",
20782079 "uuid",
20792080]
@@ -7400,6 +7401,7 @@
74007401 "chrono",
74017402 "config",
74027403 "export",
7404 "models",
74037405 "redis",
74047406 "sea-orm",
74057407 "serde",