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.
This commit is contained in:
vegard 2026-03-18 15:39:30 +00:00
parent b7e7fbf45b
commit 1e34c3c67a
6 changed files with 125 additions and 69 deletions

View file

@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::AuthUser; use crate::auth::AdminUser;
use crate::AppState; use crate::AppState;
// ============================================================================= // =============================================================================
@ -92,7 +92,7 @@ fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
pub async fn ai_overview( pub async fn ai_overview(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
) -> Result<Json<AiOverviewResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<AiOverviewResponse>, (StatusCode, Json<ErrorResponse>)> {
let aliases = sqlx::query_as::<_, AiModelAlias>( let aliases = sqlx::query_as::<_, AiModelAlias>(
"SELECT id, alias, description, is_active, created_at FROM ai_model_aliases ORDER BY alias" "SELECT id, alias, description, is_active, created_at FROM ai_model_aliases ORDER BY alias"
@ -178,7 +178,7 @@ pub struct UpdateAliasRequest {
pub async fn update_alias( pub async fn update_alias(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
Json(req): Json<UpdateAliasRequest>, Json(req): Json<UpdateAliasRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let rows = sqlx::query( let rows = sqlx::query(
@ -195,7 +195,7 @@ pub async fn update_alias(
return Err(bad_request("Alias ikke funnet")); return Err(bad_request("Alias ikke funnet"));
} }
tracing::info!(alias_id = %req.id, user = %_user.node_id, "Admin: alias oppdatert"); tracing::info!(alias_id = %req.id, user = %_admin.node_id, "Admin: alias oppdatert");
Ok(Json(serde_json::json!({ "success": true }))) Ok(Json(serde_json::json!({ "success": true })))
} }
@ -211,7 +211,7 @@ pub struct CreateAliasRequest {
pub async fn create_alias( pub async fn create_alias(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
Json(req): Json<CreateAliasRequest>, Json(req): Json<CreateAliasRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
if req.alias.trim().is_empty() { if req.alias.trim().is_empty() {
@ -233,7 +233,7 @@ pub async fn create_alias(
} }
})?; })?;
tracing::info!(alias = %req.alias, user = %_user.node_id, "Admin: alias opprettet"); tracing::info!(alias = %req.alias, user = %_admin.node_id, "Admin: alias opprettet");
Ok(Json(serde_json::json!({ "id": id, "success": true }))) Ok(Json(serde_json::json!({ "id": id, "success": true })))
} }
@ -250,7 +250,7 @@ pub struct UpdateProviderRequest {
pub async fn update_provider( pub async fn update_provider(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
Json(req): Json<UpdateProviderRequest>, Json(req): Json<UpdateProviderRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
if let Some(priority) = req.priority { if let Some(priority) = req.priority {
@ -270,7 +270,7 @@ pub async fn update_provider(
.map_err(|e| internal_error(&format!("Feil ved oppdatering av provider status: {e}")))?; .map_err(|e| internal_error(&format!("Feil ved oppdatering av provider status: {e}")))?;
} }
tracing::info!(provider_id = %req.id, user = %_user.node_id, "Admin: provider oppdatert"); tracing::info!(provider_id = %req.id, user = %_admin.node_id, "Admin: provider oppdatert");
Ok(Json(serde_json::json!({ "success": true }))) Ok(Json(serde_json::json!({ "success": true })))
} }
@ -289,7 +289,7 @@ pub struct CreateProviderRequest {
pub async fn create_provider( pub async fn create_provider(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
Json(req): Json<CreateProviderRequest>, Json(req): Json<CreateProviderRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
if req.model.trim().is_empty() { if req.model.trim().is_empty() {
@ -317,7 +317,7 @@ pub async fn create_provider(
} }
})?; })?;
tracing::info!(model = %req.model, alias_id = %req.alias_id, user = %_user.node_id, "Admin: provider opprettet"); tracing::info!(model = %req.model, alias_id = %req.alias_id, user = %_admin.node_id, "Admin: provider opprettet");
Ok(Json(serde_json::json!({ "id": id, "success": true }))) Ok(Json(serde_json::json!({ "id": id, "success": true })))
} }
@ -332,7 +332,7 @@ pub struct DeleteProviderRequest {
pub async fn delete_provider( pub async fn delete_provider(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
Json(req): Json<DeleteProviderRequest>, Json(req): Json<DeleteProviderRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let rows = sqlx::query("DELETE FROM ai_model_providers WHERE id = $1") let rows = sqlx::query("DELETE FROM ai_model_providers WHERE id = $1")
@ -345,7 +345,7 @@ pub async fn delete_provider(
return Err(bad_request("Provider ikke funnet")); return Err(bad_request("Provider ikke funnet"));
} }
tracing::info!(provider_id = %req.id, user = %_user.node_id, "Admin: provider slettet"); tracing::info!(provider_id = %req.id, user = %_admin.node_id, "Admin: provider slettet");
Ok(Json(serde_json::json!({ "success": true }))) Ok(Json(serde_json::json!({ "success": true })))
} }
@ -362,7 +362,7 @@ pub struct UpdateRoutingRequest {
pub async fn update_routing( pub async fn update_routing(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
Json(req): Json<UpdateRoutingRequest>, Json(req): Json<UpdateRoutingRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
if req.job_type.trim().is_empty() || req.alias.trim().is_empty() { if req.job_type.trim().is_empty() || req.alias.trim().is_empty() {
@ -381,7 +381,7 @@ pub async fn update_routing(
.await .await
.map_err(|e| internal_error(&format!("Feil ved oppdatering av ruting: {e}")))?; .map_err(|e| internal_error(&format!("Feil ved oppdatering av ruting: {e}")))?;
tracing::info!(job_type = %req.job_type, alias = %req.alias, user = %_user.node_id, "Admin: ruting oppdatert"); tracing::info!(job_type = %req.job_type, alias = %req.alias, user = %_admin.node_id, "Admin: ruting oppdatert");
Ok(Json(serde_json::json!({ "success": true }))) Ok(Json(serde_json::json!({ "success": true })))
} }
@ -396,7 +396,7 @@ pub struct DeleteRoutingRequest {
pub async fn delete_routing( pub async fn delete_routing(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
Json(req): Json<DeleteRoutingRequest>, Json(req): Json<DeleteRoutingRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let rows = sqlx::query("DELETE FROM ai_job_routing WHERE job_type = $1") let rows = sqlx::query("DELETE FROM ai_job_routing WHERE job_type = $1")
@ -409,7 +409,7 @@ pub async fn delete_routing(
return Err(bad_request("Rutingregel ikke funnet")); return Err(bad_request("Rutingregel ikke funnet"));
} }
tracing::info!(job_type = %req.job_type, user = %_user.node_id, "Admin: ruting slettet"); tracing::info!(job_type = %req.job_type, user = %_admin.node_id, "Admin: ruting slettet");
Ok(Json(serde_json::json!({ "success": true }))) Ok(Json(serde_json::json!({ "success": true })))
} }
@ -425,7 +425,7 @@ pub struct UsageQueryParams {
pub async fn ai_usage( pub async fn ai_usage(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
axum::extract::Query(params): axum::extract::Query<UsageQueryParams>, axum::extract::Query(params): axum::extract::Query<UsageQueryParams>,
) -> Result<Json<Vec<AiUsageSummary>>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<Vec<AiUsageSummary>>, (StatusCode, Json<ErrorResponse>)> {
let days = params.days.unwrap_or(30).min(365); let days = params.days.unwrap_or(30).min(365);

View file

@ -115,6 +115,9 @@ impl IntoResponse for AuthErrorKind {
AuthErrorKind::UnknownIdentity => { AuthErrorKind::UnknownIdentity => {
(StatusCode::UNAUTHORIZED, "Ukjent brukeridentitet") (StatusCode::UNAUTHORIZED, "Ukjent brukeridentitet")
} }
AuthErrorKind::Forbidden => {
(StatusCode::FORBIDDEN, "Krever admin-tilgang")
}
AuthErrorKind::Internal => { AuthErrorKind::Internal => {
(StatusCode::INTERNAL_SERVER_ERROR, "Intern feil ved autentisering") (StatusCode::INTERNAL_SERVER_ERROR, "Intern feil ved autentisering")
} }
@ -133,6 +136,7 @@ pub enum AuthErrorKind {
MissingToken, MissingToken,
InvalidToken(String), InvalidToken(String),
UnknownIdentity, UnknownIdentity,
Forbidden,
Internal, Internal,
} }
@ -210,3 +214,62 @@ where
// We need FromRef to extract AppState from the state // We need FromRef to extract AppState from the state
use axum::extract::FromRef; use axum::extract::FromRef;
// ---------------------------------------------------------------------------
// AdminUser extractor — krever owner/admin-edge til en samling
// ---------------------------------------------------------------------------
/// Admin-bruker: autentisert bruker som har `owner`- eller `admin`-edge til
/// minst én samling. Brukes som extractor på /admin/*-endepunkter.
#[derive(Debug, Clone)]
pub struct AdminUser {
pub node_id: Uuid,
pub authentik_sub: String,
}
impl<S> FromRequestParts<S> for AdminUser
where
S: Send + Sync,
AppState: FromRef<S>,
{
type Rejection = AuthErrorKind;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
// Autentiser først
let user = AuthUser::from_request_parts(parts, state).await?;
let app_state = AppState::from_ref(state);
// Sjekk at brukeren er admin (owner/admin-edge til en samling)
let is_admin = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS(
SELECT 1 FROM edges
WHERE source_id = $1
AND edge_type IN ('owner', 'admin')
AND target_id IN (SELECT id FROM nodes WHERE node_kind = 'collection')
)
"#,
)
.bind(user.node_id)
.fetch_one(&app_state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "PG-feil ved admin-sjekk");
AuthErrorKind::Internal
})?;
if !is_admin {
tracing::warn!(
user_id = %user.node_id,
"Ikke-admin forsøkte å bruke admin-endepunkt"
);
return Err(AuthErrorKind::Forbidden);
}
Ok(AdminUser {
node_id: user.node_id,
authentik_sub: user.authentik_sub,
})
}
}

View file

@ -9,7 +9,7 @@ use axum::{extract::{Query, State}, http::StatusCode, Json};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use crate::auth::AuthUser; use crate::auth::AdminUser;
use crate::AppState; use crate::AppState;
// ============================================================================= // =============================================================================
@ -436,7 +436,7 @@ fn read_service_logs(service: &str, max_lines: usize) -> Vec<LogEntry> {
/// GET /admin/health — komplett serverhelse-dashboard. /// GET /admin/health — komplett serverhelse-dashboard.
pub async fn health_dashboard( pub async fn health_dashboard(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
) -> Result<Json<HealthDashboard>, (StatusCode, Json<crate::intentions::ErrorResponse>)> { ) -> Result<Json<HealthDashboard>, (StatusCode, Json<crate::intentions::ErrorResponse>)> {
// Kjør alle tjeneste-sjekker parallelt // Kjør alle tjeneste-sjekker parallelt
let (pg, caddy, authentik, litellm, whisper, livekit) = tokio::join!( let (pg, caddy, authentik, litellm, whisper, livekit) = tokio::join!(
@ -463,7 +463,7 @@ pub async fn health_dashboard(
/// GET /admin/health/logs?service=maskinrommet&lines=50 /// GET /admin/health/logs?service=maskinrommet&lines=50
pub async fn health_logs( pub async fn health_logs(
_user: AuthUser, _admin: AdminUser,
Query(params): Query<LogsQuery>, Query(params): Query<LogsQuery>,
) -> Json<LogsResponse> { ) -> Json<LogsResponse> {
let max_lines = params.lines.unwrap_or(50).min(200); let max_lines = params.lines.unwrap_or(50).min(200);

View file

@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::AuthUser; use crate::auth::{AdminUser, AuthUser};
use crate::livekit; use crate::livekit;
use crate::AppState; use crate::AppState;
@ -3927,7 +3927,7 @@ pub struct InitiateMaintenanceResponse {
/// Kall GET /admin/maintenance_status først for å se aktive sesjoner. /// Kall GET /admin/maintenance_status først for å se aktive sesjoner.
pub async fn initiate_maintenance( pub async fn initiate_maintenance(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, admin: AdminUser,
Json(req): Json<InitiateMaintenanceRequest>, Json(req): Json<InitiateMaintenanceRequest>,
) -> Result<Json<InitiateMaintenanceResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<InitiateMaintenanceResponse>, (StatusCode, Json<ErrorResponse>)> {
let scheduled_at = chrono::DateTime::parse_from_rfc3339(&req.scheduled_at) let scheduled_at = chrono::DateTime::parse_from_rfc3339(&req.scheduled_at)
@ -3938,7 +3938,7 @@ pub async fn initiate_maintenance(
return Err(bad_request("scheduled_at kan ikke være i fortiden")); return Err(bad_request("scheduled_at kan ikke være i fortiden"));
} }
let user_id = _user.node_id; let user_id = admin.node_id;
let announcement_id = state let announcement_id = state
.maintenance .maintenance
@ -3963,7 +3963,7 @@ pub struct CancelMaintenanceResponse {
/// nedtellingstasken. /// nedtellingstasken.
pub async fn cancel_maintenance( pub async fn cancel_maintenance(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
) -> Result<Json<CancelMaintenanceResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<CancelMaintenanceResponse>, (StatusCode, Json<ErrorResponse>)> {
state state
.maintenance .maintenance
@ -3980,7 +3980,7 @@ pub async fn cancel_maintenance(
/// Brukes av admin-panelet for å vise aktive sesjoner før bekreftelse. /// Brukes av admin-panelet for å vise aktive sesjoner før bekreftelse.
pub async fn maintenance_status( pub async fn maintenance_status(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
) -> Result<Json<crate::maintenance::MaintenanceStatus>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<crate::maintenance::MaintenanceStatus>, (StatusCode, Json<ErrorResponse>)> {
let status = state let status = state
.maintenance .maintenance
@ -4001,7 +4001,7 @@ pub async fn maintenance_status(
/// Query-params: ?status=error&type=whisper_transcribe&collection_id=...&limit=50&offset=0 /// Query-params: ?status=error&type=whisper_transcribe&collection_id=...&limit=50&offset=0
pub async fn list_jobs( pub async fn list_jobs(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
axum::extract::Query(params): axum::extract::Query<ListJobsParams>, axum::extract::Query(params): axum::extract::Query<ListJobsParams>,
) -> Result<Json<ListJobsResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<ListJobsResponse>, (StatusCode, Json<ErrorResponse>)> {
let limit = params.limit.unwrap_or(50).min(200); let limit = params.limit.unwrap_or(50).min(200);
@ -4050,7 +4050,7 @@ pub struct ListJobsResponse {
/// Sett en feilet jobb tilbake til 'pending' for nytt forsøk. /// Sett en feilet jobb tilbake til 'pending' for nytt forsøk.
pub async fn retry_job( pub async fn retry_job(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
Json(req): Json<JobIdRequest>, Json(req): Json<JobIdRequest>,
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
let retried = crate::jobs::retry_job(&state.db, req.job_id) let retried = crate::jobs::retry_job(&state.db, req.job_id)
@ -4061,7 +4061,7 @@ pub async fn retry_job(
return Err(bad_request("Jobben finnes ikke eller har feil status for retry")); return Err(bad_request("Jobben finnes ikke eller har feil status for retry"));
} }
tracing::info!(job_id = %req.job_id, user = %_user.node_id, "Admin: jobb restartet"); tracing::info!(job_id = %req.job_id, user = %_admin.node_id, "Admin: jobb restartet");
Ok(Json(JobActionResponse { success: true })) Ok(Json(JobActionResponse { success: true }))
} }
@ -4070,7 +4070,7 @@ pub async fn retry_job(
/// Avbryt en ventende jobb. /// Avbryt en ventende jobb.
pub async fn cancel_job( pub async fn cancel_job(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
Json(req): Json<JobIdRequest>, Json(req): Json<JobIdRequest>,
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
let cancelled = crate::jobs::cancel_job(&state.db, req.job_id) let cancelled = crate::jobs::cancel_job(&state.db, req.job_id)
@ -4081,7 +4081,7 @@ pub async fn cancel_job(
return Err(bad_request("Jobben finnes ikke eller har feil status for avbryt")); return Err(bad_request("Jobben finnes ikke eller har feil status for avbryt"));
} }
tracing::info!(job_id = %req.job_id, user = %_user.node_id, "Admin: jobb avbrutt"); tracing::info!(job_id = %req.job_id, user = %_admin.node_id, "Admin: jobb avbrutt");
Ok(Json(JobActionResponse { success: true })) Ok(Json(JobActionResponse { success: true }))
} }
@ -4102,7 +4102,7 @@ pub struct JobActionResponse {
/// GET /admin/resources — samlet ressursstatus. /// GET /admin/resources — samlet ressursstatus.
pub async fn resource_status( pub async fn resource_status(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
) -> Result<Json<crate::resources::ResourceStatus>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<crate::resources::ResourceStatus>, (StatusCode, Json<ErrorResponse>)> {
let cas_root = std::env::var("CAS_ROOT") let cas_root = std::env::var("CAS_ROOT")
.unwrap_or_else(|_| "/srv/synops/media/cas".to_string()); .unwrap_or_else(|_| "/srv/synops/media/cas".to_string());
@ -4139,7 +4139,7 @@ pub async fn resource_status(
/// GET /admin/resources/disk — disk-status med historikk. /// GET /admin/resources/disk — disk-status med historikk.
pub async fn resource_disk( pub async fn resource_disk(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
) -> Result<Json<DiskOverview>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<DiskOverview>, (StatusCode, Json<ErrorResponse>)> {
let current = crate::resources::latest_disk_status(&state.db) let current = crate::resources::latest_disk_status(&state.db)
.await .await
@ -4160,7 +4160,7 @@ pub struct DiskOverview {
/// POST /admin/resources/update_rule — oppdater en prioritetsregel. /// POST /admin/resources/update_rule — oppdater en prioritetsregel.
pub async fn update_priority_rule( pub async fn update_priority_rule(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
Json(req): Json<UpdatePriorityRuleRequest>, Json(req): Json<UpdatePriorityRuleRequest>,
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
sqlx::query( sqlx::query(
@ -4192,7 +4192,7 @@ pub async fn update_priority_rule(
tracing::info!( tracing::info!(
job_type = %req.job_type, job_type = %req.job_type,
user = %_user.node_id, user = %_admin.node_id,
"Admin: prioritetsregel oppdatert" "Admin: prioritetsregel oppdatert"
); );

View file

@ -79,15 +79,16 @@ pub struct SetGainRequest {
pub async fn set_gain( pub async fn set_gain(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, user: AuthUser,
Json(req): Json<SetGainRequest>, Json(req): Json<SetGainRequest>,
) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> {
sqlx::query( sqlx::query(
"UPDATE mixer_channels SET gain = $1, updated_at = now() WHERE room_id = $2 AND target_user_id = $3" "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.gain.clamp(0.0, 1.5))
.bind(&req.room_id) .bind(&req.room_id)
.bind(&req.target_user_id) .bind(&req.target_user_id)
.bind(user.node_id.to_string())
.execute(&state.db) .execute(&state.db)
.await .await
.map_err(|e| { .map_err(|e| {
@ -111,15 +112,16 @@ pub struct SetMuteRequest {
pub async fn set_mute( pub async fn set_mute(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, user: AuthUser,
Json(req): Json<SetMuteRequest>, Json(req): Json<SetMuteRequest>,
) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> {
sqlx::query( sqlx::query(
"UPDATE mixer_channels SET is_muted = $1, updated_at = now() WHERE room_id = $2 AND target_user_id = $3" "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.is_muted)
.bind(&req.room_id) .bind(&req.room_id)
.bind(&req.target_user_id) .bind(&req.target_user_id)
.bind(user.node_id.to_string())
.execute(&state.db) .execute(&state.db)
.await .await
.map_err(|e| { .map_err(|e| {
@ -143,42 +145,32 @@ pub struct ToggleEffectRequest {
pub async fn toggle_effect( pub async fn toggle_effect(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, user: AuthUser,
Json(req): Json<ToggleEffectRequest>, Json(req): Json<ToggleEffectRequest>,
) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> {
// Hent nåværende active_effects JSON // Atomisk toggle via PG JSON-operasjoner — unngår race condition ved
let current: Option<String> = sqlx::query_scalar( // samtidige oppdateringer fra flere klienter.
"SELECT active_effects FROM mixer_channels WHERE room_id = $1 AND target_user_id = $2"
)
.bind(&req.room_id)
.bind(&req.target_user_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!("Feil ved toggle_effect (les): {e}");
internal_error("Databasefeil")
})?;
let mut effects: serde_json::Value = current
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(serde_json::json!({}));
// Toggle effekten
let current_val = effects.get(&req.effect_name).and_then(|v| v.as_bool()).unwrap_or(false);
effects[&req.effect_name] = serde_json::Value::Bool(!current_val);
let new_effects = serde_json::to_string(&effects).unwrap_or_else(|_| "{}".to_string());
sqlx::query( sqlx::query(
"UPDATE mixer_channels SET active_effects = $1, updated_at = now() WHERE room_id = $2 AND target_user_id = $3" 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(&new_effects)
.bind(&req.room_id) .bind(&req.room_id)
.bind(&req.target_user_id) .bind(&req.target_user_id)
.bind(&req.effect_name)
.bind(user.node_id.to_string())
.execute(&state.db) .execute(&state.db)
.await .await
.map_err(|e| { .map_err(|e| {
tracing::error!("Feil ved toggle_effect (skriv): {e}"); tracing::error!("Feil ved toggle_effect: {e}");
internal_error("Databasefeil") internal_error("Databasefeil")
})?; })?;
@ -198,7 +190,7 @@ pub struct SetMixerRoleRequest {
pub async fn set_mixer_role( pub async fn set_mixer_role(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, user: AuthUser,
Json(req): Json<SetMixerRoleRequest>, Json(req): Json<SetMixerRoleRequest>,
) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<MixerChannelResponse>, (StatusCode, Json<ErrorResponse>)> {
let valid_roles = ["editor", "viewer"]; let valid_roles = ["editor", "viewer"];
@ -207,11 +199,12 @@ pub async fn set_mixer_role(
} }
sqlx::query( sqlx::query(
"UPDATE mixer_channels SET role = $1, updated_at = now() WHERE room_id = $2 AND target_user_id = $3" "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.role)
.bind(&req.room_id) .bind(&req.room_id)
.bind(&req.target_user_id) .bind(&req.target_user_id)
.bind(user.node_id.to_string())
.execute(&state.db) .execute(&state.db)
.await .await
.map_err(|e| { .map_err(|e| {

View file

@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::AuthUser; use crate::auth::AdminUser;
use crate::AppState; use crate::AppState;
// ============================================================================= // =============================================================================
@ -90,7 +90,7 @@ fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
pub async fn usage_overview( pub async fn usage_overview(
State(state): State<AppState>, State(state): State<AppState>,
_user: AuthUser, _admin: AdminUser,
axum::extract::Query(params): axum::extract::Query<UsageOverviewParams>, axum::extract::Query(params): axum::extract::Query<UsageOverviewParams>,
) -> Result<Json<UsageOverviewResponse>, (StatusCode, Json<ErrorResponse>)> { ) -> Result<Json<UsageOverviewResponse>, (StatusCode, Json<ErrorResponse>)> {
let days = params.days.unwrap_or(30).clamp(1, 365); let days = params.days.unwrap_or(30).clamp(1, 365);