//! Channel routes //! //! CRUD + schedule generation require authentication (Bearer JWT). //! Viewing endpoints (list, now, epg, stream) are intentionally public so the //! TV page works without login. 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; mod crud; mod schedule; pub fn router() -> Router { Router::new() .route("/", get(crud::list_channels).post(crud::create_channel)) .route( "/{id}", get(crud::get_channel).put(crud::update_channel).delete(crud::delete_channel), ) .route( "/{id}/schedule", post(schedule::generate_schedule).get(schedule::get_active_schedule), ) .route("/{id}/schedule/history", get(schedule::list_schedule_history)) .route( "/{id}/schedule/history/{gen_id}", get(schedule::get_schedule_history_entry), ) .route( "/{id}/schedule/history/{gen_id}/rollback", post(schedule::rollback_schedule), ) .route("/{id}/now", get(broadcast::get_current_broadcast)) .route("/{id}/epg", get(broadcast::get_epg)) .route("/{id}/stream", get(broadcast::get_stream)) } // ============================================================================ // Shared helpers // ============================================================================ pub(super) fn require_owner(channel: &domain::Channel, user_id: Uuid) -> Result<(), ApiError> { if channel.owner_id != user_id { Err(ApiError::Forbidden("You don't own this channel".into())) } else { Ok(()) } } /// 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 => { // Owner always has access to their own channel without needing the password if user.map(|u| u.id) == Some(owner_id) { return Ok(()); } 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, ) -> Result, ApiError> { match s { None => Ok(default), Some(raw) => DateTime::parse_from_rfc3339(&raw) .map(|dt| dt.with_timezone(&Utc)) .map_err(|_| ApiError::validation(format!("Invalid datetime '{}' — use RFC3339", raw))), } }