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.
216 lines
6.5 KiB
Rust
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 }))
|
|
}
|