105 lines
3.4 KiB
Rust
105 lines
3.4 KiB
Rust
//! 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<AppState> {
|
|
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<String>,
|
|
default: DateTime<Utc>,
|
|
) -> Result<DateTime<Utc>, 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))),
|
|
}
|
|
}
|