//! User management HTTP handlers use axum::{ body::Bytes, extract::{Path, State}, http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}, response::{IntoResponse, Response}, Json, }; use super::{middleware::RequestMetadata, validation::ProtoQuery, AppResult, AppState}; use synctv_api_common::impls::EndpointRateLimitCategory; use synctv_proto::client::User; use synctv_proto::client::{ CloseAccountRequest, CloseAccountResponse, DeletePasskeyRequest, DeletePasskeyResponse, DeleteTotpRequest, DeleteTotpResponse, DiscoverRoomsRequest, DiscoverRoomsResponse, FavoriteRoomRequest, FavoriteRoomResponse, FinishPasskeyBindRequest, FinishSensitiveOperationVerificationRequest, FinishTotpSetupRequest, GetRoomDiscoveryRequest, ListFavoriteRoomsRequest, ListFavoriteRoomsResponse, ListMyRoomsResponse, ListPasskeysResponse, PasskeyCredential, RequestSensitiveOperationEmailCodeRequest, RequestSensitiveOperationEmailCodeResponse, RoomDiscoveryItem, RoomPathRequest, SensitiveOperationVerificationOutcome, StartPasskeyBindRequest, StartPasskeyBindResponse, StartSensitiveOperationPasskeyRequest, StartSensitiveOperationPasskeyResponse, StartSensitiveOperationVerificationRequest, StartTotpSetupRequest, StartTotpSetupResponse, TotpRecoveryCodesResponse, UnfavoriteRoomRequest, UnfavoriteRoomResponse, }; use synctv_proto::client::{ CompleteUserAvatarUploadSessionRequest, CompleteUserAvatarUploadSessionResponse, CreateUserAvatarUploadSessionRequest, CreateUserAvatarUploadSessionResponse, UpdateUserAvatarRequest, User as UserAvatarUpdateResponse, }; use synctv_proto::client::{ ConfirmEmailBindRequest, GetUserPreferencesResponse, RegenerateTotpRecoveryCodesRequest, SetTwoFactorEnabledRequest, UnbindEmailRequest, UpdateUserPreferencesRequest, UpdateUserPreferencesResponse, }; use synctv_proto::client::{ FinishOpaquePasswordUpdateRequest, StartOpaquePasswordUpdateRequest, StartOpaquePasswordUpdateResponse, }; use synctv_proto::client::{SetUsernameRequest, StartEmailBindRequest, StartEmailBindResponse}; fn file_upload_range_to_proto( range: synctv_core::models::FileUploadRange, ) -> synctv_proto::client::FileUploadRange { synctv_proto::client::FileUploadRange { start: range.start, end_inclusive: range.end_inclusive, total_size: range.total_size, } } fn upload_response_headers( complete: bool, uploaded_size_bytes: i64, uploaded_parts: &[i32], ) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert( HeaderName::from_static("x-synctv-upload-complete"), HeaderValue::from_static(if complete { "true" } else { "false" }), ); if let Ok(value) = HeaderValue::from_str(&uploaded_size_bytes.to_string()) { headers.insert( HeaderName::from_static("x-synctv-uploaded-size-bytes"), value, ); } let uploaded_parts = uploaded_parts .iter() .map(i32::to_string) .collect::>() .join(","); if let Ok(value) = HeaderValue::from_str(&uploaded_parts) { headers.insert(HeaderName::from_static("x-synctv-uploaded-parts"), value); } headers } #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct UserAvatarObjectPath { pub encoded_object_key: String, } #[derive(Debug, serde::Deserialize)] pub struct UserAvatarObjectQuery { pub token: String, } #[derive(Debug, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct PasskeyCredentialPath { pub credential_id: String, } /// Get current user info #[cfg_attr( feature = "openapi", utoipa::path( get, path = "/api/user", tag = "User", responses( (status = 200, description = "Current user profile", body = User), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn get_me( request_meta: RequestMetadata, State(state): State, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Read, |auth| async move { client_api.get_profile(&auth.user_id()).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( get, path = "/api/user/preferences", tag = "User", responses( (status = 200, description = "Current user preferences", body = GetUserPreferencesResponse), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn get_user_preferences( request_meta: RequestMetadata, State(state): State, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Read, |auth| async move { client_api.get_user_preferences(&auth.user_id()).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( patch, path = "/api/user/preferences", tag = "User", request_body = UpdateUserPreferencesRequest, responses( (status = 200, description = "User preferences updated", body = UpdateUserPreferencesResponse), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn update_user_preferences( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api .update_user_preferences(&auth.user_id(), req) .await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( put, path = "/api/user/two-factor", tag = "User", request_body = SetTwoFactorEnabledRequest, responses( (status = 200, description = "Two-factor authentication setting updated", body = GetUserPreferencesResponse), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication or sensitive operation verification required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn set_two_factor_enabled( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api .set_two_factor_enabled(&auth.user_id(), req) .await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } /// Set the current user's username. #[cfg_attr( feature = "openapi", utoipa::path( patch, path = "/api/user", tag = "User", request_body = SetUsernameRequest, responses( (status = 200, description = "Username updated", body = User), (status = 400, description = "Invalid update request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn update_user( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.set_username(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } pub async fn create_user_avatar_upload_session( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api .create_user_avatar_upload_session(&auth.user_id(), req) .await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } pub async fn update_user_avatar( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.update_user_avatar(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } pub async fn clear_user_avatar( request_meta: RequestMetadata, State(state): State, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.clear_user_avatar(&auth.user_id()).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } pub async fn upload_user_avatar_object( request_meta: RequestMetadata, State(state): State, Path(path): Path, headers: HeaderMap, body: Bytes, ) -> AppResult { let upload_token = super::required_header_str( &headers, synctv_core::service::FILE_UPLOAD_TOKEN_HEADER, "Missing file upload token", )?; let content_type = super::optional_header_str(&headers, &header::CONTENT_TYPE)?; let range = super::optional_content_range(&headers)?; let req = synctv_proto::client::UploadUserAvatarObjectRequest { encoded_object_key: path.encoded_object_key, token: upload_token.to_string(), content_type: content_type.map(str::to_string), content_range: range.map(file_upload_range_to_proto), data: body, }; let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_public_endpoint( &request_meta, EndpointRateLimitCategory::Write, move || async move { client_api.upload_user_avatar_object(req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(( upload_response_headers( response.complete, response.uploaded_size_bytes, &response.uploaded_parts, ), StatusCode::NO_CONTENT, ) .into_response()) } pub async fn complete_user_avatar_upload_session( request_meta: RequestMetadata, State(state): State, Path(path): Path, Json(mut req): Json, ) -> AppResult> { req.encoded_object_key = path.encoded_object_key; let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_public_endpoint( &request_meta, EndpointRateLimitCategory::Write, move || async move { client_api.complete_user_avatar_upload_session(req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } pub async fn get_user_avatar_object( request_meta: RequestMetadata, State(state): State, Path(path): Path, headers: HeaderMap, axum::extract::Query(query): axum::extract::Query, ) -> AppResult { let range = super::optional_file_range(&headers)?; let req = synctv_proto::client::GetUserAvatarObjectRequest { encoded_object_key: path.encoded_object_key, token: query.token, range: range.map(super::file_range_request_to_proto), }; let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let download = executor .execute_public_endpoint( &request_meta, EndpointRateLimitCategory::Read, move || async move { client_api.get_user_avatar_object(req).await }, ) .await .map_err(super::error::map_api_error)?; super::file_object_download_response(download, None) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/email/bind/start", tag = "User", request_body = StartEmailBindRequest, responses( (status = 200, description = "Email bind confirmation sent", body = StartEmailBindResponse), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn start_email_bind( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.start_email_bind(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/email/bind/confirm", tag = "User", request_body = ConfirmEmailBindRequest, responses( (status = 200, description = "Email bind confirmed", body = User), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn confirm_email_bind( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.confirm_email_bind(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/email/unbind", tag = "User", request_body = UnbindEmailRequest, responses( (status = 200, description = "Email unbound", body = User), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn unbind_email( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.unbind_email(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/sensitive-verification/start", tag = "User", request_body = StartSensitiveOperationVerificationRequest, responses( (status = 200, description = "Sensitive operation verification started", body = SensitiveOperationVerificationOutcome), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn start_sensitive_operation_verification( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api .start_sensitive_operation_verification( &auth.user_id(), auth.claims.auth_context(), req, ) .await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/sensitive-verification/passkey/start", tag = "User", request_body = StartSensitiveOperationPasskeyRequest, responses( (status = 200, description = "Sensitive operation passkey challenge created", body = StartSensitiveOperationPasskeyResponse), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn start_sensitive_operation_passkey( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api .start_sensitive_operation_passkey(&auth.user_id(), req) .await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/sensitive-verification/email/request", tag = "User", request_body = RequestSensitiveOperationEmailCodeRequest, responses( (status = 200, description = "Sensitive operation email code sent", body = RequestSensitiveOperationEmailCodeResponse), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn request_sensitive_operation_email_code( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api .request_sensitive_operation_email_code(&auth.user_id(), req) .await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/sensitive-verification/finish", tag = "User", request_body = FinishSensitiveOperationVerificationRequest, responses( (status = 200, description = "Sensitive operation verification progressed", body = SensitiveOperationVerificationOutcome), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn finish_sensitive_operation_verification( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let client_ip = request_meta.client_ip; let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint_with_control( &request_meta, EndpointRateLimitCategory::Write, |request_control, auth| async move { client_api .finish_sensitive_operation_verification( &auth.user_id(), req, client_ip, Some(&request_control), ) .await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/opaque-password/update/start", tag = "User", request_body = StartOpaquePasswordUpdateRequest, responses( (status = 200, description = "OPAQUE password update challenge created", body = StartOpaquePasswordUpdateResponse), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn start_opaque_password_update( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api .start_opaque_password_update(&auth.user_id(), req) .await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/opaque-password/update/finish", tag = "User", request_body = FinishOpaquePasswordUpdateRequest, responses( (status = 200, description = "OPAQUE password update completed", body = User), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn finish_opaque_password_update( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api .finish_opaque_password_update(&auth.user_id(), req) .await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/passkeys/bind/start", tag = "User", request_body = StartPasskeyBindRequest, responses( (status = 200, description = "Passkey bind challenge created", body = StartPasskeyBindResponse), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn start_passkey_bind( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.start_passkey_bind(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/passkeys/bind/finish", tag = "User", request_body = FinishPasskeyBindRequest, responses( (status = 200, description = "Passkey bound to current user", body = PasskeyCredential), (status = 400, description = "Invalid request", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn finish_passkey_bind( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api .finish_passkey_bind_request(&auth.user_id(), req) .await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( get, path = "/api/user/passkeys", tag = "User", responses( (status = 200, description = "Passkeys for current user", body = ListPasskeysResponse), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn list_passkeys( request_meta: RequestMetadata, State(state): State, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Read, |auth| async move { client_api.list_passkeys(&auth.user_id()).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( delete, path = "/api/user/passkeys/{credentialId}", tag = "User", request_body = DeletePasskeyRequest, params( ("credentialId" = String, Path, description = "Passkey credential id") ), responses( (status = 200, description = "Passkey deleted", body = DeletePasskeyResponse), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema), (status = 404, description = "Passkey not found", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn delete_passkey( request_meta: RequestMetadata, State(state): State, Path(path): Path, Json(mut req): Json, ) -> AppResult> { req.credential_id = path.credential_id; let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.delete_passkey(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/totp/setup/start", tag = "User", request_body = StartTotpSetupRequest, responses( (status = 200, description = "Authenticator setup created", body = StartTotpSetupResponse), (status = 401, description = "Authentication or sensitive verification required", body = crate::openapi::GoogleRpcStatusSchema), (status = 409, description = "Authenticator app already configured", body = crate::openapi::GoogleRpcStatusSchema), (status = 503, description = "Credential encryption unavailable", body = crate::openapi::GoogleRpcStatusSchema) ), security(("bearer_auth" = [])) ) )] pub async fn start_totp_setup( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.start_totp_setup(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/totp/setup/finish", tag = "User", request_body = FinishTotpSetupRequest, responses( (status = 200, description = "Authenticator setup confirmed", body = TotpRecoveryCodesResponse), (status = 401, description = "Authentication or authenticator code invalid", body = crate::openapi::GoogleRpcStatusSchema) ), security(("bearer_auth" = [])) ) )] pub async fn finish_totp_setup( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.finish_totp_setup(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/totp/recovery-codes/regenerate", tag = "User", request_body = RegenerateTotpRecoveryCodesRequest, responses( (status = 200, description = "Recovery codes regenerated", body = TotpRecoveryCodesResponse), (status = 401, description = "Authentication or sensitive verification required", body = crate::openapi::GoogleRpcStatusSchema), (status = 404, description = "Authenticator app not configured", body = crate::openapi::GoogleRpcStatusSchema) ), security(("bearer_auth" = [])) ) )] pub async fn regenerate_totp_recovery_codes( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api .regenerate_totp_recovery_codes(&auth.user_id(), req) .await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } #[cfg_attr( feature = "openapi", utoipa::path( delete, path = "/api/user/totp", tag = "User", request_body = DeleteTotpRequest, responses( (status = 200, description = "Authenticator app removed", body = DeleteTotpResponse), (status = 400, description = "Authenticator app is required by two-factor settings", body = crate::openapi::GoogleRpcStatusSchema), (status = 401, description = "Authentication or sensitive verification required", body = crate::openapi::GoogleRpcStatusSchema) ), security(("bearer_auth" = [])) ) )] pub async fn delete_totp( request_meta: RequestMetadata, State(state): State, Json(req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.delete_totp(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } /// List rooms related to the current user. #[cfg_attr( feature = "openapi", utoipa::path( get, path = "/api/user/rooms", tag = "User", params( synctv_proto::client::ListMyRoomsRequest ), responses( (status = 200, description = "Rooms related to the current user", body = ListMyRoomsResponse), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn list_my_rooms( request_meta: RequestMetadata, State(state): State, ProtoQuery(req): ProtoQuery, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Read, |auth| async move { client_api.list_my_rooms(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } /// Discover public rooms with membership, favorite, and access state for the current user. #[cfg_attr( feature = "openapi", utoipa::path( get, path = "/api/user/rooms/discover", tag = "User", params(DiscoverRoomsRequest), responses( (status = 200, description = "Authenticated room discovery", body = DiscoverRoomsResponse), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security(("bearer_auth" = [])) ) )] pub async fn discover_rooms( request_meta: RequestMetadata, State(state): State, ProtoQuery(req): ProtoQuery, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Read, |auth| async move { client_api.discover_rooms(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } /// Get one public room's discovery card for the current user. #[cfg_attr( feature = "openapi", utoipa::path( get, path = "/api/user/rooms/{roomId}/discovery", tag = "User", params(("roomId" = String, Path, description = "Room ID")), responses( (status = 200, description = "Authenticated room discovery item", body = RoomDiscoveryItem), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema), (status = 404, description = "Room not found or unavailable", body = crate::openapi::GoogleRpcStatusSchema) ), security(("bearer_auth" = [])) ) )] pub async fn get_room_discovery( request_meta: RequestMetadata, State(state): State, Path(req): Path, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Read, |auth| async move { client_api.get_room_discovery(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } pub async fn favorite_room( request_meta: RequestMetadata, State(state): State, Path(path): Path, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let req = FavoriteRoomRequest { room_id: path.room_id, }; let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.favorite_room(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } pub async fn unfavorite_room( request_meta: RequestMetadata, State(state): State, Path(path): Path, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let req = UnfavoriteRoomRequest { room_id: path.room_id, }; let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.unfavorite_room(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } pub async fn list_favorite_rooms( request_meta: RequestMetadata, State(state): State, ProtoQuery(req): ProtoQuery, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Read, |auth| async move { client_api.list_favorite_rooms(&auth.user_id(), req).await }, ) .await .map_err(super::error::map_api_error)?; Ok(Json(response)) } /// Close the authenticated account /// /// Sets `deleted_at = NOW()` on the user row and cleans up `OAuth2` mappings. /// The current token will return 401 on the next request because the security /// pipeline checks for deleted users. #[cfg_attr( feature = "openapi", utoipa::path( post, path = "/api/user/account-closure", tag = "User", request_body = CloseAccountRequest, responses( (status = 200, description = "Account closure completed", body = CloseAccountResponse), (status = 401, description = "Authentication required", body = crate::openapi::GoogleRpcStatusSchema) ), security( ("bearer_auth" = []) ) ) )] pub async fn close_account( request_meta: RequestMetadata, State(state): State, Json(_req): Json, ) -> AppResult> { let request_meta = request_meta .0 .with_timeout(Some(synctv_core::resilience::timeout::HTTP_REQUEST_TIMEOUT)); let executor = state.shared_api_runtime.client_api.clone(); let client_api = state.shared_api_runtime.client_api.clone(); let response = executor .execute_user_endpoint( &request_meta, EndpointRateLimitCategory::Write, |auth| async move { client_api.close_account(&auth.user_id()).await }, ) .await .map_err(super::AppError::from)?; Ok(Json(response)) } #[cfg(test)] mod tests { use synctv_proto::client::{CompleteUserAvatarUploadSessionRequest, DeletePasskeyRequest}; type TestResult = anyhow::Result; #[test] fn test_delete_passkey_request_overrides_path_credential_id() -> TestResult { let mut req: DeletePasskeyRequest = serde_json::from_str(r#"{"credentialId":"body_cred","verificationId":"verify_1"}"#)?; req.credential_id = "cred_1".to_string(); assert_eq!(req.credential_id, "cred_1"); assert_eq!(req.verification_id, "verify_1"); Ok(()) } #[test] fn test_complete_user_avatar_upload_session_request_overrides_path_object_key() -> TestResult { let mut req: CompleteUserAvatarUploadSessionRequest = serde_json::from_str( r#"{"encodedObjectKey":"body-key","token":"upload-token","uploadId":"upload-1"}"#, )?; req.encoded_object_key = "avatar-key".to_string(); assert_eq!(req.encoded_object_key, "avatar-key"); assert_eq!(req.token, "upload-token"); assert_eq!(req.upload_id.as_deref(), Some("upload-1")); Ok(()) } }