//! 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) -> Result, 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, headers: HeaderMap, Json(body): Json, ) -> Result, 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, headers: HeaderMap, Json(body): Json, ) -> Result, 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, headers: HeaderMap, Json(body): Json, ) -> Result { 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, headers: HeaderMap, Json(body): Json, ) -> Result { 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, headers: HeaderMap, ) -> Result, 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, headers: HeaderMap) -> Result { 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, } async fn list_invitations( State(state): State, headers: HeaderMap, ) -> Result, 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, headers: HeaderMap, Json(body): Json, ) -> Result, 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, headers: HeaderMap, Path(id): Path, ) -> Result { 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, headers: HeaderMap, ) -> Result, 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, } async fn put_source( State(state): State, headers: HeaderMap, Json(body): Json, ) -> Result, 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, headers: HeaderMap, ) -> Result, 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, headers: HeaderMap, ) -> Result, 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, headers: HeaderMap, Json(body): Json, ) -> Result<(StatusCode, Json), 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, headers: HeaderMap, Path(id): Path, ) -> Result { 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, headers: HeaderMap, Path(id): Path, ) -> Result, 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, headers: HeaderMap, Path(id): Path, Json(settings): Json, ) -> Result, 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, cursor: Option, limit: Option, } async fn list_song_requests( State(state): State, headers: HeaderMap, Path(id): Path, Query(query): Query, ) -> Result, 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, headers: HeaderMap, Path((id, request_id)): Path<(Uuid, Uuid)>, ) -> Result, 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, headers: HeaderMap, Path((id, request_id)): Path<(Uuid, Uuid)>, ) -> Result, ApiError> { change_song_request(state, headers, id, request_id, false).await } async fn cancel_song_request( State(state): State, headers: HeaderMap, Path((id, request_id)): Path<(Uuid, Uuid)>, ) -> Result, 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, 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, headers: HeaderMap, Path(id): Path, ) -> Result, 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, headers: HeaderMap, Path(id): Path, ) -> Result, 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, headers: HeaderMap, Path(id): Path, Json(body): Json, ) -> Result, 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, headers: HeaderMap, Path(public_id): Path, 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::(&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 { 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 { 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 { 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, } impl ApiError { fn new(status: StatusCode, code: &'static str, message: impl Into) -> 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 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 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 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", ) }