You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
synctv/synctv-api-http/src/http/user.rs

1418 lines
50 KiB
Rust

//! 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::<Vec<_>>()
.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<AppState>,
) -> AppResult<Json<User>> {
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<AppState>,
) -> AppResult<Json<GetUserPreferencesResponse>> {
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<AppState>,
Json(req): Json<UpdateUserPreferencesRequest>,
) -> AppResult<Json<UpdateUserPreferencesResponse>> {
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<AppState>,
Json(req): Json<SetTwoFactorEnabledRequest>,
) -> AppResult<Json<GetUserPreferencesResponse>> {
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<AppState>,
Json(req): Json<SetUsernameRequest>,
) -> AppResult<Json<User>> {
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<AppState>,
Json(req): Json<CreateUserAvatarUploadSessionRequest>,
) -> AppResult<Json<CreateUserAvatarUploadSessionResponse>> {
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<AppState>,
Json(req): Json<UpdateUserAvatarRequest>,
) -> AppResult<Json<UserAvatarUpdateResponse>> {
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<AppState>,
) -> AppResult<Json<UserAvatarUpdateResponse>> {
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<AppState>,
Path(path): Path<UserAvatarObjectPath>,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Response> {
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<AppState>,
Path(path): Path<UserAvatarObjectPath>,
Json(mut req): Json<CompleteUserAvatarUploadSessionRequest>,
) -> AppResult<Json<CompleteUserAvatarUploadSessionResponse>> {
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<AppState>,
Path(path): Path<UserAvatarObjectPath>,
headers: HeaderMap,
axum::extract::Query(query): axum::extract::Query<UserAvatarObjectQuery>,
) -> AppResult<Response> {
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<AppState>,
Json(req): Json<StartEmailBindRequest>,
) -> AppResult<Json<StartEmailBindResponse>> {
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<AppState>,
Json(req): Json<ConfirmEmailBindRequest>,
) -> AppResult<Json<User>> {
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<AppState>,
Json(req): Json<UnbindEmailRequest>,
) -> AppResult<Json<User>> {
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<AppState>,
Json(req): Json<StartSensitiveOperationVerificationRequest>,
) -> AppResult<Json<SensitiveOperationVerificationOutcome>> {
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<AppState>,
Json(req): Json<StartSensitiveOperationPasskeyRequest>,
) -> AppResult<Json<StartSensitiveOperationPasskeyResponse>> {
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<AppState>,
Json(req): Json<RequestSensitiveOperationEmailCodeRequest>,
) -> AppResult<Json<RequestSensitiveOperationEmailCodeResponse>> {
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<AppState>,
Json(req): Json<FinishSensitiveOperationVerificationRequest>,
) -> AppResult<Json<SensitiveOperationVerificationOutcome>> {
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<AppState>,
Json(req): Json<StartOpaquePasswordUpdateRequest>,
) -> AppResult<Json<StartOpaquePasswordUpdateResponse>> {
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<AppState>,
Json(req): Json<FinishOpaquePasswordUpdateRequest>,
) -> AppResult<Json<User>> {
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<AppState>,
Json(req): Json<StartPasskeyBindRequest>,
) -> AppResult<Json<StartPasskeyBindResponse>> {
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<AppState>,
Json(req): Json<FinishPasskeyBindRequest>,
) -> AppResult<Json<PasskeyCredential>> {
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<AppState>,
) -> AppResult<Json<ListPasskeysResponse>> {
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<AppState>,
Path(path): Path<PasskeyCredentialPath>,
Json(mut req): Json<DeletePasskeyRequest>,
) -> AppResult<Json<DeletePasskeyResponse>> {
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<AppState>,
Json(req): Json<StartTotpSetupRequest>,
) -> AppResult<Json<StartTotpSetupResponse>> {
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<AppState>,
Json(req): Json<FinishTotpSetupRequest>,
) -> AppResult<Json<TotpRecoveryCodesResponse>> {
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<AppState>,
Json(req): Json<RegenerateTotpRecoveryCodesRequest>,
) -> AppResult<Json<TotpRecoveryCodesResponse>> {
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<AppState>,
Json(req): Json<DeleteTotpRequest>,
) -> AppResult<Json<DeleteTotpResponse>> {
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<AppState>,
ProtoQuery(req): ProtoQuery<synctv_proto::client::ListMyRoomsRequest>,
) -> AppResult<Json<ListMyRoomsResponse>> {
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<AppState>,
ProtoQuery(req): ProtoQuery<DiscoverRoomsRequest>,
) -> AppResult<Json<DiscoverRoomsResponse>> {
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<AppState>,
Path(req): Path<GetRoomDiscoveryRequest>,
) -> AppResult<Json<RoomDiscoveryItem>> {
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<AppState>,
Path(path): Path<RoomPathRequest>,
) -> AppResult<Json<FavoriteRoomResponse>> {
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<AppState>,
Path(path): Path<RoomPathRequest>,
) -> AppResult<Json<UnfavoriteRoomResponse>> {
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<AppState>,
ProtoQuery(req): ProtoQuery<ListFavoriteRoomsRequest>,
) -> AppResult<Json<ListFavoriteRoomsResponse>> {
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<AppState>,
Json(_req): Json<CloseAccountRequest>,
) -> AppResult<Json<CloseAccountResponse>> {
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<T = ()> = anyhow::Result<T>;
#[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(())
}
}