feat: add access control to channels with various modes

- Introduced AccessMode enum to define channel access levels: Public, PasswordProtected, AccountRequired, and OwnerOnly.
- Updated Channel and ProgrammingBlock entities to include access_mode and access_password_hash fields.
- Enhanced create and update channel functionality to handle access mode and password.
- Implemented access checks in channel routes based on the defined access modes.
- Modified frontend components to support channel creation and editing with access control options.
- Added ChannelPasswordModal for handling password input when accessing restricted channels.
- Updated API calls to include channel and block passwords as needed.
- Created database migrations to add access_mode and access_password_hash columns to channels table.
This commit is contained in:
2026-03-14 01:45:10 +01:00
parent 924e162563
commit 81df6eb8ff
25 changed files with 635 additions and 53 deletions

View File

@@ -1,7 +1,7 @@
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use chrono::Utc;
@@ -13,18 +13,41 @@ use domain::{DomainError, ScheduleEngineService};
use crate::{
dto::{CurrentBroadcastResponse, ScheduledSlotResponse},
error::ApiError,
extractors::OptionalCurrentUser,
state::AppState,
};
use super::parse_optional_dt;
use super::{check_access, parse_optional_dt};
fn channel_password(headers: &HeaderMap) -> Option<&str> {
headers
.get("X-Channel-Password")
.and_then(|v| v.to_str().ok())
}
fn block_password(headers: &HeaderMap) -> Option<&str> {
headers
.get("X-Block-Password")
.and_then(|v| v.to_str().ok())
}
/// What is currently playing right now on this channel.
/// Returns 204 No Content when the channel is in a gap between blocks (no-signal).
pub(super) async fn get_current_broadcast(
State(state): State<AppState>,
Path(channel_id): Path<Uuid>,
OptionalCurrentUser(user): OptionalCurrentUser,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let _channel = state.channel_service.find_by_id(channel_id).await?;
let channel = state.channel_service.find_by_id(channel_id).await?;
check_access(
&channel.access_mode,
channel.access_password_hash.as_deref(),
user.as_ref(),
channel.owner_id,
channel_password(&headers),
)?;
let now = Utc::now();
let schedule = state
@@ -35,11 +58,21 @@ pub(super) async fn get_current_broadcast(
match ScheduleEngineService::get_current_broadcast(&schedule, now) {
None => Ok(StatusCode::NO_CONTENT.into_response()),
Some(broadcast) => Ok(Json(CurrentBroadcastResponse {
slot: broadcast.slot.into(),
offset_secs: broadcast.offset_secs,
})
.into_response()),
Some(broadcast) => {
let block_access_mode = channel
.schedule_config
.blocks
.iter()
.find(|b| b.id == broadcast.slot.source_block_id)
.map(|b| b.access_mode.clone())
.unwrap_or_default();
Ok(Json(CurrentBroadcastResponse {
block_access_mode: block_access_mode.clone(),
slot: ScheduledSlotResponse::with_block_access(broadcast.slot, &channel),
offset_secs: broadcast.offset_secs,
})
.into_response())
}
}
}
@@ -57,9 +90,19 @@ pub(super) struct EpgQuery {
pub(super) async fn get_epg(
State(state): State<AppState>,
Path(channel_id): Path<Uuid>,
OptionalCurrentUser(user): OptionalCurrentUser,
headers: HeaderMap,
Query(params): Query<EpgQuery>,
) -> Result<impl IntoResponse, ApiError> {
let _channel = state.channel_service.find_by_id(channel_id).await?;
let channel = state.channel_service.find_by_id(channel_id).await?;
check_access(
&channel.access_mode,
channel.access_password_hash.as_deref(),
user.as_ref(),
channel.owner_id,
channel_password(&headers),
)?;
let now = Utc::now();
let from = parse_optional_dt(params.from, now)?;
@@ -78,7 +121,7 @@ pub(super) async fn get_epg(
let slots: Vec<ScheduledSlotResponse> = ScheduleEngineService::get_epg(&schedule, from, until)
.into_iter()
.cloned()
.map(Into::into)
.map(|slot| ScheduledSlotResponse::with_block_access(slot, &channel))
.collect();
Ok(Json(slots))
@@ -90,8 +133,18 @@ pub(super) async fn get_epg(
pub(super) async fn get_stream(
State(state): State<AppState>,
Path(channel_id): Path<Uuid>,
OptionalCurrentUser(user): OptionalCurrentUser,
headers: HeaderMap,
) -> Result<Response, ApiError> {
let _channel = state.channel_service.find_by_id(channel_id).await?;
let channel = state.channel_service.find_by_id(channel_id).await?;
check_access(
&channel.access_mode,
channel.access_password_hash.as_deref(),
user.as_ref(),
channel.owner_id,
channel_password(&headers),
)?;
let now = Utc::now();
let schedule = state
@@ -105,6 +158,22 @@ pub(super) async fn get_stream(
Some(b) => b,
};
// Block-level access check
if let Some(block) = channel
.schedule_config
.blocks
.iter()
.find(|b| b.id == broadcast.slot.source_block_id)
{
check_access(
&block.access_mode,
block.access_password_hash.as_deref(),
user.as_ref(),
channel.owner_id,
block_password(&headers),
)?;
}
let url = state
.schedule_engine
.get_stream_url(&broadcast.slot.item.id)

View File

@@ -34,8 +34,20 @@ pub(super) async fn create_channel(
.create(user.id, &payload.name, &payload.timezone)
.await?;
let mut changed = false;
if let Some(desc) = payload.description {
channel.description = Some(desc);
changed = true;
}
if let Some(mode) = payload.access_mode {
channel.access_mode = mode;
changed = true;
}
if let Some(pw) = payload.access_password.as_deref().filter(|p| !p.is_empty()) {
channel.access_password_hash = Some(infra::auth::hash_password(pw));
changed = true;
}
if changed {
channel = state.channel_service.update(channel).await?;
}
@@ -79,6 +91,16 @@ pub(super) async fn update_channel(
if let Some(auto) = payload.auto_schedule {
channel.auto_schedule = auto;
}
if let Some(mode) = payload.access_mode {
channel.access_mode = mode;
}
if let Some(pw) = payload.access_password {
if pw.is_empty() {
channel.access_password_hash = None;
} else {
channel.access_password_hash = Some(infra::auth::hash_password(&pw));
}
}
channel.updated_at = Utc::now();
let channel = state.channel_service.update(channel).await?;

View File

@@ -8,6 +8,8 @@ use axum::{Router, routing::{get, post}};
use chrono::{DateTime, Utc};
use uuid::Uuid;
use domain::{AccessMode, User};
use crate::{error::ApiError, state::AppState};
mod broadcast;
@@ -42,6 +44,40 @@ pub(super) fn require_owner(channel: &domain::Channel, user_id: Uuid) -> Result<
}
}
/// Gate access to a channel or block based on its `AccessMode`.
pub(super) fn check_access(
mode: &AccessMode,
password_hash: Option<&str>,
user: Option<&User>,
owner_id: Uuid,
supplied_password: Option<&str>,
) -> Result<(), ApiError> {
match mode {
AccessMode::Public => Ok(()),
AccessMode::PasswordProtected => {
let hash = password_hash.ok_or(ApiError::PasswordRequired)?;
let supplied = supplied_password.unwrap_or("").trim();
if supplied.is_empty() {
return Err(ApiError::PasswordRequired);
}
if !infra::auth::verify_password(supplied, hash) {
return Err(ApiError::PasswordRequired);
}
Ok(())
}
AccessMode::AccountRequired => {
if user.is_some() { Ok(()) } else { Err(ApiError::AuthRequired) }
}
AccessMode::OwnerOnly => {
if user.map(|u| u.id) == Some(owner_id) {
Ok(())
} else {
Err(ApiError::Forbidden("owner only".into()))
}
}
}
}
pub(super) fn parse_optional_dt(
s: Option<String>,
default: DateTime<Utc>,