synops/maskinrommet/src/mixer.rs
vegard 1e34c3c67a Valider fase 15–16: sikkerhet, konsistens og atomisk toggle
Tre fikser funnet under validering:

1. SIKKERHET: Admin-endepunkter manglet autorisasjonssjekk.
   Alle /admin/*-endepunkter brukte kun AuthUser (autentisert),
   ikke admin-rolle. Ny AdminUser-extractor sjekker owner/admin-edge
   til samling — returnerer 403 Forbidden for ikke-admins.
   Berører: maintenance, jobs, resources, health, ai, usage.

2. Race condition i toggle_effect: les-modifiser-skriv uten transaksjon
   på active_effects JSON. Erstattet med atomisk PG jsonb-operasjon.

3. Manglende updated_by i set_gain, set_mute, set_mixer_role, toggle_effect.
   Nå spores hvem som endret mixer-tilstanden.
2026-03-18 15:39:30 +00:00

216 lines
6.5 KiB
Rust

//! Mixer-kanaler — HTTP API for delt lydmixer-tilstand.
//!
//! Skriver direkte til PG; NOTIFY-trigger propagerer endringer til
//! WebSocket-klienter.
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use crate::auth::AuthUser;
use crate::AppState;
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: msg.to_string() }))
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: msg.to_string() }))
}
// =============================================================================
// Create mixer channel
// =============================================================================
#[derive(Deserialize)]
pub struct CreateMixerChannelRequest {
pub room_id: String,
pub target_user_id: String,
}
#[derive(Serialize)]
pub struct MixerChannelResponse {
pub ok: bool,
}
pub async fn create_mixer_channel(
State(state): State<AppState>,
_user: AuthUser,
Json(req): Json<CreateMixerChannelRequest>,
) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> {
if req.room_id.is_empty() || req.target_user_id.is_empty() {
return Err(bad_request("room_id og target_user_id er påkrevd"));
}
sqlx::query(
r#"
INSERT INTO mixer_channels (room_id, target_user_id, updated_by, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (room_id, target_user_id) DO NOTHING
"#,
)
.bind(&req.room_id)
.bind(&req.target_user_id)
.bind(&req.target_user_id) // updated_by = the user joining
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("Feil ved opprettelse av mixer-kanal: {e}");
internal_error("Databasefeil")
})?;
Ok(Json(MixerChannelResponse { ok: true }))
}
// =============================================================================
// Set gain
// =============================================================================
#[derive(Deserialize)]
pub struct SetGainRequest {
pub room_id: String,
pub target_user_id: String,
pub gain: f64,
}
pub async fn set_gain(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<SetGainRequest>,
) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> {
sqlx::query(
"UPDATE mixer_channels SET gain = $1, updated_by = $4, updated_at = now() WHERE room_id = $2 AND target_user_id = $3"
)
.bind(req.gain.clamp(0.0, 1.5))
.bind(&req.room_id)
.bind(&req.target_user_id)
.bind(user.node_id.to_string())
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("Feil ved set_gain: {e}");
internal_error("Databasefeil")
})?;
Ok(Json(MixerChannelResponse { ok: true }))
}
// =============================================================================
// Set mute
// =============================================================================
#[derive(Deserialize)]
pub struct SetMuteRequest {
pub room_id: String,
pub target_user_id: String,
pub is_muted: bool,
}
pub async fn set_mute(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<SetMuteRequest>,
) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> {
sqlx::query(
"UPDATE mixer_channels SET is_muted = $1, updated_by = $4, updated_at = now() WHERE room_id = $2 AND target_user_id = $3"
)
.bind(req.is_muted)
.bind(&req.room_id)
.bind(&req.target_user_id)
.bind(user.node_id.to_string())
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("Feil ved set_mute: {e}");
internal_error("Databasefeil")
})?;
Ok(Json(MixerChannelResponse { ok: true }))
}
// =============================================================================
// Toggle effect
// =============================================================================
#[derive(Deserialize)]
pub struct ToggleEffectRequest {
pub room_id: String,
pub target_user_id: String,
pub effect_name: String,
}
pub async fn toggle_effect(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<ToggleEffectRequest>,
) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> {
// Atomisk toggle via PG JSON-operasjoner — unngår race condition ved
// samtidige oppdateringer fra flere klienter.
sqlx::query(
r#"UPDATE mixer_channels
SET active_effects = (
CASE
WHEN (active_effects::jsonb->>$3)::boolean IS NOT DISTINCT FROM true
THEN (active_effects::jsonb || jsonb_build_object($3, false))::text
ELSE (active_effects::jsonb || jsonb_build_object($3, true))::text
END
),
updated_by = $4,
updated_at = now()
WHERE room_id = $1 AND target_user_id = $2"#,
)
.bind(&req.room_id)
.bind(&req.target_user_id)
.bind(&req.effect_name)
.bind(user.node_id.to_string())
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("Feil ved toggle_effect: {e}");
internal_error("Databasefeil")
})?;
Ok(Json(MixerChannelResponse { ok: true }))
}
// =============================================================================
// Set role
// =============================================================================
#[derive(Deserialize)]
pub struct SetMixerRoleRequest {
pub room_id: String,
pub target_user_id: String,
pub role: String,
}
pub async fn set_mixer_role(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<SetMixerRoleRequest>,
) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> {
let valid_roles = ["editor", "viewer"];
if !valid_roles.contains(&req.role.as_str()) {
return Err(bad_request("Ugyldig rolle. Gyldige: editor, viewer"));
}
sqlx::query(
"UPDATE mixer_channels SET role = $1, updated_by = $4, updated_at = now() WHERE room_id = $2 AND target_user_id = $3"
)
.bind(&req.role)
.bind(&req.room_id)
.bind(&req.target_user_id)
.bind(user.node_id.to_string())
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!("Feil ved set_mixer_role: {e}");
internal_error("Databasefeil")
})?;
Ok(Json(MixerChannelResponse { ok: true }))
}