Skip to content
Clone

fix(api): update agreement path for user-specific access

ZhenYi committed
parent bfb28e3 commit 7794d37 Browse files

139 files changed

+1280 −722

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

File tree 139
Modified config/default.toml View config/default.toml
@@ -110,6 +110,8 @@
110110
111111[web]
112112web_base_url = "http://localhost:3000"
113cors_allow_origins = []
114cors_allow_credentials = false
113115
114116[user]
115117deletion_grace_days = 14
@@ -118,6 +120,14 @@
118120email_verify_max_attempts = 5
119121email_verify_code_len = 6
120122
123[quota]
124space_create_per_day = 10
125room_create_per_day = 50
126issue_create_per_day = 300
127comment_create_per_day = 1000
128dm_open_per_day = 100
129webhook_create_per_day = 20
130
121131[space]
122132max_pagination = 100
123133default_pagination = 20
@@ -127,6 +137,7 @@
127137max_alias_name_len = 64
128138max_invite_email_len = 254
129139allow_self_join_groups = false
140max_dir_nodes = 5000
130141
131142[chat]
132143max_pagination = 200
@@ -175,6 +186,8 @@
175186git_command_timeout_secs = 300
176187git_rpc_timeout_secs = 3600
177188base_url = "http://localhost:3000"
189graphql_complexity_limit = 1000
190graphql_introspection = true
178191
179192[git.sync]
180193lock_ttl_secs = 120
Modified lib/auth/src/handlers/accept_agreement.rs View lib/auth/src/handlers/accept_agreement.rs
@@ -1,6 +1,7 @@
11use axum::extract::{Path, State};
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use serde::{Deserialize, Serialize};
2use axum::Json;
3
4use export::rest::{AppResponseData};use serde::{Deserialize, Serialize};
45use session::Session;
56use utoipa::ToSchema;
67
@@ -21,7 +22,7 @@
2122 params(
2223 ("key" = String, Path, description = "agreement key")
2324 ),
24 request_body = AppRequestData<AcceptAgreementCmd>,
25 request_body = AcceptAgreementCmd,
2526 responses(
2627 (status = 200, description = "agreement acceptance recorded", body = AppResponseData<AcceptAgreementResp>)
2728 )
@@ -30,11 +31,10 @@
3031 State(service): State<AuthService>,
3132 session: Session,
3233 Path(key): Path<String>,
33 payload: AppRequest<AcceptAgreementCmd>,
34 Json(cmd): Json<AcceptAgreementCmd>,
3435) -> Result<AppResponseData<AcceptAgreementResp>, ApiError> {
3536 let uid = require_uid(&session)?;
36 let cmd = payload.into_inner();
37 let resp = service
37 let resp = service
3838 .accept_agreement(uid, &key, &cmd.version)
3939 .await
4040 .map_err(ApiError::from)?;
Modified lib/auth/src/handlers/add_ssh_key.rs View lib/auth/src/handlers/add_ssh_key.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -11,7 +12,7 @@
1112 post,
1213 path = "/auth/ssh-keys",
1314 tag = "security",
14 request_body = AppRequestData<AddSshKeyCmd>,
15 request_body = AddSshKeyCmd,
1516 responses(
1617 (status = 200, description = "ssh key registered", body = AppResponseData<SshKeyDto>)
1718 )
@@ -19,11 +20,11 @@
1920pub async fn add_ssh_key(
2021 State(service): State<AuthService>,
2122 session: Session,
22 payload: AppRequest<AddSshKeyCmd>,
23 Json(cmd): Json<AddSshKeyCmd>,
2324) -> Result<AppResponseData<SshKeyDto>, ApiError> {
2425 let uid = require_uid(&session)?;
2526 let resp = service
26 .add_ssh_key(uid, payload.into_inner())
27 .add_ssh_key(uid, cmd)
2728 .await
2829 .map_err(ApiError::from)?;
2930 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/change_password.rs View lib/auth/src/handlers/change_password.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData, NoData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -11,7 +12,7 @@
1112 post,
1213 path = "/auth/password/change",
1314 tag = "auth",
14 request_body = AppRequestData<ChangePasswordCmd>,
15 request_body = ChangePasswordCmd,
1516 responses(
1617 (status = 200, description = "password changed, existing sessions invalidated", body = AppResponseData<NoData>)
1718 )
@@ -19,11 +20,11 @@
1920pub async fn change_password(
2021 State(service): State<AuthService>,
2122 session: Session,
22 payload: AppRequest<ChangePasswordCmd>,
23 Json(cmd): Json<ChangePasswordCmd>,
2324) -> Result<AppResponseData<NoData>, ApiError> {
2425 let uid = require_uid(&session)?;
2526 service
26 .change_password(uid, payload.into_inner())
27 .change_password(uid, cmd)
2728 .await
2829 .map_err(ApiError::from)?;
2930 Ok(AppResponseData::empty())
Modified lib/auth/src/handlers/confirm_email_change.rs View lib/auth/src/handlers/confirm_email_change.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use serde::{Deserialize, Serialize};
2use axum::Json;
3
4use export::rest::{AppResponseData, NoData};use serde::{Deserialize, Serialize};
45use session::Session;
56use utoipa::ToSchema;
67use uuid::Uuid;
@@ -19,7 +20,7 @@
1920 post,
2021 path = "/auth/email/change/confirm",
2122 tag = "auth",
22 request_body = AppRequestData<ConfirmEmailChangeCmd>,
23 request_body = ConfirmEmailChangeCmd,
2324 responses(
2425 (status = 200, description = "email changed to the ticket address", body = AppResponseData<NoData>)
2526 )
@@ -27,11 +28,10 @@
2728pub async fn confirm_email_change(
2829 State(service): State<AuthService>,
2930 session: Session,
30 payload: AppRequest<ConfirmEmailChangeCmd>,
31 Json(cmd): Json<ConfirmEmailChangeCmd>,
3132) -> Result<AppResponseData<NoData>, ApiError> {
3233 let uid = require_uid(&session)?;
33 let cmd = payload.into_inner();
34 service
34 service
3535 .confirm_email_change(uid, cmd.ticket_id, &cmd.code)
3636 .await
3737 .map_err(ApiError::from)?;
Modified lib/auth/src/handlers/create_access_key.rs View lib/auth/src/handlers/create_access_key.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -11,7 +12,7 @@
1112 post,
1213 path = "/auth/access-keys",
1314 tag = "security",
14 request_body = AppRequestData<CreateAccessKeyCmd>,
15 request_body = CreateAccessKeyCmd,
1516 responses(
1617 (status = 200, description = "access key created, token shown only in this response", body = AppResponseData<CreateAccessKeyResp>)
1718 )
@@ -19,11 +20,11 @@
1920pub async fn create_access_key(
2021 State(service): State<AuthService>,
2122 session: Session,
22 payload: AppRequest<CreateAccessKeyCmd>,
23 Json(cmd): Json<CreateAccessKeyCmd>,
2324) -> Result<AppResponseData<CreateAccessKeyResp>, ApiError> {
2425 let uid = require_uid(&session)?;
2526 let resp = service
26 .create_access_key(uid, payload.into_inner())
27 .create_access_key(uid, cmd)
2728 .await
2829 .map_err(ApiError::from)?;
2930 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/create_report.rs View lib/auth/src/handlers/create_report.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -11,7 +12,7 @@
1112 post,
1213 path = "/me/reports",
1314 tag = "reports",
14 request_body = AppRequestData<CreateReportCmd>,
15 request_body = CreateReportCmd,
1516 responses(
1617 (status = 200, description = "report filed", body = AppResponseData<ReportDto>)
1718 )
@@ -19,11 +20,11 @@
1920pub async fn create_report(
2021 State(service): State<AuthService>,
2122 session: Session,
22 payload: AppRequest<CreateReportCmd>,
23 Json(cmd): Json<CreateReportCmd>,
2324) -> Result<AppResponseData<ReportDto>, ApiError> {
2425 let uid = require_uid(&session)?;
2526 let resp = service
26 .create_report(uid, payload.into_inner())
27 .create_report(uid, cmd)
2728 .await
2829 .map_err(ApiError::from)?;
2930 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/forgot_password.rs View lib/auth/src/handlers/forgot_password.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -10,7 +11,7 @@
1011 post,
1112 path = "/auth/password/forgot",
1213 tag = "auth",
13 request_body = AppRequestData<ForgotPasswordCmd>,
14 request_body = ForgotPasswordCmd,
1415 responses(
1516 (status = 200, description = "reset token issued, delivery is not disclosed", body = AppResponseData<ForgotPasswordResp>)
1617 )
@@ -18,10 +19,10 @@
1819pub async fn forgot_password(
1920 State(service): State<AuthService>,
2021 session: Session,
21 payload: AppRequest<ForgotPasswordCmd>,
22 Json(cmd): Json<ForgotPasswordCmd>,
2223) -> Result<AppResponseData<ForgotPasswordResp>, ApiError> {
2324 let resp = service
24 .forgot_password(&session, payload.into_inner())
25 .forgot_password(&session, cmd)
2526 .await
2627 .map_err(ApiError::from)?;
2728 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/login.rs View lib/auth/src/handlers/login.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -10,7 +11,7 @@
1011 post,
1112 path = "/auth/login",
1213 tag = "auth",
13 request_body = AppRequestData<LoginCmd>,
14 request_body = LoginCmd,
1415 responses(
1516 (status = 200, description = "login accepted, session established", body = AppResponseData<LoginResp>)
1617 )
@@ -18,10 +19,10 @@
1819pub async fn login(
1920 State(service): State<AuthService>,
2021 session: Session,
21 payload: AppRequest<LoginCmd>,
22 Json(cmd): Json<LoginCmd>,
2223) -> Result<AppResponseData<LoginResp>, ApiError> {
2324 let resp = service
24 .login(&session, payload.into_inner())
25 .login(&session, cmd)
2526 .await
2627 .map_err(ApiError::from)?;
2728 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/login_2fa.rs View lib/auth/src/handlers/login_2fa.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -10,7 +11,7 @@
1011 post,
1112 path = "/auth/login/2fa",
1213 tag = "auth",
13 request_body = AppRequestData<TwoFactorLoginCmd>,
14 request_body = TwoFactorLoginCmd,
1415 responses(
1516 (status = 200, description = "two-factor challenge accepted, session established", body = AppResponseData<LoginResp>)
1617 )
@@ -18,10 +19,10 @@
1819pub async fn login_2fa(
1920 State(service): State<AuthService>,
2021 session: Session,
21 payload: AppRequest<TwoFactorLoginCmd>,
22 Json(cmd): Json<TwoFactorLoginCmd>,
2223) -> Result<AppResponseData<LoginResp>, ApiError> {
2324 let resp = service
24 .login_2fa(&session, payload.into_inner())
25 .login_2fa(&session, cmd)
2526 .await
2627 .map_err(ApiError::from)?;
2728 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/reclaim.rs View lib/auth/src/handlers/reclaim.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -10,7 +11,7 @@
1011 post,
1112 path = "/auth/email/reclaim",
1213 tag = "auth",
13 request_body = AppRequestData<ReclaimCmd>,
14 request_body = ReclaimCmd,
1415 responses(
1516 (status = 200, description = "inactive account reclaim issued", body = AppResponseData<ReclaimResp>)
1617 )
@@ -18,10 +19,10 @@
1819pub async fn reclaim(
1920 State(service): State<AuthService>,
2021 session: Session,
21 payload: AppRequest<ReclaimCmd>,
22 Json(cmd): Json<ReclaimCmd>,
2223) -> Result<AppResponseData<ReclaimResp>, ApiError> {
2324 let resp = service
24 .reclaim(&session, payload.into_inner())
25 .reclaim(&session, cmd)
2526 .await
2627 .map_err(ApiError::from)?;
2728 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/regenerate_recovery_codes.rs View lib/auth/src/handlers/regenerate_recovery_codes.rs
@@ -1,17 +1,18 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
2use axum::Json;
43
4use export::rest::{AppResponseData};use session::Session;
5
56use crate::errors::ApiError;
67use crate::services::AuthService;
78use crate::services::two_factor::{RecoveryCodesResp, RegenerateRecoveryCodesCmd};
89use session::share::session::require_uid;
910
1011#[utoipa::path(
11 post,
12 put,
1213 path = "/auth/2fa/recovery-codes",
1314 tag = "auth",
14 request_body = AppRequestData<RegenerateRecoveryCodesCmd>,
15 request_body = RegenerateRecoveryCodesCmd,
1516 responses(
1617 (status = 200, description = "replacement codes issued, shown only in this response", body = AppResponseData<RecoveryCodesResp>)
1718 )
@@ -19,11 +20,11 @@
1920pub async fn regenerate_recovery_codes(
2021 State(service): State<AuthService>,
2122 session: Session,
22 payload: AppRequest<RegenerateRecoveryCodesCmd>,
23 Json(cmd): Json<RegenerateRecoveryCodesCmd>,
2324) -> Result<AppResponseData<RecoveryCodesResp>, ApiError> {
2425 let uid = require_uid(&session)?;
2526 let resp = service
26 .regenerate_recovery_codes(uid, payload.into_inner())
27 .regenerate_recovery_codes(uid, cmd)
2728 .await
2829 .map_err(ApiError::from)?;
2930 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/register.rs View lib/auth/src/handlers/register.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -10,7 +11,7 @@
1011 post,
1112 path = "/auth/register",
1213 tag = "auth",
13 request_body = AppRequestData<RegisterCmd>,
14 request_body = RegisterCmd,
1415 responses(
1516 (status = 200, description = "registration pending email verification", body = AppResponseData<RegisterResp>)
1617 )
@@ -18,10 +19,10 @@
1819pub async fn register(
1920 State(service): State<AuthService>,
2021 session: Session,
21 payload: AppRequest<RegisterCmd>,
22 Json(cmd): Json<RegisterCmd>,
2223) -> Result<AppResponseData<RegisterResp>, ApiError> {
2324 let resp = service
24 .register(&session, payload.into_inner())
25 .register(&session, cmd)
2526 .await
2627 .map_err(ApiError::from)?;
2728 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/request_deletion.rs View lib/auth/src/handlers/request_deletion.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use serde::{Deserialize, Serialize};
2use axum::Json;
3
4use export::rest::{AppResponseData};use serde::{Deserialize, Serialize};
45use session::Session;
56use utoipa::ToSchema;
67
@@ -18,7 +19,7 @@
1819 post,
1920 path = "/me/deletion",
2021 tag = "deletion",
21 request_body = AppRequestData<RequestDeletionCmd>,
22 request_body = RequestDeletionCmd,
2223 responses(
2324 (status = 200, description = "deletion request recorded", body = AppResponseData<DeletionDto>)
2425 )
@@ -26,11 +27,10 @@
2627pub async fn request_deletion(
2728 State(service): State<AuthService>,
2829 session: Session,
29 payload: AppRequest<RequestDeletionCmd>,
30 Json(cmd): Json<RequestDeletionCmd>,
3031) -> Result<AppResponseData<DeletionDto>, ApiError> {
3132 let uid = require_uid(&session)?;
32 let cmd = payload.into_inner();
33 let resp = service
33 let resp = service
3434 .request_deletion(uid, cmd.reason)
3535 .await
3636 .map_err(ApiError::from)?;
Modified lib/auth/src/handlers/request_email_change.rs View lib/auth/src/handlers/request_email_change.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -11,7 +12,7 @@
1112 post,
1213 path = "/auth/email/change",
1314 tag = "auth",
14 request_body = AppRequestData<RequestEmailChangeCmd>,
15 request_body = RequestEmailChangeCmd,
1516 responses(
1617 (status = 200, description = "email change ticket created", body = AppResponseData<EmailChangeTicketDto>)
1718 )
@@ -19,11 +20,11 @@
1920pub async fn request_email_change(
2021 State(service): State<AuthService>,
2122 session: Session,
22 payload: AppRequest<RequestEmailChangeCmd>,
23 Json(cmd): Json<RequestEmailChangeCmd>,
2324) -> Result<AppResponseData<EmailChangeTicketDto>, ApiError> {
2425 let uid = require_uid(&session)?;
2526 let resp = service
26 .request_email_change(uid, payload.into_inner())
27 .request_email_change(uid, cmd)
2728 .await
2829 .map_err(ApiError::from)?;
2930 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/resend_code.rs View lib/auth/src/handlers/resend_code.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -10,7 +11,7 @@
1011 post,
1112 path = "/auth/email/resend",
1213 tag = "auth",
13 request_body = AppRequestData<ResendCodeCmd>,
14 request_body = ResendCodeCmd,
1415 responses(
1516 (status = 200, description = "verification code resent", body = AppResponseData<ResendCodeResp>)
1617 )
@@ -18,10 +19,10 @@
1819pub async fn resend_code(
1920 State(service): State<AuthService>,
2021 session: Session,
21 payload: AppRequest<ResendCodeCmd>,
22 Json(cmd): Json<ResendCodeCmd>,
2223) -> Result<AppResponseData<ResendCodeResp>, ApiError> {
2324 let resp = service
24 .resend_code(&session, payload.into_inner())
25 .resend_code(&session, cmd)
2526 .await
2627 .map_err(ApiError::from)?;
2728 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/reset_password.rs View lib/auth/src/handlers/reset_password.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
2use axum::Json;
33
4use export::rest::{AppResponseData, NoData};
45use crate::errors::ApiError;
56use crate::services::AuthService;
67use crate::services::password::ResetPasswordCmd;
@@ -9,17 +10,17 @@
910 post,
1011 path = "/auth/password/reset",
1112 tag = "auth",
12 request_body = AppRequestData<ResetPasswordCmd>,
13 request_body = ResetPasswordCmd,
1314 responses(
1415 (status = 200, description = "password replaced, existing sessions invalidated", body = AppResponseData<NoData>)
1516 )
1617)]
1718pub async fn reset_password(
1819 State(service): State<AuthService>,
19 payload: AppRequest<ResetPasswordCmd>,
20 Json(cmd): Json<ResetPasswordCmd>,
2021) -> Result<AppResponseData<NoData>, ApiError> {
2122 service
22 .reset_password(payload.into_inner())
23 .reset_password(cmd)
2324 .await
2425 .map_err(ApiError::from)?;
2526 Ok(AppResponseData::empty())
Modified lib/auth/src/handlers/totp_disable.rs View lib/auth/src/handlers/totp_disable.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData, NoData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -11,7 +12,7 @@
1112 post,
1213 path = "/auth/2fa/disable",
1314 tag = "auth",
14 request_body = AppRequestData<TotpCodeCmd>,
15 request_body = TotpCodeCmd,
1516 responses(
1617 (status = 200, description = "two-factor authentication disabled and recovery codes removed", body = AppResponseData<NoData>)
1718 )
@@ -19,11 +20,11 @@
1920pub async fn totp_disable(
2021 State(service): State<AuthService>,
2122 session: Session,
22 payload: AppRequest<TotpCodeCmd>,
23 Json(cmd): Json<TotpCodeCmd>,
2324) -> Result<AppResponseData<NoData>, ApiError> {
2425 let uid = require_uid(&session)?;
2526 service
26 .totp_disable(uid, payload.into_inner())
27 .totp_disable(uid, cmd)
2728 .await
2829 .map_err(ApiError::from)?;
2930 Ok(AppResponseData::empty())
Modified lib/auth/src/handlers/totp_enable.rs View lib/auth/src/handlers/totp_enable.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -11,7 +12,7 @@
1112 post,
1213 path = "/auth/2fa/enable",
1314 tag = "auth",
14 request_body = AppRequestData<TotpEnableCmd>,
15 request_body = TotpEnableCmd,
1516 responses(
1617 (status = 200, description = "two-factor authentication enabled and recovery codes generated", body = AppResponseData<RecoveryCodesResp>)
1718 )
@@ -19,11 +20,11 @@
1920pub async fn totp_enable(
2021 State(service): State<AuthService>,
2122 session: Session,
22 payload: AppRequest<TotpEnableCmd>,
23 Json(cmd): Json<TotpEnableCmd>,
2324) -> Result<AppResponseData<RecoveryCodesResp>, ApiError> {
2425 let uid = require_uid(&session)?;
2526 let resp = service
26 .totp_enable(uid, payload.into_inner())
27 .totp_enable(uid, cmd)
2728 .await
2829 .map_err(ApiError::from)?;
2930 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/handlers/update_preferences.rs View lib/auth/src/handlers/update_preferences.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData, NoData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -11,7 +12,7 @@
1112 patch,
1213 path = "/me/preferences",
1314 tag = "account",
14 request_body = AppRequestData<UpdatePreferencesCmd>,
15 request_body = UpdatePreferencesCmd,
1516 responses(
1617 (status = 200, description = "preferences updated", body = AppResponseData<NoData>)
1718 )
@@ -19,11 +20,11 @@
1920pub async fn update_preferences(
2021 State(service): State<AuthService>,
2122 session: Session,
22 payload: AppRequest<UpdatePreferencesCmd>,
23 Json(cmd): Json<UpdatePreferencesCmd>,
2324) -> Result<AppResponseData<NoData>, ApiError> {
2425 let uid = require_uid(&session)?;
2526 service
26 .update_preferences(uid, payload.into_inner())
27 .update_preferences(uid, cmd)
2728 .await
2829 .map_err(ApiError::from)?;
2930 Ok(AppResponseData::empty())
Modified lib/auth/src/handlers/update_profile.rs View lib/auth/src/handlers/update_profile.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData, NoData};
3use session::Session;
2use axum::Json;
3
4use export::rest::{AppResponseData, NoData};use session::Session;
45
56use crate::errors::ApiError;
67use crate::services::AuthService;
@@ -11,7 +12,7 @@
1112 patch,
1213 path = "/me",
1314 tag = "account",
14 request_body = AppRequestData<UpdateProfileCmd>,
15 request_body = UpdateProfileCmd,
1516 responses(
1617 (status = 200, description = "profile fields updated", body = AppResponseData<NoData>)
1718 )
@@ -19,11 +20,11 @@
1920pub async fn update_profile(
2021 State(service): State<AuthService>,
2122 session: Session,
22 payload: AppRequest<UpdateProfileCmd>,
23 Json(cmd): Json<UpdateProfileCmd>,
2324) -> Result<AppResponseData<NoData>, ApiError> {
2425 let uid = require_uid(&session)?;
2526 service
26 .update_profile(uid, payload.into_inner())
27 .update_profile(uid, cmd)
2728 .await
2829 .map_err(ApiError::from)?;
2930 Ok(AppResponseData::empty())
Modified lib/auth/src/handlers/verify_email.rs View lib/auth/src/handlers/verify_email.rs
@@ -1,6 +1,7 @@
11use axum::extract::State;
2use export::rest::{AppRequest, AppRequestData, AppResponseData};
2use axum::Json;
33
4use export::rest::{AppResponseData};
45use crate::errors::ApiError;
56use crate::services::AuthService;
67use crate::services::email::{VerifyEmailCmd, VerifyEmailResp};
@@ -9,17 +10,17 @@
910 post,
1011 path = "/auth/email/verify",
1112 tag = "auth",
12 request_body = AppRequestData<VerifyEmailCmd>,
13 request_body = VerifyEmailCmd,
1314 responses(
1415 (status = 200, description = "verification code accepted", body = AppResponseData<VerifyEmailResp>)
1516 )
1617)]
1718pub async fn verify_email(
1819 State(service): State<AuthService>,
19 payload: AppRequest<VerifyEmailCmd>,
20 Json(cmd): Json<VerifyEmailCmd>,
2021) -> Result<AppResponseData<VerifyEmailResp>, ApiError> {
2122 let resp = service
22 .verify_verification_code(payload.into_inner())
23 .verify_verification_code(cmd)
2324 .await
2425 .map_err(ApiError::from)?;
2526 Ok(AppResponseData::ok(resp))
Modified lib/auth/src/services/deletion.rs View lib/auth/src/services/deletion.rs
@@ -47,12 +47,14 @@
4747 ));
4848 }
4949 let now = OffsetDateTime::now_utc();
50 let grace_days = self.config().user.deletion_grace_days as i64;
51 let scheduled_for = now + time::Duration::days(grace_days);
5052 let row = user_deletion_requests::ActiveModel {
5153 id: Set(Uuid::now_v7()),
5254 user_id: Set(uid),
5355 reason: Set(reason),
54 status: Set(UserDeletionRequestStatus::Pending),
55 scheduled_for: Set(None),
56 status: Set(UserDeletionRequestStatus::Scheduled),
57 scheduled_for: Set(Some(scheduled_for)),
5658 completed_at: Set(None),
5759 cancelled_at: Set(None),
5860 created_at: Set(now),
Modified lib/auth/src/services/login.rs View lib/auth/src/services/login.rs
@@ -51,7 +51,7 @@
5151impl From<&user::Model> for UserPublic {
5252 fn from(user: &user::Model) -> Self {
5353 Self {
54 uid: user.uid,
54 user_id: user.uid,
5555 username: user.username.clone(),
5656 display_name: user.display_name.clone(),
5757 avatar_url: user.avatar_url.clone(),
Modified lib/auth/src/services/profile.rs View lib/auth/src/services/profile.rs
@@ -11,7 +11,7 @@
1111
1212#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1313pub struct UserMeDto {
14 pub uid: Uuid,
14 pub user_id: Uuid,
1515 pub username: String,
1616 pub display_name: String,
1717 pub avatar_url: String,
@@ -72,7 +72,7 @@
7272 .one(db.read())
7373 .await?;
7474 let me = UserMeDto {
75 uid: user.uid,
75 user_id: user.uid,
7676 username: user.username,
7777 display_name: user.display_name,
7878 avatar_url: user.avatar_url,
Modified lib/auth/src/services/register.rs View lib/auth/src/services/register.rs
@@ -25,7 +25,7 @@
2525
2626#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
2727pub struct RegisterResp {
28 pub uid: Uuid,
28 pub user_id: Uuid,
2929 pub pending: bool,
3030 pub purpose: String,
3131 pub temp_key: TempKey,
@@ -183,7 +183,7 @@
183183 }
184184
185185 Ok(RegisterResp {
186 uid,
186 user_id: uid,
187187 pending: true,
188188 purpose: "signup".to_string(),
189189 temp_key: artifacts.temp_key,
Modified lib/auth/src/services/users.rs View lib/auth/src/services/users.rs
@@ -12,7 +12,7 @@
1212 return Err(AuthError::NotFound);
1313 };
1414 Ok(UserPublic {
15 uid: user.uid,
15 user_id: user.uid,
1616 username: user.username,
1717 display_name: user.display_name,
1818 avatar_url: user.avatar_url,
Modified lib/auth/src/share/types.rs View lib/auth/src/share/types.rs
@@ -15,7 +15,7 @@
1515
1616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
1717pub struct UserPublic {
18 pub uid: Uuid,
18 pub user_id: Uuid,
1919 pub username: String,
2020 pub display_name: String,
2121 pub avatar_url: String,
Modified lib/auth/src/errors.rs View lib/auth/src/errors.rs
@@ -1,7 +1,7 @@
11use std::fmt;
22
33use axum::Json;
4use axum::extract::rejection::{PathRejection, QueryRejection};
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
55use axum::http::StatusCode;
66use axum::response::{IntoResponse, Response};
77use export::rest::{AppResponseData, NoData};
@@ -157,7 +157,7 @@
157157 }
158158 AuthError::Unauthorized => Self::Unauthorized(Self::payload(401, "unauthorized").with_error_code("unauthorized")),
159159 AuthError::Forbidden => Self::Forbidden(Self::payload(403, "forbidden").with_error_code("forbidden")),
160 AuthError::NotFound => Self::NotFound(Self::payload(404, "not found").with_error_code("not_found")),
160 AuthError::NotFound => Self::NotFound(Self::payload(404, "auth resource not found").with_error_code("not_found")),
161161 AuthError::Conflict(msg) => Self::Conflict(AppResponseData::err(409, msg).with_error_code("conflict")),
162162 AuthError::RateLimited => Self::RateLimited(Self::payload(429, "rate limit exceeded").with_error_code("rate_limited")),
163163 AuthError::Internal(message) => {
@@ -195,14 +195,28 @@
195195}
196196
197197impl From<PathRejection> for ApiError {
198 fn from(_error: PathRejection) -> Self {
199 Self::BadRequest(Self::payload(400, "bad request"))
198 fn from(error: PathRejection) -> Self {
199 Self::BadRequest(
200 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
201 )
202 }
203}
204
205impl From<JsonRejection> for ApiError {
206 fn from(error: JsonRejection) -> Self {
207 Self::BadRequest(
208 AppResponseData::err(400, "invalid request body")
209 .with_error(error.body_text())
210 .with_error_code("invalid_request_body"),
211 )
200212 }
201213}
202214
203215impl From<QueryRejection> for ApiError {
204 fn from(_error: QueryRejection) -> Self {
205 Self::BadRequest(Self::payload(400, "bad request"))
216 fn from(error: QueryRejection) -> Self {
217 Self::BadRequest(
218 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
219 )
206220 }
207221}
208222
Modified lib/auth/src/routes.rs View lib/auth/src/routes.rs
@@ -80,7 +80,7 @@
8080 .route("/auth/2fa/disable", post(totp_disable))
8181 .route(
8282 "/auth/2fa/recovery-codes",
83 get(recovery_codes).post(regenerate_recovery_codes),
83 get(recovery_codes).put(regenerate_recovery_codes),
8484 )
8585 .route(
8686 "/auth/access-keys",
Modified lib/chat/src/handlers/dms.rs View lib/chat/src/handlers/dms.rs
@@ -15,6 +15,7 @@
1515 request_body = OpenDmCmd,
1616 responses(
1717 (status = 200, description = "dm opened", body = AppResponseData<DmDto>),
18 (status = 429, description = "creation quota exceeded", body = AppResponseData<NoData>),
1819 (status = 404, description = "not found", body = AppResponseData<NoData>)
1920 )
2021)]
Modified lib/chat/src/handlers/rooms.rs View lib/chat/src/handlers/rooms.rs
@@ -6,8 +6,8 @@
66
77use crate::errors::ApiError;
88use crate::services::rooms::{
9 AddMembersCmd, CreateRoomInviteCmd, RoomBriefDto, RoomInviteDto, RoomMemberDto,
10 RoomMemberQuery, UpdateRoomCmd,
9 AcceptRoomInviteCmd, AddMembersCmd, CreateRoomInviteCmd, RoomBriefDto, RoomInviteDto,
10 RoomMemberDto, RoomMemberQuery, UpdateRoomCmd,
1111};
1212use crate::services::service::ChatService;
1313
@@ -181,20 +181,21 @@
181181
182182#[utoipa::path(
183183 post,
184 path = "/chat/invites/{accept_code}/accept",
184 path = "/chat/invites/accept",
185185 tag = "chat",
186 params(("accept_code" = String, Path, description = "invite accept code")),
186 request_body = AcceptRoomInviteCmd,
187187 responses(
188188 (status = 200, description = "joined via invite", body = AppResponseData<RoomBriefDto>),
189 (status = 404, description = "not found", body = AppResponseData<NoData>)
189 (status = 404, description = "not found", body = AppResponseData<NoData>),
190 (status = 429, description = "rate limit exceeded", body = AppResponseData<NoData>)
190191 )
191192)]
192193pub async fn accept_room_invite(
193194 State(service): State<ChatService>,
194195 session: Session,
195 Path(accept_code): Path<String>,
196 Json(cmd): Json<AcceptRoomInviteCmd>,
196197) -> Result<AppResponseData<RoomBriefDto>, ApiError> {
197198 let uid = require_uid(&session)?;
198 let room = service.accept_room_invite(uid, &accept_code).await?;
199 let room = service.accept_room_invite(uid, &cmd.code).await?;
199200 Ok(AppResponseData::ok(room))
200201}
Modified lib/chat/src/handlers/webhooks.rs View lib/chat/src/handlers/webhooks.rs
@@ -17,6 +17,7 @@
1717 request_body = CreateWebhookCmd,
1818 responses(
1919 (status = 200, description = "webhook created", body = AppResponseData<WebhookSecretDto>),
20 (status = 429, description = "creation quota exceeded", body = AppResponseData<NoData>),
2021 (status = 403, description = "forbidden", body = AppResponseData<NoData>)
2122 )
2223)]
Modified lib/chat/src/services/dms.rs View lib/chat/src/services/dms.rs
@@ -39,6 +39,20 @@
3939
4040impl ChatService {
4141 pub async fn open_dm(&self, caller: Uuid, cmd: OpenDmCmd) -> Result<DmDto, ChatError> {
42 if !self
43 .state
44 .redis
45 .quota_allow(
46 &format!("quota:dm_open:{caller}"),
47 self.config().quota.dm_open_per_day,
48 86_400,
49 )
50 .await
51 {
52 return Err(ChatError::RateLimited(
53 "DM creation quota exceeded, retry tomorrow".to_string(),
54 ));
55 }
4256 if cmd.peer_user_id == caller {
4357 return Err(ChatError::BadRequest(
4458 "cannot open DM with self".to_string(),
Modified lib/chat/src/services/messages.rs View lib/chat/src/services/messages.rs
@@ -50,7 +50,7 @@
5050 pub mentions: Vec<Uuid>,
5151 pub mention_everyone: bool,
5252 pub attachments: Vec<AttachmentDto>,
53 pub flags: i64,
53 pub has_thread: bool,
5454 pub pinned: bool,
5555 pub pinned_by: Option<Uuid>,
5656 pub pinned_at: Option<OffsetDateTime>,
@@ -82,6 +82,8 @@
8282 pub thread_broadcast: bool,
8383 #[serde(default)]
8484 pub attachments: Vec<Uuid>,
85 /// client-generated idempotency key, unique per room; retrying with the
86 /// same nonce returns the original message instead of duplicating
8587 #[serde(default)]
8688 pub nonce: Option<String>,
8789 #[serde(default)]
@@ -201,7 +203,7 @@
201203 mentions,
202204 mention_everyone: m.mention_everyone,
203205 attachments: attachments_by_msg.remove(&m.id).unwrap_or_default(),
204 flags: m.flags,
206 has_thread: m.flags & messages::FLAG_HAS_THREAD != 0,
205207 pinned: m.pinned,
206208 pinned_by: m.pinned_by,
207209 pinned_at: m.pinned_at,
Modified lib/chat/src/services/rooms.rs View lib/chat/src/services/rooms.rs
@@ -66,6 +66,11 @@
6666}
6767
6868#[derive(Debug, Clone, Deserialize, ToSchema)]
69pub struct AcceptRoomInviteCmd {
70 pub code: String,
71}
72
73#[derive(Debug, Clone, Deserialize, ToSchema)]
6974pub struct AddMembersCmd {
7075 pub user_ids: Vec<Uuid>,
7176}
@@ -615,7 +620,7 @@
615620 .filter(room_members::Column::LeftAt.is_null())
616621 .order_by_asc(room_members::Column::JoinedAt)
617622 .limit(limit)
618 .offset(q.offset.unwrap_or(0))
623 .offset(q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET))
619624 .all(self.db().read())
620625 .await?;
621626 let uids: Vec<Uuid> = rows.iter().map(|r| r.user_id).collect();
@@ -699,6 +704,16 @@
699704 caller: Uuid,
700705 accept_code: &str,
701706 ) -> Result<RoomBriefDto, ChatError> {
707 if !self
708 .state
709 .redis
710 .quota_allow(&format!("chat:invite_accept:{caller}"), 10, 60)
711 .await
712 {
713 return Err(ChatError::RateLimited(
714 "too many invite acceptance attempts, retry in 60s".to_string(),
715 ));
716 }
702717 let invite = room_invites::Entity::find()
703718 .filter(room_invites::Column::AcceptCode.eq(accept_code))
704719 .one(self.db().write())
Modified lib/chat/src/services/search.rs View lib/chat/src/services/search.rs
@@ -66,7 +66,7 @@
6666 .limit
6767 .unwrap_or(self.config().chat.default_pagination)
6868 .clamp(1, self.config().chat.max_pagination);
69 let offset = q.offset.unwrap_or(0);
69 let offset = q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET);
7070
7171 // Private rooms must be restricted to their members; public space
7272 // rooms are readable by every space member.
Modified lib/chat/src/services/threads.rs View lib/chat/src/services/threads.rs
@@ -257,7 +257,7 @@
257257 .limit
258258 .unwrap_or(self.config().chat.default_pagination)
259259 .clamp(1, self.config().chat.max_pagination);
260 let offset = q.offset.unwrap_or(0);
260 let offset = q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET);
261261
262262 let mut select = threads::Entity::find().filter(threads::Column::RoomId.eq(room_id));
263263 if let Some(archived) = q.archived {
Modified lib/chat/src/services/webhooks.rs View lib/chat/src/services/webhooks.rs
@@ -109,6 +109,20 @@
109109 room_id: Uuid,
110110 cmd: CreateWebhookCmd,
111111 ) -> Result<WebhookSecretDto, ChatError> {
112 if !self
113 .state
114 .redis
115 .quota_allow(
116 &format!("quota:webhook_create:{caller}"),
117 self.config().quota.webhook_create_per_day,
118 86_400,
119 )
120 .await
121 {
122 return Err(ChatError::RateLimited(
123 "webhook creation quota exceeded, retry tomorrow".to_string(),
124 ));
125 }
112126 let ctx = require_room_readable(self, room_id, caller).await?;
113127 match &ctx.space {
114128 Some(space) => {
Modified lib/chat/src/ws/mod.rs View lib/chat/src/ws/mod.rs
@@ -19,7 +19,7 @@
1919use crate::services::service::ChatService;
2020use crate::share::realtime::RoomEvent;
2121use frames::{ClientFrame, ServerFrame};
22use ticket::verify_ticket;
22use ticket::{consume_ticket, verify_ticket};
2323
2424#[derive(Debug, Deserialize)]
2525pub struct WsQuery {
@@ -42,7 +42,7 @@
4242 Query(query): Query<WsQuery>,
4343 ws: WebSocketUpgrade,
4444) -> Response {
45 let (uid, session_id) = match verify_ticket(&service.ticket_secret(), &query.ticket) {
45 let verified = match verify_ticket(&service.ticket_secret(), &query.ticket) {
4646 Ok(value) => value,
4747 Err(_) => {
4848 return ws.on_upgrade(|socket| async move {
@@ -50,6 +50,13 @@
5050 });
5151 }
5252 };
53 if !consume_ticket(&service.state.redis, verified.jti, verified.ttl_left_secs).await {
54 return ws.on_upgrade(|socket| async move {
55 let _ = close_with(socket, "invalid ticket").await;
56 });
57 }
58 let uid = verified.uid;
59 let session_id = verified.sid;
5360
5461 ws.max_message_size(service.config().chat.ws.max_frame_bytes)
5562 .on_upgrade(move |socket| handle_socket(socket, service, uid, session_id))
Modified lib/chat/src/ws/ticket.rs View lib/chat/src/ws/ticket.rs
@@ -13,6 +13,7 @@
1313struct TicketPayload {
1414 uid: Uuid,
1515 sid: Uuid,
16 jti: Uuid,
1617 exp: i64,
1718}
1819
@@ -25,6 +26,7 @@
2526 let payload = TicketPayload {
2627 uid,
2728 sid: Uuid::now_v7(),
29 jti: Uuid::now_v7(),
2830 exp: OffsetDateTime::now_utc().unix_timestamp() + ttl_secs as i64,
2931 };
3032 let payload_b64 =
@@ -36,7 +38,14 @@
3638 Ok(format!("{payload_b64}.{sig}"))
3739}
3840
39pub fn verify_ticket(secret: &str, ticket: &str) -> Result<(Uuid, Uuid), ChatError> {
41pub struct VerifiedTicket {
42 pub uid: Uuid,
43 pub sid: Uuid,
44 pub jti: Uuid,
45 pub ttl_left_secs: i64,
46}
47
48pub fn verify_ticket(secret: &str, ticket: &str) -> Result<VerifiedTicket, ChatError> {
4049 if secret.is_empty() {
4150 return Err(ChatError::Unauthorized);
4251 }
@@ -57,8 +66,48 @@
5766 .map_err(|_| ChatError::Unauthorized)?;
5867 let payload: TicketPayload =
5968 serde_json::from_slice(&payload_bytes).map_err(|_| ChatError::Unauthorized)?;
60 if payload.exp < OffsetDateTime::now_utc().unix_timestamp() {
69 let now = OffsetDateTime::now_utc().unix_timestamp();
70 if payload.exp < now {
6171 return Err(ChatError::Unauthorized);
6272 }
63 Ok((payload.uid, payload.sid))
73 Ok(VerifiedTicket {
74 uid: payload.uid,
75 sid: payload.sid,
76 jti: payload.jti,
77 ttl_left_secs: payload.exp - now,
78 })
79}
80
81/// Marks a ticket as consumed exactly once. Returns false when the ticket
82/// was already used (replay) or redis failed.
83pub async fn consume_ticket(
84 redis: &export::AppRedis,
85 jti: Uuid,
86 ttl_secs: i64,
87) -> bool {
88 use export::RedisConnection;
89 let mut conn = redis.connection_clone();
90 let result: Result<Option<String>, redis::RedisError> = match &mut conn {
91 RedisConnection::Standalone(c) => {
92 redis::cmd("SET")
93 .arg(format!("chat:ws_ticket_used:{jti}"))
94 .arg("1")
95 .arg("NX")
96 .arg("EX")
97 .arg((ttl_secs + 1).max(1))
98 .query_async(c)
99 .await
100 }
101 RedisConnection::Cluster(c) => {
102 redis::cmd("SET")
103 .arg(format!("chat:ws_ticket_used:{jti}"))
104 .arg("1")
105 .arg("NX")
106 .arg("EX")
107 .arg((ttl_secs + 1).max(1))
108 .query_async(c)
109 .await
110 }
111 };
112 matches!(result, Ok(Some(_)))
64113}
Modified lib/chat/src/errors.rs View lib/chat/src/errors.rs
@@ -1,7 +1,7 @@
11use std::fmt;
22
33use axum::Json;
4use axum::extract::rejection::{PathRejection, QueryRejection};
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
55use axum::http::StatusCode;
66use axum::response::{IntoResponse, Response};
77use export::rest::{AppResponseData, NoData};
@@ -82,6 +82,7 @@
8282 space::errors::SpaceError::Forbidden => Self::Forbidden,
8383 space::errors::SpaceError::NotFound => Self::NotFound,
8484 space::errors::SpaceError::Conflict(msg) => Self::Conflict(msg),
85 space::errors::SpaceError::RateLimited(msg) => Self::RateLimited(msg),
8586 space::errors::SpaceError::Internal(msg) => Self::Internal(msg),
8687 }
8788 }
@@ -164,7 +165,7 @@
164165 ChatError::BadRequest(msg) => Self::BadRequest(AppResponseData::err(400, msg).with_error_code("bad_request")),
165166 ChatError::Unauthorized => Self::Unauthorized(Self::payload(401, "unauthorized").with_error_code("unauthorized")),
166167 ChatError::Forbidden => Self::Forbidden(Self::payload(403, "forbidden").with_error_code("forbidden")),
167 ChatError::NotFound => Self::NotFound(Self::payload(404, "not found").with_error_code("not_found")),
168 ChatError::NotFound => Self::NotFound(Self::payload(404, "chat resource not found").with_error_code("not_found")),
168169 ChatError::Conflict(msg) => Self::Conflict(AppResponseData::err(409, msg).with_error_code("conflict")),
169170 ChatError::RateLimited(msg) => Self::TooManyRequests(AppResponseData::err(429, msg).with_error_code("rate_limited")),
170171 ChatError::Internal(message) => {
@@ -196,11 +197,23 @@
196197}
197198
198199impl From<PathRejection> for ApiError {
199 fn from(_error: PathRejection) -> Self {
200 Self::BadRequest(Self::payload(400, "bad request"))
200 fn from(error: PathRejection) -> Self {
201 Self::BadRequest(
202 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
203 )
201204 }
202205}
203206
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
204217impl From<serde_json::Error> for ApiError {
205218 fn from(_error: serde_json::Error) -> Self {
206219 Self::BadRequest(Self::payload(400, "bad request"))
@@ -208,7 +221,9 @@
208221}
209222
210223impl From<QueryRejection> for ApiError {
211 fn from(_error: QueryRejection) -> Self {
212 Self::BadRequest(Self::payload(400, "bad request"))
224 fn from(error: QueryRejection) -> Self {
225 Self::BadRequest(
226 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
227 )
213228 }
214229}
Modified lib/chat/src/openapi.rs View lib/chat/src/openapi.rs
@@ -4,6 +4,8 @@
44#[derive(OpenApi)]
55#[openapi(
66 paths(
7 space::handlers::create_room::create_room,
8 space::handlers::delete_room::delete_room,
79 crate::handlers::rooms::list_space_rooms,
810 crate::handlers::rooms::get_room,
911 crate::handlers::rooms::update_room,
Modified lib/chat/src/routes.rs View lib/chat/src/routes.rs
@@ -36,7 +36,22 @@
3636use crate::share::guard::chat_guard;
3737use crate::ws::ws_upgrade;
3838
39pub fn router(service: ChatService) -> Router<ChatService> {
39pub fn router(service: ChatService, space_service: space::services::service::SpaceService) -> Router<ChatService> {
40 let space_room_routes = Router::new()
41 .route(
42 "/chat/spaces/{space_name}/rooms",
43 axum::routing::post(space::handlers::create_room::create_room),
44 )
45 .route(
46 "/chat/rooms/{room_id}",
47 axum::routing::delete(space::handlers::delete_room::delete_room),
48 )
49 .with_state(space_service)
50 .route_layer(axum::middleware::from_fn_with_state(
51 service.clone(),
52 chat_guard,
53 ));
54
4055 let guarded = Router::new()
4156 .route("/chat/spaces/{space_name}/rooms", get(list_space_rooms))
4257 .route("/chat/rooms/{room_id}", get(get_room).patch(update_room))
@@ -49,11 +64,8 @@
4964 get(list_room_members).post(add_room_members),
5065 )
5166 .route("/chat/rooms/{room_id}/invites", post(create_room_invite))
67 .route("/chat/invites/accept", post(accept_room_invite))
5268 .route(
53 "/chat/invites/{accept_code}/accept",
54 post(accept_room_invite),
55 )
56 .route(
5769 "/chat/rooms/{room_id}/messages",
5870 get(list_messages).post(send_message),
5971 )
@@ -192,5 +204,5 @@
192204 .route("/chat/ws", get(ws_upgrade))
193205 .route("/chat/hooks/{token}", post(post_via_webhook));
194206
195 guarded.merge(open)
207 guarded.merge(open).merge(space_room_routes)
196208}
Modified lib/config/src/git.rs View lib/config/src/git.rs
@@ -36,6 +36,8 @@
3636 pub sync: SyncSettings,
3737 pub ssh: GitSshConfig,
3838 pub base_url: String,
39 pub graphql_complexity_limit: u64,
40 pub graphql_introspection: bool,
3941}
4042
4143impl Default for GitConfig {
@@ -49,6 +51,8 @@
4951 sync: SyncSettings::default(),
5052 ssh: GitSshConfig::default(),
5153 base_url: "http://localhost:3000".to_string(),
54 graphql_complexity_limit: 1000,
55 graphql_introspection: false,
5256 }
5357 }
5458}
Modified lib/config/src/lib.rs View lib/config/src/lib.rs
@@ -12,6 +12,7 @@
1212pub mod redis;
1313pub mod s3;
1414pub mod session;
15pub mod quota;
1516pub mod space;
1617pub mod user;
1718pub mod web;
@@ -53,6 +54,7 @@
5354 pub auth: AuthConfig,
5455 pub web: WebConfig,
5556 pub user: UserConfig,
57 pub quota: quota::QuotaConfig,
5658 pub space: SpaceConfig,
5759 pub issues: IssueConfig,
5860 pub git: GitConfig,
@@ -78,6 +80,7 @@
7880 self.email_queue
7981 .is_valid()
8082 .map_err(ConfigError::Validation)?;
83 self.session.is_valid().map_err(ConfigError::Validation)?;
8184 self.apihost.is_valid().map_err(ConfigError::Validation)?;
8285 self.githost.is_valid().map_err(ConfigError::Validation)?;
8386 Ok(())
Added lib/config/src/quota.rs View lib/config/src/quota.rs
@@ -0,0 +1,13 @@
1use serde::{Deserialize, Serialize};
2
3/// Per-user rolling-24h creation quotas. 0 disables the quota.
4#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5#[serde(default)]
6pub struct QuotaConfig {
7 pub space_create_per_day: i64,
8 pub room_create_per_day: i64,
9 pub issue_create_per_day: i64,
10 pub comment_create_per_day: i64,
11 pub dm_open_per_day: i64,
12 pub webhook_create_per_day: i64,
13}
Modified lib/config/src/session.rs View lib/config/src/session.rs
@@ -42,3 +42,22 @@
4242 pub purge_database_update_secs: Option<i64>,
4343 pub enable_bloom_filter: Option<bool>,
4444}
45
46impl SessionConfig {
47 pub fn is_valid(&self) -> Result<(), String> {
48 if self.cookie_same_site == Some(SameSite::None) && self.cookie_secure != Some(true) {
49 return Err(
50 "session.cookie_same_site = \"none\" requires session.cookie_secure = true".to_string(),
51 );
52 }
53 if self.cookie_prefix_host == Some(true)
54 && (self.cookie_secure != Some(true) || self.cookie_path.as_deref() != Some("/"))
55 {
56 return Err(
57 "session.cookie_prefix_host requires cookie_secure = true and cookie_path = \"/\""
58 .to_string(),
59 );
60 }
61 Ok(())
62 }
63}
Modified lib/config/src/space.rs View lib/config/src/space.rs
@@ -11,6 +11,7 @@
1111 pub max_alias_name_len: usize,
1212 pub max_invite_email_len: usize,
1313 pub allow_self_join_groups: bool,
14 pub max_dir_nodes: u64,
1415}
1516
1617impl Default for SpaceConfig {
@@ -24,6 +25,7 @@
2425 max_alias_name_len: 64,
2526 max_invite_email_len: 254,
2627 allow_self_join_groups: false,
28 max_dir_nodes: 5000,
2729 }
2830 }
2931}
Modified lib/config/src/web.rs View lib/config/src/web.rs
@@ -4,4 +4,6 @@
44#[serde(default)]
55pub struct WebConfig {
66 pub web_base_url: String,
7 pub cors_allow_origins: Vec<String>,
8 pub cors_allow_credentials: bool,
79}
Added lib/export/src/health.rs View lib/export/src/health.rs
@@ -0,0 +1,45 @@
1use axum::Router;
2use axum::extract::State;
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use sea_orm::{ConnectionTrait, DbBackend, Statement};
6
7use crate::AppState;
8
9async fn healthz() -> Response {
10 (StatusCode::OK, "ok").into_response()
11}
12
13async fn readyz(State(state): State<AppState>) -> Response {
14 let db_ok = state
15 .database
16 .read()
17 .query_one_raw(Statement::from_sql_and_values(
18 DbBackend::Postgres,
19 "SELECT 1",
20 [],
21 ))
22 .await
23 .is_ok();
24 let mut conn = state.redis.connection_clone();
25 let redis_ok: bool = match &mut conn {
26 crate::redis::RedisConnection::Standalone(c) => {
27 redis::cmd("PING").query_async::<String>(c).await.is_ok()
28 }
29 crate::redis::RedisConnection::Cluster(c) => {
30 redis::cmd("PING").query_async::<String>(c).await.is_ok()
31 }
32 };
33 if db_ok && redis_ok {
34 (StatusCode::OK, "ready").into_response()
35 } else {
36 (StatusCode::SERVICE_UNAVAILABLE, "not ready").into_response()
37 }
38}
39
40pub fn router(state: AppState) -> Router {
41 Router::new()
42 .route("/healthz", axum::routing::get(healthz))
43 .route("/readyz", axum::routing::get(readyz))
44 .with_state(state)
45}
Modified lib/export/src/lib.rs View lib/export/src/lib.rs
@@ -1,4 +1,5 @@
11pub mod database;
2pub mod health;
23pub mod error;
34pub mod middleware;
45pub mod pagination;
Modified lib/export/src/pagination.rs View lib/export/src/pagination.rs
@@ -3,6 +3,7 @@
33
44pub const DEFAULT_PAGE_LIMIT: u64 = 50;
55pub const MAX_PAGE_LIMIT: u64 = 100;
6pub const MAX_OFFSET: u64 = 5000;
67
78#[derive(Debug, Clone, Copy, Deserialize, IntoParams, ToSchema)]
89pub struct PageQuery {
@@ -16,7 +17,7 @@
1617 }
1718
1819 pub fn offset(&self) -> u64 {
19 self.offset.unwrap_or(0)
20 self.offset.unwrap_or(0).clamp(0, MAX_OFFSET)
2021 }
2122
2223 pub fn page(&self) -> (u64, u64) {
Modified lib/export/src/redis.rs View lib/export/src/redis.rs
@@ -111,6 +111,75 @@
111111 self.connections[index].clone()
112112 }
113113
114 /// `SET key 1 NX EX ttl`; returns false when the key already exists
115 /// (already consumed / inside an active window) or redis fails.
116 pub async fn set_nx_ex(&self, key: &str, ttl_secs: u64) -> bool {
117 let mut conn = self.connection_clone();
118 let result: Result<Option<String>, redis::RedisError> = match &mut conn {
119 RedisConnection::Standalone(c) => {
120 redis::cmd("SET")
121 .arg(key)
122 .arg("1")
123 .arg("NX")
124 .arg("EX")
125 .arg(ttl_secs.max(1))
126 .query_async(c)
127 .await
128 }
129 RedisConnection::Cluster(c) => {
130 redis::cmd("SET")
131 .arg(key)
132 .arg("1")
133 .arg("NX")
134 .arg("EX")
135 .arg(ttl_secs.max(1))
136 .query_async(c)
137 .await
138 }
139 };
140 matches!(result, Ok(Some(_)))
141 }
142
143 /// `INCR key` with a window TTL set on first increment; returns the count.
144 pub async fn incr_window(&self, key: &str, window_secs: u64) -> i64 {
145 let mut conn = self.connection_clone();
146 let result: Result<i64, redis::RedisError> = match &mut conn {
147 RedisConnection::Standalone(c) => {
148 let count: i64 = redis::cmd("INCR")
149 .arg(key)
150 .query_async(c)
151 .await
152 .unwrap_or(0);
153 if count == 1 {
154 let _: Result<redis::Value, redis::RedisError> =
155 redis::cmd("EXPIRE").arg(key).arg(window_secs).query_async(c).await;
156 }
157 Ok(count)
158 }
159 RedisConnection::Cluster(c) => {
160 let count: i64 = redis::cmd("INCR")
161 .arg(key)
162 .query_async(c)
163 .await
164 .unwrap_or(0);
165 if count == 1 {
166 let _: Result<redis::Value, redis::RedisError> =
167 redis::cmd("EXPIRE").arg(key).arg(window_secs).query_async(c).await;
168 }
169 Ok(count)
170 }
171 };
172 result.unwrap_or(0)
173 }
174
175 /// Rolling-window quota check; `max == 0` disables the quota.
176 pub async fn quota_allow(&self, key: &str, max: i64, window_secs: u64) -> bool {
177 if max <= 0 {
178 return true;
179 }
180 self.incr_window(key, window_secs).await <= max
181 }
182
114183 /// Opens a multiplexed connection to every master node of the cluster.
115184 ///
116185 /// Masters are discovered at call time via `CLUSTER SLOTS`, so this keeps
Modified lib/export/src/rest.rs View lib/export/src/rest.rs
@@ -1,11 +1,8 @@
11use axum::Json;
2use axum::extract::{FromRequest, Request};
32use axum::http::StatusCode;
43use axum::response::{IntoResponse, Response};
5use serde::de::DeserializeOwned;
64use serde::{Deserialize, Serialize};
75use std::fmt;
8use std::ops::{Deref, DerefMut};
96use utoipa::ToSchema;
107
118#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -22,13 +19,6 @@
2219 pub log_id: String,
2320}
2421
25#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
26pub struct AppRequestData<T> {
27 pub request_id: String,
28 pub at: String,
29 pub data: Option<T>,
30}
31
3222
3323impl<T> AppResponseData<T>
3424where
@@ -130,46 +120,5 @@
130120 let status =
131121 StatusCode::from_u16(self.code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
132122 (status, Json(self)).into_response()
133 }
134}
135
136#[derive(Debug)]
137pub struct AppRequest<T>(pub T);
138
139impl<T> AppRequest<T> {
140 pub fn into_inner(self) -> T {
141 self.0
142 }
143}
144
145impl<T> Deref for AppRequest<T> {
146 type Target = T;
147
148 fn deref(&self) -> &Self::Target {
149 &self.0
150 }
151}
152
153impl<T> DerefMut for AppRequest<T> {
154 fn deref_mut(&mut self) -> &mut Self::Target {
155 &mut self.0
156 }
157}
158
159impl<T, S> FromRequest<S> for AppRequest<T>
160where
161 T: DeserializeOwned + 'static,
162 S: Send + Sync,
163{
164 type Rejection = AppResponseData<()>;
165 async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
166 let Json(payload) = Json::<AppRequestData<T>>::from_request(req, state)
167 .await
168 .map_err(|e| AppResponseData::<()>::err(400, format!("invalid request body: {}", e)))?;
169
170 match payload.data {
171 Some(data) => Ok(AppRequest(data)),
172 None => Err(AppResponseData::<()>::err(400, "missing request data")),
173 }
174123 }
175124}
Modified lib/git/src/graphql/query/repo.rs View lib/git/src/graphql/query/repo.rs
@@ -51,11 +51,11 @@
5151 object_format: ObjectFormatEnum::from(parse_object_format(
5252 &self.inner.ctx.model.object_format,
5353 )),
54 nums_branches: self.inner.ctx.model.nums_branches,
55 nums_tags: self.inner.ctx.model.nums_tags,
56 nums_commits: self.inner.ctx.model.nums_commits,
57 nums_star: self.inner.ctx.model.nums_star,
58 nums_watch: self.inner.ctx.model.nums_watch,
54 branches_count: self.inner.ctx.model.nums_branches,
55 tags_count: self.inner.ctx.model.nums_tags,
56 commits_count: self.inner.ctx.model.nums_commits,
57 stars_count: self.inner.ctx.model.nums_star,
58 watchers_count: self.inner.ctx.model.nums_watch,
5959 license_name: self.inner.ctx.model.license_name.clone(),
6060 }
6161 }
Modified lib/git/src/graphql/types/repo.rs View lib/git/src/graphql/types/repo.rs
@@ -12,11 +12,11 @@
1212 pub is_archived: bool,
1313 pub enable_lfs: bool,
1414 pub object_format: ObjectFormatEnum,
15 pub nums_branches: i32,
16 pub nums_tags: i32,
17 pub nums_commits: i32,
18 pub nums_star: i32,
19 pub nums_watch: i32,
15 pub branches_count: i32,
16 pub tags_count: i32,
17 pub commits_count: i32,
18 pub stars_count: i32,
19 pub watchers_count: i32,
2020 pub license_name: Option<String>,
2121}
2222
Modified lib/git/src/graphql/mod.rs View lib/git/src/graphql/mod.rs
@@ -16,9 +16,15 @@
1616/// Build a read-only graphql schema. The mutation and subscription
1717/// types are intentionally empty — gitdata exposes only query.
1818pub fn build_schema(state: export::AppState) -> GitSchema {
19 Schema::build(Query, EmptyMutation, EmptySubscription)
20 .data(Arc::new(GitService::new(state)))
21 .finish()
19 let introspection_enabled = state.config.git.graphql_introspection;
20 let mut builder = Schema::build(Query, EmptyMutation, EmptySubscription)
21 .data(Arc::new(GitService::new(state.clone())))
22 .limit_complexity(state.config.git.graphql_complexity_limit.max(1) as usize)
23 .limit_depth(15);
24 if !introspection_enabled {
25 builder = builder.disable_introspection();
26 }
27 builder.finish()
2228}
2329
2430#[cfg(test)]
Modified lib/git/src/handler/html/badge.rs View lib/git/src/handler/html/badge.rs
@@ -35,7 +35,7 @@
3535 data-space=(ctx.space_name)
3636 data-repo=(ctx.repo_name)
3737 data-default-branch=(ctx.dto.default_branch)
38 data-commits=(ctx.dto.nums_commits)
38 data-commits=(ctx.dto.commits_count)
3939 data-size=(ctx.dto.size)
4040 data-archived=(if ctx.dto.is_archived { "true" } else { "false" }) {
4141 span class="rounded bg-white px-1.5 py-0.5 font-mono text-[10px]" {
@@ -45,7 +45,7 @@
4545 svg class="h-3 w-3" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" {
4646 path d="M11.93 8.5a4.002 4.002 0 01-7.86 0H.75a.75.75 0 010-1.5h3.32a4.002 4.002 0 017.86 0h3.32a.75.75 0 010 1.5zm-1.43-.75a2.5 2.5 0 10-5 0 2.5 2.5 0 005 0z" {}
4747 }
48 span class="font-mono" { (ctx.dto.nums_commits) }
48 span class="font-mono" { (ctx.dto.commits_count) }
4949 span { "commits" }
5050 }
5151 span class="flex items-center gap-1" {
Modified lib/git/src/handler/html/mod.rs View lib/git/src/handler/html/mod.rs
@@ -347,5 +347,5 @@
347347}
348348
349349pub fn empty_state(dto: &RepoDto) -> bool {
350 dto.nums_commits == 0
350 dto.commits_count == 0
351351}
Modified lib/git/src/handler/html/refs_dropdown.rs View lib/git/src/handler/html/refs_dropdown.rs
@@ -102,7 +102,7 @@
102102 }
103103 }
104104 p class="mt-1 text-xs text-zinc-500" {
105 (ctx.dto.nums_branches) " branches · " (ctx.dto.nums_tags) " tags"
105 (ctx.dto.branches_count) " branches · " (ctx.dto.tags_count) " tags"
106106 }
107107 }
108108 };
Modified lib/git/src/handler/html/refs_summary.rs View lib/git/src/handler/html/refs_summary.rs
@@ -26,8 +26,8 @@
2626 Ok(c) => c,
2727 Err(resp) => return resp,
2828 };
29 let branch_count = ctx.dto.nums_branches;
30 let tag_count = ctx.dto.nums_tags;
29 let branch_count = ctx.dto.branches_count;
30 let tag_count = ctx.dto.tags_count;
3131 let base = "git-refs-summary flex items-center gap-3 text-sm text-zinc-600";
3232 let cls = archived_class(&ctx.dto, base);
3333 let markup = html! {
Modified lib/git/src/handler/html/star_button.rs View lib/git/src/handler/html/star_button.rs
@@ -36,7 +36,7 @@
3636 let cls = archived_class(&ctx.dto, base);
3737 let space = ctx.space_name.clone();
3838 let repo = ctx.repo_name.clone();
39 let star_count = ctx.dto.nums_star;
39 let star_count = ctx.dto.stars_count;
4040
4141 let markup = html! {
4242 div class=(cls)
Modified lib/git/src/handler/html/watch_button.rs View lib/git/src/handler/html/watch_button.rs
@@ -43,7 +43,7 @@
4343 let cls = archived_class(&ctx.dto, base);
4444 let space = ctx.space_name.clone();
4545 let repo = ctx.repo_name.clone();
46 let watch_count = ctx.dto.nums_watch;
46 let watch_count = ctx.dto.watchers_count;
4747
4848 let mode_text = match watch_mode {
4949 WatchMode::Watch => "Watching",
Modified lib/git/src/handler/lfs/unlock.rs View lib/git/src/handler/lfs/unlock.rs
@@ -15,8 +15,8 @@
1515}
1616
1717#[utoipa::path(
18 delete,
19 path = "/repos/{space_name}/{repo_name}/lfs/locks",
18 post,
19 path = "/repos/{space_name}/{repo_name}/lfs/locks/unlock",
2020 tag = "lfs",
2121 params(RepoPath),
2222 request_body = LfsUnlockBody,
Modified lib/git/src/handler/lfs/unlock_by_id.rs View lib/git/src/handler/lfs/unlock_by_id.rs
@@ -21,8 +21,8 @@
2121}
2222
2323#[utoipa::path(
24 delete,
25 path = "/repos/{space_name}/{repo_name}/lfs/locks/{lock_id}",
24 post,
25 path = "/repos/{space_name}/{repo_name}/lfs/locks/{lock_id}/unlock",
2626 tag = "lfs",
2727 params(LfsLockIdPath),
2828 request_body = LfsUnlockByIdBody,
Modified lib/git/src/handler/repos/list.rs View lib/git/src/handler/repos/list.rs
@@ -4,7 +4,7 @@
44
55use crate::errors::{ApiError, GitApiError};
66use crate::service::GitService;
7use crate::service::repos::RepoDto;
7use crate::service::repos::RepoSummaryDto;
88
99#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
1010pub struct SpacePath {
@@ -17,14 +17,14 @@
1717 tag = "repos",
1818 params(SpacePath),
1919 responses(
20 (status = 200, description = "list repos", body = AppResponseData<Vec<RepoDto>>)
20 (status = 200, description = "list repos", body = AppResponseData<Vec<RepoSummaryDto>>)
2121 )
2222)]
2323pub async fn list_repos(
2424 State(service): State<GitService>,
2525 session: Session,
2626 Path(path): Path<SpacePath>,
27) -> Result<AppResponseData<Vec<RepoDto>>, ApiError> {
27) -> Result<AppResponseData<Vec<RepoSummaryDto>>, ApiError> {
2828 let caller = session.uid();
2929 let out = service
3030 .list_repos(path.space_name, caller)
Modified lib/git/src/handler/licenses.rs View lib/git/src/handler/licenses.rs
@@ -32,8 +32,8 @@
3232 State(service): State<GitService>,
3333 Query(q): Query<LicenseListQuery>,
3434) -> Result<AppResponseData<Vec<LicenseDto>>, ApiError> {
35 let limit = q.limit.unwrap_or(50).clamp(1, 100);
36 let offset = q.offset.unwrap_or(0);
35 let limit = q.limit.unwrap_or(export::pagination::DEFAULT_PAGE_LIMIT).clamp(1, export::pagination::MAX_PAGE_LIMIT);
36 let offset = q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET);
3737 let out = service
3838 .list_licenses(limit, offset)
3939 .await
Modified lib/git/src/handler/stars.rs View lib/git/src/handler/stars.rs
@@ -133,7 +133,7 @@
133133 )
134134 .await?;
135135 let limit = q.limit.unwrap_or(50).clamp(1, 100);
136 let offset = q.offset.unwrap_or(0);
136 let offset = q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET);
137137 let out = service
138138 .list_stargazers(&ctx, limit, offset)
139139 .await
Modified lib/git/src/handler/topics.rs View lib/git/src/handler/topics.rs
@@ -67,7 +67,7 @@
6767 Query(q): Query<TopicListQuery>,
6868) -> Result<AppResponseData<Vec<TopicDto>>, ApiError> {
6969 let limit = q.limit.unwrap_or(50).clamp(1, 100);
70 let offset = q.offset.unwrap_or(0);
70 let offset = q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET);
7171 let out = service
7272 .list_topics(limit, offset)
7373 .await
@@ -110,7 +110,7 @@
110110 Query(q): Query<TopicListQuery>,
111111) -> Result<AppResponseData<Vec<TopicRepoDto>>, ApiError> {
112112 let limit = q.limit.unwrap_or(50).clamp(1, 100);
113 let offset = q.offset.unwrap_or(0);
113 let offset = q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET);
114114 let out = service
115115 .list_repos_by_topic(&p.name, limit, offset)
116116 .await
Modified lib/git/src/handler/watches.rs View lib/git/src/handler/watches.rs
@@ -1,6 +1,6 @@
11use axum::Json;
22use axum::extract::{Path, Query, State};
3use export::rest::{AppRequestData, AppResponseData};
3use export::rest::AppResponseData;
44use models::common::watch_mode::WatchMode;
55use serde::Deserialize;
66use session::Session;
@@ -133,7 +133,7 @@
133133 )
134134 .await?;
135135 let limit = q.limit.unwrap_or(50).clamp(1, 100);
136 let offset = q.offset.unwrap_or(0);
136 let offset = q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET);
137137 let out = service
138138 .list_watchers(&ctx, q.mode, limit, offset)
139139 .await
@@ -141,5 +141,3 @@
141141 Ok(AppResponseData::ok(out))
142142}
143143
144#[allow(dead_code)]
145fn _typecheck(_: AppRequestData<UpsertWatchCmd>) {}
Modified lib/git/src/handler/whitelists.rs View lib/git/src/handler/whitelists.rs
@@ -1,11 +1,14 @@
11use axum::Json;
22use axum::extract::{Path, State};
3use export::rest::{AppRequestData, AppResponseData, NoData};
3use export::rest::{AppResponseData, NoData};
44use serde::Deserialize;
55use utoipa::IntoParams;
66use uuid::Uuid;
77
8use session::Session;
9
810use crate::errors::{ApiError, GitApiError};
11use crate::handler::require_repo_admin;
912use crate::service::GitService;
1013use crate::service::whitelists::{
1114 AddBranchWhitelistCmd, AddTagWhitelistCmd, BranchWhitelistDto, TagWhitelistDto,
@@ -53,11 +56,15 @@
5356)]
5457pub async fn add_branch_whitelist(
5558 State(service): State<GitService>,
59 session: Session,
5660 Path(p): Path<BranchPolicyPath>,
5761 Json(cmd): Json<AddBranchWhitelistCmd>,
5862) -> Result<AppResponseData<BranchWhitelistDto>, ApiError> {
63 let (ctx, _) =
64 require_repo_admin(&service, &session, p.space_name.clone(), p.repo_name.clone())
65 .await?;
5966 let out = service
60 .add_branch_whitelist(p.policy_id, cmd)
67 .add_branch_whitelist(ctx.model.id, p.policy_id, cmd)
6168 .await
6269 .map_err(GitApiError::from)?;
6370 Ok(AppResponseData::ok(out))
@@ -74,10 +81,14 @@
7481)]
7582pub async fn remove_branch_whitelist(
7683 State(service): State<GitService>,
84 session: Session,
7785 Path(p): Path<BranchWhitelistPath>,
7886) -> Result<AppResponseData<NoData>, ApiError> {
87 let (ctx, _) =
88 require_repo_admin(&service, &session, p.space_name.clone(), p.repo_name.clone())
89 .await?;
7990 service
80 .remove_branch_whitelist(p.policy_id, p.whitelist_id)
91 .remove_branch_whitelist(ctx.model.id, p.policy_id, p.whitelist_id)
8192 .await
8293 .map_err(GitApiError::from)?;
8394 Ok(AppResponseData::empty())
@@ -94,10 +105,14 @@
94105)]
95106pub async fn list_branch_whitelists(
96107 State(service): State<GitService>,
108 session: Session,
97109 Path(p): Path<BranchPolicyPath>,
98110) -> Result<AppResponseData<Vec<BranchWhitelistDto>>, ApiError> {
111 let (ctx, _) =
112 require_repo_admin(&service, &session, p.space_name.clone(), p.repo_name.clone())
113 .await?;
99114 let out = service
100 .list_branch_whitelists(p.policy_id)
115 .list_branch_whitelists(ctx.model.id, p.policy_id)
101116 .await
102117 .map_err(GitApiError::from)?;
103118 Ok(AppResponseData::ok(out))
@@ -115,11 +130,15 @@
115130)]
116131pub async fn add_tag_whitelist(
117132 State(service): State<GitService>,
133 session: Session,
118134 Path(p): Path<TagPolicyPath>,
119135 Json(cmd): Json<AddTagWhitelistCmd>,
120136) -> Result<AppResponseData<TagWhitelistDto>, ApiError> {
137 let (ctx, _) =
138 require_repo_admin(&service, &session, p.space_name.clone(), p.repo_name.clone())
139 .await?;
121140 let out = service
122 .add_tag_whitelist(p.policy_id, cmd)
141 .add_tag_whitelist(ctx.model.id, p.policy_id, cmd)
123142 .await
124143 .map_err(GitApiError::from)?;
125144 Ok(AppResponseData::ok(out))
@@ -136,10 +155,14 @@
136155)]
137156pub async fn remove_tag_whitelist(
138157 State(service): State<GitService>,
158 session: Session,
139159 Path(p): Path<TagWhitelistPath>,
140160) -> Result<AppResponseData<NoData>, ApiError> {
161 let (ctx, _) =
162 require_repo_admin(&service, &session, p.space_name.clone(), p.repo_name.clone())
163 .await?;
141164 service
142 .remove_tag_whitelist(p.policy_id, p.user_id)
165 .remove_tag_whitelist(ctx.model.id, p.policy_id, p.user_id)
143166 .await
144167 .map_err(GitApiError::from)?;
145168 Ok(AppResponseData::empty())
@@ -156,14 +179,16 @@
156179)]
157180pub async fn list_tag_whitelists(
158181 State(service): State<GitService>,
182 session: Session,
159183 Path(p): Path<TagPolicyPath>,
160184) -> Result<AppResponseData<Vec<TagWhitelistDto>>, ApiError> {
185 let (ctx, _) =
186 require_repo_admin(&service, &session, p.space_name.clone(), p.repo_name.clone())
187 .await?;
161188 let out = service
162 .list_tag_whitelists(p.policy_id)
189 .list_tag_whitelists(ctx.model.id, p.policy_id)
163190 .await
164191 .map_err(GitApiError::from)?;
165192 Ok(AppResponseData::ok(out))
166193}
167194
168#[allow(dead_code)]
169fn _typecheck(_: AppRequestData<AddBranchWhitelistCmd>, _: AppRequestData<AddTagWhitelistCmd>) {}
Modified lib/git/src/service/hooks.rs View lib/git/src/service/hooks.rs
@@ -5,7 +5,7 @@
55use crate::share::errors::GitError;
66
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
8#[serde(rename_all = "kebab-case")]
8#[serde(rename_all = "snake_case")]
99pub enum HookEventKind {
1010 PreReceive,
1111 PostReceive,
Modified lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -28,17 +28,16 @@
2828#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
2929pub struct RepoDto {
3030 pub id: Uuid,
31 pub dir_id: Uuid,
3231 pub name: String,
3332 pub description: Option<String>,
3433 pub website: Option<String>,
3534 pub default_branch: String,
36 pub nums_watch: i32,
37 pub nums_star: i32,
38 pub nums_pr: i32,
39 pub nums_commits: i32,
40 pub nums_branches: i32,
41 pub nums_tags: i32,
35 pub watchers_count: i32,
36 pub stars_count: i32,
37 pub pulls_count: i32,
38 pub commits_count: i32,
39 pub branches_count: i32,
40 pub tags_count: i32,
4241 pub enable_lfs: bool,
4342 pub is_private: bool,
4443 pub is_archived: bool,
@@ -56,11 +55,51 @@
5655 pub archived_at: Option<OffsetDateTime>,
5756}
5857
58#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
59pub struct RepoSummaryDto {
60 pub id: Uuid,
61 pub name: String,
62 pub description: Option<String>,
63 pub default_branch: String,
64 pub stars_count: i32,
65 pub watchers_count: i32,
66 pub is_private: bool,
67 pub is_archived: bool,
68 pub size: i64,
69 pub avatar_url: Option<String>,
70 pub primary_language: Option<String>,
71 pub license_name: Option<String>,
72 pub updated_at: OffsetDateTime,
73}
74
75impl From<repos::Model> for RepoSummaryDto {
76 fn from(m: repos::Model) -> Self {
77 Self {
78 id: m.id,
79 name: m.name,
80 description: m.description,
81 default_branch: m
82 .default_branch
83 .strip_prefix("refs/heads/")
84 .unwrap_or(&m.default_branch)
85 .to_string(),
86 stars_count: m.nums_star,
87 watchers_count: m.nums_watch,
88 is_private: m.is_private,
89 is_archived: m.is_archived,
90 size: m.size,
91 avatar_url: m.avatar_url,
92 primary_language: m.primary_language,
93 license_name: m.license_name,
94 updated_at: m.updated_at,
95 }
96 }
97}
98
5999impl From<repos::Model> for RepoDto {
60100 fn from(m: repos::Model) -> Self {
61101 Self {
62102 id: m.id,
63 dir_id: m.dir_id,
64103 name: m.name,
65104 description: m.description,
66105 website: m.website,
@@ -69,12 +108,12 @@
69108 .strip_prefix("refs/heads/")
70109 .unwrap_or(&m.default_branch)
71110 .to_string(),
72 nums_watch: m.nums_watch,
73 nums_star: m.nums_star,
74 nums_pr: m.nums_pr,
75 nums_commits: m.nums_commits,
76 nums_branches: m.nums_branches,
77 nums_tags: m.nums_tags,
111 watchers_count: m.nums_watch,
112 stars_count: m.nums_star,
113 pulls_count: m.nums_pr,
114 commits_count: m.nums_commits,
115 branches_count: m.nums_branches,
116 tags_count: m.nums_tags,
78117 enable_lfs: m.enable_lfs,
79118 is_private: m.is_private,
80119 is_archived: m.is_archived,
@@ -347,7 +386,7 @@
347386 &self,
348387 space_name: String,
349388 caller: Option<Uuid>,
350 ) -> Result<Vec<RepoDto>, GitError> {
389 ) -> Result<Vec<RepoSummaryDto>, GitError> {
351390 let space = self.lookup_space(&space_name).await?;
352391 let dirs = dirs::Entity::find()
353392 .filter(dirs::Column::SpaceId.eq(space.id))
@@ -377,7 +416,7 @@
377416 Ok(rows
378417 .into_iter()
379418 .filter(|r| !r.is_private || owner_view || accessible.contains(&r.id))
380 .map(RepoDto::from)
419 .map(RepoSummaryDto::from)
381420 .collect())
382421 }
383422
@@ -477,12 +516,12 @@
477516 .list_refs(ListRefsOptions::default())
478517 .await
479518 .map_err(map_baby)?;
480 let nums_branches = refs
519 let branches_count = refs
481520 .iter()
482521 .filter(|r| r.ref_type == RefType::Branch)
483522 .count() as i32;
484 let nums_tags = refs.iter().filter(|r| r.ref_type == RefType::Tag).count() as i32;
485 let nums_commits = count_commits(
523 let tags_count = refs.iter().filter(|r| r.ref_type == RefType::Tag).count() as i32;
524 let commits_count = count_commits(
486525 &ctx.repo_dir(),
487526 self.state.config.git.git_command_timeout_secs,
488527 )
@@ -492,9 +531,9 @@
492531 let now = OffsetDateTime::now_utc();
493532 let am = repos::ActiveModel {
494533 id: Unchanged(ctx.model.id),
495 nums_branches: Set(nums_branches),
496 nums_tags: Set(nums_tags),
497 nums_commits: Set(nums_commits),
534 nums_branches: Set(branches_count),
535 nums_tags: Set(tags_count),
536 nums_commits: Set(commits_count),
498537 git_size: Set(git_size),
499538 updated_at: Set(now),
500539 ..Default::default()
Modified lib/git/src/service/whitelists.rs View lib/git/src/service/whitelists.rs
@@ -84,10 +84,14 @@
8484
8585 pub async fn add_branch_whitelist(
8686 &self,
87 repo_id: Uuid,
8788 policy_id: Uuid,
8889 cmd: AddBranchWhitelistCmd,
8990 ) -> Result<BranchWhitelistDto, GitError> {
90 let _ = self.ensure_branch_policy(policy_id).await?;
91 let policy = self.ensure_branch_policy(policy_id).await?;
92 if policy.repo_id != repo_id {
93 return Err(GitError::NotFound);
94 }
9195 if cmd.user_id.is_some() == cmd.group_id.is_some() {
9296 return Err(GitError::InvalidArgument(
9397 "exactly one of user_id or group_id must be set".to_string(),
@@ -116,9 +120,14 @@
116120
117121 pub async fn remove_branch_whitelist(
118122 &self,
123 repo_id: Uuid,
119124 policy_id: Uuid,
120125 whitelist_id: Uuid,
121126 ) -> Result<(), GitError> {
127 let policy = self.ensure_branch_policy(policy_id).await?;
128 if policy.repo_id != repo_id {
129 return Err(GitError::NotFound);
130 }
122131 repo_branch_protection_whitelists::Entity::delete_by_id(whitelist_id)
123132 .filter(repo_branch_protection_whitelists::Column::PolicyId.eq(policy_id))
124133 .exec(self.db_write())
@@ -128,9 +137,13 @@
128137
129138 pub async fn list_branch_whitelists(
130139 &self,
140 repo_id: Uuid,
131141 policy_id: Uuid,
132142 ) -> Result<Vec<BranchWhitelistDto>, GitError> {
133 let _ = self.ensure_branch_policy(policy_id).await?;
143 let policy = self.ensure_branch_policy(policy_id).await?;
144 if policy.repo_id != repo_id {
145 return Err(GitError::NotFound);
146 }
134147 let rows = repo_branch_protection_whitelists::Entity::find()
135148 .filter(repo_branch_protection_whitelists::Column::PolicyId.eq(policy_id))
136149 .all(self.db_read())
@@ -140,10 +153,14 @@
140153
141154 pub async fn add_tag_whitelist(
142155 &self,
156 repo_id: Uuid,
143157 policy_id: Uuid,
144158 cmd: AddTagWhitelistCmd,
145159 ) -> Result<TagWhitelistDto, GitError> {
146 let _ = self.ensure_tag_policy(policy_id).await?;
160 let policy = self.ensure_tag_policy(policy_id).await?;
161 if policy.repo_id != repo_id {
162 return Err(GitError::NotFound);
163 }
147164 let existing = repo_tag_protection_whitelists::Entity::find_by_id((policy_id, cmd.user_id))
148165 .one(self.db_read())
149166 .await?;
@@ -163,9 +180,14 @@
163180
164181 pub async fn remove_tag_whitelist(
165182 &self,
183 repo_id: Uuid,
166184 policy_id: Uuid,
167185 user_id: Uuid,
168186 ) -> Result<(), GitError> {
187 let policy = self.ensure_tag_policy(policy_id).await?;
188 if policy.repo_id != repo_id {
189 return Err(GitError::NotFound);
190 }
169191 repo_tag_protection_whitelists::Entity::delete_by_id((policy_id, user_id))
170192 .exec(self.db_write())
171193 .await?;
@@ -174,9 +196,13 @@
174196
175197 pub async fn list_tag_whitelists(
176198 &self,
199 repo_id: Uuid,
177200 policy_id: Uuid,
178201 ) -> Result<Vec<TagWhitelistDto>, GitError> {
179 let _ = self.ensure_tag_policy(policy_id).await?;
202 let policy = self.ensure_tag_policy(policy_id).await?;
203 if policy.repo_id != repo_id {
204 return Err(GitError::NotFound);
205 }
180206 let rows = repo_tag_protection_whitelists::Entity::find()
181207 .filter(repo_tag_protection_whitelists::Column::PolicyId.eq(policy_id))
182208 .all(self.db_read())
Modified lib/git/src/share/validate.rs View lib/git/src/share/validate.rs
@@ -108,12 +108,9 @@
108108 ));
109109 }
110110 if !u.contains("://") {
111 if u.contains('@') {
112 return Err(GitError::InvalidArgument(format!(
113 "remote url `{u}` is not supported"
114 )));
115 }
116 return Ok(());
111 return Err(GitError::InvalidArgument(format!(
112 "remote url `{u}` is not supported: use a full URL with scheme (https://, ssh://, git://)"
113 )));
117114 }
118115 let (scheme, rest) = u
119116 .split_once("://")
Modified lib/git/src/errors.rs View lib/git/src/errors.rs
@@ -1,7 +1,7 @@
11use std::fmt;
22
33use axum::Json;
4use axum::extract::rejection::{PathRejection, QueryRejection};
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
55use axum::http::StatusCode;
66use axum::response::{IntoResponse, Response};
77use export::rest::{AppResponseData, NoData};
@@ -155,13 +155,27 @@
155155}
156156
157157impl From<PathRejection> for ApiError {
158 fn from(_error: PathRejection) -> Self {
159 Self::BadRequest(Self::payload(400, "bad request"))
158 fn from(error: PathRejection) -> Self {
159 Self::BadRequest(
160 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
161 )
162 }
163}
164
165impl From<JsonRejection> for ApiError {
166 fn from(error: JsonRejection) -> Self {
167 Self::BadRequest(
168 AppResponseData::err(400, "invalid request body")
169 .with_error(error.body_text())
170 .with_error_code("invalid_request_body"),
171 )
160172 }
161173}
162174
163175impl From<QueryRejection> for ApiError {
164 fn from(_error: QueryRejection) -> Self {
165 Self::BadRequest(Self::payload(400, "bad request"))
176 fn from(error: QueryRejection) -> Self {
177 Self::BadRequest(
178 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
179 )
166180 }
167181}
Modified lib/git/src/openapi.rs View lib/git/src/openapi.rs
@@ -49,7 +49,6 @@
4949 handler::hooks::execute_script::execute_script,
5050 handler::licenses::get_license,
5151 handler::licenses::list_licenses,
52 handler::licenses::upsert_license,
5352 handler::lfs::list_locks::list_locks,
5453 handler::lfs::lock::lock,
5554 handler::lfs::unlock::unlock,
@@ -109,25 +108,6 @@
109108 handler::whitelists::list_tag_whitelists,
110109 handler::whitelists::remove_branch_whitelist,
111110 handler::whitelists::remove_tag_whitelist,
112 handler::html::clone_buttons::render_clone_buttons,
113 handler::html::badge::render_badge,
114 handler::html::quickstart::render_quickstart,
115 handler::html::header::render_header,
116 handler::html::last_commit::render_last_commit,
117 handler::html::refs_summary::render_refs_summary,
118 handler::html::refs_dropdown::render_refs_dropdown,
119 handler::html::tree::render_tree,
120 handler::html::tree_row::render_tree_row,
121 handler::html::breadcrumbs::render_breadcrumbs,
122 handler::html::blob_header::render_blob_header,
123 handler::html::blob_content::render_blob_content,
124 handler::html::readme::render_readme,
125 handler::html::commits::render_commits,
126 handler::html::commit_diff::render_commit_diff,
127 handler::html::blame::render_blame,
128 handler::html::languages::render_languages,
129 handler::html::star_button::render_star_button,
130 handler::html::watch_button::render_watch_button,
131111 ),
132112 components(schemas(
133113 export::rest::NoData,
@@ -189,6 +169,7 @@
189169 crate::service::remotes::RemoteAuthDto,
190170 crate::service::repos::CreateRepoCmd,
191171 crate::service::repos::RepoDto,
172 crate::service::repos::RepoSummaryDto,
192173 crate::service::repos::UpdateRepoCmd,
193174 crate::service::signatures::VerifyResultDto,
194175 crate::service::stars::RepoStarDto,
Modified lib/git/src/routes.rs View lib/git/src/routes.rs
@@ -127,6 +127,7 @@
127127 let schema = crate::graphql::build_schema(service.state.clone());
128128 Router::new()
129129 .nest("/repos", repos_internal_router())
130 .merge(licenses_internal_router())
130131 .layer(Extension(schema))
131132}
132133
@@ -283,15 +284,19 @@
283284 )
284285 .route(
285286 "/{space_name}/{repo_name}/lfs/locks",
286 get(list_locks).post(lock).delete(unlock),
287 get(list_locks).post(lock),
287288 )
288289 .route(
290 "/{space_name}/{repo_name}/lfs/locks/unlock",
291 post(unlock),
292 )
293 .route(
289294 "/{space_name}/{repo_name}/lfs/locks/verify",
290295 post(verify_locks),
291296 )
292297 .route(
293 "/{space_name}/{repo_name}/lfs/locks/{lock_id}",
294 delete(unlock_by_id),
298 "/{space_name}/{repo_name}/lfs/locks/{lock_id}/unlock",
299 post(unlock_by_id),
295300 )
296301 .route(
297302 "/{space_name}/{repo_name}/hooks/{script}",
@@ -433,5 +438,9 @@
433438fn licenses_router() -> Router<GitService> {
434439 Router::new()
435440 .route("/", get(list_licenses))
436 .route("/{name}", get(get_license).put(upsert_license))
441 .route("/{name}", get(get_license))
442}
443
444fn licenses_internal_router() -> Router<GitService> {
445 Router::new().route("/licenses/{name}", put(upsert_license))
437446}
Modified lib/issues/src/handlers/comments/create_comment.rs View lib/issues/src/handlers/comments/create_comment.rs
@@ -16,6 +16,7 @@
1616 request_body = CreateCommentCmd,
1717 responses(
1818 (status = 200, description = "create comment", body = AppResponseData<CommentDto>),
19 (status = 429, description = "creation quota exceeded", body = AppResponseData<NoData>),
1920 (status = 400, description = "bad request", body = AppResponseData<NoData>),
2021 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
2122 (status = 404, description = "not found", body = AppResponseData<NoData>)
Modified lib/issues/src/handlers/issues/create_issue.rs View lib/issues/src/handlers/issues/create_issue.rs
@@ -16,6 +16,7 @@
1616 request_body = CreateIssueCmd,
1717 responses(
1818 (status = 200, description = "create issue", body = AppResponseData<IssueDto>),
19 (status = 429, description = "creation quota exceeded", body = AppResponseData<NoData>),
1920 (status = 400, description = "bad request", body = AppResponseData<NoData>),
2021 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
2122 (status = 404, description = "not found", body = AppResponseData<NoData>)
Renamed lib/issues/src/handlers/reactions/create_comment_reaction.rs → lib/issues/src/handlers/reactions/add_comment_reaction.rs View lib/issues/src/handlers/reactions/add_comment_reaction.rs
@@ -8,20 +8,20 @@
88use session::share::session::require_uid;
99
1010#[utoipa::path(
11 post,
12 path = "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/comments/{comment_id}/reactions",
11 put,
12 path = "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/comments/{comment_id}/reactions/@me",
1313 tag = "issues",
1414 params(CommentReactionPath),
1515 request_body = CreateReactionCmd,
1616 responses(
17 (status = 200, description = "create comment reaction", body = AppResponseData<ReactionDto>),
17 (status = 200, description = "add my comment reaction", body = AppResponseData<ReactionDto>),
1818 (status = 400, description = "bad request", body = AppResponseData<NoData>),
1919 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
2020 (status = 404, description = "not found", body = AppResponseData<NoData>),
2121 (status = 409, description = "conflict", body = AppResponseData<NoData>)
2222 )
2323)]
24pub async fn create_comment_reaction(
24pub async fn add_comment_reaction(
2525 State(service): State<IssueService>,
2626 session: Session,
2727 Path(path): Path<CommentReactionPath>,
Renamed lib/issues/src/handlers/reactions/create_reaction.rs → lib/issues/src/handlers/reactions/add_reaction.rs View lib/issues/src/handlers/reactions/add_reaction.rs
@@ -9,20 +9,20 @@
99use session::share::session::require_uid;
1010
1111#[utoipa::path(
12 post,
13 path = "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/reactions",
12 put,
13 path = "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/reactions/@me",
1414 tag = "issues",
1515 params(IssuePath),
1616 request_body = CreateReactionCmd,
1717 responses(
18 (status = 200, description = "create reaction", body = AppResponseData<ReactionDto>),
18 (status = 200, description = "add my reaction", body = AppResponseData<ReactionDto>),
1919 (status = 400, description = "bad request", body = AppResponseData<NoData>),
2020 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
2121 (status = 404, description = "not found", body = AppResponseData<NoData>),
2222 (status = 409, description = "conflict", body = AppResponseData<NoData>)
2323 )
2424)]
25pub async fn create_reaction(
25pub async fn add_reaction(
2626 State(service): State<IssueService>,
2727 session: Session,
2828 Path(path): Path<IssuePath>,
Modified lib/issues/src/handlers/reactions/mod.rs View lib/issues/src/handlers/reactions/mod.rs
@@ -1,5 +1,6 @@
1pub mod create_comment_reaction;
2pub mod create_reaction;
3pub mod delete_reaction;
1pub mod add_comment_reaction;
2pub mod add_reaction;
43pub mod list_comment_reactions;
54pub mod list_reactions;
5pub mod remove_comment_reaction;
6pub mod remove_reaction;
Added lib/issues/src/handlers/reactions/remove_comment_reaction.rs View lib/issues/src/handlers/reactions/remove_comment_reaction.rs
@@ -0,0 +1,39 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::reactions::{CommentReactionPath, EmojiQuery};
7use crate::services::service::IssueService;
8use session::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/comments/{comment_id}/reactions/@me",
13 tag = "issues",
14 params(CommentReactionPath, EmojiQuery),
15 responses(
16 (status = 200, description = "removed my comment reaction", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn remove_comment_reaction(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<CommentReactionPath>,
25 Query(q): Query<EmojiQuery>,
26) -> Result<AppResponseData<NoData>, ApiError> {
27 let uid = require_uid(&session)?;
28 service
29 .remove_comment_reaction_by_emoji(
30 uid,
31 &path.space_name,
32 path.board_id,
33 path.issue_id,
34 path.comment_id,
35 &q.emoji,
36 )
37 .await?;
38 Ok(AppResponseData::ok(NoData))
39}
Renamed lib/issues/src/handlers/reactions/delete_reaction.rs → lib/issues/src/handlers/reactions/remove_reaction.rs View lib/issues/src/handlers/reactions/remove_reaction.rs
@@ -1,37 +1,33 @@
1use axum::extract::{Path, State};
1use axum::extract::{Path, Query, State};
22use export::rest::{AppResponseData, NoData};
33use session::Session;
44
55use crate::errors::ApiError;
6use crate::services::reactions::ReactionPath;
6use crate::services::issues::IssuePath;
7use crate::services::reactions::EmojiQuery;
78use crate::services::service::IssueService;
89use session::share::session::require_uid;
910
1011#[utoipa::path(
1112 delete,
12 path = "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/reactions/{reaction_id}",
13 path = "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/reactions/@me",
1314 tag = "issues",
14 params(ReactionPath),
15 params(IssuePath, EmojiQuery),
1516 responses(
16 (status = 200, description = "delete reaction", body = AppResponseData<NoData>),
17 (status = 200, description = "removed my reaction", body = AppResponseData<NoData>),
1718 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
1819 (status = 404, description = "not found", body = AppResponseData<NoData>)
1920 )
2021)]
21pub async fn delete_reaction(
22pub async fn remove_reaction(
2223 State(service): State<IssueService>,
2324 session: Session,
24 Path(path): Path<ReactionPath>,
25 Path(path): Path<IssuePath>,
26 Query(q): Query<EmojiQuery>,
2527) -> Result<AppResponseData<NoData>, ApiError> {
2628 let uid = require_uid(&session)?;
2729 service
28 .delete_reaction(
29 uid,
30 &path.space_name,
31 path.board_id,
32 path.issue_id,
33 path.reaction_id,
34 )
30 .remove_reaction_by_emoji(uid, &path.space_name, path.board_id, path.issue_id, &q.emoji)
3531 .await?;
3632 Ok(AppResponseData::ok(NoData))
3733}
Modified lib/issues/src/services/boards.rs View lib/issues/src/services/boards.rs
@@ -27,8 +27,8 @@
2727 pub name: String,
2828 pub icon: Option<String>,
2929 pub description: Option<String>,
30 pub nums_open_issue: i64,
31 pub nums_closed_issue: i64,
30 pub open_issues_count: i64,
31 pub closed_issues_count: i64,
3232 pub created_at: OffsetDateTime,
3333 pub updated_at: Option<OffsetDateTime>,
3434}
@@ -75,15 +75,15 @@
7575}
7676
7777impl IssueService {
78 fn board_dto(board: issues::Model, nums_open_issue: i64, nums_closed_issue: i64) -> BoardDto {
78 fn board_dto(board: issues::Model, open_issues_count: i64, closed_issues_count: i64) -> BoardDto {
7979 BoardDto {
8080 id: board.id,
8181 dir_id: board.dir_id,
8282 name: board.name,
8383 icon: board.icon,
8484 description: board.description,
85 nums_open_issue,
86 nums_closed_issue,
85 open_issues_count,
86 closed_issues_count,
8787 created_at: board.created_at,
8888 updated_at: board.updated_at,
8989 }
@@ -220,8 +220,8 @@
220220 name: board.name,
221221 icon: board.icon,
222222 description: board.description,
223 nums_open_issue: 0,
224 nums_closed_issue: 0,
223 open_issues_count: 0,
224 closed_issues_count: 0,
225225 created_at: board.created_at,
226226 updated_at: board.updated_at,
227227 })
Modified lib/issues/src/services/comments.rs View lib/issues/src/services/comments.rs
@@ -71,6 +71,20 @@
7171 issue_id: Uuid,
7272 cmd: CreateCommentCmd,
7373 ) -> Result<CommentDto, IssueError> {
74 if !self
75 .state
76 .redis
77 .quota_allow(
78 &format!("quota:comment_create:{caller}"),
79 self.config().quota.comment_create_per_day,
80 86_400,
81 )
82 .await
83 {
84 return Err(IssueError::RateLimited(
85 "comment creation quota exceeded, retry tomorrow".to_string(),
86 ));
87 }
7488 let space = lookup_space_by_name(self, space_name).await?;
7589 require_member(self, &space, caller).await?;
7690 let board = lookup_board(self, &space, board_id).await?;
Modified lib/issues/src/services/issue_users.rs View lib/issues/src/services/issue_users.rs
@@ -16,7 +16,7 @@
1616#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1717pub struct IssueUserDto {
1818 pub id: Uuid,
19 pub uid: Uuid,
19 pub user_id: Uuid,
2020 pub user: Option<UserBriefDto>,
2121 pub issue_id: Uuid,
2222 pub is_read: bool,
@@ -91,7 +91,7 @@
9191 let user = user_map.get(&u.uid).cloned();
9292 IssueUserDto {
9393 id: u.id,
94 uid: u.uid,
94 user_id: u.uid,
9595 user,
9696 issue_id: u.issue_id,
9797 is_read: u.is_read,
@@ -150,7 +150,7 @@
150150
151151 Ok(IssueUserDto {
152152 id: updated.id,
153 uid: updated.uid,
153 user_id: updated.uid,
154154 user,
155155 issue_id: updated.issue_id,
156156 is_read: updated.is_read,
Modified lib/issues/src/services/issues.rs View lib/issues/src/services/issues.rs
@@ -42,11 +42,11 @@
4242pub struct IssueDto {
4343 pub id: Uuid,
4444 pub board_id: Uuid,
45 pub autoincrement_id: i32,
45 pub number: i32,
4646 pub author: Option<UserBriefDto>,
4747 pub title: String,
4848 pub description: Option<String>,
49 pub body: String,
49 pub content: String,
5050 pub milestone_id: Option<Uuid>,
5151 pub state: IssueState,
5252 pub created_at: OffsetDateTime,
@@ -55,18 +55,28 @@
5555}
5656
5757#[derive(Debug, Clone, Serialize, ToSchema)]
58pub struct IssuePage {
59 pub items: Vec<IssueDto>,
60 pub total: i64,
58pub struct IssueListItemDto {
59 pub id: Uuid,
60 pub board_id: Uuid,
61 pub number: i32,
62 pub author: Option<UserBriefDto>,
63 pub title: String,
64 pub milestone_id: Option<Uuid>,
65 pub state: IssueState,
66 pub created_at: OffsetDateTime,
67 pub updated_at: Option<OffsetDateTime>,
68 pub closed_at: Option<OffsetDateTime>,
6169}
6270
71pub type IssuePage = export::pagination::Page<IssueListItemDto>;
72
6373#[derive(Debug, Clone, Deserialize, ToSchema)]
6474pub struct CreateIssueCmd {
6575 pub title: String,
6676 #[serde(default)]
6777 pub description: Option<String>,
6878 #[serde(default)]
69 pub body: String,
79 pub content: String,
7080 #[serde(default)]
7181 pub milestone_id: Option<Uuid>,
7282}
@@ -78,7 +88,7 @@
7888 #[serde(default)]
7989 pub description: Option<Option<String>>,
8090 #[serde(default)]
81 pub body: Option<String>,
91 pub content: Option<String>,
8292 #[serde(default)]
8393 pub milestone_id: Option<Option<Uuid>>,
8494}
@@ -204,6 +214,20 @@
204214 board_id: Uuid,
205215 cmd: CreateIssueCmd,
206216 ) -> Result<IssueDto, IssueError> {
217 if !self
218 .state
219 .redis
220 .quota_allow(
221 &format!("quota:issue_create:{caller}"),
222 self.config().quota.issue_create_per_day,
223 86_400,
224 )
225 .await
226 {
227 return Err(IssueError::RateLimited(
228 "issue creation quota exceeded, retry tomorrow".to_string(),
229 ));
230 }
207231 let space = lookup_space_by_name(self, space_name).await?;
208232 require_member(self, &space, caller).await?;
209233 let board = lookup_board(self, &space, board_id).await?;
@@ -214,7 +238,7 @@
214238 "issue title is empty or too long".to_string(),
215239 ));
216240 }
217 if cmd.body.chars().count() > self.config().issues.max_body_len {
241 if cmd.content.chars().count() > self.config().issues.max_body_len {
218242 return Err(IssueError::BadRequest("issue body is too long".to_string()));
219243 }
220244
@@ -228,7 +252,7 @@
228252 })?;
229253 }
230254
231 let full_text = format!("{} {}", title, cmd.body);
255 let full_text = format!("{} {}", title, cmd.content);
232256 let mentioned_uids = self
233257 .collect_mentioned_uids(&space, &full_text, caller)
234258 .await?;
@@ -237,7 +261,7 @@
237261 let issue_id = Uuid::now_v7();
238262 let title_str = title.to_string();
239263 let desc_opt = cmd.description.clone();
240 let body_str = cmd.body.clone();
264 let body_str = cmd.content.clone();
241265 let milestone_opt = cmd.milestone_id;
242266 let space_lower_for_txn = space.lower_name.clone();
243267 let mentioned_for_txn = mentioned_uids.clone();
@@ -366,11 +390,11 @@
366390 Ok(IssueDto {
367391 id: inserted.id,
368392 board_id: inserted.collection_id,
369 autoincrement_id: inserted.autoincrement_id,
393 number: inserted.autoincrement_id,
370394 author,
371395 title: inserted.title,
372396 description: inserted.description,
373 body: inserted.body,
397 content: inserted.body,
374398 milestone_id: inserted.milestone_id,
375399 state: IssueState::Open,
376400 created_at: inserted.created_at,
@@ -448,14 +472,12 @@
448472 } else {
449473 IssueState::Open
450474 };
451 IssueDto {
475 IssueListItemDto {
452476 id: i.id,
453477 board_id: i.collection_id,
454 autoincrement_id: i.autoincrement_id,
478 number: i.autoincrement_id,
455479 author,
456480 title: i.title,
457 description: i.description,
458 body: i.body,
459481 milestone_id: i.milestone_id,
460482 state,
461483 created_at: i.created_at,
@@ -465,7 +487,12 @@
465487 })
466488 .collect();
467489
468 Ok(IssuePage { items, total })
490 Ok(IssuePage {
491 items,
492 total,
493 limit,
494 offset,
495 })
469496 }
470497
471498 pub async fn get_issue(
@@ -491,11 +518,11 @@
491518 Ok(IssueDto {
492519 id: item.id,
493520 board_id: item.collection_id,
494 autoincrement_id: item.autoincrement_id,
521 number: item.autoincrement_id,
495522 author,
496523 title: item.title,
497524 description: item.description,
498 body: item.body,
525 content: item.body,
499526 milestone_id: item.milestone_id,
500527 state,
501528 created_at: item.created_at,
@@ -540,7 +567,7 @@
540567 }
541568
542569 if cmd
543 .body
570 .content
544571 .as_ref()
545572 .is_some_and(|b| b.chars().count() > self.config().issues.max_body_len)
546573 {
@@ -580,7 +607,7 @@
580607 }
581608 am.description = Set(desc);
582609 }
583 if let Some(b) = cmd.body {
610 if let Some(b) = cmd.content {
584611 if b != old_body {
585612 body_changed = Some(b.clone());
586613 }
@@ -702,11 +729,11 @@
702729 Ok(IssueDto {
703730 id: updated.id,
704731 board_id: updated.collection_id,
705 autoincrement_id: updated.autoincrement_id,
732 number: updated.autoincrement_id,
706733 author,
707734 title: updated.title,
708735 description: updated.description,
709 body: updated.body,
736 content: updated.body,
710737 milestone_id: updated.milestone_id,
711738 state,
712739 created_at: updated.created_at,
@@ -773,11 +800,11 @@
773800 return Ok(IssueDto {
774801 id: item.id,
775802 board_id: item.collection_id,
776 autoincrement_id: item.autoincrement_id,
803 number: item.autoincrement_id,
777804 author,
778805 title: item.title,
779806 description: item.description,
780 body: item.body,
807 content: item.body,
781808 milestone_id: item.milestone_id,
782809 state,
783810 created_at: item.created_at,
@@ -818,11 +845,11 @@
818845 Ok(IssueDto {
819846 id: updated.id,
820847 board_id: updated.collection_id,
821 autoincrement_id: updated.autoincrement_id,
848 number: updated.autoincrement_id,
822849 author,
823850 title: updated.title,
824851 description: updated.description,
825 body: updated.body,
852 content: updated.body,
826853 milestone_id: updated.milestone_id,
827854 state,
828855 created_at: updated.created_at,
Modified lib/issues/src/services/milestones.rs View lib/issues/src/services/milestones.rs
@@ -25,8 +25,8 @@
2525 pub description: Option<String>,
2626 pub is_closed: bool,
2727 pub due_at: Option<OffsetDateTime>,
28 pub nums_issues: i64,
29 pub nums_closed_issues: i64,
28 pub issues_count: i64,
29 pub closed_issues_count: i64,
3030 pub created_at: OffsetDateTime,
3131 pub updated_at: Option<OffsetDateTime>,
3232}
@@ -76,8 +76,8 @@
7676impl IssueService {
7777 fn milestone_dto(
7878 m: milestone::Model,
79 nums_issues: i64,
80 nums_closed_issues: i64,
79 issues_count: i64,
80 closed_issues_count: i64,
8181 ) -> MilestoneDto {
8282 MilestoneDto {
8383 id: m.id,
@@ -86,8 +86,8 @@
8686 description: m.description,
8787 is_closed: m.is_closed,
8888 due_at: m.due_at,
89 nums_issues,
90 nums_closed_issues,
89 issues_count,
90 closed_issues_count,
9191 created_at: m.created_at,
9292 updated_at: m.updated_at,
9393 }
@@ -196,8 +196,8 @@
196196 description: inserted.description,
197197 is_closed: inserted.is_closed,
198198 due_at: inserted.due_at,
199 nums_issues: 0,
200 nums_closed_issues: 0,
199 issues_count: 0,
200 closed_issues_count: 0,
201201 created_at: inserted.created_at,
202202 updated_at: inserted.updated_at,
203203 })
Modified lib/issues/src/services/reactions.rs View lib/issues/src/services/reactions.rs
@@ -53,6 +53,11 @@
5353}
5454
5555#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
56pub struct EmojiQuery {
57 pub emoji: String,
58}
59
60#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
5661pub struct CommentReactionPath {
5762 pub space_name: String,
5863 pub board_id: Uuid,
@@ -283,6 +288,67 @@
283288 Ok(result)
284289 }
285290
291 pub async fn remove_reaction_by_emoji(
292 &self,
293 caller: Uuid,
294 space_name: &str,
295 board_id: Uuid,
296 issue_id: Uuid,
297 emoji: &str,
298 ) -> Result<(), IssueError> {
299 let space = lookup_space_by_name(self, space_name).await?;
300 require_member(self, &space, caller).await?;
301 let board = lookup_board(self, &space, board_id).await?;
302 let _ = lookup_issue(self, &board, issue_id).await?;
303
304 let emoji = self.validate_emoji(emoji)?;
305 let row = issue_reactions::Entity::find()
306 .filter(issue_reactions::Column::IssueId.eq(Some(issue_id)))
307 .filter(issue_reactions::Column::CommentId.is_null())
308 .filter(issue_reactions::Column::UserId.eq(caller))
309 .filter(issue_reactions::Column::Emoji.eq(&emoji))
310 .one(self.db().write())
311 .await?
312 .ok_or(IssueError::NotFound)?;
313 issue_reactions::Entity::delete_by_id(row.id)
314 .exec(self.db().write())
315 .await?;
316 Ok(())
317 }
318
319 pub async fn remove_comment_reaction_by_emoji(
320 &self,
321 caller: Uuid,
322 space_name: &str,
323 board_id: Uuid,
324 issue_id: Uuid,
325 comment_id: Uuid,
326 emoji: &str,
327 ) -> Result<(), IssueError> {
328 let space = lookup_space_by_name(self, space_name).await?;
329 require_member(self, &space, caller).await?;
330 let board = lookup_board(self, &space, board_id).await?;
331 let _ = lookup_issue(self, &board, issue_id).await?;
332 let _ = comment::Entity::find_by_id(comment_id)
333 .filter(comment::Column::IssueId.eq(Some(issue_id)))
334 .one(self.db().read())
335 .await?
336 .ok_or(IssueError::NotFound)?;
337
338 let emoji = self.validate_emoji(emoji)?;
339 let row = issue_reactions::Entity::find()
340 .filter(issue_reactions::Column::CommentId.eq(Some(comment_id)))
341 .filter(issue_reactions::Column::UserId.eq(caller))
342 .filter(issue_reactions::Column::Emoji.eq(&emoji))
343 .one(self.db().write())
344 .await?
345 .ok_or(IssueError::NotFound)?;
346 issue_reactions::Entity::delete_by_id(row.id)
347 .exec(self.db().write())
348 .await?;
349 Ok(())
350 }
351
286352 pub async fn delete_reaction(
287353 &self,
288354 caller: Uuid,
Modified lib/issues/src/errors.rs View lib/issues/src/errors.rs
@@ -1,7 +1,7 @@
11use std::fmt;
22
33use axum::Json;
4use axum::extract::rejection::{PathRejection, QueryRejection};
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
55use axum::http::StatusCode;
66use axum::response::{IntoResponse, Response};
77use export::rest::{AppResponseData, NoData};
@@ -14,6 +14,7 @@
1414 Forbidden,
1515 NotFound,
1616 Conflict(String),
17 RateLimited(String),
1718 Internal(String),
1819}
1920
@@ -25,6 +26,7 @@
2526 Self::Forbidden => write!(f, "forbidden"),
2627 Self::NotFound => write!(f, "not found"),
2728 Self::Conflict(message) => write!(f, "{message}"),
29 Self::RateLimited(message) => write!(f, "{message}"),
2830 Self::Internal(message) => write!(f, "{message}"),
2931 }
3032 }
@@ -74,6 +76,7 @@
7476 space::errors::SpaceError::Forbidden => Self::Forbidden,
7577 space::errors::SpaceError::NotFound => Self::NotFound,
7678 space::errors::SpaceError::Conflict(msg) => Self::Conflict(msg),
79 space::errors::SpaceError::RateLimited(msg) => Self::RateLimited(msg),
7780 space::errors::SpaceError::Internal(msg) => Self::Internal(msg),
7881 }
7982 }
@@ -103,6 +106,8 @@
103106 NotFound(#[to_schema] AppResponseData<NoData>),
104107 #[response(status = 409, description = "resource conflict")]
105108 Conflict(#[to_schema] AppResponseData<NoData>),
109 #[response(status = 429, description = "rate limit exceeded")]
110 TooManyRequests(#[to_schema] AppResponseData<NoData>),
106111 #[response(status = 500, description = "internal server error")]
107112 Internal(#[to_schema] AppResponseData<NoData>),
108113}
@@ -119,6 +124,7 @@
119124 Self::Forbidden(envelope) => (403, envelope),
120125 Self::NotFound(envelope) => (404, envelope),
121126 Self::Conflict(envelope) => (409, envelope),
127 Self::TooManyRequests(envelope) => (429, envelope),
122128 Self::Internal(envelope) => (500, envelope),
123129 }
124130 }
@@ -141,8 +147,11 @@
141147 IssueError::BadRequest(msg) => Self::BadRequest(AppResponseData::err(400, msg).with_error_code("bad_request")),
142148 IssueError::Unauthorized => Self::Unauthorized(Self::payload(401, "unauthorized").with_error_code("unauthorized")),
143149 IssueError::Forbidden => Self::Forbidden(Self::payload(403, "forbidden").with_error_code("forbidden")),
144 IssueError::NotFound => Self::NotFound(Self::payload(404, "not found").with_error_code("not_found")),
150 IssueError::NotFound => Self::NotFound(Self::payload(404, "issue resource not found").with_error_code("not_found")),
145151 IssueError::Conflict(msg) => Self::Conflict(AppResponseData::err(409, msg).with_error_code("conflict")),
152 IssueError::RateLimited(msg) => {
153 Self::TooManyRequests(AppResponseData::err(429, msg).with_error_code("rate_limited"))
154 }
146155 IssueError::Internal(message) => {
147156 tracing::warn!(error = %message, "issues handler failed");
148157 Self::Internal(Self::payload(500, "internal server error"))
@@ -178,14 +187,28 @@
178187}
179188
180189impl From<PathRejection> for ApiError {
181 fn from(_error: PathRejection) -> Self {
182 Self::BadRequest(Self::payload(400, "bad request"))
190 fn from(error: PathRejection) -> Self {
191 Self::BadRequest(
192 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
193 )
183194 }
184195}
185196
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
186207impl From<QueryRejection> for ApiError {
187 fn from(_error: QueryRejection) -> Self {
188 Self::BadRequest(Self::payload(400, "bad request"))
208 fn from(error: QueryRejection) -> Self {
209 Self::BadRequest(
210 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
211 )
189212 }
190213}
191214
Modified lib/issues/src/openapi.rs View lib/issues/src/openapi.rs
@@ -34,11 +34,12 @@
3434 crate::handlers::milestones::get_milestone::get_milestone,
3535 crate::handlers::milestones::update_milestone::update_milestone,
3636 crate::handlers::milestones::delete_milestone::delete_milestone,
37 crate::handlers::reactions::create_reaction::create_reaction,
3837 crate::handlers::reactions::list_reactions::list_reactions,
39 crate::handlers::reactions::create_comment_reaction::create_comment_reaction,
38 crate::handlers::reactions::add_reaction::add_reaction,
39 crate::handlers::reactions::remove_reaction::remove_reaction,
4040 crate::handlers::reactions::list_comment_reactions::list_comment_reactions,
41 crate::handlers::reactions::delete_reaction::delete_reaction,
41 crate::handlers::reactions::add_comment_reaction::add_comment_reaction,
42 crate::handlers::reactions::remove_comment_reaction::remove_comment_reaction,
4243 crate::handlers::watches::get_watch::get_watch,
4344 crate::handlers::watches::upsert_watch::upsert_watch,
4445 crate::handlers::watches::delete_watch::delete_watch,
@@ -104,7 +105,8 @@
104105 crate::services::boards::ListBoardQuery,
105106 crate::services::boards::BoardPath,
106107 crate::services::issues::IssueDto,
107 crate::services::issues::IssuePage,
108 crate::services::issues::IssueListItemDto,
109 export::pagination::Page<crate::services::issues::IssueListItemDto>,
108110 crate::services::issues::IssueState,
109111 crate::services::issues::CreateIssueCmd,
110112 crate::services::issues::UpdateIssueCmd,
Modified lib/issues/src/routes.rs View lib/issues/src/routes.rs
@@ -44,9 +44,10 @@
4444use crate::handlers::pins::create_pin::create_pin;
4545use crate::handlers::pins::delete_pin::delete_pin;
4646use crate::handlers::pins::list_pins::list_pins;
47use crate::handlers::reactions::create_comment_reaction::create_comment_reaction;
48use crate::handlers::reactions::create_reaction::create_reaction;
49use crate::handlers::reactions::delete_reaction::delete_reaction;
47use crate::handlers::reactions::add_comment_reaction::add_comment_reaction;
48use crate::handlers::reactions::add_reaction::add_reaction;
49use crate::handlers::reactions::remove_comment_reaction::remove_comment_reaction;
50use crate::handlers::reactions::remove_reaction::remove_reaction;
5051use crate::handlers::reactions::list_comment_reactions::list_comment_reactions;
5152use crate::handlers::reactions::list_reactions::list_reactions;
5253use crate::handlers::repo_relations::create_repo_relation::create_repo_relation;
@@ -123,15 +124,19 @@
123124 )
124125 .route(
125126 "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/reactions",
126 get(list_reactions).post(create_reaction),
127 get(list_reactions),
127128 )
128129 .route(
129 "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/reactions/{reaction_id}",
130 delete(delete_reaction),
130 "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/reactions/@me",
131 axum::routing::put(add_reaction).delete(remove_reaction),
131132 )
132133 .route(
133134 "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/comments/{comment_id}/reactions",
134 get(list_comment_reactions).post(create_comment_reaction),
135 get(list_comment_reactions),
136 )
137 .route(
138 "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/comments/{comment_id}/reactions/@me",
139 axum::routing::put(add_comment_reaction).delete(remove_comment_reaction),
135140 )
136141 .route(
137142 "/issues/{space_name}/boards/{board_id}/issues/{issue_id}/watch",
Modified lib/space/src/handlers/accept_invite.rs View lib/space/src/handlers/accept_invite.rs
@@ -14,6 +14,7 @@
1414 request_body = AcceptInviteCmd,
1515 responses(
1616 (status = 200, description = "invite accepted", body = AppResponseData<NoData>),
17 (status = 429, description = "creation quota exceeded", body = AppResponseData<NoData>),
1718 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
1819 (status = 404, description = "invite not found", body = AppResponseData<NoData>),
1920 (status = 409, description = "conflict", body = AppResponseData<NoData>)
Modified lib/space/src/handlers/assign_group.rs View lib/space/src/handlers/assign_group.rs
@@ -10,7 +10,7 @@
1010
1111#[utoipa::path(
1212 post,
13 path = "/spaces/{space_name}/members/{uid}/groups",
13 path = "/spaces/{space_name}/members/{user_id}/groups",
1414 tag = "space",
1515 params(MemberPath),
1616 request_body = AssignGroupCmd,
@@ -29,7 +29,7 @@
2929) -> Result<AppResponseData<NoData>, ApiError> {
3030 let caller = require_uid(&session)?;
3131 service
32 .assign_group(caller, &path.space_name, path.uid, cmd)
32 .assign_group(caller, &path.space_name, path.user_id, cmd)
3333 .await?;
3434 Ok(AppResponseData::empty())
3535}
Modified lib/space/src/handlers/create_room.rs View lib/space/src/handlers/create_room.rs
@@ -10,12 +10,13 @@
1010
1111#[utoipa::path(
1212 post,
13 path = "/spaces/{space_name}/rooms",
14 tag = "room",
13 path = "/chat/spaces/{space_name}/rooms",
14 tag = "chat",
1515 params(SpaceNamePath),
1616 request_body = CreateRoomCmd,
1717 responses(
1818 (status = 200, description = "room created", body = AppResponseData<RoomDto>),
19 (status = 429, description = "creation quota exceeded", body = AppResponseData<NoData>),
1920 (status = 400, description = "bad request", body = AppResponseData<NoData>),
2021 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
2122 (status = 404, description = "space not found", body = AppResponseData<NoData>),
Modified lib/space/src/handlers/create_space.rs View lib/space/src/handlers/create_space.rs
@@ -14,6 +14,7 @@
1414 request_body = CreateSpaceCmd,
1515 responses(
1616 (status = 200, description = "space created", body = AppResponseData<SpaceDto>),
17 (status = 429, description = "creation quota exceeded", body = AppResponseData<NoData>),
1718 (status = 400, description = "bad request", body = AppResponseData<NoData>),
1819 (status = 409, description = "space name conflict", body = AppResponseData<NoData>)
1920 )
Modified lib/space/src/handlers/delete_room.rs View lib/space/src/handlers/delete_room.rs
@@ -7,17 +7,13 @@
77use crate::services::service::SpaceService;
88use session::share::session::require_uid;
99
10#[derive(serde::Deserialize, utoipa::IntoParams)]
11pub struct RoomPath {
12 pub space_name: String,
13 pub room_id: Uuid,
14}
15
1610#[utoipa::path(
1711 delete,
18 path = "/spaces/{space_name}/rooms/{room_id}",
19 tag = "room",
20 params(RoomPath),
12 path = "/chat/rooms/{room_id}",
13 tag = "chat",
14 params(
15 ("room_id" = Uuid, Path, description = "room id")
16 ),
2117 responses(
2218 (status = 200, description = "room deleted", body = AppResponseData<NoData>),
2319 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
@@ -27,11 +23,9 @@
2723pub async fn delete_room(
2824 State(service): State<SpaceService>,
2925 session: Session,
30 Path(path): Path<RoomPath>,
26 Path(room_id): Path<Uuid>,
3127) -> Result<AppResponseData<NoData>, ApiError> {
3228 let uid = require_uid(&session)?;
33 service
34 .delete_room(uid, &path.space_name, path.room_id)
35 .await?;
29 service.delete_room(uid, room_id).await?;
3630 Ok(AppResponseData::empty())
3731}
Modified lib/space/src/handlers/get_ban.rs View lib/space/src/handlers/get_ban.rs
@@ -10,7 +10,7 @@
1010
1111#[utoipa::path(
1212 get,
13 path = "/spaces/{space_name}/bans/{uid}",
13 path = "/spaces/{space_name}/bans/{user_id}",
1414 tag = "space",
1515 params(MemberPath),
1616 responses(
@@ -25,6 +25,6 @@
2525 Path(path): Path<MemberPath>,
2626) -> Result<AppResponseData<BanDto>, ApiError> {
2727 let caller = require_uid(&session)?;
28 let ban = service.get_ban(caller, &path.space_name, path.uid).await?;
28 let ban = service.get_ban(caller, &path.space_name, path.user_id).await?;
2929 Ok(AppResponseData::ok(ban))
3030}
Modified lib/space/src/handlers/get_member.rs View lib/space/src/handlers/get_member.rs
@@ -13,12 +13,12 @@
1313#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
1414pub struct MemberPath {
1515 pub space_name: String,
16 pub uid: Uuid,
16 pub user_id: Uuid,
1717}
1818
1919#[utoipa::path(
2020 get,
21 path = "/spaces/{space_name}/members/{uid}",
21 path = "/spaces/{space_name}/members/{user_id}",
2222 tag = "space",
2323 params(MemberPath),
2424 responses(
@@ -33,6 +33,6 @@
3333 Path(path): Path<MemberPath>,
3434) -> Result<AppResponseData<MemberDto>, ApiError> {
3535 let uid = require_uid(&session)?;
36 let member = service.get_member(uid, &path.space_name, path.uid).await?;
36 let member = service.get_member(uid, &path.space_name, path.user_id).await?;
3737 Ok(AppResponseData::ok(member))
3838}
Modified lib/space/src/handlers/remove_member.rs View lib/space/src/handlers/remove_member.rs
@@ -9,7 +9,7 @@
99
1010#[utoipa::path(
1111 delete,
12 path = "/spaces/{space_name}/members/{uid}",
12 path = "/spaces/{space_name}/members/{user_id}",
1313 tag = "space",
1414 params(MemberPath),
1515 responses(
@@ -26,7 +26,7 @@
2626) -> Result<AppResponseData<NoData>, ApiError> {
2727 let caller = require_uid(&session)?;
2828 service
29 .remove_member(caller, &path.space_name, path.uid)
29 .remove_member(caller, &path.space_name, path.user_id)
3030 .await?;
3131 Ok(AppResponseData::empty())
3232}
Modified lib/space/src/handlers/unassign_group.rs View lib/space/src/handlers/unassign_group.rs
@@ -12,13 +12,13 @@
1212#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
1313pub struct MemberGroupPath {
1414 pub space_name: String,
15 pub uid: Uuid,
15 pub user_id: Uuid,
1616 pub group_id: Uuid,
1717}
1818
1919#[utoipa::path(
2020 delete,
21 path = "/spaces/{space_name}/members/{uid}/groups/{group_id}",
21 path = "/spaces/{space_name}/members/{user_id}/groups/{group_id}",
2222 tag = "space",
2323 params(MemberGroupPath),
2424 responses(
@@ -34,7 +34,7 @@
3434) -> Result<AppResponseData<NoData>, ApiError> {
3535 let caller = require_uid(&session)?;
3636 service
37 .unassign_group(caller, &path.space_name, path.uid, path.group_id)
37 .unassign_group(caller, &path.space_name, path.user_id, path.group_id)
3838 .await?;
3939 Ok(AppResponseData::empty())
4040}
Modified lib/space/src/handlers/unban_user.rs View lib/space/src/handlers/unban_user.rs
@@ -9,7 +9,7 @@
99
1010#[utoipa::path(
1111 delete,
12 path = "/spaces/{space_name}/bans/{uid}",
12 path = "/spaces/{space_name}/bans/{user_id}",
1313 tag = "space",
1414 params(MemberPath),
1515 responses(
@@ -25,7 +25,7 @@
2525) -> Result<AppResponseData<NoData>, ApiError> {
2626 let caller = require_uid(&session)?;
2727 service
28 .unban_user(caller, &path.space_name, path.uid)
28 .unban_user(caller, &path.space_name, path.user_id)
2929 .await?;
3030 Ok(AppResponseData::empty())
3131}
Modified lib/space/src/handlers/update_ban.rs View lib/space/src/handlers/update_ban.rs
@@ -10,7 +10,7 @@
1010
1111#[utoipa::path(
1212 patch,
13 path = "/spaces/{space_name}/bans/{uid}",
13 path = "/spaces/{space_name}/bans/{user_id}",
1414 tag = "space",
1515 params(MemberPath),
1616 request_body = UpdateBanCmd,
@@ -28,7 +28,7 @@
2828) -> Result<AppResponseData<BanDto>, ApiError> {
2929 let caller = require_uid(&session)?;
3030 let ban = service
31 .update_ban(caller, &path.space_name, path.uid, cmd)
31 .update_ban(caller, &path.space_name, path.user_id, cmd)
3232 .await?;
3333 Ok(AppResponseData::ok(ban))
3434}
Modified lib/space/src/handlers/update_member.rs View lib/space/src/handlers/update_member.rs
@@ -10,7 +10,7 @@
1010
1111#[utoipa::path(
1212 patch,
13 path = "/spaces/{space_name}/members/{uid}",
13 path = "/spaces/{space_name}/members/{user_id}",
1414 tag = "space",
1515 params(MemberPath),
1616 request_body = UpdateMemberCmd,
@@ -29,7 +29,7 @@
2929) -> Result<AppResponseData<MemberDto>, ApiError> {
3030 let caller = require_uid(&session)?;
3131 let member = service
32 .update_member(caller, &path.space_name, path.uid, cmd)
32 .update_member(caller, &path.space_name, path.user_id, cmd)
3333 .await?;
3434 Ok(AppResponseData::ok(member))
3535}
Modified lib/space/src/services/bans.rs View lib/space/src/services/bans.rs
@@ -296,7 +296,7 @@
296296 .limit
297297 .unwrap_or(default_pagination)
298298 .clamp(1, max_pagination);
299 let offset = q.offset.unwrap_or(0);
299 let offset = q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET);
300300
301301 let select = space_bans::Entity::find()
302302 .filter(space_bans::Column::SpaceId.eq(space.id))
Modified lib/space/src/services/dir.rs View lib/space/src/services/dir.rs
@@ -7,7 +7,8 @@
77use models::space::space_member_groups;
88use models::users::user;
99use sea_orm::{
10 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
10 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
11 Set, TransactionTrait,
1112};
1213use serde::{Deserialize, Serialize};
1314use time::OffsetDateTime;
@@ -250,6 +251,17 @@
250251
251252 let is_owner = space.owner_id == caller;
252253
254 let total_nodes = dirs::Entity::find()
255 .filter(dirs::Column::SpaceId.eq(space.id))
256 .count(self.db().read())
257 .await? as u64;
258 let max_nodes = self.config().space.max_dir_nodes;
259 if total_nodes > max_nodes {
260 return Err(SpaceError::BadRequest(format!(
261 "directory tree has {total_nodes} nodes, exceeds max_dir_nodes {max_nodes}; use a scoped query instead"
262 )));
263 }
264
253265 let nodes = dirs::Entity::find()
254266 .filter(dirs::Column::SpaceId.eq(space.id))
255267 .order_by_asc(dirs::Column::Position)
Modified lib/space/src/services/groups.rs View lib/space/src/services/groups.rs
@@ -397,7 +397,7 @@
397397 .limit
398398 .unwrap_or(default_pagination)
399399 .clamp(1, max_pagination);
400 let offset = q.offset.unwrap_or(0);
400 let offset = q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET);
401401
402402 let select = space_groups::Entity::find()
403403 .filter(space_groups::Column::SpaceId.eq(space.id))
Modified lib/space/src/services/images.rs View lib/space/src/services/images.rs
@@ -17,7 +17,7 @@
1717pub const SPACE_IMAGE_PREFIX: &str = "space-images";
1818
1919#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
20#[serde(rename_all = "kebab-case")]
20#[serde(rename_all = "snake_case")]
2121pub enum SpaceImageKind {
2222 Banner,
2323 InviteBanner,
@@ -28,8 +28,8 @@
2828 pub fn as_str(&self) -> &'static str {
2929 match self {
3030 Self::Banner => "banner",
31 Self::InviteBanner => "invite-banner",
32 Self::EmbedSplash => "embed-splash",
31 Self::InviteBanner => "invite_banner",
32 Self::EmbedSplash => "embed_splash",
3333 }
3434 }
3535}
Modified lib/space/src/services/invites.rs View lib/space/src/services/invites.rs
@@ -75,6 +75,13 @@
7575 true
7676}
7777
78fn random_accept_code() -> String {
79 use rand::RngCore;
80 let mut buf = [0u8; 16];
81 rand::rngs::OsRng.fill_bytes(&mut buf);
82 buf.iter().map(|b| format!("{b:02x}")).collect()
83}
84
7885impl SpaceService {
7986 pub async fn create_invite(
8087 &self,
@@ -204,7 +211,7 @@
204211
205212 let now = OffsetDateTime::now_utc();
206213 let invite_id = Uuid::now_v7();
207 let accept_code = format!("{}-{}", Uuid::now_v7().simple(), Uuid::now_v7().simple());
214 let accept_code = random_accept_code();
208215 let final_email = email_norm.clone().unwrap_or_default();
209216
210217 let invite_row = space_invite::ActiveModel {
@@ -342,6 +349,16 @@
342349 caller: Uuid,
343350 cmd: AcceptInviteCmd,
344351 ) -> Result<(), SpaceError> {
352 if !self
353 .state
354 .redis
355 .quota_allow(&format!("space:invite_accept:{caller}"), 10, 60)
356 .await
357 {
358 return Err(SpaceError::RateLimited(
359 "too many invite acceptance attempts, retry in 60s".to_string(),
360 ));
361 }
345362 let invite = space_invite::Entity::find()
346363 .filter(space_invite::Column::AcceptCode.eq(&cmd.accept_code))
347364 .one(self.db().read())
@@ -513,7 +530,7 @@
513530 .limit
514531 .unwrap_or(default_pagination)
515532 .clamp(1, max_pagination);
516 let offset = q.offset.unwrap_or(0);
533 let offset = q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET);
517534
518535 let select = space_invite::Entity::find()
519536 .filter(space_invite::Column::SpaceId.eq(space.id))
Modified lib/space/src/services/members.rs View lib/space/src/services/members.rs
@@ -450,7 +450,7 @@
450450 .limit
451451 .unwrap_or(default_pagination)
452452 .clamp(1, max_pagination);
453 let offset = q.offset.unwrap_or(0);
453 let offset = q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET);
454454
455455 let select = space_members::Entity::find()
456456 .filter(space_members::Column::SpaceId.eq(space.id))
Modified lib/space/src/services/rooms.rs View lib/space/src/services/rooms.rs
@@ -1,5 +1,6 @@
11use models::room::room_members;
22use models::room::rooms::{self, RoomKind};
3use models::space::spaces;
34use models::space::space_members::{self, MemberStatus};
45use sea_orm::{
56 ActiveModelTrait, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, QuerySelect, Set,
@@ -88,6 +89,20 @@
8889 space_name: &str,
8990 cmd: CreateRoomCmd,
9091 ) -> Result<RoomDto, SpaceError> {
92 if !self
93 .state
94 .redis
95 .quota_allow(
96 &format!("quota:room_create:{caller}"),
97 self.config().quota.room_create_per_day,
98 86_400,
99 )
100 .await
101 {
102 return Err(SpaceError::RateLimited(
103 "room creation quota exceeded, retry tomorrow".to_string(),
104 ));
105 }
91106 let space = lookup_space_by_name(self, space_name).await?;
92107 if space.owner_id != caller {
93108 return Err(SpaceError::Forbidden);
@@ -199,27 +214,27 @@
199214 Ok(RoomDto::from(inserted))
200215 }
201216
202 pub async fn delete_room(
203 &self,
204 caller: Uuid,
205 space_name: &str,
206 room_id: Uuid,
207 ) -> Result<(), SpaceError> {
208 let space = lookup_space_by_name(self, space_name).await?;
209 if space.owner_id != caller {
210 return Err(SpaceError::Forbidden);
211 }
212 if !space.is_active {
217 pub async fn delete_room(&self, caller: Uuid, room_id: Uuid) -> Result<(), SpaceError> {
218 let room = rooms::Entity::find_by_id(room_id)
219 .one(self.db().read())
220 .await?
221 .ok_or(SpaceError::NotFound)?;
222
223 if !room.is_active {
213224 return Err(SpaceError::NotFound);
214225 }
215226
216 let room = rooms::Entity::find_by_id(room_id)
217 .filter(rooms::Column::SpaceId.eq(space.id))
227 let Some(space_id) = room.space_id else {
228 return Err(SpaceError::NotFound);
229 };
230 let space = spaces::Entity::find_by_id(space_id)
218231 .one(self.db().read())
219232 .await?
220233 .ok_or(SpaceError::NotFound)?;
221
222 if !room.is_active {
234 if space.owner_id != caller {
235 return Err(SpaceError::Forbidden);
236 }
237 if !space.is_active {
223238 return Err(SpaceError::NotFound);
224239 }
225240
Modified lib/space/src/services/spaces.rs View lib/space/src/services/spaces.rs
@@ -30,7 +30,7 @@
3030
3131#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
3232pub struct UserBriefDto {
33 pub uid: Uuid,
33 pub user_id: Uuid,
3434 pub username: String,
3535 pub display_name: String,
3636 pub avatar_url: String,
@@ -39,7 +39,7 @@
3939impl From<user::Model> for UserBriefDto {
4040 fn from(u: user::Model) -> Self {
4141 Self {
42 uid: u.uid,
42 user_id: u.uid,
4343 username: u.username,
4444 display_name: u.display_name,
4545 avatar_url: u.avatar_url,
@@ -59,7 +59,7 @@
5959 pub is_active: bool,
6060 pub allow_join: bool,
6161 pub allow_invite: bool,
62 pub nums_members: i64,
62 pub members_count: i64,
6363 pub welcome_message: Option<String>,
6464 pub allow_join_level: AllowJoinLevel,
6565 pub member_must_enable_2fa: bool,
@@ -154,6 +154,20 @@
154154 caller: Uuid,
155155 cmd: CreateSpaceCmd,
156156 ) -> Result<SpaceDto, SpaceError> {
157 if !self
158 .state
159 .redis
160 .quota_allow(
161 &format!("quota:space_create:{caller}"),
162 self.config().quota.space_create_per_day,
163 86_400,
164 )
165 .await
166 {
167 return Err(SpaceError::RateLimited(
168 "space creation quota exceeded, retry tomorrow".to_string(),
169 ));
170 }
157171 let name = cmd.name.trim();
158172 if name.is_empty() || name.chars().count() > self.config().space.max_space_name_len {
159173 return Err(SpaceError::BadRequest(
@@ -277,7 +291,7 @@
277291 is_active: inserted_space.is_active,
278292 allow_join: inserted_space.allow_join,
279293 allow_invite: inserted_space.allow_invite,
280 nums_members: inserted_space.nums_members,
294 members_count: inserted_space.nums_members,
281295 welcome_message: inserted_space.welcome_message,
282296 allow_join_level: inserted_space.allow_join_level,
283297 member_must_enable_2fa: inserted_space.member_must_enable_2fa,
@@ -322,7 +336,7 @@
322336 is_active: space.is_active,
323337 allow_join: space.allow_join,
324338 allow_invite: space.allow_invite,
325 nums_members: space.nums_members,
339 members_count: space.nums_members,
326340 welcome_message: space.welcome_message,
327341 allow_join_level: space.allow_join_level,
328342 member_must_enable_2fa: space.member_must_enable_2fa,
@@ -434,7 +448,7 @@
434448 is_active: updated.is_active,
435449 allow_join: updated.allow_join,
436450 allow_invite: updated.allow_invite,
437 nums_members: updated.nums_members,
451 members_count: updated.nums_members,
438452 welcome_message: updated.welcome_message,
439453 allow_join_level: updated.allow_join_level,
440454 member_must_enable_2fa: updated.member_must_enable_2fa,
@@ -465,7 +479,7 @@
465479 .limit
466480 .unwrap_or(default_pagination)
467481 .clamp(1, max_pagination);
468 let offset = q.offset.unwrap_or(0);
482 let offset = q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET);
469483
470484 let select = spaces::Entity::find()
471485 .filter(spaces::Column::OwnerId.eq(caller))
@@ -495,7 +509,7 @@
495509 is_active: s.is_active,
496510 allow_join: s.allow_join,
497511 allow_invite: s.allow_invite,
498 nums_members: s.nums_members,
512 members_count: s.nums_members,
499513 welcome_message: s.welcome_message,
500514 allow_join_level: s.allow_join_level,
501515 member_must_enable_2fa: s.member_must_enable_2fa,
Modified lib/space/src/errors.rs View lib/space/src/errors.rs
@@ -1,7 +1,7 @@
11use std::fmt;
22
33use axum::Json;
4use axum::extract::rejection::{PathRejection, QueryRejection};
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
55use axum::http::StatusCode;
66use axum::response::{IntoResponse, Response};
77use export::rest::{AppResponseData, NoData};
@@ -14,6 +14,7 @@
1414 Forbidden,
1515 NotFound,
1616 Conflict(String),
17 RateLimited(String),
1718 Internal(String),
1819}
1920
@@ -25,6 +26,7 @@
2526 Self::Forbidden => write!(f, "forbidden"),
2627 Self::NotFound => write!(f, "not found"),
2728 Self::Conflict(message) => write!(f, "{message}"),
29 Self::RateLimited(message) => write!(f, "{message}"),
2830 Self::Internal(message) => write!(f, "{message}"),
2931 }
3032 }
@@ -96,6 +98,8 @@
9698 NotFound(#[to_schema] AppResponseData<NoData>),
9799 #[response(status = 409, description = "resource conflict")]
98100 Conflict(#[to_schema] AppResponseData<NoData>),
101 #[response(status = 429, description = "rate limit exceeded")]
102 TooManyRequests(#[to_schema] AppResponseData<NoData>),
99103 #[response(status = 500, description = "internal server error")]
100104 Internal(#[to_schema] AppResponseData<NoData>),
101105}
@@ -112,6 +116,7 @@
112116 Self::Forbidden(envelope) => (403, envelope),
113117 Self::NotFound(envelope) => (404, envelope),
114118 Self::Conflict(envelope) => (409, envelope),
119 Self::TooManyRequests(envelope) => (429, envelope),
115120 Self::Internal(envelope) => (500, envelope),
116121 }
117122 }
@@ -134,8 +139,11 @@
134139 SpaceError::BadRequest(msg) => Self::BadRequest(AppResponseData::err(400, msg).with_error_code("bad_request")),
135140 SpaceError::Unauthorized => Self::Unauthorized(Self::payload(401, "unauthorized").with_error_code("unauthorized")),
136141 SpaceError::Forbidden => Self::Forbidden(Self::payload(403, "forbidden").with_error_code("forbidden")),
137 SpaceError::NotFound => Self::NotFound(Self::payload(404, "not found").with_error_code("not_found")),
142 SpaceError::NotFound => Self::NotFound(Self::payload(404, "space resource not found").with_error_code("not_found")),
138143 SpaceError::Conflict(msg) => Self::Conflict(AppResponseData::err(409, msg).with_error_code("conflict")),
144 SpaceError::RateLimited(msg) => {
145 Self::TooManyRequests(AppResponseData::err(429, msg).with_error_code("rate_limited"))
146 }
139147 SpaceError::Internal(message) => {
140148 tracing::warn!(error = %message, "space handler failed");
141149 Self::Internal(Self::payload(500, "internal server error"))
@@ -171,14 +179,28 @@
171179}
172180
173181impl From<PathRejection> for ApiError {
174 fn from(_error: PathRejection) -> Self {
175 Self::BadRequest(Self::payload(400, "bad request"))
182 fn from(error: PathRejection) -> Self {
183 Self::BadRequest(
184 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
185 )
186 }
187}
188
189impl From<JsonRejection> for ApiError {
190 fn from(error: JsonRejection) -> Self {
191 Self::BadRequest(
192 AppResponseData::err(400, "invalid request body")
193 .with_error(error.body_text())
194 .with_error_code("invalid_request_body"),
195 )
176196 }
177197}
178198
179199impl From<QueryRejection> for ApiError {
180 fn from(_error: QueryRejection) -> Self {
181 Self::BadRequest(Self::payload(400, "bad request"))
200 fn from(error: QueryRejection) -> Self {
201 Self::BadRequest(
202 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
203 )
182204 }
183205}
184206
Modified lib/space/src/routes.rs View lib/space/src/routes.rs
@@ -9,11 +9,9 @@
99use crate::handlers::create_group::create_group;
1010use crate::handlers::create_invite::create_invite;
1111use crate::handlers::create_join_rule::create_join_rule;
12use crate::handlers::create_room::create_room;
1312use crate::handlers::create_space::create_space;
1413use crate::handlers::delete_group::delete_group;
1514use crate::handlers::delete_join_rule::delete_join_rule;
16use crate::handlers::delete_room::delete_room;
1715use crate::handlers::delete_space::delete_space;
1816use crate::handlers::delete_space_avatar::delete_space_avatar;
1917use crate::handlers::delete_space_image::delete_space_image;
@@ -77,8 +75,6 @@
7775 "/spaces/{space_name}/images/{kind}",
7876 put(upload_space_image).delete(delete_space_image),
7977 )
80 .route("/spaces/{space_name}/rooms", post(create_room))
81 .route("/spaces/{space_name}/rooms/{room_id}", delete(delete_room))
8278 .route(
8379 "/spaces/{space_name}/dir",
8480 post(create_dir_node).get(get_space_dir_tree),
@@ -113,7 +109,7 @@
113109 post(add_member).get(list_members),
114110 )
115111 .route(
116 "/spaces/{space_name}/members/{uid}",
112 "/spaces/{space_name}/members/{user_id}",
117113 get(get_member).patch(update_member).delete(remove_member),
118114 )
119115 .route(
@@ -125,7 +121,7 @@
125121 patch(update_group).delete(delete_group),
126122 )
127123 .route(
128 "/spaces/{space_name}/members/{uid}/groups",
124 "/spaces/{space_name}/members/{user_id}/groups",
129125 post(assign_group),
130126 )
131127 .route(
@@ -141,7 +137,7 @@
141137 patch(update_join_rule).delete(delete_join_rule),
142138 )
143139 .route(
144 "/spaces/{space_name}/members/{uid}/groups/{group_id}",
140 "/spaces/{space_name}/members/{user_id}/groups/{group_id}",
145141 delete(unassign_group),
146142 )
147143 .route(
@@ -154,7 +150,7 @@
154150 )
155151 .route("/spaces/{space_name}/bans", post(ban_user).get(list_bans))
156152 .route(
157 "/spaces/{space_name}/bans/{uid}",
153 "/spaces/{space_name}/bans/{user_id}",
158154 get(get_ban).patch(update_ban).delete(unban_user),
159155 )
160156 .route_layer(axum::middleware::from_fn_with_state(
Modified lib/space/Cargo.toml View lib/space/Cargo.toml
@@ -4,6 +4,7 @@
44edition.workspace = true
55
66[dependencies]
7rand = { workspace = true }
78async-trait = { workspace = true }
89config = { workspace = true }
910export = { workspace = true }
Deleted lib/user/src/handlers/cancel_scheduled_message.rs
@@ -1,29 +0,0 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use uuid::Uuid;
5
6use crate::errors::ApiError;
7use crate::services::service::UserService;
8use session::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/me/messages/scheduled/{id}",
13 tag = "user",
14 params(
15 ("id" = Uuid, Path, description = "scheduled message id")
16 ),
17 responses(
18 (status = 200, description = "cancel scheduled message", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn cancel_scheduled_message(
22 State(service): State<UserService>,
23 session: Session,
24 Path(id): Path<Uuid>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service.cancel_scheduled_message(uid, id).await?;
28 Ok(AppResponseData::empty())
29}
Deleted lib/user/src/handlers/confirm_deletion.rs
@@ -1,25 +0,0 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::deletion::DeletionDto;
7use crate::services::service::UserService;
8use session::share::session::require_uid;
9
10#[utoipa::path(
11 post,
12 path = "/me/deletion/confirm",
13 tag = "user",
14 responses(
15 (status = 200, description = "deletion confirmed", body = AppResponseData<DeletionDto>)
16 )
17)]
18pub async fn confirm_deletion(
19 State(service): State<UserService>,
20 session: Session,
21) -> Result<AppResponseData<DeletionDto>, ApiError> {
22 let uid = require_uid(&session)?;
23 let deletion = service.confirm_deletion(uid).await?;
24 Ok(AppResponseData::ok(deletion))
25}
Modified lib/user/src/handlers/get_profile_detail.rs View lib/user/src/handlers/get_profile_detail.rs
@@ -9,10 +9,10 @@
99
1010#[utoipa::path(
1111 get,
12 path = "/users/{uid}/profile-detail",
12 path = "/users/{user_id}/profile-detail",
1313 tag = "user",
1414 params(
15 ("uid" = Uuid, Path, description = "target user id")
15 ("user_id" = Uuid, Path, description = "target user id")
1616 ),
1717 responses(
1818 (status = 200, description = "user profile detail", body = AppResponseData<ProfileDetailDto>)
@@ -21,9 +21,9 @@
2121pub async fn get_profile_detail(
2222 State(service): State<UserService>,
2323 session: Session,
24 Path(uid): Path<Uuid>,
24 Path(user_id): Path<Uuid>,
2525) -> Result<AppResponseData<ProfileDetailDto>, ApiError> {
2626 let viewer = session.uid();
27 let detail = service.profile_detail(viewer, uid).await?;
27 let detail = service.profile_detail(viewer, user_id).await?;
2828 Ok(AppResponseData::ok(detail))
2929}
Deleted lib/user/src/handlers/list_scheduled_messages.rs
@@ -1,28 +0,0 @@
1use axum::extract::{Query, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::notifications::ScheduledMessageDto;
7use crate::services::service::UserService;
8use crate::share::page::PageQuery;
9use session::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/me/messages/scheduled",
14 tag = "user",
15 params(PageQuery),
16 responses(
17 (status = 200, description = "list scheduled messages", body = AppResponseData<Vec<ScheduledMessageDto>>)
18 )
19)]
20pub async fn list_scheduled_messages(
21 State(service): State<UserService>,
22 session: Session,
23 Query(query): Query<PageQuery>,
24) -> Result<AppResponseData<Vec<ScheduledMessageDto>>, ApiError> {
25 let uid = require_uid(&session)?;
26 let list = service.list_scheduled_messages(uid, query).await?;
27 Ok(AppResponseData::ok(list))
28}
Modified lib/user/src/handlers/mod.rs View lib/user/src/handlers/mod.rs
@@ -1,6 +1,4 @@
11pub mod add_email;
2pub mod cancel_scheduled_message;
3pub mod confirm_deletion;
42pub mod create_meme;
53pub mod delete_avatar;
64pub mod delete_email;
@@ -12,7 +10,6 @@
1210pub mod list_memes;
1311pub mod list_push_subscriptions;
1412pub mod list_saved_messages;
15pub mod list_scheduled_messages;
1613pub mod list_sessions;
1714pub mod my_reports;
1815pub mod my_spaces;
Modified lib/user/src/services/avatar.rs View lib/user/src/services/avatar.rs
@@ -63,7 +63,7 @@
6363
6464#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
6565pub struct AvatarDto {
66 pub uid: Uuid,
66 pub user_id: Uuid,
6767 pub url: String,
6868 pub format: AvatarFormat,
6969 pub bytes: u64,
@@ -142,7 +142,7 @@
142142 }
143143
144144 Ok(AvatarDto {
145 uid,
145 user_id: uid,
146146 url: stored_url,
147147 format: fmt,
148148 bytes: bytes.len() as u64,
@@ -190,7 +190,7 @@
190190 let ext = user.avatar_url.rsplit('.').next().unwrap_or_default();
191191 let fmt = AvatarFormat::from_extension(ext).unwrap_or(AvatarFormat::Jpg);
192192 Ok(Some(AvatarDto {
193 uid,
193 user_id: uid,
194194 url: user.avatar_url,
195195 format: fmt,
196196 bytes: 0,
Deleted lib/user/src/services/deletion.rs
@@ -1,56 +0,0 @@
1use models::users::user_deletion_requests::{self, UserDeletionRequestStatus};
2use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use utoipa::ToSchema;
6use uuid::Uuid;
7
8use crate::errors::UserError;
9use crate::services::service::UserService;
10
11#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
12pub struct DeletionDto {
13 pub id: Uuid,
14 pub reason: Option<String>,
15 pub status: String,
16 pub scheduled_for: Option<OffsetDateTime>,
17 pub created_at: OffsetDateTime,
18}
19
20fn status_str(status: UserDeletionRequestStatus) -> &'static str {
21 match status {
22 UserDeletionRequestStatus::Pending => "pending",
23 UserDeletionRequestStatus::Scheduled => "scheduled",
24 UserDeletionRequestStatus::Completed => "completed",
25 UserDeletionRequestStatus::Cancelled => "cancelled",
26 }
27}
28
29impl UserService {
30 pub async fn confirm_deletion(&self, uid: Uuid) -> Result<DeletionDto, UserError> {
31 let row = user_deletion_requests::Entity::find()
32 .filter(user_deletion_requests::Column::UserId.eq(uid))
33 .filter(user_deletion_requests::Column::Status.eq(UserDeletionRequestStatus::Pending))
34 .one(self.db().read())
35 .await?
36 .ok_or(UserError::NotFound)?;
37
38 let now = OffsetDateTime::now_utc();
39 let grace_days = self.config().user.deletion_grace_days;
40 let scheduled_for = now + time::Duration::days(grace_days as i64);
41
42 let mut am: user_deletion_requests::ActiveModel = row.into();
43 am.status = Set(UserDeletionRequestStatus::Scheduled);
44 am.scheduled_for = Set(Some(scheduled_for));
45 am.updated_at = Set(now);
46 let saved = am.update(self.db().write()).await?;
47
48 Ok(DeletionDto {
49 id: saved.id,
50 reason: saved.reason,
51 status: status_str(saved.status).to_string(),
52 scheduled_for: saved.scheduled_for,
53 created_at: saved.created_at,
54 })
55 }
56}
Modified lib/user/src/services/mod.rs View lib/user/src/services/mod.rs
@@ -1,5 +1,4 @@
11pub mod avatar;
2pub mod deletion;
32pub mod emails;
43pub mod notifications;
54pub mod profile;
Modified lib/user/src/services/notifications.rs View lib/user/src/services/notifications.rs
@@ -1,7 +1,6 @@
11use models::users::user_recent_mentions;
22use models::users::user_reports::{self, ReportStatus};
33use models::users::user_saved_messages;
4use models::users::user_scheduled_messages::{self, ScheduledMessageStatus};
54use sea_orm::{
65 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
76};
@@ -85,29 +84,7 @@
8584 pub message_id: Uuid,
8685}
8786
88#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
89pub struct ScheduledMessageDto {
90 pub id: Uuid,
91 pub target_channel_id: Uuid,
92 pub payload: serde_json::Value,
93 pub scheduled_at: OffsetDateTime,
94 pub status: ScheduledMessageStatus,
95 pub created_at: OffsetDateTime,
96}
9787
98impl From<user_scheduled_messages::Model> for ScheduledMessageDto {
99 fn from(m: user_scheduled_messages::Model) -> Self {
100 Self {
101 id: m.id,
102 target_channel_id: m.target_channel_id,
103 payload: m.payload,
104 scheduled_at: m.scheduled_at,
105 status: m.status,
106 created_at: m.created_at,
107 }
108 }
109}
110
11188impl UserService {
11289 pub async fn recent_mentions(
11390 &self,
@@ -186,32 +163,5 @@
186163 Ok(())
187164 }
188165
189 pub async fn list_scheduled_messages(
190 &self,
191 uid: Uuid,
192 q: PageQuery,
193 ) -> Result<Vec<ScheduledMessageDto>, UserError> {
194 let rows = user_scheduled_messages::Entity::find()
195 .filter(user_scheduled_messages::Column::UserId.eq(uid))
196 .order_by_asc(user_scheduled_messages::Column::ScheduledAt)
197 .limit(q.limit())
198 .offset(q.offset())
199 .all(self.db().read())
200 .await?;
201 Ok(rows.into_iter().map(ScheduledMessageDto::from).collect())
202 }
203166
204 pub async fn cancel_scheduled_message(&self, uid: Uuid, id: Uuid) -> Result<(), UserError> {
205 let row = user_scheduled_messages::Entity::find_by_id(id)
206 .one(self.db().read())
207 .await?
208 .ok_or(UserError::NotFound)?;
209 if row.user_id != uid {
210 return Err(UserError::Forbidden);
211 }
212 let mut am: user_scheduled_messages::ActiveModel = row.into();
213 am.status = Set(ScheduledMessageStatus::Cancelled);
214 am.update(self.db().write()).await?;
215 Ok(())
216 }
217167}
Modified lib/user/src/services/profile.rs View lib/user/src/services/profile.rs
@@ -37,7 +37,7 @@
3737 pub owner_uid: Uuid,
3838 pub avatar_url: Option<String>,
3939 pub is_private: bool,
40 pub nums_members: i64,
40 pub members_count: i64,
4141 pub created_at: OffsetDateTime,
4242}
4343
@@ -91,7 +91,7 @@
9191
9292#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
9393pub struct ProfileDetailDto {
94 pub uid: Uuid,
94 pub user_id: Uuid,
9595 pub username: String,
9696 pub display_name: String,
9797 pub avatar_url: String,
@@ -167,7 +167,7 @@
167167 owner_uid: space.owner_id,
168168 avatar_url: space.avatar_url,
169169 is_private: space.is_private,
170 nums_members: space.nums_members,
170 members_count: space.nums_members,
171171 created_at: space.created_at,
172172 };
173173 out.push(MySpaceDto {
@@ -296,7 +296,7 @@
296296 let emit_real_name = is_owner || p.show_real_name;
297297 let emit_location = is_owner || p.show_location;
298298 Ok(ProfileDetailDto {
299 uid: user.uid,
299 user_id: user.uid,
300300 username: user.username,
301301 display_name: user.display_name,
302302 avatar_url: user.avatar_url,
@@ -317,7 +317,7 @@
317317 })
318318 } else {
319319 Ok(ProfileDetailDto {
320 uid: user.uid,
320 user_id: user.uid,
321321 username: user.username,
322322 display_name: user.display_name,
323323 avatar_url: user.avatar_url,
Modified lib/user/src/services/sessions.rs View lib/user/src/services/sessions.rs
@@ -79,7 +79,7 @@
7979 .unwrap_or(DEFAULT_PAGE_LIMIT)
8080 .clamp(1, MAX_PAGE_LIMIT),
8181 )
82 .offset(q.offset.unwrap_or(0))
82 .offset(q.offset.unwrap_or(0).clamp(0, export::pagination::MAX_OFFSET))
8383 .all(self.db().read())
8484 .await?;
8585 Ok(rows.into_iter().map(SessionDto::from).collect())
Modified lib/user/src/share/types.rs View lib/user/src/share/types.rs
@@ -1,13 +1,2 @@
1use serde::{Deserialize, Serialize};
2use utoipa::ToSchema;
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, ToSchema)]
6pub struct UserPublic {
7 pub uid: Uuid,
8 pub username: String,
9 pub display_name: String,
10 pub avatar_url: String,
11}
121
132pub type ListQuery = export::pagination::PageQuery;
Modified lib/user/src/errors.rs View lib/user/src/errors.rs
@@ -1,7 +1,7 @@
11use std::fmt;
22
33use axum::Json;
4use axum::extract::rejection::{PathRejection, QueryRejection};
4use axum::extract::rejection::{JsonRejection, PathRejection, QueryRejection};
55use axum::http::StatusCode;
66use axum::response::{IntoResponse, Response};
77use export::rest::{AppResponseData, NoData};
@@ -140,7 +140,7 @@
140140 UserError::BadRequest(msg) => Self::BadRequest(AppResponseData::err(400, msg).with_error_code("bad_request")),
141141 UserError::Unauthorized => Self::Unauthorized(Self::payload(401, "unauthorized").with_error_code("unauthorized")),
142142 UserError::Forbidden => Self::Forbidden(Self::payload(403, "forbidden").with_error_code("forbidden")),
143 UserError::NotFound => Self::NotFound(Self::payload(404, "not found").with_error_code("not_found")),
143 UserError::NotFound => Self::NotFound(Self::payload(404, "user resource not found").with_error_code("not_found")),
144144 UserError::Conflict(msg) => Self::Conflict(AppResponseData::err(409, msg).with_error_code("conflict")),
145145 UserError::Internal(message) => {
146146 tracing::warn!(error = %message, "user handler failed");
@@ -177,14 +177,28 @@
177177}
178178
179179impl From<PathRejection> for ApiError {
180 fn from(_error: PathRejection) -> Self {
181 Self::BadRequest(Self::payload(400, "bad request"))
180 fn from(error: PathRejection) -> Self {
181 Self::BadRequest(
182 AppResponseData::err(400, "invalid request path").with_error(error.body_text()),
183 )
184 }
185}
186
187impl From<JsonRejection> for ApiError {
188 fn from(error: JsonRejection) -> Self {
189 Self::BadRequest(
190 AppResponseData::err(400, "invalid request body")
191 .with_error(error.body_text())
192 .with_error_code("invalid_request_body"),
193 )
182194 }
183195}
184196
185197impl From<QueryRejection> for ApiError {
186 fn from(_error: QueryRejection) -> Self {
187 Self::BadRequest(Self::payload(400, "bad request"))
198 fn from(error: QueryRejection) -> Self {
199 Self::BadRequest(
200 AppResponseData::err(400, "invalid query parameters").with_error(error.body_text()),
201 )
188202 }
189203}
190204
Modified lib/user/src/openapi.rs View lib/user/src/openapi.rs
@@ -2,17 +2,15 @@
22use models::users::user::ProfileVisibility;
33use models::users::user_push_subscriptions::PushSubscriptionPlatform;
44use models::users::user_reports::ReportStatus;
5use models::users::user_scheduled_messages::ScheduledMessageStatus;
65use models::users::user_sessions::SessionKind;
76use models::users::user_suspensions::SuspensionType;
87use utoipa::OpenApi;
98
109use crate::handlers;
1110use crate::services::avatar::{AvatarDto, AvatarFormat, UploadAvatarCmd};
12use crate::services::deletion::DeletionDto;
1311use crate::services::emails::{AddEmailCmd, AddEmailResp, EmailDto, VerifyEmailCmd};
1412use crate::services::notifications::{
15 RecentMentionDto, ReportDto, SaveMessageCmd, SavedMessageDto, ScheduledMessageDto,
13 RecentMentionDto, ReportDto, SaveMessageCmd, SavedMessageDto,
1614};
1715use crate::services::profile::{
1816 ListLimitQuery, MemeDto, MySpaceDto, ProfileDetailDto, SpaceDto, UpsertMemeCmd,
@@ -49,9 +47,6 @@
4947 handlers::list_saved_messages::list_saved_messages,
5048 handlers::save_message::save_message,
5149 handlers::unsave_message::unsave_message,
52 handlers::list_scheduled_messages::list_scheduled_messages,
53 handlers::cancel_scheduled_message::cancel_scheduled_message,
54 handlers::confirm_deletion::confirm_deletion,
5550 handlers::my_suspensions::my_suspensions,
5651 ),
5752 components(schemas(
@@ -78,15 +73,12 @@
7873 ReportDto,
7974 SavedMessageDto,
8075 SaveMessageCmd,
81 ScheduledMessageDto,
82 DeletionDto,
8376 SuspensionDto,
8477 IncludeRevokedQuery,
8578 ProfileVisibility,
8679 SessionKind,
8780 PushSubscriptionPlatform,
8881 ReportStatus,
89 ScheduledMessageStatus,
9082 SuspensionType,
9183 ))
9284)]
Modified lib/user/src/routes.rs View lib/user/src/routes.rs
@@ -2,8 +2,6 @@
22use axum::routing::{delete, get, patch, post};
33
44use crate::handlers::add_email::add_email;
5use crate::handlers::cancel_scheduled_message::cancel_scheduled_message;
6use crate::handlers::confirm_deletion::confirm_deletion;
75use crate::handlers::create_meme::create_meme;
86use crate::handlers::delete_avatar::delete_avatar;
97use crate::handlers::delete_email::delete_email;
@@ -15,7 +13,6 @@
1513use crate::handlers::list_memes::list_memes;
1614use crate::handlers::list_push_subscriptions::list_push_subscriptions;
1715use crate::handlers::list_saved_messages::list_saved_messages;
18use crate::handlers::list_scheduled_messages::list_scheduled_messages;
1916use crate::handlers::list_sessions::list_sessions;
2017use crate::handlers::my_reports::my_reports;
2118use crate::handlers::my_spaces::my_spaces;
@@ -34,7 +31,7 @@
3431
3532pub fn router(service: UserService) -> Router<UserService> {
3633 let public: Router<UserService> =
37 Router::new().route("/users/{uid}/profile-detail", get(get_profile_detail));
34 Router::new().route("/users/{user_id}/profile-detail", get(get_profile_detail));
3835
3936 let protected = Router::new()
4037 .route(
@@ -68,12 +65,6 @@
6865 get(list_saved_messages).post(save_message),
6966 )
7067 .route("/me/messages/saved/{message_id}", delete(unsave_message))
71 .route("/me/messages/scheduled", get(list_scheduled_messages))
72 .route(
73 "/me/messages/scheduled/{id}",
74 delete(cancel_scheduled_message),
75 )
76 .route("/me/deletion/confirm", post(confirm_deletion))
7768 .route("/me/suspensions", get(my_suspensions))
7869 .route_layer(axum::middleware::from_fn_with_state(
7970 service.clone(),
Modified lib/webapi/src/lib.rs View lib/webapi/src/lib.rs
@@ -12,19 +12,52 @@
1212
1313const MAX_REQUEST_BODY_BYTES: usize = 2 * 1024 * 1024;
1414
15fn build_cors_layer(web: &config::web::WebConfig) -> tower_http::cors::CorsLayer {
16 use axum::http::{HeaderName, header};
17 use tower_http::cors::{AllowOrigin, CorsLayer};
18
19 if web.cors_allow_origins.is_empty() {
20 return CorsLayer::new();
21 }
22 let origins: Vec<axum::http::HeaderValue> = web
23 .cors_allow_origins
24 .iter()
25 .filter_map(|o| o.parse().ok())
26 .collect();
27 CorsLayer::new()
28 .allow_origin(AllowOrigin::list(origins))
29 .allow_credentials(web.cors_allow_credentials)
30 .allow_methods([
31 axum::http::Method::GET,
32 axum::http::Method::POST,
33 axum::http::Method::PUT,
34 axum::http::Method::PATCH,
35 axum::http::Method::DELETE,
36 axum::http::Method::OPTIONS,
37 ])
38 .allow_headers([
39 header::CONTENT_TYPE,
40 header::AUTHORIZATION,
41 HeaderName::from_static("x-request-id"),
42 ])
43}
44
1545pub async fn router(state: AppState) -> Result<Router, axum_session::SessionError> {
1646 let store = store::connect(state.redis.clone(), &state.config.session).await?;
1747
48 let cors = build_cors_layer(&state.config.web);
49
1850 let auth = auth::services::AuthService::new(state.clone());
1951 let chat = chat::services::service::ChatService::new(state.clone());
2052 let issues = issues::services::service::IssueService::new(state.clone());
2153 let space = space::services::service::SpaceService::new(state.clone());
2254 let user = user::services::service::UserService::new(state.clone());
23 let git = git::service::GitService::new(state);
55 let git = git::service::GitService::new(state.clone());
56 let health_state = state;
2457
2558 let public: Router = Router::new()
2659 .merge(auth::routes::router(auth.clone()).with_state(auth.clone()))
27 .merge(chat::routes::router(chat.clone()).with_state(chat))
60 .merge(chat::routes::router(chat.clone(), space.clone()).with_state(chat))
2861 .merge(issues::routes::router(issues.clone()).with_state(issues))
2962 .merge(space::routes::router(space.clone()).with_state(space))
3063 .merge(user::routes::router(user.clone()).with_state(user))
@@ -40,6 +73,8 @@
4073 .nest("/api/v1", public)
4174 .nest("/api/v1/internal", internal)
4275 .nest("/pages", auth::routes::pages_router(auth.clone()))
76 .merge(export::health::router(health_state))
77 .layer(cors)
4378 .layer(SessionLayer::new(store))
4479 .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY_BYTES))
4580 .layer(
Modified lib/webapi/src/openapi.rs View lib/webapi/src/openapi.rs
@@ -4,6 +4,79 @@
44#[openapi(info(title = "gitdata api", version = "1.0"))]
55pub struct WebApiDoc;
66
7const CONVENTIONS: &str = r#"
8## Conventions
9
10### Response envelope
11Every JSON response uses the same envelope:
12
13| field | meaning |
14|---|---|
15| `code` | always equal to the HTTP status code |
16| `message` | human-readable result / error summary |
17| `data` | payload, `null` for errors and empty results |
18| `error` | field-level / technical detail (e.g. validation errors), may be `null` |
19| `error_code` | stable machine-readable code on 4xx (e.g. `conflict`, `rate_limited`), may be `null` |
20| `request_id` / `log_id` | correlation ids, also returned as `X-Request-Id` / `X-Log-Id` headers |
21
22Successful `POST` creation returns `200` (not `201`); empty results use `200` with `data: null`.
23- `POST /auth/logout` is idempotent: repeating it always succeeds with `200`.
24
25### Authentication
26- `session_cookie`: primary web session auth (cookie `gitdata_session`).
27- `basic_access_key`: HTTP Basic auth with an access key created via
28 `POST /auth/access-keys` (id as user, secret as password). **Scope: git
29 repository endpoints only** (`/repos/*`, `/graphql`); it is NOT accepted by
30 chat/issues/space/user endpoints.
31
32### URL & parameter style
33- Paths are `kebab-case`, plural nouns, nesting expresses ownership.
34- Query/path/JSON field names are `snake_case`; enum values are `snake_case`.
35- All endpoints are versioned under `/api/v1`. `/api/v1/internal/*` requires a
36 session belonging to a configured platform admin and is NOT documented here.
37- State-transition actions use `POST {resource}/{id}/{verb}` (close/reopen);
38 idempotent whole-resource state uses `PUT` (default branch, pin, read-state);
39 partial updates use `PATCH`.
40
41### Pagination
42- Offset style: `?limit=&offset=` (default 50, max 100 unless stated),
43 response `data` is `{ items, total, limit, offset }`.
44- Cursor style (chat message/sequence data): `?before_seq=&after_seq=&limit=`,
45 response contains `has_more`.
46
47### PATCH semantics (three-state fields)
48Update commands use `Option<Option<T>>` for clearable fields:
49- field **absent** → unchanged
50- field `null` → cleared
51- field value → set
52
53Example: `{"milestone_id": null}` unassigns the milestone; omitting
54`milestone_id` leaves it untouched.
55
56### 2FA login exchange
57`POST /auth/login` responds with `needs_2fa` and an opaque one-time
58`challenge` string; pass it to `POST /auth/login/2fa` together with the
59TOTP/recovery code. The challenge is single-use and short-lived.
60
61### Operations
62- `GET /healthz` — liveness probe (no auth, outside `/api/v1`).
63- `GET /readyz` — readiness probe: database + redis check, `503` when down.
64- Runtime metrics are pushed to the configured OTLP collector (30s interval);
65 there is no scrape endpoint.
66- Cross-origin browser access is disabled by default; the API expects a
67 same-origin reverse proxy. Enable `web.cors_allow_origins` to allowlist
68 other origins.
69
70### Abuse controls
71- Creation endpoints are subject to per-user rolling 24h quotas
72 (`[quota]` config; 0 disables). Exceeding a quota returns `429`
73 with `error_code: "rate_limited"`.
74
75### Idempotency
76- `POST /chat/rooms/{room_id}/messages` accepts `nonce`: unique per room;
77 retrying with the same nonce returns the original message instead of a duplicate.
78"#;
79
780pub fn api_doc() -> utoipa::openapi::OpenApi {
881 let mut doc = WebApiDoc::openapi();
982 doc.merge(auth::openapi::AuthApiDoc::openapi());
@@ -13,5 +86,26 @@
1386 doc.merge(user::openapi::UserApiDoc::openapi());
1487 doc.merge(git::openapi::GitApiDoc::openapi());
1588 doc.servers = Some(vec![utoipa::openapi::Server::new("/api/v1")]);
89 doc.info.description = Some(CONVENTIONS.to_string());
90 doc.info.license = None;
91 {
92 use utoipa::openapi::security::{ApiKey, ApiKeyValue, Http, HttpAuthScheme, SecurityScheme};
93 let components = doc.components.get_or_insert_with(utoipa::openapi::Components::default);
94 components.add_security_scheme(
95 "session_cookie",
96 SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new("gitdata_session"))),
97 );
98 components.add_security_scheme(
99 "basic_access_key",
100 SecurityScheme::Http(Http::new(HttpAuthScheme::Basic)),
101 );
102 }
103 doc.security = Some(vec![
104 utoipa::openapi::security::SecurityRequirement::new("session_cookie", Vec::<String>::new()),
105 utoipa::openapi::security::SecurityRequirement::new(
106 "basic_access_key",
107 Vec::<String>::new(),
108 ),
109 ]);
16110 doc
17111}
Modified lib/webapi/Cargo.toml View lib/webapi/Cargo.toml
@@ -4,6 +4,7 @@
44edition.workspace = true
55
66[dependencies]
7config = { path = "../config" }
78auth = { workspace = true }
89chat = { workspace = true }
910issues = { workspace = true }
Modified Cargo.lock View Cargo.lock
@@ -7464,6 +7464,7 @@
74647464 "email",
74657465 "export",
74667466 "models",
7467 "rand 0.8.7",
74677468 "sea-orm",
74687469 "serde",
74697470 "serde_json",
@@ -8832,6 +8833,7 @@
88328833 "axum-otel-metrics",
88338834 "axum_session",
88348835 "chat",
8836 "config",
88358837 "export",
88368838 "git",
88378839 "issues",
Modified Cargo.toml View Cargo.toml
@@ -56,7 +56,7 @@
5656axum = { version = "0.8.9", features = ["ws"] }
5757axum_session = { version = "0.21.0", features = ["key-store"] }
5858tower = "0.5.3"
59tower-http = { version = "0.7.0", features = ["trace", "limit"] }
59tower-http = { version = "0.7.0", features = ["trace", "limit", "cors"] }
6060axum-otel-metrics = "0.14.1"
6161opentelemetry = "0.32"
6262opentelemetry_sdk = { version = "0.32.1", features = ["rt-tokio"] }
Modified openapi.json View openapi.json

Diff too large to display.