1448 lines
45 KiB
Rust
1448 lines
45 KiB
Rust
//! Axum HTTP/WebSocket adapter and public trust boundary.
|
|
//!
|
|
//! Handlers derive the tenant from a server-side session cookie; owner IDs from
|
|
//! request bodies are never trusted. This module also enforces same-origin
|
|
//! mutation checks, response cache policy, component-token WebSocket
|
|
//! authentication, bounded request bodies and security headers for the static
|
|
//! control console and OBS entry point.
|
|
|
|
use std::{sync::Arc, time::Duration};
|
|
|
|
use axum::{
|
|
Json, Router,
|
|
extract::{
|
|
DefaultBodyLimit, Path, Query, Request, State,
|
|
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade, close_code},
|
|
},
|
|
http::{HeaderMap, HeaderValue, StatusCode, header},
|
|
middleware::{self, Next},
|
|
response::{IntoResponse, Redirect, Response},
|
|
routing::{any, delete, get, post},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::{Value, json};
|
|
use sha2::{Digest, Sha256};
|
|
use subtle::ConstantTimeEq;
|
|
use tower_http::services::{ServeDir, ServeFile};
|
|
use tracing::{error, warn};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{
|
|
app::AppState,
|
|
auth::{AuthError, SessionIdentity, UserRole},
|
|
components::ComponentInstance,
|
|
credentials::{CookieCloudCredentials, CookieCloudSecrets, fetch_bilibili_cookie},
|
|
domain::{
|
|
COMPONENT_PROTOCOL_VERSION, ComponentMessage, DanmakuEvent, DanmakuSegment, EnterEvent,
|
|
GiftDetails, GiftEvent, LiveEvent, LiveEventPayload, PlatformViewer,
|
|
},
|
|
repository::{ComponentView, RepositoryError},
|
|
song_request::{SONG_REQUEST_KIND, SongListScope, SongRequestError},
|
|
};
|
|
|
|
const SECURE_SESSION_COOKIE: &str = "__Host-lxc_session";
|
|
const DEVELOPMENT_SESSION_COOKIE: &str = "lxc_session";
|
|
|
|
pub fn router(state: AppState) -> Router {
|
|
Router::new()
|
|
.route("/health", get(health))
|
|
.route("/api/v1/auth/me", get(auth_me))
|
|
.route("/api/v1/auth/setup/start", post(setup_start))
|
|
.route("/api/v1/auth/setup/confirm", post(enrollment_confirm))
|
|
.route("/api/v1/auth/register/start", post(register_start))
|
|
.route("/api/v1/auth/register/confirm", post(enrollment_confirm))
|
|
.route("/api/v1/auth/login", post(login))
|
|
.route("/api/v1/auth/logout", post(logout))
|
|
.route(
|
|
"/api/v1/invitations",
|
|
get(list_invitations).post(create_invitation),
|
|
)
|
|
.route("/api/v1/invitations/{id}", delete(revoke_invitation))
|
|
.route("/api/v1/source", get(get_source).put(put_source))
|
|
.route("/api/v1/source/reconnect", post(reconnect_source))
|
|
.route(
|
|
"/api/v1/components",
|
|
get(list_components).post(create_component),
|
|
)
|
|
.route("/api/v1/components/{id}", delete(delete_component))
|
|
.route(
|
|
"/api/v1/components/{id}/settings",
|
|
get(get_component_settings).put(put_component_settings),
|
|
)
|
|
.route(
|
|
"/api/v1/components/{id}/token",
|
|
get(get_component_token).post(rotate_component_token),
|
|
)
|
|
.route(
|
|
"/api/v1/components/{id}/test-events",
|
|
post(component_test_event),
|
|
)
|
|
.route(
|
|
"/api/v1/components/{public_id}/stream",
|
|
get(component_stream),
|
|
)
|
|
.route(
|
|
"/api/v1/components/{id}/song-requests",
|
|
get(list_song_requests),
|
|
)
|
|
.route(
|
|
"/api/v1/components/{id}/song-requests/{request_id}/promote",
|
|
post(promote_song_request),
|
|
)
|
|
.route(
|
|
"/api/v1/components/{id}/song-requests/{request_id}/complete",
|
|
post(complete_song_request),
|
|
)
|
|
.route(
|
|
"/api/v1/components/{id}/song-requests/{request_id}/cancel",
|
|
post(cancel_song_request),
|
|
)
|
|
.route("/api/{*path}", any(api_not_found))
|
|
.route("/", get(spa_page))
|
|
.route("/login", get(spa_page))
|
|
.route("/register", get(spa_page))
|
|
.route("/setup", get(spa_page))
|
|
.route("/control", get(control_redirect))
|
|
.route("/control/", get(spa_page))
|
|
.route("/control/login", get(spa_page))
|
|
.route("/control/register", get(spa_page))
|
|
.route("/control/setup", get(spa_page))
|
|
.route("/control/invitations", get(spa_page))
|
|
.route("/obs/{public_id}", get(spa_page))
|
|
.fallback_service(
|
|
ServeDir::new("/app/web").not_found_service(ServeFile::new("/app/web/index.html")),
|
|
)
|
|
.layer(DefaultBodyLimit::max(64 * 1024))
|
|
.layer(middleware::from_fn(security_headers))
|
|
.with_state(state)
|
|
}
|
|
|
|
async fn security_headers(request: Request, next: Next) -> Response {
|
|
let path = request.uri().path();
|
|
let no_store = path.starts_with("/api/");
|
|
let revalidate = path == "/control/sw.js"
|
|
|| path == "/control/manifest.webmanifest"
|
|
|| path == "/"
|
|
|| path == "/login"
|
|
|| path == "/register"
|
|
|| path == "/setup"
|
|
|| path == "/control"
|
|
|| path.starts_with("/control/")
|
|
|| path.starts_with("/obs/");
|
|
let mut response = next.run(request).await;
|
|
if no_store {
|
|
response.headers_mut().insert(
|
|
header::CACHE_CONTROL,
|
|
HeaderValue::from_static("no-store, max-age=0"),
|
|
);
|
|
} else if revalidate {
|
|
response.headers_mut().insert(
|
|
header::CACHE_CONTROL,
|
|
HeaderValue::from_static("no-cache, max-age=0, must-revalidate"),
|
|
);
|
|
}
|
|
response.headers_mut().insert(
|
|
"x-content-type-options",
|
|
HeaderValue::from_static("nosniff"),
|
|
);
|
|
response
|
|
.headers_mut()
|
|
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
|
response.headers_mut().insert(
|
|
"content-security-policy",
|
|
HeaderValue::from_static(
|
|
"default-src 'self'; base-uri 'none'; object-src 'none'; script-src 'self'; worker-src 'self'; manifest-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' ws: wss:; form-action 'self'; frame-ancestors 'none'",
|
|
),
|
|
);
|
|
response
|
|
}
|
|
|
|
async fn health(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
|
state
|
|
.db
|
|
.get()
|
|
.await
|
|
.map_err(internal)?
|
|
.query_one("SELECT 1", &[])
|
|
.await
|
|
.map_err(internal)?;
|
|
Ok(Json(json!({"ok":true})))
|
|
}
|
|
|
|
async fn spa_page() -> Response {
|
|
match tokio::fs::read("/app/web/index.html").await {
|
|
Ok(contents) => (
|
|
[(&header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
|
contents,
|
|
)
|
|
.into_response(),
|
|
Err(error) => {
|
|
error!(%error, "web entry point is missing");
|
|
StatusCode::NOT_FOUND.into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn control_redirect() -> Redirect {
|
|
Redirect::permanent("/control/")
|
|
}
|
|
|
|
async fn api_not_found() -> ApiError {
|
|
ApiError::new(StatusCode::NOT_FOUND, "not_found", "API endpoint not found")
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SetupStartRequest {
|
|
username: String,
|
|
bootstrap_password: String,
|
|
}
|
|
|
|
async fn setup_start(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<SetupStartRequest>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
if !state.repository.setup_required().await? {
|
|
return Err(ApiError::new(
|
|
StatusCode::CONFLICT,
|
|
"setup_completed",
|
|
"Initial setup has already been completed",
|
|
));
|
|
}
|
|
let ip = client_ip(&headers);
|
|
if let Err(limited) = state.login_limiter.check("__bootstrap__", &ip).await {
|
|
return Err(ApiError::new(
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
"rate_limited",
|
|
"Too many setup attempts",
|
|
)
|
|
.with_retry_after(limited.retry_after));
|
|
}
|
|
if !constant_time_secret_eq(&body.bootstrap_password, &state.config.bootstrap_password) {
|
|
state.login_limiter.failure("__bootstrap__", &ip).await;
|
|
return Err(ApiError::new(
|
|
StatusCode::UNAUTHORIZED,
|
|
"invalid_bootstrap_proof",
|
|
"Invalid setup credentials",
|
|
));
|
|
}
|
|
state.login_limiter.success("__bootstrap__", &ip).await;
|
|
let invitation = state
|
|
.auth
|
|
.create_bootstrap_invitation(&state.config.legacy_room_id, Duration::from_secs(15 * 60))
|
|
.await?;
|
|
let enrollment = state
|
|
.auth
|
|
.registration_start(&invitation.code, &body.username)
|
|
.await?;
|
|
Ok(Json(serde_json::to_value(enrollment).map_err(internal)?))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct RegisterStartRequest {
|
|
username: String,
|
|
invite_code: String,
|
|
}
|
|
|
|
async fn register_start(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<RegisterStartRequest>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
consume_enrollment_budget(&state, &headers).await?;
|
|
let enrollment = state
|
|
.auth
|
|
.registration_start(&body.invite_code, &body.username)
|
|
.await?;
|
|
Ok(Json(serde_json::to_value(enrollment).map_err(internal)?))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct EnrollmentConfirmRequest {
|
|
enrollment_token: String,
|
|
code: String,
|
|
}
|
|
|
|
async fn enrollment_confirm(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<EnrollmentConfirmRequest>,
|
|
) -> Result<Response, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
consume_enrollment_budget(&state, &headers).await?;
|
|
let result = state
|
|
.auth
|
|
.registration_confirm(&body.enrollment_token, &body.code)
|
|
.await?;
|
|
if result.user.role == UserRole::SystemAdmin {
|
|
if let Err(error) = state
|
|
.import_legacy_owner(result.user.id, &result.user.room_id)
|
|
.await
|
|
{
|
|
// Account/TOTP creation is already committed. Do not strand the
|
|
// user behind an unusable enrollment token because a live source
|
|
// was temporarily unavailable.
|
|
warn!(%error, user_id = %result.user.id, "legacy source import failed; owner can configure it in control panel");
|
|
}
|
|
} else {
|
|
if let Err(error) = state.repository.hydrate_tenant(result.user.id).await {
|
|
// Registration, recovery-code creation and the initial session are
|
|
// already committed atomically. Cache hydration is recoverable on
|
|
// the next component read and must not hide those one-time codes.
|
|
warn!(%error, user_id = %result.user.id, "new tenant cache hydration deferred");
|
|
}
|
|
}
|
|
let payload = json!({
|
|
"user": result.user,
|
|
"recoveryCodes": result.recovery_codes,
|
|
"defaultSourceId": result.default_source_id,
|
|
"defaultComponentId": result.default_component_id,
|
|
});
|
|
let mut response = Json(payload).into_response();
|
|
set_session_cookie(
|
|
&state,
|
|
&mut response,
|
|
&result.session_token,
|
|
state.config.session_ttl_hours * 3_600,
|
|
)?;
|
|
Ok(response)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct LoginRequest {
|
|
username: String,
|
|
code: String,
|
|
}
|
|
|
|
async fn login(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<LoginRequest>,
|
|
) -> Result<Response, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
let ip = client_ip(&headers);
|
|
if let Err(limited) = state.login_limiter.check(&body.username, &ip).await {
|
|
return Err(ApiError::new(
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
"rate_limited",
|
|
format!(
|
|
"Too many authentication attempts; retry in {} seconds",
|
|
limited.retry_after.as_secs().max(1)
|
|
),
|
|
)
|
|
.with_retry_after(limited.retry_after));
|
|
}
|
|
let result = if body.code.trim().chars().all(|value| value.is_ascii_digit())
|
|
&& body.code.trim().len() == 6
|
|
{
|
|
state.auth.login(&body.username, &body.code).await
|
|
} else {
|
|
state
|
|
.auth
|
|
.login_with_recovery_code(&body.username, &body.code)
|
|
.await
|
|
};
|
|
let result = match result {
|
|
Ok(result) => result,
|
|
Err(error) => {
|
|
state.login_limiter.failure(&body.username, &ip).await;
|
|
return Err(ApiError::from(error).into_generic_login());
|
|
}
|
|
};
|
|
state.login_limiter.success(&body.username, &ip).await;
|
|
let mut response = Json(json!({"user": result.user})).into_response();
|
|
set_session_cookie(
|
|
&state,
|
|
&mut response,
|
|
&result.session_token,
|
|
state.config.session_ttl_hours * 3_600,
|
|
)?;
|
|
Ok(response)
|
|
}
|
|
|
|
async fn auth_me(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let setup_required = state.repository.setup_required().await?;
|
|
let Some(token) = session_cookie(&state, &headers) else {
|
|
return Ok(Json(json!({"user":null,"setupRequired":setup_required})));
|
|
};
|
|
match state.auth.authenticate_session(&token).await {
|
|
Ok(session) => Ok(Json(json!({
|
|
"user": session.user,
|
|
"setupRequired": setup_required,
|
|
}))),
|
|
Err(AuthError::InvalidSession) => {
|
|
Ok(Json(json!({"user":null,"setupRequired":setup_required})))
|
|
}
|
|
Err(error) => Err(error.into()),
|
|
}
|
|
}
|
|
|
|
async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Response, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
if let Some(token) = session_cookie(&state, &headers) {
|
|
state.auth.revoke_session(&token).await?;
|
|
}
|
|
let mut response = Json(json!({"ok":true})).into_response();
|
|
clear_session_cookie(&state, &mut response)?;
|
|
Ok(response)
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct CreateInvitationRequest {
|
|
room_id: String,
|
|
expires_in_hours: Option<u64>,
|
|
}
|
|
|
|
async fn list_invitations(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let session = require_session(&state, &headers).await?;
|
|
require_system_admin(&session)?;
|
|
let invitations = state.repository.list_invitations(session.user.id).await?;
|
|
Ok(Json(json!({"invitations":invitations})))
|
|
}
|
|
|
|
async fn create_invitation(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<CreateInvitationRequest>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
let session = require_session(&state, &headers).await?;
|
|
require_system_admin(&session)?;
|
|
let hours = body
|
|
.expires_in_hours
|
|
.unwrap_or(state.config.invitation_ttl_hours as u64)
|
|
.clamp(1, 8_760);
|
|
let invitation = state
|
|
.auth
|
|
.create_invitation(
|
|
session.user.id,
|
|
&body.room_id,
|
|
Duration::from_secs(hours * 3_600),
|
|
)
|
|
.await?;
|
|
Ok(Json(json!({"invitation":invitation})))
|
|
}
|
|
|
|
async fn revoke_invitation(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
let session = require_session(&state, &headers).await?;
|
|
require_system_admin(&session)?;
|
|
state.auth.revoke_invitation(session.user.id, id).await?;
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
async fn get_source(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let session = require_session(&state, &headers).await?;
|
|
let source_id = state.repository.source_id(session.user.id).await?;
|
|
let credentials = state
|
|
.auth
|
|
.get_cookiecloud_credentials(session.user.id)
|
|
.await?;
|
|
let status = state.supervisor.status(source_id).await;
|
|
let (host, key_configured, password_configured) = credentials
|
|
.map(|credentials| {
|
|
(
|
|
credentials.host,
|
|
!credentials.secrets.key.is_empty(),
|
|
!credentials.secrets.password.is_empty(),
|
|
)
|
|
})
|
|
.unwrap_or_else(|| {
|
|
(
|
|
state.config.default_cookiecloud_host().to_owned(),
|
|
false,
|
|
false,
|
|
)
|
|
});
|
|
Ok(Json(json!({
|
|
"source": {
|
|
"id": source_id,
|
|
"roomId": session.user.room_id,
|
|
"connected": status.as_ref().is_some_and(|status| status.connected),
|
|
"detail": status.map(|status| status.detail).unwrap_or_else(|| {
|
|
if password_configured { "Live listener is stopped".into() } else { "Configure CookieCloud to start listening".into() }
|
|
}),
|
|
"cookieCloud": {
|
|
"host": host,
|
|
"keyConfigured": key_configured,
|
|
"passwordConfigured": password_configured,
|
|
}
|
|
}
|
|
})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct PutSourceRequest {
|
|
room_id: String,
|
|
cookie_cloud: PutCookieCloud,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PutCookieCloud {
|
|
host: String,
|
|
key: String,
|
|
password: Option<String>,
|
|
}
|
|
|
|
async fn put_source(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<PutSourceRequest>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
let session = require_session(&state, &headers).await?;
|
|
if body.room_id.trim() != session.user.room_id {
|
|
return Err(ApiError::new(
|
|
StatusCode::BAD_REQUEST,
|
|
"room_is_immutable",
|
|
"The room bound by the invitation cannot be changed",
|
|
));
|
|
}
|
|
let existing = state
|
|
.auth
|
|
.get_cookiecloud_credentials(session.user.id)
|
|
.await?;
|
|
let credentials = CookieCloudCredentials {
|
|
host: if body.cookie_cloud.host.trim().is_empty() {
|
|
existing
|
|
.as_ref()
|
|
.map(|value| value.host.clone())
|
|
.unwrap_or_else(|| state.config.default_cookiecloud_host().to_owned())
|
|
} else {
|
|
body.cookie_cloud.host
|
|
},
|
|
secrets: CookieCloudSecrets {
|
|
key: if body.cookie_cloud.key.trim().is_empty() {
|
|
existing
|
|
.as_ref()
|
|
.map(|value| value.secrets.key.clone())
|
|
.unwrap_or_default()
|
|
} else {
|
|
body.cookie_cloud.key
|
|
},
|
|
password: body.cookie_cloud.password.unwrap_or_else(|| {
|
|
existing
|
|
.as_ref()
|
|
.map(|value| value.secrets.password.clone())
|
|
.unwrap_or_default()
|
|
}),
|
|
},
|
|
};
|
|
let mut credentials = credentials;
|
|
credentials.host = state
|
|
.config
|
|
.allowed_cookiecloud_host(&credentials.host)
|
|
.map_err(|message| {
|
|
ApiError::new(
|
|
StatusCode::BAD_REQUEST,
|
|
"cookiecloud_host_not_allowed",
|
|
message,
|
|
)
|
|
})?;
|
|
// Validate the remote credentials before replacing a working encrypted
|
|
// record. No Bilibili cookie is ever returned to the browser.
|
|
fetch_bilibili_cookie(&state.http, &credentials)
|
|
.await
|
|
.map_err(|message| {
|
|
ApiError::new(StatusCode::BAD_REQUEST, "cookiecloud_invalid", message)
|
|
})?;
|
|
state
|
|
.auth
|
|
.set_cookiecloud_credentials(session.user.id, &credentials)
|
|
.await?;
|
|
state
|
|
.restart_user_source(session.user.id)
|
|
.await
|
|
.map_err(internal)?;
|
|
get_source(State(state), headers).await
|
|
}
|
|
|
|
async fn reconnect_source(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
let session = require_session(&state, &headers).await?;
|
|
state
|
|
.restart_user_source(session.user.id)
|
|
.await
|
|
.map_err(internal)?;
|
|
Ok(Json(json!({"ok":true})))
|
|
}
|
|
|
|
async fn list_components(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let session = require_session(&state, &headers).await?;
|
|
let components = state.repository.list_components(session.user.id).await?;
|
|
Ok(Json(json!({"components":components})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct CreateComponentRequest {
|
|
kind: String,
|
|
name: String,
|
|
}
|
|
|
|
async fn create_component(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Json(body): Json<CreateComponentRequest>,
|
|
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
let session = require_session(&state, &headers).await?;
|
|
let component = state
|
|
.repository
|
|
.create_component(session.user.id, &body.kind, &body.name)
|
|
.await?;
|
|
Ok((
|
|
StatusCode::CREATED,
|
|
Json(json!({"component":ComponentView::from(&component)})),
|
|
))
|
|
}
|
|
|
|
async fn delete_component(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<StatusCode, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
let session = require_session(&state, &headers).await?;
|
|
state
|
|
.repository
|
|
.delete_component(session.user.id, id)
|
|
.await?;
|
|
state.hub.remove(id);
|
|
Ok(StatusCode::NO_CONTENT)
|
|
}
|
|
|
|
async fn get_component_settings(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let session = require_session(&state, &headers).await?;
|
|
let component = state.repository.get_component(session.user.id, id).await?;
|
|
Ok(Json(json!({"settings":component.settings})))
|
|
}
|
|
|
|
async fn put_component_settings(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path(id): Path<Uuid>,
|
|
Json(settings): Json<Value>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
let session = require_session(&state, &headers).await?;
|
|
let component = state
|
|
.repository
|
|
.update_component_settings(session.user.id, id, settings)
|
|
.await?;
|
|
let message = settings_message(
|
|
&component,
|
|
&session.user.room_id,
|
|
"component.settings.updated",
|
|
);
|
|
state.hub.publish(component.id, Arc::new(message));
|
|
if component.kind == "danmaku_overlay" {
|
|
state.hub.publish(
|
|
component.id,
|
|
Arc::new(settings_message(
|
|
&component,
|
|
&session.user.room_id,
|
|
"overlay.settings.updated",
|
|
)),
|
|
);
|
|
}
|
|
Ok(Json(json!({"settings":component.settings})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SongRequestsQuery {
|
|
scope: Option<String>,
|
|
cursor: Option<i64>,
|
|
limit: Option<i64>,
|
|
}
|
|
|
|
async fn list_song_requests(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path(id): Path<Uuid>,
|
|
Query(query): Query<SongRequestsQuery>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let session = require_session(&state, &headers).await?;
|
|
let component = state.repository.get_component(session.user.id, id).await?;
|
|
require_song_component(&component)?;
|
|
let scope = match query.scope.as_deref().unwrap_or("active") {
|
|
"active" => SongListScope::Active,
|
|
"history" => SongListScope::History,
|
|
_ => {
|
|
return Err(ApiError::new(
|
|
StatusCode::BAD_REQUEST,
|
|
"invalid_scope",
|
|
"scope must be active or history",
|
|
));
|
|
}
|
|
};
|
|
let page = state
|
|
.song_requests
|
|
.list_page(
|
|
&component,
|
|
scope,
|
|
query.cursor.unwrap_or(0),
|
|
query.limit.unwrap_or(50),
|
|
)
|
|
.await?;
|
|
Ok(Json(serde_json::to_value(page).map_err(internal)?))
|
|
}
|
|
|
|
async fn promote_song_request(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path((id, request_id)): Path<(Uuid, Uuid)>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
let session = require_session(&state, &headers).await?;
|
|
let component = state.repository.get_component(session.user.id, id).await?;
|
|
require_song_component(&component)?;
|
|
state
|
|
.song_requests
|
|
.promote(&component, request_id, &session.user.room_id)
|
|
.await?;
|
|
Ok(Json(json!({"ok":true})))
|
|
}
|
|
|
|
async fn complete_song_request(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path((id, request_id)): Path<(Uuid, Uuid)>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
change_song_request(state, headers, id, request_id, false).await
|
|
}
|
|
|
|
async fn cancel_song_request(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path((id, request_id)): Path<(Uuid, Uuid)>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
change_song_request(state, headers, id, request_id, true).await
|
|
}
|
|
|
|
async fn change_song_request(
|
|
state: AppState,
|
|
headers: HeaderMap,
|
|
component_id: Uuid,
|
|
request_id: Uuid,
|
|
cancelled: bool,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
let session = require_session(&state, &headers).await?;
|
|
let component = state
|
|
.repository
|
|
.get_component(session.user.id, component_id)
|
|
.await?;
|
|
require_song_component(&component)?;
|
|
state
|
|
.song_requests
|
|
.finish(&component, request_id, cancelled, &session.user.room_id)
|
|
.await?;
|
|
Ok(Json(json!({"ok":true})))
|
|
}
|
|
|
|
fn require_song_component(component: &ComponentInstance) -> Result<(), ApiError> {
|
|
if component.kind == SONG_REQUEST_KIND {
|
|
Ok(())
|
|
} else {
|
|
Err(ApiError::new(
|
|
StatusCode::NOT_FOUND,
|
|
"song_component_not_found",
|
|
"Song request component not found",
|
|
))
|
|
}
|
|
}
|
|
|
|
async fn get_component_token(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
let session = require_session(&state, &headers).await?;
|
|
let summary = state.repository.token_summary(session.user.id, id).await?;
|
|
Ok(Json(json!({
|
|
"publicId": id,
|
|
"configured": summary.configured,
|
|
"updatedAt": summary.updated_at,
|
|
"lastUsedAt": summary.last_used_at,
|
|
})))
|
|
}
|
|
|
|
async fn rotate_component_token(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
let session = require_session(&state, &headers).await?;
|
|
let token = state
|
|
.auth
|
|
.rotate_component_access_token(
|
|
session.user.id,
|
|
id,
|
|
"OBS browser source",
|
|
&["events:subscribe".into()],
|
|
None,
|
|
)
|
|
.await?;
|
|
// Closing the component channel disconnects every socket authenticated by
|
|
// a now-revoked token. The next subscriber receives a fresh channel.
|
|
state.hub.remove(id);
|
|
Ok(Json(json!({
|
|
"publicId": id,
|
|
"configured": true,
|
|
"token": token.token,
|
|
"path": format!("/obs/{id}#token={}", token.token),
|
|
"updatedAt": chrono::Utc::now(),
|
|
})))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
|
enum TestEventRequest {
|
|
Enter {
|
|
uid: String,
|
|
name: String,
|
|
},
|
|
Danmaku {
|
|
uid: String,
|
|
name: String,
|
|
text: String,
|
|
},
|
|
Gift {
|
|
uid: String,
|
|
name: String,
|
|
#[serde(rename = "giftName")]
|
|
gift_name: String,
|
|
battery: i32,
|
|
quantity: i32,
|
|
},
|
|
}
|
|
|
|
async fn component_test_event(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path(id): Path<Uuid>,
|
|
Json(body): Json<TestEventRequest>,
|
|
) -> Result<Json<Value>, ApiError> {
|
|
same_origin(&state, &headers)?;
|
|
let session = require_session(&state, &headers).await?;
|
|
let component = state.repository.get_component(session.user.id, id).await?;
|
|
let viewer = |uid: String, name: String| PlatformViewer { uid, name };
|
|
let payload = match body {
|
|
TestEventRequest::Enter { uid, name } => LiveEventPayload::Enter(EnterEvent {
|
|
viewer: viewer(uid, name),
|
|
}),
|
|
TestEventRequest::Danmaku { uid, name, text } => LiveEventPayload::Danmaku(DanmakuEvent {
|
|
viewer: viewer(uid, name),
|
|
segments: vec![DanmakuSegment::Text { text: text.clone() }],
|
|
text,
|
|
}),
|
|
TestEventRequest::Gift {
|
|
uid,
|
|
name,
|
|
gift_name,
|
|
battery,
|
|
quantity,
|
|
} => {
|
|
let quantity = quantity.max(1);
|
|
let unit_price = i64::from(battery.max(0));
|
|
let total_price = unit_price.saturating_mul(i64::from(quantity));
|
|
LiveEventPayload::Gift(GiftEvent {
|
|
viewer: viewer(uid, name),
|
|
gift: GiftDetails {
|
|
id: None,
|
|
name: gift_name,
|
|
coin_type: "gold".into(),
|
|
unit_price,
|
|
total_price,
|
|
price_cny: total_price as f64 / 1000.0,
|
|
image_url: None,
|
|
animation_url: None,
|
|
effect_type: None,
|
|
stay_time: None,
|
|
},
|
|
quantity,
|
|
source_event_id: format!("test-{}", Uuid::new_v4()),
|
|
})
|
|
}
|
|
};
|
|
let mut event = LiveEvent::new(
|
|
component.owner_id,
|
|
component.source_id,
|
|
"test",
|
|
session.user.room_id,
|
|
payload,
|
|
);
|
|
event.simulated = true;
|
|
let runtime = state.registry.runtime(&component.kind).map_err(|error| {
|
|
ApiError::new(
|
|
StatusCode::BAD_REQUEST,
|
|
"component_invalid",
|
|
error.to_string(),
|
|
)
|
|
})?;
|
|
if runtime
|
|
.subscriptions(&component)
|
|
.map_err(internal)?
|
|
.matches(&event)
|
|
{
|
|
for handler in runtime.handlers() {
|
|
if handler.accepts(&component, &event) {
|
|
handler
|
|
.handle(&component, Arc::new(event.clone()))
|
|
.await
|
|
.map_err(internal)?;
|
|
}
|
|
}
|
|
if let Some(message) = runtime.project(&component, &event).map_err(internal)? {
|
|
state.hub.publish(component.id, Arc::new(message));
|
|
}
|
|
}
|
|
Ok(Json(json!({"ok":true,"eventId":event.id})))
|
|
}
|
|
|
|
async fn component_stream(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Path(public_id): Path<Uuid>,
|
|
upgrade: WebSocketUpgrade,
|
|
) -> Response {
|
|
if let Err(error) = same_origin(&state, &headers) {
|
|
return error.into_response();
|
|
}
|
|
let Ok(permit) = state.component_socket_slots.clone().try_acquire_owned() else {
|
|
return ApiError::new(
|
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
"component_connection_limit",
|
|
"Too many component connections",
|
|
)
|
|
.into_response();
|
|
};
|
|
upgrade
|
|
.max_message_size(16 * 1024)
|
|
.max_frame_size(16 * 1024)
|
|
.on_upgrade(move |socket| async move {
|
|
let _permit = permit;
|
|
component_ws(socket, state, public_id).await;
|
|
})
|
|
}
|
|
|
|
async fn component_ws(mut socket: WebSocket, state: AppState, component_id: Uuid) {
|
|
let first = tokio::time::timeout(Duration::from_secs(8), socket.recv()).await;
|
|
let Ok(Some(Ok(Message::Text(text)))) = first else {
|
|
close_ws(&mut socket, "Authentication frame required").await;
|
|
return;
|
|
};
|
|
let Ok(value) = serde_json::from_str::<Value>(&text) else {
|
|
close_ws(&mut socket, "Invalid authentication frame").await;
|
|
return;
|
|
};
|
|
if value.get("type").and_then(Value::as_str) != Some("authenticate") {
|
|
close_ws(&mut socket, "Authentication frame required").await;
|
|
return;
|
|
}
|
|
let Some(token) = value
|
|
.get("token")
|
|
.and_then(Value::as_str)
|
|
.filter(|token| !token.is_empty())
|
|
else {
|
|
close_ws(&mut socket, "OBS token required").await;
|
|
return;
|
|
};
|
|
let identity = match state.auth.authenticate_component_access_token(token).await {
|
|
Ok(identity)
|
|
if identity.component_instance_id == component_id
|
|
&& identity
|
|
.scopes
|
|
.iter()
|
|
.any(|scope| scope == "events:subscribe") =>
|
|
{
|
|
identity
|
|
}
|
|
_ => {
|
|
close_ws(&mut socket, "Invalid component token").await;
|
|
return;
|
|
}
|
|
};
|
|
let component = match state
|
|
.repository
|
|
.get_component(identity.owner_user_id, component_id)
|
|
.await
|
|
{
|
|
Ok(component) => component,
|
|
Err(_) => {
|
|
close_ws(&mut socket, "Component is unavailable").await;
|
|
return;
|
|
}
|
|
};
|
|
let mut receiver = state.hub.subscribe(component_id);
|
|
if socket
|
|
.send(Message::Text(
|
|
json!({"version":COMPONENT_PROTOCOL_VERSION,"type":"authenticated","componentId":component_id,"componentKind":component.kind})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
let room_id = match state.repository.room_id(identity.owner_user_id).await {
|
|
Ok(room_id) => room_id,
|
|
Err(_) => {
|
|
close_ws(&mut socket, "Component is unavailable").await;
|
|
return;
|
|
}
|
|
};
|
|
let snapshot = settings_message(&component, &room_id, "component.settings.snapshot");
|
|
if send_component_message(&mut socket, &snapshot)
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
if component.kind == "danmaku_overlay"
|
|
&& send_component_message(
|
|
&mut socket,
|
|
&settings_message(&component, &room_id, "overlay.settings.snapshot"),
|
|
)
|
|
.await
|
|
.is_err()
|
|
{
|
|
return;
|
|
}
|
|
let runtime = match state.registry.runtime(&component.kind) {
|
|
Ok(runtime) => runtime,
|
|
Err(_) => {
|
|
close_ws(&mut socket, "Component is unavailable").await;
|
|
return;
|
|
}
|
|
};
|
|
let component_snapshots = match runtime.snapshot(&component, &room_id).await {
|
|
Ok(messages) => messages,
|
|
Err(error) => {
|
|
warn!(component_id = %component.id, %error, "component snapshot failed");
|
|
close_ws(&mut socket, "Component snapshot is unavailable").await;
|
|
return;
|
|
}
|
|
};
|
|
for message in component_snapshots {
|
|
if send_component_message(&mut socket, &message).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
loop {
|
|
tokio::select! {
|
|
event = receiver.recv() => match event {
|
|
Ok(message) => {
|
|
if send_component_message(&mut socket, &message).await.is_err() { break; }
|
|
}
|
|
// A durable component cannot safely guess dropped deltas. A
|
|
// disconnect makes the browser obtain a fresh paged snapshot;
|
|
// passive visual components can keep their legacy best-effort
|
|
// behavior.
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) if component.kind == SONG_REQUEST_KIND => break,
|
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
|
},
|
|
incoming = socket.recv() => match incoming {
|
|
Some(Ok(Message::Ping(value))) => {
|
|
if socket.send(Message::Pong(value)).await.is_err() { break; }
|
|
}
|
|
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn send_component_message(
|
|
socket: &mut WebSocket,
|
|
message: &ComponentMessage,
|
|
) -> Result<(), ()> {
|
|
let text = serde_json::to_string(message).map_err(|_| ())?;
|
|
socket
|
|
.send(Message::Text(text.into()))
|
|
.await
|
|
.map_err(|_| ())
|
|
}
|
|
|
|
async fn close_ws(socket: &mut WebSocket, reason: &'static str) {
|
|
let _ = socket
|
|
.send(Message::Close(Some(CloseFrame {
|
|
code: close_code::POLICY,
|
|
reason: reason.into(),
|
|
})))
|
|
.await;
|
|
}
|
|
|
|
fn settings_message(
|
|
component: &ComponentInstance,
|
|
room_id: &str,
|
|
event_type: &str,
|
|
) -> ComponentMessage {
|
|
ComponentMessage::new(
|
|
component,
|
|
room_id,
|
|
event_type,
|
|
json!({"settings":component.settings}),
|
|
)
|
|
}
|
|
|
|
async fn require_session(
|
|
state: &AppState,
|
|
headers: &HeaderMap,
|
|
) -> Result<SessionIdentity, ApiError> {
|
|
let token = session_cookie(state, headers).ok_or_else(|| {
|
|
ApiError::new(
|
|
StatusCode::UNAUTHORIZED,
|
|
"authentication_required",
|
|
"Authentication required",
|
|
)
|
|
})?;
|
|
state
|
|
.auth
|
|
.authenticate_session(&token)
|
|
.await
|
|
.map_err(Into::into)
|
|
}
|
|
|
|
fn require_system_admin(session: &SessionIdentity) -> Result<(), ApiError> {
|
|
if session.user.role == UserRole::SystemAdmin {
|
|
Ok(())
|
|
} else {
|
|
Err(ApiError::new(
|
|
StatusCode::FORBIDDEN,
|
|
"system_admin_required",
|
|
"System administrator permission required",
|
|
))
|
|
}
|
|
}
|
|
|
|
fn session_cookie(state: &AppState, headers: &HeaderMap) -> Option<String> {
|
|
cookie_value(headers, session_cookie_name(state))
|
|
}
|
|
|
|
fn session_cookie_name(state: &AppState) -> &'static str {
|
|
if state.config.secure_cookies {
|
|
SECURE_SESSION_COOKIE
|
|
} else {
|
|
DEVELOPMENT_SESSION_COOKIE
|
|
}
|
|
}
|
|
|
|
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
|
headers
|
|
.get(header::COOKIE)?
|
|
.to_str()
|
|
.ok()?
|
|
.split(';')
|
|
.find_map(|part| {
|
|
let (key, value) = part.trim().split_once('=')?;
|
|
(key == name).then(|| value.to_owned())
|
|
})
|
|
}
|
|
|
|
fn set_session_cookie(
|
|
state: &AppState,
|
|
response: &mut Response,
|
|
token: &str,
|
|
max_age_seconds: i64,
|
|
) -> Result<(), ApiError> {
|
|
let secure = if state.config.secure_cookies {
|
|
"; Secure"
|
|
} else {
|
|
""
|
|
};
|
|
let value = format!(
|
|
"{}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age_seconds}{secure}",
|
|
session_cookie_name(state)
|
|
);
|
|
response.headers_mut().insert(
|
|
header::SET_COOKIE,
|
|
HeaderValue::from_str(&value).map_err(internal)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn clear_session_cookie(state: &AppState, response: &mut Response) -> Result<(), ApiError> {
|
|
let secure = if state.config.secure_cookies {
|
|
"; Secure"
|
|
} else {
|
|
""
|
|
};
|
|
let value = format!(
|
|
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}",
|
|
session_cookie_name(state)
|
|
);
|
|
response.headers_mut().insert(
|
|
header::SET_COOKIE,
|
|
HeaderValue::from_str(&value).map_err(internal)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn same_origin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> {
|
|
if headers
|
|
.get("sec-fetch-site")
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value == "cross-site")
|
|
{
|
|
return Err(ApiError::new(
|
|
StatusCode::FORBIDDEN,
|
|
"cross_site_request",
|
|
"Cross-site request rejected",
|
|
));
|
|
}
|
|
let Some(origin) = headers
|
|
.get(header::ORIGIN)
|
|
.and_then(|value| value.to_str().ok())
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let host = headers
|
|
.get("x-forwarded-host")
|
|
.or_else(|| headers.get(header::HOST))
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or_default();
|
|
let allowed = if state.config.secure_cookies {
|
|
origin == format!("https://{host}")
|
|
} else {
|
|
origin == format!("https://{host}") || origin == format!("http://{host}")
|
|
};
|
|
if allowed {
|
|
Ok(())
|
|
} else {
|
|
Err(ApiError::new(
|
|
StatusCode::FORBIDDEN,
|
|
"origin_mismatch",
|
|
"Request origin does not match this site",
|
|
))
|
|
}
|
|
}
|
|
|
|
fn client_ip(headers: &HeaderMap) -> String {
|
|
headers
|
|
.get("x-real-ip")
|
|
.or_else(|| headers.get("x-forwarded-for"))
|
|
.and_then(|value| value.to_str().ok())
|
|
.and_then(|value| value.split(',').next())
|
|
.unwrap_or("unknown")
|
|
.trim()
|
|
.chars()
|
|
.take(96)
|
|
.collect()
|
|
}
|
|
|
|
async fn consume_enrollment_budget(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> {
|
|
state
|
|
.enrollment_limiter
|
|
.consume_ip("enrollment", &client_ip(headers))
|
|
.await
|
|
.map_err(|limited| {
|
|
ApiError::new(
|
|
StatusCode::TOO_MANY_REQUESTS,
|
|
"enrollment_rate_limited",
|
|
"Too many registration attempts",
|
|
)
|
|
.with_retry_after(limited.retry_after)
|
|
})
|
|
}
|
|
|
|
fn constant_time_secret_eq(provided: &str, expected: &str) -> bool {
|
|
let provided = Sha256::digest(provided.as_bytes());
|
|
let expected = Sha256::digest(expected.as_bytes());
|
|
bool::from(provided.ct_eq(&expected))
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ErrorBody {
|
|
error: &'static str,
|
|
code: &'static str,
|
|
message: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct ApiError {
|
|
status: StatusCode,
|
|
code: &'static str,
|
|
message: String,
|
|
retry_after: Option<Duration>,
|
|
}
|
|
|
|
impl ApiError {
|
|
fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
|
|
Self {
|
|
status,
|
|
code,
|
|
message: message.into(),
|
|
retry_after: None,
|
|
}
|
|
}
|
|
|
|
fn with_retry_after(mut self, retry_after: Duration) -> Self {
|
|
self.retry_after = Some(retry_after);
|
|
self
|
|
}
|
|
|
|
fn into_generic_login(mut self) -> Self {
|
|
if self.status != StatusCode::TOO_MANY_REQUESTS {
|
|
self.status = StatusCode::UNAUTHORIZED;
|
|
self.code = "invalid_credentials";
|
|
self.message = "Invalid username or authentication code".into();
|
|
}
|
|
self
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for ApiError {
|
|
fn into_response(self) -> Response {
|
|
if self.status.is_server_error() {
|
|
error!(code = self.code, message = %self.message, "API request failed");
|
|
}
|
|
let mut response = (
|
|
self.status,
|
|
Json(ErrorBody {
|
|
error: self.code,
|
|
code: self.code,
|
|
message: self.message,
|
|
}),
|
|
)
|
|
.into_response();
|
|
if let Some(retry_after) = self.retry_after
|
|
&& let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string())
|
|
{
|
|
response.headers_mut().insert(header::RETRY_AFTER, value);
|
|
}
|
|
response
|
|
}
|
|
}
|
|
|
|
impl From<AuthError> for ApiError {
|
|
fn from(error: AuthError) -> Self {
|
|
match error {
|
|
AuthError::InvalidInput(_) => {
|
|
Self::new(StatusCode::BAD_REQUEST, "invalid_input", error.to_string())
|
|
}
|
|
AuthError::BootstrapAlreadyCompleted => {
|
|
Self::new(StatusCode::CONFLICT, "setup_completed", error.to_string())
|
|
}
|
|
AuthError::Forbidden => {
|
|
Self::new(StatusCode::FORBIDDEN, "forbidden", error.to_string())
|
|
}
|
|
AuthError::InvitationUnavailable => Self::new(
|
|
StatusCode::BAD_REQUEST,
|
|
"invitation_unavailable",
|
|
error.to_string(),
|
|
),
|
|
AuthError::EnrollmentUnavailable => Self::new(
|
|
StatusCode::BAD_REQUEST,
|
|
"enrollment_unavailable",
|
|
error.to_string(),
|
|
),
|
|
AuthError::AccountUnavailable | AuthError::RoomUnavailable => Self::new(
|
|
StatusCode::CONFLICT,
|
|
"account_unavailable",
|
|
error.to_string(),
|
|
),
|
|
AuthError::InvalidTotp
|
|
| AuthError::TotpReplay
|
|
| AuthError::InvalidCredentials
|
|
| AuthError::InvalidSession => Self::new(
|
|
StatusCode::UNAUTHORIZED,
|
|
"invalid_credentials",
|
|
"Invalid or expired credentials",
|
|
),
|
|
AuthError::InvalidComponentToken => Self::new(
|
|
StatusCode::UNAUTHORIZED,
|
|
"invalid_component_token",
|
|
"Invalid or expired component token",
|
|
),
|
|
AuthError::ComponentUnavailable => Self::new(
|
|
StatusCode::NOT_FOUND,
|
|
"component_not_found",
|
|
"Component not found",
|
|
),
|
|
other => internal(other),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<RepositoryError> for ApiError {
|
|
fn from(error: RepositoryError) -> Self {
|
|
match error {
|
|
RepositoryError::NotFound => {
|
|
Self::new(StatusCode::NOT_FOUND, "not_found", "Resource not found")
|
|
}
|
|
RepositoryError::Forbidden => Self::new(
|
|
StatusCode::FORBIDDEN,
|
|
"forbidden",
|
|
"Operation is not permitted",
|
|
),
|
|
RepositoryError::Invalid(message) => {
|
|
Self::new(StatusCode::BAD_REQUEST, "invalid_input", message)
|
|
}
|
|
other => internal(other),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<SongRequestError> for ApiError {
|
|
fn from(error: SongRequestError) -> Self {
|
|
match error {
|
|
SongRequestError::NotFound => Self::new(
|
|
StatusCode::NOT_FOUND,
|
|
"song_request_not_found",
|
|
"Song request was not found",
|
|
),
|
|
SongRequestError::Conflict(message) => {
|
|
Self::new(StatusCode::CONFLICT, "song_request_conflict", message)
|
|
}
|
|
SongRequestError::Invalid(message) => {
|
|
Self::new(StatusCode::BAD_REQUEST, "invalid_song_request", message)
|
|
}
|
|
other => internal(other),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn internal(error: impl std::fmt::Display) -> ApiError {
|
|
error!(%error, "internal operation failed");
|
|
ApiError::new(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
"internal_error",
|
|
"Internal server error",
|
|
)
|
|
}
|