refactor(api): reorder imports and update code formatting across repository endpoints

- Reordered actix-web imports to standardize import order
- Reordered crate module imports to follow alphabetical ordering
- Updated function calls to use multi-line formatting for better readability
- Standardized blank lines around documentation comments
- Applied consistent formatting to response handling methods
- Normalized import organization across all repository-related API files
- Improved code consistency and maintainability through standardized formatting
- Applied formatting updates to all repository endpoint implementations
This commit is contained in:
zhenyi
2026-06-07 19:41:33 +08:00
parent 7368ba676c
commit 4028f0d943
149 changed files with 4962 additions and 369 deletions
+58
View File
@@ -0,0 +1,58 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::UserGpgKey;
use crate::service::AppService;
use crate::service::user::keys::AddGpgKeyParams;
use crate::session::Session;
/// Add a GPG key
///
/// Registers a new GPG public key for the authenticated user.
/// Requires authentication.
///
/// Parameters:
/// - public_key: ASCII-armored PGP public key block
/// - key_id: Short key ID or full fingerprint
/// - primary_email: Primary email associated with the key (optional)
/// - expires_at: Optional expiration date for the key
///
/// Effects:
/// - Key fingerprint is computed and stored
/// - Key is immediately usable for verifying signed commits/tags
/// - Duplicate keys are rejected
///
/// Returns the created GPG key with fingerprint and metadata.
#[utoipa::path(
post,
path = "/api/v1/user/keys/gpg",
tag = "User",
operation_id = "userAddGpgKey",
request_body(
content = AddGpgKeyParams,
description = "GPG key creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "GPG key added successfully. Returns the created key with fingerprint and metadata.", body = ApiResponse<UserGpgKey>),
(status = 400, description = "Invalid parameters: invalid PGP key format", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 409, description = "GPG key with this fingerprint already exists", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn add_gpg_key(
service: web::Data<AppService>,
session: Session,
params: web::Json<AddGpgKeyParams>,
) -> Result<HttpResponse, AppError> {
let key = service
.user
.user_add_gpg_key(&session, params.into_inner())
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(key)))
}
+58
View File
@@ -0,0 +1,58 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::UserSshKey;
use crate::service::AppService;
use crate::service::user::keys::AddSshKeyParams;
use crate::session::Session;
/// Add an SSH key
///
/// Registers a new SSH public key for the authenticated user.
/// Requires authentication.
///
/// Parameters:
/// - title: Human-readable label for the key (e.g., "Work Laptop")
/// - public_key: SSH public key string (supports RSA, Ed25519, ECDSA, DSA)
/// - key_type: Key algorithm type ("rsa", "ed25519", "ecdsa", "dsa")
/// - expires_at: Optional expiration date for the key
///
/// Effects:
/// - Key fingerprint is computed and stored
/// - Key is immediately usable for Git operations
/// - Duplicate keys are rejected
///
/// Returns the created SSH key with fingerprint and metadata.
#[utoipa::path(
post,
path = "/api/v1/user/keys/ssh",
tag = "User",
operation_id = "userAddSshKey",
request_body(
content = AddSshKeyParams,
description = "SSH key creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "SSH key added successfully. Returns the created key with fingerprint and metadata.", body = ApiResponse<UserSshKey>),
(status = 400, description = "Invalid parameters: invalid key format, unsupported key type, or type mismatch", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 409, description = "SSH key with this fingerprint already exists", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn add_ssh_key(
service: web::Data<AppService>,
session: Session,
params: web::Json<AddSshKeyParams>,
) -> Result<HttpResponse, AppError> {
let key = service
.user
.user_add_ssh_key(&session, params.into_inner())
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(key)))
}
+46
View File
@@ -0,0 +1,46 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
/// Delete user account
///
/// Permanently deletes the authenticated user's account and all associated data.
/// Requires authentication.
///
/// Preconditions:
/// - User must transfer or delete all owned workspaces
/// - User must transfer or delete all owned repositories
///
/// Effects:
/// - All user data is removed (SSH keys, GPG keys, sessions, devices, OAuth links, etc.)
/// - User is soft-deleted (marked as deleted, not physically removed)
/// - Current session is cleared
/// - Account cannot be recovered
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/user/account",
tag = "User",
operation_id = "userDeleteAccount",
responses(
(status = 200, description = "Account deleted successfully. All user data has been removed.", body = ApiResponse<String>),
(status = 400, description = "Cannot delete: user still owns workspaces or repositories", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "User not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_account(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
service.user.user_delete_account(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Account deleted successfully".to_string())))
}
+53
View File
@@ -0,0 +1,53 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Device ID (UUID)
pub device_id: uuid::Uuid,
}
/// Delete a user device
///
/// Removes a registered device from the authenticated user's trusted devices.
/// Requires authentication.
///
/// Effects:
/// - Device is permanently removed
/// - Device can no longer be used for 2FA bypass
/// - Device will need to be re-registered and verified if needed
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/user/security/devices/{device_id}",
tag = "User",
operation_id = "userDeleteDevice",
params(PathParams),
responses(
(status = 200, description = "Device deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Device not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_device(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.user
.user_delete_device(&session, path.device_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Device deleted successfully".to_string())))
}
+53
View File
@@ -0,0 +1,53 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// GPG key ID (UUID)
pub key_id: uuid::Uuid,
}
/// Delete a GPG key
///
/// Revokes a GPG key belonging to the authenticated user.
/// Requires authentication.
///
/// Effects:
/// - Key is marked as revoked (soft-deleted)
/// - Key can no longer be used for verifying commits/tags
/// - Revoked keys remain visible in key history
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/user/keys/gpg/{key_id}",
tag = "User",
operation_id = "userDeleteGpgKey",
params(PathParams),
responses(
(status = 200, description = "GPG key revoked successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "GPG key not found or already revoked", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_gpg_key(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.user
.user_delete_gpg_key(&session, path.key_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("GPG key revoked successfully".to_string())))
}
+53
View File
@@ -0,0 +1,53 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// SSH key ID (UUID)
pub key_id: uuid::Uuid,
}
/// Delete an SSH key
///
/// Revokes an SSH key belonging to the authenticated user.
/// Requires authentication.
///
/// Effects:
/// - Key is marked as revoked (soft-deleted)
/// - Key can no longer be used for Git operations
/// - Revoked keys remain visible in key history
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/user/keys/ssh/{key_id}",
tag = "User",
operation_id = "userDeleteSshKey",
params(PathParams),
responses(
(status = 200, description = "SSH key revoked successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "SSH key not found or already revoked", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_ssh_key(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.user
.user_delete_ssh_key(&session, path.key_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("SSH key revoked successfully".to_string())))
}
+39
View File
@@ -0,0 +1,39 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::User;
use crate::service::AppService;
use crate::session::Session;
/// Get current user account
///
/// Returns the authenticated user's account information including:
/// - Username, display name, and bio
/// - Avatar URL
/// - Account status and role
/// - Last login and creation timestamps
///
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/user/account",
tag = "User",
operation_id = "userGetAccount",
responses(
(status = 200, description = "Account retrieved successfully. Returns user account with all metadata.", body = ApiResponse<User>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "User not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn get_account(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let user = service.user.user_account(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(user)))
}
+42
View File
@@ -0,0 +1,42 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::UserAppearance;
use crate::service::AppService;
use crate::session::Session;
/// Get user appearance settings
///
/// Returns the authenticated user's UI appearance preferences including:
/// - Theme (system, light, dark)
/// - Color scheme (system, light, dark)
/// - Density (compact, comfortable)
/// - Font size (small, medium, large)
/// - Editor theme
/// - Markdown preview toggle
/// - Reduced motion toggle
///
/// If no settings exist, defaults are created automatically.
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/user/appearance",
tag = "User",
operation_id = "userGetAppearance",
responses(
(status = 200, description = "Appearance settings retrieved successfully. Returns all UI preference settings.", body = ApiResponse<UserAppearance>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn get_appearance(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let appearance = service.user.user_appearance(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(appearance)))
}
+42
View File
@@ -0,0 +1,42 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::UserNotifySetting;
use crate::service::AppService;
use crate::session::Session;
/// Get user notification settings
///
/// Returns the authenticated user's notification preferences including:
/// - Email notification toggle
/// - Web push notification toggle
/// - Mention notification toggle
/// - Review notification toggle
/// - Security notification toggle
/// - Marketing email toggle
/// - Digest frequency (realtime, daily, weekly, off)
///
/// If no settings exist, defaults are created automatically.
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/user/notifications",
tag = "User",
operation_id = "userGetNotifications",
responses(
(status = 200, description = "Notification settings retrieved successfully. Returns all notification preferences.", body = ApiResponse<UserNotifySetting>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn get_notifications(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let settings = service.user.user_notify_setting(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(settings)))
}
+40
View File
@@ -0,0 +1,40 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::UserProfile;
use crate::service::AppService;
use crate::session::Session;
/// Get user profile
///
/// Returns the authenticated user's public profile information including:
/// - Full name and company
/// - Location and website URL
/// - Twitter username
/// - Timezone and language
/// - Profile README
///
/// If no profile exists, an empty profile is created automatically.
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/user/profile",
tag = "User",
operation_id = "userGetProfile",
responses(
(status = 200, description = "Profile retrieved successfully. Returns all profile fields.", body = ApiResponse<UserProfile>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn get_profile(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let profile = service.user.user_profile(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(profile)))
}
+35
View File
@@ -0,0 +1,35 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::UserDevice;
use crate::service::AppService;
use crate::session::Session;
/// List user devices
///
/// Returns all registered devices for the authenticated user.
/// Devices are sorted by last seen time (most recent first).
/// Includes device metadata such as name, type, fingerprint, and trust status.
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/user/security/devices",
tag = "User",
operation_id = "userListDevices",
responses(
(status = 200, description = "Devices listed successfully. Returns array of device objects with metadata.", body = ApiResponse<Vec<UserDevice>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_devices(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let devices = service.user.user_devices(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(devices)))
}
+35
View File
@@ -0,0 +1,35 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::UserGpgKey;
use crate::service::AppService;
use crate::session::Session;
/// List user GPG keys
///
/// Returns all GPG public keys registered by the authenticated user.
/// Keys are sorted by creation date (newest first).
/// Only non-revoked keys are included.
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/user/keys/gpg",
tag = "User",
operation_id = "userListGpgKeys",
responses(
(status = 200, description = "GPG keys listed successfully. Returns array of GPG key objects with fingerprints and metadata.", body = ApiResponse<Vec<UserGpgKey>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_gpg_keys(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let keys = service.user.user_gpg_keys(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(keys)))
}
+35
View File
@@ -0,0 +1,35 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::user::security::UserOAuthInfo;
use crate::session::Session;
/// List OAuth accounts
///
/// Returns all linked OAuth/third-party login accounts for the authenticated user.
/// Accounts are sorted by link date (most recent first).
/// Includes provider information, usernames, and token expiry status.
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/user/security/oauth",
tag = "User",
operation_id = "userListOAuthAccounts",
responses(
(status = 200, description = "OAuth accounts listed successfully. Returns array of linked OAuth accounts with provider details.", body = ApiResponse<Vec<UserOAuthInfo>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_oauth_accounts(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let accounts = service.user.user_oauth_accounts(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(accounts)))
}
+55
View File
@@ -0,0 +1,55 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::user::security::UserPersonalAccessTokenInfo;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of tokens to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of tokens to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List personal access tokens
///
/// Returns a paginated list of all personal access tokens (PATs) for the authenticated user.
/// Tokens are sorted by creation date (newest first).
/// Includes token names, scopes, last used timestamps, and expiry status.
/// Note: Token values are never returned after creation for security reasons.
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/user/security/tokens",
tag = "User",
operation_id = "userListTokens",
params(QueryParams),
responses(
(status = 200, description = "Personal access tokens listed successfully. Returns array of token metadata objects (token values are never exposed).", body = ApiResponse<Vec<UserPersonalAccessTokenInfo>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_tokens(
service: web::Data<AppService>,
session: Session,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let tokens = service
.user
.user_personal_access_tokens(
&session,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(tokens)))
}
+55
View File
@@ -0,0 +1,55 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::UserSecurityLog;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of log entries to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of log entries to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List security logs
///
/// Returns a paginated list of security events for the authenticated user.
/// Entries are sorted by creation date (newest first).
/// Includes event types, descriptions, IP addresses, and user agents.
/// Useful for auditing account activity and detecting suspicious behavior.
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/user/security/logs",
tag = "User",
operation_id = "userListSecurityLogs",
params(QueryParams),
responses(
(status = 200, description = "Security logs listed successfully. Returns array of security event entries with metadata.", body = ApiResponse<Vec<UserSecurityLog>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_security_logs(
service: web::Data<AppService>,
session: Session,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let logs = service
.user
.user_security_logs(
&session,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(logs)))
}
+54
View File
@@ -0,0 +1,54 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::user::security::UserSessionInfo;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of sessions to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of sessions to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List user sessions
///
/// Returns a paginated list of all active and recently-expired sessions for the authenticated user.
/// Sessions are sorted by last activity (most recent first).
/// Includes session metadata such as IP address, user agent, and expiration time.
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/user/security/sessions",
tag = "User",
operation_id = "userListSessions",
params(QueryParams),
responses(
(status = 200, description = "Sessions listed successfully. Returns array of session objects with metadata.", body = ApiResponse<Vec<UserSessionInfo>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_sessions(
service: web::Data<AppService>,
session: Session,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let sessions = service
.user
.user_sessions(
&session,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(sessions)))
}
+35
View File
@@ -0,0 +1,35 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::UserSshKey;
use crate::service::AppService;
use crate::session::Session;
/// List user SSH keys
///
/// Returns all SSH public keys registered by the authenticated user.
/// Keys are sorted by creation date (newest first).
/// Only non-revoked keys are included.
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/user/keys/ssh",
tag = "User",
operation_id = "userListSshKeys",
responses(
(status = 200, description = "SSH keys listed successfully. Returns array of SSH key objects with fingerprints and metadata.", body = ApiResponse<Vec<UserSshKey>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_ssh_keys(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let keys = service.user.user_ssh_keys(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(keys)))
}
+114
View File
@@ -0,0 +1,114 @@
pub mod add_gpg_key;
pub mod add_ssh_key;
pub mod delete_account;
pub mod delete_device;
pub mod delete_gpg_key;
pub mod delete_ssh_key;
pub mod get_account;
pub mod get_appearance;
pub mod get_notifications;
pub mod get_profile;
pub mod list_devices;
pub mod list_gpg_keys;
pub mod list_oauth_accounts;
pub mod list_personal_access_tokens;
pub mod list_security_logs;
pub mod list_sessions;
pub mod list_ssh_keys;
pub mod revoke_personal_access_token;
pub mod revoke_session;
pub mod unlink_oauth;
pub mod update_account;
pub mod update_appearance;
pub mod update_notifications;
pub mod update_profile;
pub mod upload_avatar;
use actix_web::web;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/user")
// Account
.route("/account", web::get().to(get_account::get_account))
.route("/account", web::put().to(update_account::update_account))
.route(
"/account/avatar",
web::post().to(upload_avatar::upload_avatar),
)
.route("/account", web::delete().to(delete_account::delete_account))
// Appearance
.route("/appearance", web::get().to(get_appearance::get_appearance))
.route(
"/appearance",
web::put().to(update_appearance::update_appearance),
)
// Profile
.route("/profile", web::get().to(get_profile::get_profile))
.route("/profile", web::put().to(update_profile::update_profile))
// Notifications
.route(
"/notifications",
web::get().to(get_notifications::get_notifications),
)
.route(
"/notifications",
web::put().to(update_notifications::update_notifications),
)
// SSH Keys
.route("/keys/ssh", web::get().to(list_ssh_keys::list_ssh_keys))
.route("/keys/ssh", web::post().to(add_ssh_key::add_ssh_key))
.route(
"/keys/ssh/{key_id}",
web::delete().to(delete_ssh_key::delete_ssh_key),
)
// GPG Keys
.route("/keys/gpg", web::get().to(list_gpg_keys::list_gpg_keys))
.route("/keys/gpg", web::post().to(add_gpg_key::add_gpg_key))
.route(
"/keys/gpg/{key_id}",
web::delete().to(delete_gpg_key::delete_gpg_key),
)
// Security - Sessions
.route(
"/security/sessions",
web::get().to(list_sessions::list_sessions),
)
.route(
"/security/sessions/{session_id}",
web::delete().to(revoke_session::revoke_session),
)
// Security - Devices
.route(
"/security/devices",
web::get().to(list_devices::list_devices),
)
.route(
"/security/devices/{device_id}",
web::delete().to(delete_device::delete_device),
)
// Security - OAuth
.route(
"/security/oauth",
web::get().to(list_oauth_accounts::list_oauth_accounts),
)
.route(
"/security/oauth/{oauth_id}",
web::delete().to(unlink_oauth::unlink_oauth),
)
// Security - Logs
.route(
"/security/logs",
web::get().to(list_security_logs::list_security_logs),
)
// Security - Personal Access Tokens
.route(
"/security/tokens",
web::get().to(list_personal_access_tokens::list_tokens),
)
.route(
"/security/tokens/{token_id}",
web::delete().to(revoke_personal_access_token::revoke_token),
),
);
}
+55
View File
@@ -0,0 +1,55 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Token ID (UUID)
pub token_id: uuid::Uuid,
}
/// Revoke a personal access token
///
/// Immediately revokes a personal access token belonging to the authenticated user.
/// Requires authentication.
///
/// Effects:
/// - Token is marked as revoked and can no longer be used
/// - All API calls using this token will fail with 401 Unauthorized
/// - Revoked tokens remain visible in token list for audit purposes
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/user/security/tokens/{token_id}",
tag = "User",
operation_id = "userRevokeToken",
params(PathParams),
responses(
(status = 200, description = "Personal access token revoked successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Token not found or already revoked", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn revoke_token(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.user
.user_revoke_personal_access_token(&session, path.token_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(
"Personal access token revoked successfully".to_string(),
)))
}
+53
View File
@@ -0,0 +1,53 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Session ID (UUID)
pub session_id: uuid::Uuid,
}
/// Revoke a user session
///
/// Immediately terminates a specific session belonging to the authenticated user.
/// Requires authentication.
///
/// Effects:
/// - Session is marked as revoked
/// - Session can no longer be used for authentication
/// - Active connections using this session are disconnected
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/user/security/sessions/{session_id}",
tag = "User",
operation_id = "userRevokeSession",
params(PathParams),
responses(
(status = 200, description = "Session revoked successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Session not found or already revoked", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn revoke_session(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.user
.user_revoke_session(&session, path.session_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Session revoked successfully".to_string())))
}
+58
View File
@@ -0,0 +1,58 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// OAuth account ID (UUID)
pub oauth_id: uuid::Uuid,
}
/// Unlink an OAuth account
///
/// Removes a linked OAuth/third-party login account from the authenticated user.
/// Requires authentication.
///
/// Preconditions:
/// - User must have at least one remaining login method (password or another OAuth account)
///
/// Effects:
/// - OAuth account link is permanently removed
/// - User can no longer log in with this OAuth provider unless re-linked
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/user/security/oauth/{oauth_id}",
tag = "User",
operation_id = "userUnlinkOAuth",
params(PathParams),
responses(
(status = 200, description = "OAuth account unlinked successfully.", body = ApiResponse<String>),
(status = 400, description = "Cannot unlink: this is the last login method (set a password first)", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "OAuth account not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unlink_oauth(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.user
.user_unlink_oauth(&session, path.oauth_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(
"OAuth account unlinked successfully".to_string(),
)))
}
+55
View File
@@ -0,0 +1,55 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::User;
use crate::service::AppService;
use crate::service::user::account::UpdateUserAccountParams;
use crate::session::Session;
/// Update user account
///
/// Updates the authenticated user's account settings.
/// Requires authentication.
///
/// Updatable fields:
/// - username: New username (must be unique across the platform)
/// - display_name: Human-readable display name
/// - bio: Short biography text
/// - visibility: Profile visibility ("public", "private", or "internal")
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated user account with all metadata.
#[utoipa::path(
put,
path = "/api/v1/user/account",
tag = "User",
operation_id = "userUpdateAccount",
request_body(
content = UpdateUserAccountParams,
description = "Account update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Account updated successfully. Returns updated user account with all metadata.", body = ApiResponse<User>),
(status = 400, description = "Invalid parameters: empty username or unsupported visibility value", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "User not found", body = ApiErrorResponse),
(status = 409, description = "Username already taken", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update_account(
service: web::Data<AppService>,
session: Session,
params: web::Json<UpdateUserAccountParams>,
) -> Result<HttpResponse, AppError> {
let user = service
.user
.user_update_account(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(user)))
}
+56
View File
@@ -0,0 +1,56 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::UserAppearance;
use crate::service::AppService;
use crate::service::user::appearance::UpdateUserAppearanceParams;
use crate::session::Session;
/// Update user appearance settings
///
/// Updates the authenticated user's UI appearance preferences.
/// Requires authentication.
///
/// Updatable fields:
/// - theme: UI theme ("system", "light", "dark")
/// - color_scheme: Color scheme ("system", "light", "dark")
/// - density: UI density ("compact", "comfortable")
/// - font_size: Font size ("small", "medium", "large")
/// - editor_theme: Code editor theme name
/// - markdown_preview: Enable/disable markdown live preview
/// - reduced_motion: Enable/disable reduced motion
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated appearance settings.
#[utoipa::path(
put,
path = "/api/v1/user/appearance",
tag = "User",
operation_id = "userUpdateAppearance",
request_body(
content = UpdateUserAppearanceParams,
description = "Appearance update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Appearance settings updated successfully. Returns all updated UI preferences.", body = ApiResponse<UserAppearance>),
(status = 400, description = "Invalid parameters: unsupported theme, color scheme, density, or font size", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update_appearance(
service: web::Data<AppService>,
session: Session,
params: web::Json<UpdateUserAppearanceParams>,
) -> Result<HttpResponse, AppError> {
let appearance = service
.user
.user_update_appearance(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(appearance)))
}
+56
View File
@@ -0,0 +1,56 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::UserNotifySetting;
use crate::service::AppService;
use crate::service::user::notify::UpdateUserNotifySettingParams;
use crate::session::Session;
/// Update user notification settings
///
/// Updates the authenticated user's notification preferences.
/// Requires authentication.
///
/// Updatable fields:
/// - email_notifications: Enable/disable email notifications
/// - web_notifications: Enable/disable web push notifications
/// - mention_notifications: Enable/disable @mention notifications
/// - review_notifications: Enable/disable code review notifications
/// - security_notifications: Enable/disable security notifications
/// - marketing_emails: Enable/disable marketing emails
/// - digest_frequency: Digest email frequency ("realtime", "daily", "weekly", "off")
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated notification settings.
#[utoipa::path(
put,
path = "/api/v1/user/notifications",
tag = "User",
operation_id = "userUpdateNotifications",
request_body(
content = UpdateUserNotifySettingParams,
description = "Notification settings update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Notification settings updated successfully. Returns all updated preferences.", body = ApiResponse<UserNotifySetting>),
(status = 400, description = "Invalid parameters: unsupported digest frequency", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update_notifications(
service: web::Data<AppService>,
session: Session,
params: web::Json<UpdateUserNotifySettingParams>,
) -> Result<HttpResponse, AppError> {
let settings = service
.user
.user_update_notify_setting(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(settings)))
}
+57
View File
@@ -0,0 +1,57 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::UserProfile;
use crate::service::AppService;
use crate::service::user::profile::UpdateUserProfileParams;
use crate::session::Session;
/// Update user profile
///
/// Updates the authenticated user's public profile information.
/// Requires authentication.
///
/// Updatable fields:
/// - full_name: Full legal name or display name
/// - company: Organization or company name
/// - location: Geographic location (e.g., "San Francisco, CA")
/// - website_url: Personal or company website URL
/// - twitter_username: Twitter/X handle
/// - timezone: IANA timezone identifier (e.g., "America/New_York")
/// - language: Preferred language code (e.g., "en", "zh-CN")
/// - profile_readme: Markdown content for profile README
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated profile with all fields.
#[utoipa::path(
put,
path = "/api/v1/user/profile",
tag = "User",
operation_id = "userUpdateProfile",
request_body(
content = UpdateUserProfileParams,
description = "Profile update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Profile updated successfully. Returns all updated profile fields.", body = ApiResponse<UserProfile>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update_profile(
service: web::Data<AppService>,
session: Session,
params: web::Json<UpdateUserProfileParams>,
) -> Result<HttpResponse, AppError> {
let profile = service
.user
.user_update_profile(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(profile)))
}
+56
View File
@@ -0,0 +1,56 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::user::account::{UploadUserAvatarParams, UserAvatarResponse};
use crate::session::Session;
/// Upload user avatar
///
/// Uploads a new avatar image for the authenticated user.
/// Requires authentication.
///
/// Parameters:
/// - data: Raw avatar image bytes (PNG, JPEG, or WebP, max 5MB)
/// - content_type: MIME type of the image (e.g., "image/png")
/// - file_name: Original file name (used to infer file extension)
///
/// Effects:
/// - Avatar image is stored in S3-compatible object storage
/// - Previous avatar is deleted from storage
/// - User's avatar URL is updated
///
/// Returns the new avatar URL and storage key.
#[utoipa::path(
post,
path = "/api/v1/user/account/avatar",
tag = "User",
operation_id = "userUploadAvatar",
request_body(
content = UploadUserAvatarParams,
description = "Avatar upload parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Avatar uploaded successfully. Returns the new avatar URL and storage key.", body = ApiResponse<UserAvatarResponse>),
(status = 400, description = "Invalid parameters: unsupported file type or image too large", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "User not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error or S3 storage failure", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn upload_avatar(
service: web::Data<AppService>,
session: Session,
params: web::Json<UploadUserAvatarParams>,
) -> Result<HttpResponse, AppError> {
let response = service
.user
.user_upload_avatar(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(response)))
}