diff --git a/k-tv-backend/api/src/dto.rs b/k-tv-backend/api/src/dto.rs index ec4d0a5..30008cf 100644 --- a/k-tv-backend/api/src/dto.rs +++ b/k-tv-backend/api/src/dto.rs @@ -58,6 +58,9 @@ pub struct CreateChannelRequest { pub description: Option, /// IANA timezone, e.g. "UTC" or "America/New_York" pub timezone: String, + pub access_mode: Option, + /// Plain-text password; hashed before storage. + pub access_password: Option, } /// All fields are optional — only provided fields are updated. @@ -70,6 +73,9 @@ pub struct UpdateChannelRequest { pub schedule_config: Option, pub recycle_policy: Option, pub auto_schedule: Option, + pub access_mode: Option, + /// Empty string clears the password; non-empty re-hashes. + pub access_password: Option, } #[derive(Debug, Serialize)] @@ -82,6 +88,7 @@ pub struct ChannelResponse { pub schedule_config: domain::ScheduleConfig, pub recycle_policy: domain::RecyclePolicy, pub auto_schedule: bool, + pub access_mode: domain::AccessMode, pub created_at: DateTime, pub updated_at: DateTime, } @@ -97,6 +104,7 @@ impl From for ChannelResponse { schedule_config: c.schedule_config, recycle_policy: c.recycle_policy, auto_schedule: c.auto_schedule, + access_mode: c.access_mode, created_at: c.created_at, updated_at: c.updated_at, } @@ -147,6 +155,8 @@ pub struct ScheduledSlotResponse { pub end_at: DateTime, pub item: MediaItemResponse, pub source_block_id: Uuid, + #[serde(default)] + pub block_access_mode: domain::AccessMode, } impl From for ScheduledSlotResponse { @@ -157,6 +167,27 @@ impl From for ScheduledSlotResponse { end_at: s.end_at, item: s.item.into(), source_block_id: s.source_block_id, + block_access_mode: domain::AccessMode::default(), + } + } +} + +impl ScheduledSlotResponse { + pub fn with_block_access(slot: domain::ScheduledSlot, channel: &domain::Channel) -> Self { + let block_access_mode = channel + .schedule_config + .blocks + .iter() + .find(|b| b.id == slot.source_block_id) + .map(|b| b.access_mode.clone()) + .unwrap_or_default(); + Self { + id: slot.id, + start_at: slot.start_at, + end_at: slot.end_at, + item: slot.item.into(), + source_block_id: slot.source_block_id, + block_access_mode, } } } @@ -169,6 +200,8 @@ pub struct CurrentBroadcastResponse { /// Seconds elapsed since the start of the current item — use this as the /// initial seek position for the player. pub offset_secs: u32, + /// Access mode of the block currently playing. The stream is gated by this. + pub block_access_mode: domain::AccessMode, } #[derive(Debug, Serialize)] diff --git a/k-tv-backend/api/src/error.rs b/k-tv-backend/api/src/error.rs index 3a364fb..72584ee 100644 --- a/k-tv-backend/api/src/error.rs +++ b/k-tv-backend/api/src/error.rs @@ -29,6 +29,12 @@ pub enum ApiError { #[error("Unauthorized: {0}")] Unauthorized(String), + + #[error("password_required")] + PasswordRequired, + + #[error("auth_required")] + AuthRequired, } /// Error response body @@ -110,6 +116,22 @@ impl IntoResponse for ApiError { details: Some(msg.clone()), }, ), + + ApiError::PasswordRequired => ( + StatusCode::UNAUTHORIZED, + ErrorResponse { + error: "password_required".to_string(), + details: None, + }, + ), + + ApiError::AuthRequired => ( + StatusCode::UNAUTHORIZED, + ErrorResponse { + error: "auth_required".to_string(), + details: None, + }, + ), }; (status, Json(error_response)).into_response() diff --git a/k-tv-backend/api/src/extractors.rs b/k-tv-backend/api/src/extractors.rs index 0b25acf..28055c2 100644 --- a/k-tv-backend/api/src/extractors.rs +++ b/k-tv-backend/api/src/extractors.rs @@ -38,6 +38,29 @@ impl FromRequestParts for CurrentUser { } } +/// Optional current user — returns None instead of error when auth is missing/invalid. +pub struct OptionalCurrentUser(pub Option); + +impl FromRequestParts for OptionalCurrentUser { + type Rejection = ApiError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + #[cfg(feature = "auth-jwt")] + { + return Ok(OptionalCurrentUser(try_jwt_auth(parts, state).await.ok())); + } + + #[cfg(not(feature = "auth-jwt"))] + { + let _ = (parts, state); + Ok(OptionalCurrentUser(None)) + } + } +} + /// Authenticate using JWT Bearer token #[cfg(feature = "auth-jwt")] async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result { diff --git a/k-tv-backend/api/src/main.rs b/k-tv-backend/api/src/main.rs index e7c2b46..6f6150f 100644 --- a/k-tv-backend/api/src/main.rs +++ b/k-tv-backend/api/src/main.rs @@ -6,7 +6,9 @@ use std::net::SocketAddr; use std::time::Duration as StdDuration; use axum::Router; +use axum::http::{HeaderName, HeaderValue}; use std::sync::Arc; +use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; use domain::{ChannelService, IMediaProvider, ScheduleEngineService, UserService}; use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository}; @@ -102,6 +104,24 @@ async fn main() -> anyhow::Result<()> { let app = apply_standard_middleware(app, &server_config); + // Wrap with an outer CorsLayer that includes the custom password headers. + // Being outermost it handles OPTIONS preflights before k_core's inner layer. + let origins: Vec = config + .cors_allowed_origins + .iter() + .filter_map(|o| o.parse().ok()) + .collect(); + let cors = CorsLayer::new() + .allow_origin(AllowOrigin::list(origins)) + .allow_methods(AllowMethods::any()) + .allow_headers(AllowHeaders::list([ + axum::http::header::AUTHORIZATION, + axum::http::header::CONTENT_TYPE, + HeaderName::from_static("x-channel-password"), + HeaderName::from_static("x-block-password"), + ])); + let app = app.layer(cors); + let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?; let listener = TcpListener::bind(addr).await?; diff --git a/k-tv-backend/api/src/routes/channels/broadcast.rs b/k-tv-backend/api/src/routes/channels/broadcast.rs index 6fb1f52..9fccd7f 100644 --- a/k-tv-backend/api/src/routes/channels/broadcast.rs +++ b/k-tv-backend/api/src/routes/channels/broadcast.rs @@ -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, Path(channel_id): Path, + OptionalCurrentUser(user): OptionalCurrentUser, + headers: HeaderMap, ) -> Result { - 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, Path(channel_id): Path, + OptionalCurrentUser(user): OptionalCurrentUser, + headers: HeaderMap, Query(params): Query, ) -> Result { - 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 = 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, Path(channel_id): Path, + OptionalCurrentUser(user): OptionalCurrentUser, + headers: HeaderMap, ) -> Result { - 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) diff --git a/k-tv-backend/api/src/routes/channels/crud.rs b/k-tv-backend/api/src/routes/channels/crud.rs index 754ea4f..d5546bb 100644 --- a/k-tv-backend/api/src/routes/channels/crud.rs +++ b/k-tv-backend/api/src/routes/channels/crud.rs @@ -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?; diff --git a/k-tv-backend/api/src/routes/channels/mod.rs b/k-tv-backend/api/src/routes/channels/mod.rs index a9b70b2..29dd4da 100644 --- a/k-tv-backend/api/src/routes/channels/mod.rs +++ b/k-tv-backend/api/src/routes/channels/mod.rs @@ -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, default: DateTime, diff --git a/k-tv-backend/domain/src/entities.rs b/k-tv-backend/domain/src/entities.rs index 680c9fd..48828a7 100644 --- a/k-tv-backend/domain/src/entities.rs +++ b/k-tv-backend/domain/src/entities.rs @@ -9,7 +9,8 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::value_objects::{ - BlockId, ChannelId, ContentType, FillStrategy, MediaFilter, MediaItemId, RecyclePolicy, SlotId, + AccessMode, BlockId, ChannelId, ContentType, FillStrategy, MediaFilter, MediaItemId, + RecyclePolicy, SlotId, }; /// A user in the system. @@ -82,6 +83,8 @@ pub struct Channel { pub schedule_config: ScheduleConfig, pub recycle_policy: RecyclePolicy, pub auto_schedule: bool, + pub access_mode: AccessMode, + pub access_password_hash: Option, pub created_at: DateTime, pub updated_at: DateTime, } @@ -102,6 +105,8 @@ impl Channel { schedule_config: ScheduleConfig::default(), recycle_policy: RecyclePolicy::default(), auto_schedule: false, + access_mode: AccessMode::default(), + access_password_hash: None, created_at: now, updated_at: now, } @@ -176,6 +181,14 @@ pub struct ProgrammingBlock { /// regardless of what other blocks aired. #[serde(default)] pub ignore_recycle_policy: bool, + + /// Who can watch the stream during this block. Gates only /stream, not /now. + #[serde(default)] + pub access_mode: AccessMode, + + /// Bcrypt/argon2 hash of the block password (when access_mode = PasswordProtected). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub access_password_hash: Option, } fn default_true() -> bool { @@ -198,6 +211,8 @@ impl ProgrammingBlock { content: BlockContent::Algorithmic { filter, strategy }, loop_on_finish: true, ignore_recycle_policy: false, + access_mode: AccessMode::default(), + access_password_hash: None, } } @@ -215,6 +230,8 @@ impl ProgrammingBlock { content: BlockContent::Manual { items }, loop_on_finish: true, ignore_recycle_policy: false, + access_mode: AccessMode::default(), + access_password_hash: None, } } } diff --git a/k-tv-backend/domain/src/value_objects/scheduling.rs b/k-tv-backend/domain/src/value_objects/scheduling.rs index bb0457d..a75bc95 100644 --- a/k-tv-backend/domain/src/value_objects/scheduling.rs +++ b/k-tv-backend/domain/src/value_objects/scheduling.rs @@ -1,6 +1,17 @@ use serde::{Deserialize, Serialize}; use std::fmt; +/// Controls who can view a channel's broadcast and stream. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AccessMode { + #[default] + Public, + PasswordProtected, + AccountRequired, + OwnerOnly, +} + /// Opaque media item identifier — format is provider-specific internally. /// The domain never inspects the string; it just passes it back to the provider. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/k-tv-backend/infra/src/channel_repository/mapping.rs b/k-tv-backend/infra/src/channel_repository/mapping.rs index 8b64284..9847d56 100644 --- a/k-tv-backend/infra/src/channel_repository/mapping.rs +++ b/k-tv-backend/infra/src/channel_repository/mapping.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use sqlx::FromRow; use uuid::Uuid; -use domain::{Channel, ChannelId, DomainError, RecyclePolicy, ScheduleConfig, UserId}; +use domain::{AccessMode, Channel, ChannelId, DomainError, RecyclePolicy, ScheduleConfig, UserId}; #[derive(Debug, FromRow)] pub(super) struct ChannelRow { @@ -14,6 +14,8 @@ pub(super) struct ChannelRow { pub schedule_config: String, pub recycle_policy: String, pub auto_schedule: i64, + pub access_mode: String, + pub access_password_hash: Option, pub created_at: String, pub updated_at: String, } @@ -44,6 +46,11 @@ impl TryFrom for Channel { DomainError::RepositoryError(format!("Invalid recycle_policy JSON: {}", e)) })?; + let access_mode: AccessMode = serde_json::from_value( + serde_json::Value::String(row.access_mode), + ) + .unwrap_or_default(); + Ok(Channel { id, owner_id, @@ -53,6 +60,8 @@ impl TryFrom for Channel { schedule_config, recycle_policy, auto_schedule: row.auto_schedule != 0, + access_mode, + access_password_hash: row.access_password_hash, created_at: parse_dt(&row.created_at)?, updated_at: parse_dt(&row.updated_at)?, }) @@ -60,4 +69,4 @@ impl TryFrom for Channel { } pub(super) const SELECT_COLS: &str = - "id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, created_at, updated_at"; + "id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at"; diff --git a/k-tv-backend/infra/src/channel_repository/postgres.rs b/k-tv-backend/infra/src/channel_repository/postgres.rs index 776553c..169aff6 100644 --- a/k-tv-backend/infra/src/channel_repository/postgres.rs +++ b/k-tv-backend/infra/src/channel_repository/postgres.rs @@ -58,19 +58,26 @@ impl ChannelRepository for PostgresChannelRepository { DomainError::RepositoryError(format!("Failed to serialize recycle_policy: {}", e)) })?; + let access_mode = serde_json::to_value(&channel.access_mode) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_else(|| "public".to_owned()); + sqlx::query( r#" INSERT INTO channels - (id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + (id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT(id) DO UPDATE SET - name = EXCLUDED.name, - description = EXCLUDED.description, - timezone = EXCLUDED.timezone, - schedule_config = EXCLUDED.schedule_config, - recycle_policy = EXCLUDED.recycle_policy, - auto_schedule = EXCLUDED.auto_schedule, - updated_at = EXCLUDED.updated_at + name = EXCLUDED.name, + description = EXCLUDED.description, + timezone = EXCLUDED.timezone, + schedule_config = EXCLUDED.schedule_config, + recycle_policy = EXCLUDED.recycle_policy, + auto_schedule = EXCLUDED.auto_schedule, + access_mode = EXCLUDED.access_mode, + access_password_hash = EXCLUDED.access_password_hash, + updated_at = EXCLUDED.updated_at "#, ) .bind(channel.id.to_string()) @@ -81,6 +88,8 @@ impl ChannelRepository for PostgresChannelRepository { .bind(&schedule_config) .bind(&recycle_policy) .bind(channel.auto_schedule as i64) + .bind(&access_mode) + .bind(&channel.access_password_hash) .bind(channel.created_at.to_rfc3339()) .bind(channel.updated_at.to_rfc3339()) .execute(&self.pool) diff --git a/k-tv-backend/infra/src/channel_repository/sqlite.rs b/k-tv-backend/infra/src/channel_repository/sqlite.rs index 8fa291f..3f09eb6 100644 --- a/k-tv-backend/infra/src/channel_repository/sqlite.rs +++ b/k-tv-backend/infra/src/channel_repository/sqlite.rs @@ -58,19 +58,26 @@ impl ChannelRepository for SqliteChannelRepository { DomainError::RepositoryError(format!("Failed to serialize recycle_policy: {}", e)) })?; + let access_mode = serde_json::to_value(&channel.access_mode) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_else(|| "public".to_owned()); + sqlx::query( r#" INSERT INTO channels - (id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, owner_id, name, description, timezone, schedule_config, recycle_policy, auto_schedule, access_mode, access_password_hash, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - description = excluded.description, - timezone = excluded.timezone, - schedule_config = excluded.schedule_config, - recycle_policy = excluded.recycle_policy, - auto_schedule = excluded.auto_schedule, - updated_at = excluded.updated_at + name = excluded.name, + description = excluded.description, + timezone = excluded.timezone, + schedule_config = excluded.schedule_config, + recycle_policy = excluded.recycle_policy, + auto_schedule = excluded.auto_schedule, + access_mode = excluded.access_mode, + access_password_hash = excluded.access_password_hash, + updated_at = excluded.updated_at "#, ) .bind(channel.id.to_string()) @@ -81,6 +88,8 @@ impl ChannelRepository for SqliteChannelRepository { .bind(&schedule_config) .bind(&recycle_policy) .bind(channel.auto_schedule as i64) + .bind(&access_mode) + .bind(&channel.access_password_hash) .bind(channel.created_at.to_rfc3339()) .bind(channel.updated_at.to_rfc3339()) .execute(&self.pool) diff --git a/k-tv-backend/migrations_sqlite/20240104000000_add_access_control_to_channels.sql b/k-tv-backend/migrations_sqlite/20240104000000_add_access_control_to_channels.sql new file mode 100644 index 0000000..39561bc --- /dev/null +++ b/k-tv-backend/migrations_sqlite/20240104000000_add_access_control_to_channels.sql @@ -0,0 +1,2 @@ +ALTER TABLE channels ADD COLUMN access_mode TEXT NOT NULL DEFAULT 'public'; +ALTER TABLE channels ADD COLUMN access_password_hash TEXT; diff --git a/k-tv-frontend/app/(main)/dashboard/components/create-channel-dialog.tsx b/k-tv-frontend/app/(main)/dashboard/components/create-channel-dialog.tsx index 9b32d80..fd2d166 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/create-channel-dialog.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/create-channel-dialog.tsx @@ -9,6 +9,7 @@ import { DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import type { AccessMode } from "@/lib/types"; interface CreateChannelDialogProps { open: boolean; @@ -17,6 +18,8 @@ interface CreateChannelDialogProps { name: string; timezone: string; description: string; + access_mode?: AccessMode; + access_password?: string; }) => void; isPending: boolean; error?: string | null; @@ -32,10 +35,18 @@ export function CreateChannelDialog({ const [name, setName] = useState(""); const [timezone, setTimezone] = useState("UTC"); const [description, setDescription] = useState(""); + const [accessMode, setAccessMode] = useState("public"); + const [accessPassword, setAccessPassword] = useState(""); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - onSubmit({ name, timezone, description }); + onSubmit({ + name, + timezone, + description, + access_mode: accessMode !== "public" ? accessMode : undefined, + access_password: accessMode === "password_protected" && accessPassword ? accessPassword : undefined, + }); }; const handleOpenChange = (next: boolean) => { @@ -45,6 +56,8 @@ export function CreateChannelDialog({ setName(""); setTimezone("UTC"); setDescription(""); + setAccessMode("public"); + setAccessPassword(""); } } }; @@ -99,6 +112,33 @@ export function CreateChannelDialog({ /> +
+ + +
+ + {accessMode === "password_protected" && ( +
+ + setAccessPassword(e.target.value)} + placeholder="Channel password" + className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none" + /> +
+ )} + {error &&

{error}

} diff --git a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx index 88ac46f..c099de0 100644 --- a/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx +++ b/k-tv-frontend/app/(main)/dashboard/components/edit-channel-sheet.tsx @@ -11,6 +11,7 @@ import { SeriesPicker } from "./series-picker"; import { FilterPreview } from "./filter-preview"; import { useCollections, useSeries, useGenres } from "@/hooks/use-library"; import type { + AccessMode, ChannelResponse, ProgrammingBlock, BlockContent, @@ -42,6 +43,8 @@ const mediaFilterSchema = z.object({ search_term: z.string().nullable().optional(), }); +const accessModeSchema = z.enum(["public", "password_protected", "account_required", "owner_only"]); + const blockSchema = z.object({ id: z.string(), name: z.string().min(1, "Block name is required"), @@ -60,6 +63,8 @@ const blockSchema = z.object({ ]), loop_on_finish: z.boolean().optional(), ignore_recycle_policy: z.boolean().optional(), + access_mode: accessModeSchema.optional(), + access_password: z.string().optional(), }); const channelFormSchema = z.object({ @@ -73,6 +78,8 @@ const channelFormSchema = z.object({ min_available_ratio: z.number().min(0, "Must be ≥ 0").max(1, "Must be ≤ 1"), }), auto_schedule: z.boolean(), + access_mode: accessModeSchema.optional(), + access_password: z.string().optional(), }); type FieldErrors = Record; @@ -216,6 +223,7 @@ function defaultBlock(startMins = 20 * 60, durationMins = 60): ProgrammingBlock content: { type: "algorithmic", filter: defaultFilter(), strategy: "random" }, loop_on_finish: true, ignore_recycle_policy: false, + access_mode: "public", }; } @@ -603,6 +611,29 @@ function BlockEditor({ block, index, isSelected, color, errors, onChange, onRemo

One Jellyfin item ID per line, played in order.

)} + + {/* Block-level access control */} +
+

Block access

+ onChange({ ...block, access_mode: v as AccessMode })} + > + + + + + + {(block.access_mode === "password_protected") && ( + onChange({ ...block, access_password: e.target.value })} + className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none" + /> + )} +
)} @@ -678,6 +709,8 @@ interface EditChannelSheetProps { schedule_config: { blocks: ProgrammingBlock[] }; recycle_policy: RecyclePolicy; auto_schedule: boolean; + access_mode?: AccessMode; + access_password?: string; }, ) => void; isPending: boolean; @@ -702,6 +735,8 @@ export function EditChannelSheet({ min_available_ratio: 0.1, }); const [autoSchedule, setAutoSchedule] = useState(false); + const [accessMode, setAccessMode] = useState("public"); + const [accessPassword, setAccessPassword] = useState(""); const [selectedBlockId, setSelectedBlockId] = useState(null); const [fieldErrors, setFieldErrors] = useState({}); @@ -713,6 +748,8 @@ export function EditChannelSheet({ setBlocks(channel.schedule_config.blocks); setRecyclePolicy(channel.recycle_policy); setAutoSchedule(channel.auto_schedule); + setAccessMode(channel.access_mode ?? "public"); + setAccessPassword(""); setSelectedBlockId(null); setFieldErrors({}); } @@ -723,7 +760,8 @@ export function EditChannelSheet({ if (!channel) return; const result = channelFormSchema.safeParse({ - name, description, timezone, blocks, recycle_policy: recyclePolicy, auto_schedule: autoSchedule, + name, description, timezone, blocks, recycle_policy: recyclePolicy, + auto_schedule: autoSchedule, access_mode: accessMode, access_password: accessPassword, }); if (!result.success) { @@ -739,6 +777,8 @@ export function EditChannelSheet({ schedule_config: { blocks }, recycle_policy: recyclePolicy, auto_schedule: autoSchedule, + access_mode: accessMode !== "public" ? accessMode : "public", + access_password: accessPassword || "", }); }; @@ -822,6 +862,25 @@ export function EditChannelSheet({ /> + + + { setAccessMode(v as AccessMode); setAccessPassword(""); }}> + + + + + + + + {accessMode === "password_protected" && ( + + + + )} {/* Programming blocks */} diff --git a/k-tv-frontend/app/(main)/dashboard/page.tsx b/k-tv-frontend/app/(main)/dashboard/page.tsx index 57825e3..531845b 100644 --- a/k-tv-frontend/app/(main)/dashboard/page.tsx +++ b/k-tv-frontend/app/(main)/dashboard/page.tsx @@ -107,9 +107,17 @@ export default function DashboardPage() { name: string; timezone: string; description: string; + access_mode?: import("@/lib/types").AccessMode; + access_password?: string; }) => { createChannel.mutate( - { name: data.name, timezone: data.timezone, description: data.description || undefined }, + { + name: data.name, + timezone: data.timezone, + description: data.description || undefined, + access_mode: data.access_mode, + access_password: data.access_password, + }, { onSuccess: () => setCreateOpen(false) }, ); }; @@ -123,6 +131,8 @@ export default function DashboardPage() { schedule_config: { blocks: ProgrammingBlock[] }; recycle_policy: RecyclePolicy; auto_schedule: boolean; + access_mode?: import("@/lib/types").AccessMode; + access_password?: string; }, ) => { updateChannel.mutate( diff --git a/k-tv-frontend/app/(main)/tv/components/channel-password-modal.tsx b/k-tv-frontend/app/(main)/tv/components/channel-password-modal.tsx new file mode 100644 index 0000000..45559b2 --- /dev/null +++ b/k-tv-frontend/app/(main)/tv/components/channel-password-modal.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useState } from "react"; + +interface ChannelPasswordModalProps { + label: string; + onSubmit: (password: string) => void; + onCancel: () => void; +} + +export function ChannelPasswordModal({ + label, + onSubmit, + onCancel, +}: ChannelPasswordModalProps) { + const [value, setValue] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (value.trim()) onSubmit(value.trim()); + }; + + return ( +
+
+
+

+ {label} +

+
+ setValue(e.target.value)} + placeholder="Enter password" + className="rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none" + /> +
+ + +
+
+
+ ); +} diff --git a/k-tv-frontend/app/(main)/tv/components/index.ts b/k-tv-frontend/app/(main)/tv/components/index.ts index 56d0450..9e6e6df 100644 --- a/k-tv-frontend/app/(main)/tv/components/index.ts +++ b/k-tv-frontend/app/(main)/tv/components/index.ts @@ -16,3 +16,5 @@ export type { UpNextBannerProps } from "./up-next-banner"; export { NoSignal } from "./no-signal"; export type { NoSignalProps, NoSignalVariant } from "./no-signal"; + +export { ChannelPasswordModal } from "./channel-password-modal"; diff --git a/k-tv-frontend/app/(main)/tv/components/no-signal.tsx b/k-tv-frontend/app/(main)/tv/components/no-signal.tsx index 78bdbff..b1d9acc 100644 --- a/k-tv-frontend/app/(main)/tv/components/no-signal.tsx +++ b/k-tv-frontend/app/(main)/tv/components/no-signal.tsx @@ -1,6 +1,6 @@ -import { WifiOff, AlertTriangle, Loader2 } from "lucide-react"; +import { WifiOff, AlertTriangle, Loader2, Lock } from "lucide-react"; -type NoSignalVariant = "no-signal" | "error" | "loading"; +type NoSignalVariant = "no-signal" | "error" | "loading" | "locked"; interface NoSignalProps { variant?: NoSignalVariant; @@ -27,6 +27,11 @@ const VARIANTS: Record< heading: "Loading", defaultMessage: "Tuning in…", }, + locked: { + icon: , + heading: "Access Restricted", + defaultMessage: "You don't have permission to watch this channel.", + }, }; export function NoSignal({ variant = "no-signal", message, children }: NoSignalProps) { diff --git a/k-tv-frontend/app/(main)/tv/page.tsx b/k-tv-frontend/app/(main)/tv/page.tsx index 082162a..6dee411 100644 --- a/k-tv-frontend/app/(main)/tv/page.tsx +++ b/k-tv-frontend/app/(main)/tv/page.tsx @@ -10,6 +10,7 @@ import { ScheduleOverlay, UpNextBanner, NoSignal, + ChannelPasswordModal, } from "./components"; import type { SubtitleTrack } from "./components/video-player"; import { Maximize2, Minimize2, Volume1, Volume2, VolumeX } from "lucide-react"; @@ -76,6 +77,18 @@ function TvPageContent() { // Video ref — used to resume playback if autoplay was blocked on load const videoRef = useRef(null); + // Access control — persisted per channel in localStorage + const [channelPasswords, setChannelPasswords] = useState>(() => { + try { return JSON.parse(localStorage.getItem("channel_passwords") ?? "{}"); } catch { return {}; } + }); + const [blockPasswords, setBlockPasswords] = useState>(() => { + try { return JSON.parse(localStorage.getItem("block_passwords") ?? "{}"); } catch { return {}; } + }); + const [showChannelPasswordModal, setShowChannelPasswordModal] = useState(false); + const [showBlockPasswordModal, setShowBlockPasswordModal] = useState(false); + + const channelPassword = channel ? channelPasswords[channel.id] : undefined; + // Stream error recovery const [streamError, setStreamError] = useState(false); @@ -160,10 +173,13 @@ function TvPageContent() { }, []); // Per-channel data - const { data: broadcast, isLoading: isLoadingBroadcast } = - useCurrentBroadcast(channel?.id ?? ""); - const { data: epgSlots } = useEpg(channel?.id ?? ""); - const { data: streamUrl } = useStreamUrl(channel?.id, token, broadcast?.slot.id); + const { data: broadcast, isLoading: isLoadingBroadcast, error: broadcastError } = + useCurrentBroadcast(channel?.id ?? "", channelPassword); + const blockPassword = broadcast?.slot.id ? blockPasswords[broadcast.slot.id] : undefined; + const { data: epgSlots } = useEpg(channel?.id ?? "", undefined, undefined, channelPassword); + const { data: streamUrl, error: streamUrlError } = useStreamUrl( + channel?.id, token, broadcast?.slot.id, channelPassword, blockPassword, + ); // iOS Safari: track fullscreen state via webkit video element events. // Re-run when streamUrl changes so we catch the video element after it mounts. @@ -180,6 +196,20 @@ function TvPageContent() { }; }, [streamUrl]); + // Show channel password modal when broadcast returns password_required + useEffect(() => { + if ((broadcastError as Error)?.message === "password_required") { + setShowChannelPasswordModal(true); + } + }, [broadcastError]); + + // Show block password modal when stream URL fetch returns password_required + useEffect(() => { + if ((streamUrlError as Error)?.message === "password_required") { + setShowBlockPasswordModal(true); + } + }, [streamUrlError]); + // Clear transient states when a new slot is detected useEffect(() => { setStreamError(false); @@ -354,6 +384,24 @@ function TvPageContent() { setStreamError(false); }, [queryClient, channel?.id, broadcast?.slot.id]); + const submitChannelPassword = useCallback((password: string) => { + if (!channel) return; + const next = { ...channelPasswords, [channel.id]: password }; + setChannelPasswords(next); + try { localStorage.setItem("channel_passwords", JSON.stringify(next)); } catch {} + setShowChannelPasswordModal(false); + queryClient.invalidateQueries({ queryKey: ["broadcast", channel.id] }); + }, [channel, channelPasswords, queryClient]); + + const submitBlockPassword = useCallback((password: string) => { + if (!broadcast?.slot.id) return; + const next = { ...blockPasswords, [broadcast.slot.id]: password }; + setBlockPasswords(next); + try { localStorage.setItem("block_passwords", JSON.stringify(next)); } catch {} + setShowBlockPasswordModal(false); + queryClient.invalidateQueries({ queryKey: ["stream-url", channel?.id, broadcast.slot.id] }); + }, [broadcast?.slot.id, blockPasswords, channel?.id, queryClient]); + // ------------------------------------------------------------------ // Render helpers // ------------------------------------------------------------------ @@ -370,6 +418,16 @@ function TvPageContent() { /> ); } + + // Channel-level access errors (not password — those show a modal) + const broadcastErrMsg = (broadcastError as Error)?.message; + if (broadcastErrMsg === "auth_required") { + return ; + } + if (broadcastErrMsg && broadcastError && (broadcastError as { status?: number }).status === 403) { + return ; + } + if (isLoadingBroadcast) { return ; } @@ -381,6 +439,16 @@ function TvPageContent() { /> ); } + + // Block-level access errors (not password — those show a modal overlay) + const streamErrMsg = (streamUrlError as Error)?.message; + if (streamErrMsg === "auth_required") { + return ; + } + if (streamUrlError && (streamUrlError as { status?: number }).status === 403) { + return ; + } + if (streamError) { return ( @@ -431,6 +499,24 @@ function TvPageContent() { {/* ── Base layer ─────────────────────────────────────────────── */}
{renderBase()}
+ {/* ── Channel password modal ──────────────────────────────────── */} + {showChannelPasswordModal && ( + setShowChannelPasswordModal(false)} + /> + )} + + {/* ── Block password modal ────────────────────────────────────── */} + {showBlockPasswordModal && ( + setShowBlockPasswordModal(false)} + /> + )} + {/* ── Autoplay blocked prompt ─────────────────────────────────── */} {needsInteraction && (
diff --git a/k-tv-frontend/app/api/stream/[channelId]/route.ts b/k-tv-frontend/app/api/stream/[channelId]/route.ts index dda01ca..d7eeed3 100644 --- a/k-tv-frontend/app/api/stream/[channelId]/route.ts +++ b/k-tv-frontend/app/api/stream/[channelId]/route.ts @@ -26,11 +26,15 @@ export async function GET( ) { const { channelId } = await params; const token = request.nextUrl.searchParams.get("token"); + const channelPassword = request.nextUrl.searchParams.get("channel_password"); + const blockPassword = request.nextUrl.searchParams.get("block_password"); let res: Response; try { const headers: Record = {}; if (token) headers["Authorization"] = `Bearer ${token}`; + if (channelPassword) headers["X-Channel-Password"] = channelPassword; + if (blockPassword) headers["X-Block-Password"] = blockPassword; res = await fetch(`${API_URL}/channels/${channelId}/stream`, { headers, redirect: "manual", @@ -43,6 +47,11 @@ export async function GET( return new Response(null, { status: 204 }); } + if (res.status === 401 || res.status === 403) { + const body = await res.json().catch(() => ({})); + return Response.json(body, { status: res.status }); + } + if (res.status === 307 || res.status === 302 || res.status === 301) { const location = res.headers.get("Location"); if (location) { diff --git a/k-tv-frontend/hooks/use-channels.ts b/k-tv-frontend/hooks/use-channels.ts index a6876bf..99e5b20 100644 --- a/k-tv-frontend/hooks/use-channels.ts +++ b/k-tv-frontend/hooks/use-channels.ts @@ -98,22 +98,22 @@ export function useActiveSchedule(channelId: string) { }); } -export function useCurrentBroadcast(channelId: string) { +export function useCurrentBroadcast(channelId: string, channelPassword?: string) { const { token } = useAuthContext(); return useQuery({ - queryKey: ["broadcast", channelId], - queryFn: () => api.schedule.getCurrentBroadcast(channelId, token ?? ""), + queryKey: ["broadcast", channelId, channelPassword], + queryFn: () => api.schedule.getCurrentBroadcast(channelId, token ?? "", channelPassword), enabled: !!channelId, refetchInterval: 30_000, retry: false, }); } -export function useEpg(channelId: string, from?: string, until?: string) { +export function useEpg(channelId: string, from?: string, until?: string, channelPassword?: string) { const { token } = useAuthContext(); return useQuery({ - queryKey: ["epg", channelId, from, until], - queryFn: () => api.schedule.getEpg(channelId, token ?? "", from, until), + queryKey: ["epg", channelId, from, until, channelPassword], + queryFn: () => api.schedule.getEpg(channelId, token ?? "", from, until, channelPassword), enabled: !!channelId, }); } diff --git a/k-tv-frontend/hooks/use-tv.ts b/k-tv-frontend/hooks/use-tv.ts index 2f95d46..c4ea700 100644 --- a/k-tv-frontend/hooks/use-tv.ts +++ b/k-tv-frontend/hooks/use-tv.ts @@ -130,17 +130,25 @@ export function useStreamUrl( channelId: string | undefined, token: string | null, slotId: string | undefined, + channelPassword?: string, + blockPassword?: string, ) { return useQuery({ - queryKey: ["stream-url", channelId, slotId], + queryKey: ["stream-url", channelId, slotId, channelPassword, blockPassword], queryFn: async (): Promise => { const params = new URLSearchParams(); if (token) params.set("token", token); + if (channelPassword) params.set("channel_password", channelPassword); + if (blockPassword) params.set("block_password", blockPassword); const res = await fetch(`/api/stream/${channelId}?${params}`, { cache: "no-store", }); if (res.status === 204) return null; - if (!res.ok) throw new Error(`Stream resolve failed: ${res.status}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const msg = body?.error ?? `Stream resolve failed: ${res.status}`; + throw new Error(msg); + } const { url } = await res.json(); return url as string; }, diff --git a/k-tv-frontend/lib/api.ts b/k-tv-frontend/lib/api.ts index 080e9d9..59fdd9f 100644 --- a/k-tv-frontend/lib/api.ts +++ b/k-tv-frontend/lib/api.ts @@ -152,24 +152,31 @@ export const api = { getActive: (channelId: string, token: string) => request(`/channels/${channelId}/schedule`, { token }), - getCurrentBroadcast: (channelId: string, token: string) => - request(`/channels/${channelId}/now`, { + getCurrentBroadcast: (channelId: string, token: string, channelPassword?: string) => { + const headers: Record = {}; + if (channelPassword) headers["X-Channel-Password"] = channelPassword; + return request(`/channels/${channelId}/now`, { token, - }), + headers, + }); + }, getEpg: ( channelId: string, token: string, from?: string, until?: string, + channelPassword?: string, ) => { const params = new URLSearchParams(); if (from) params.set("from", from); if (until) params.set("until", until); const qs = params.toString(); + const headers: Record = {}; + if (channelPassword) headers["X-Channel-Password"] = channelPassword; return request( `/channels/${channelId}/epg${qs ? `?${qs}` : ""}`, - { token }, + { token, headers }, ); }, }, diff --git a/k-tv-frontend/lib/types.ts b/k-tv-frontend/lib/types.ts index 1e82f25..82d9ea3 100644 --- a/k-tv-frontend/lib/types.ts +++ b/k-tv-frontend/lib/types.ts @@ -2,6 +2,8 @@ export type ContentType = "movie" | "episode" | "short"; +export type AccessMode = "public" | "password_protected" | "account_required" | "owner_only"; + export type FillStrategy = "best_fit" | "sequential" | "random"; export interface MediaFilter { @@ -67,6 +69,9 @@ export interface ProgrammingBlock { loop_on_finish?: boolean; /** When true, skip the channel-level recycle policy for this block. Default false on backend. */ ignore_recycle_policy?: boolean; + access_mode?: AccessMode; + /** Plain-text password sent to API; hashed server-side. Only set on write operations. */ + access_password?: string; } export interface ScheduleConfig { @@ -104,6 +109,7 @@ export interface ChannelResponse { schedule_config: ScheduleConfig; recycle_policy: RecyclePolicy; auto_schedule: boolean; + access_mode: AccessMode; created_at: string; updated_at: string; } @@ -112,6 +118,8 @@ export interface CreateChannelRequest { name: string; timezone: string; description?: string; + access_mode?: AccessMode; + access_password?: string; } export interface UpdateChannelRequest { @@ -121,6 +129,9 @@ export interface UpdateChannelRequest { schedule_config?: ScheduleConfig; recycle_policy?: RecyclePolicy; auto_schedule?: boolean; + access_mode?: AccessMode; + /** Empty string clears the password. */ + access_password?: string; } // Media & Schedule @@ -150,6 +161,7 @@ export interface ScheduledSlotResponse { start_at: string; /** RFC3339 */ end_at: string; + block_access_mode: AccessMode; } export interface ScheduleResponse { @@ -165,4 +177,5 @@ export interface ScheduleResponse { export interface CurrentBroadcastResponse { slot: ScheduledSlotResponse; offset_secs: number; + block_access_mode: AccessMode; }