Files
lxc-streamutils/apps/server-rust/src/http_api.rs
T
2026-07-18 11:44:58 -07:00

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",
)
}