Refactor schedule and user repositories into modular structure
- Moved schedule repository logic into separate modules for SQLite and PostgreSQL implementations. - Created a mapping module for shared data structures and mapping functions in the schedule repository. - Added new mapping module for user repository to handle user data transformations. - Implemented PostgreSQL and SQLite user repository adapters with necessary CRUD operations. - Added tests for user repository functionality, including saving, finding, and deleting users.
This commit is contained in:
@@ -1,257 +0,0 @@
|
||||
//! Authentication routes
|
||||
//!
|
||||
//! Provides login, register, logout, token, and OIDC endpoints.
|
||||
//! All authentication is JWT-based. OIDC state is stored in an encrypted cookie.
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Json, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::{LoginRequest, RegisterRequest, TokenResponse, UserResponse},
|
||||
error::ApiError,
|
||||
extractors::CurrentUser,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
let r = Router::new()
|
||||
.route("/login", post(login))
|
||||
.route("/register", post(register))
|
||||
.route("/logout", post(logout))
|
||||
.route("/me", get(me));
|
||||
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
let r = r.route("/token", post(get_token));
|
||||
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
let r = r
|
||||
.route("/login/oidc", get(oidc_login))
|
||||
.route("/callback", get(oidc_callback));
|
||||
|
||||
r
|
||||
}
|
||||
|
||||
/// Login with email + password → JWT token
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user = state
|
||||
.user_service
|
||||
.find_by_email(payload.email.as_ref())
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?;
|
||||
|
||||
let hash = user
|
||||
.password_hash
|
||||
.as_deref()
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?;
|
||||
|
||||
if !infra::auth::verify_password(payload.password.as_ref(), hash) {
|
||||
return Err(ApiError::Unauthorized("Invalid credentials".to_string()));
|
||||
}
|
||||
|
||||
let token = create_jwt(&user, &state)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Register a new local user → JWT token
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
if !state.config.allow_registration {
|
||||
return Err(ApiError::Forbidden("Registration is disabled".to_string()));
|
||||
}
|
||||
|
||||
let password_hash = infra::auth::hash_password(payload.password.as_ref());
|
||||
|
||||
let user = state
|
||||
.user_service
|
||||
.create_local(payload.email.as_ref(), &password_hash)
|
||||
.await?;
|
||||
|
||||
let token = create_jwt(&user, &state)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Logout — JWT is stateless; instruct the client to drop the token
|
||||
async fn logout() -> impl IntoResponse {
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
/// Get current user info from JWT
|
||||
async fn me(CurrentUser(user): CurrentUser) -> Result<impl IntoResponse, ApiError> {
|
||||
Ok(Json(UserResponse {
|
||||
id: user.id,
|
||||
email: user.email.into_inner(),
|
||||
created_at: user.created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Issue a new JWT for the currently authenticated user (OIDC→JWT exchange or token refresh)
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
async fn get_token(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let token = create_jwt(&user, &state)?;
|
||||
|
||||
Ok(Json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Helper: create JWT for a user
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
fn create_jwt(user: &domain::User, state: &AppState) -> Result<String, ApiError> {
|
||||
let validator = state
|
||||
.jwt_validator
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
|
||||
|
||||
validator
|
||||
.create_token(user)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create token: {}", e)))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "auth-jwt"))]
|
||||
fn create_jwt(_user: &domain::User, _state: &AppState) -> Result<String, ApiError> {
|
||||
Err(ApiError::Internal("JWT feature not enabled".to_string()))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Routes
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
#[derive(serde::Deserialize)]
|
||||
struct CallbackParams {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
/// Start OIDC login: generate authorization URL and store state in encrypted cookie
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
async fn oidc_login(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::PrivateCookieJar,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
use axum::http::header;
|
||||
use axum::response::Response;
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
|
||||
let service = state
|
||||
.oidc_service
|
||||
.as_ref()
|
||||
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
|
||||
|
||||
let (auth_data, oidc_state) = service.get_authorization_url();
|
||||
|
||||
let state_json = serde_json::to_string(&oidc_state)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to serialize OIDC state: {}", e)))?;
|
||||
|
||||
let cookie = Cookie::build(("oidc_state", state_json))
|
||||
.max_age(time::Duration::minutes(5))
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.secure(state.config.secure_cookie)
|
||||
.path("/")
|
||||
.build();
|
||||
|
||||
let updated_jar = jar.add(cookie);
|
||||
|
||||
let redirect = axum::response::Redirect::to(auth_data.url.as_str()).into_response();
|
||||
let (mut parts, body) = redirect.into_parts();
|
||||
parts.headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
"no-cache, no-store, must-revalidate".parse().unwrap(),
|
||||
);
|
||||
parts
|
||||
.headers
|
||||
.insert(header::PRAGMA, "no-cache".parse().unwrap());
|
||||
parts.headers.insert(header::EXPIRES, "0".parse().unwrap());
|
||||
|
||||
Ok((updated_jar, Response::from_parts(parts, body)))
|
||||
}
|
||||
|
||||
/// Handle OIDC callback: verify state cookie, complete exchange, issue JWT, clear cookie
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
async fn oidc_callback(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::PrivateCookieJar,
|
||||
axum::extract::Query(params): axum::extract::Query<CallbackParams>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
use infra::auth::oidc::OidcState;
|
||||
|
||||
let service = state
|
||||
.oidc_service
|
||||
.as_ref()
|
||||
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
|
||||
|
||||
// Read and decrypt OIDC state from cookie
|
||||
let cookie = jar
|
||||
.get("oidc_state")
|
||||
.ok_or(ApiError::Validation("Missing OIDC state cookie".into()))?;
|
||||
|
||||
let oidc_state: OidcState = serde_json::from_str(cookie.value())
|
||||
.map_err(|_| ApiError::Validation("Invalid OIDC state cookie".into()))?;
|
||||
|
||||
// Verify CSRF token
|
||||
if params.state != oidc_state.csrf_token.as_ref() {
|
||||
return Err(ApiError::Validation("Invalid CSRF token".into()));
|
||||
}
|
||||
|
||||
// Complete OIDC exchange
|
||||
let oidc_user = service
|
||||
.resolve_callback(
|
||||
domain::AuthorizationCode::new(params.code),
|
||||
oidc_state.nonce,
|
||||
oidc_state.pkce_verifier,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let user = state
|
||||
.user_service
|
||||
.find_or_create(&oidc_user.subject, &oidc_user.email)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
// Clear the OIDC state cookie
|
||||
let cleared_jar = jar.remove(axum_extra::extract::cookie::Cookie::from("oidc_state"));
|
||||
|
||||
let token = create_jwt(&user, &state)?;
|
||||
|
||||
Ok((
|
||||
cleared_jar,
|
||||
Json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
}),
|
||||
))
|
||||
}
|
||||
104
k-tv-backend/api/src/routes/auth/local.rs
Normal file
104
k-tv-backend/api/src/routes/auth/local.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
dto::{LoginRequest, RegisterRequest, TokenResponse, UserResponse},
|
||||
error::ApiError,
|
||||
extractors::CurrentUser,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use super::create_jwt;
|
||||
|
||||
/// Login with email + password → JWT token
|
||||
pub(super) async fn login(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<LoginRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let user = state
|
||||
.user_service
|
||||
.find_by_email(payload.email.as_ref())
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?;
|
||||
|
||||
let hash = user
|
||||
.password_hash
|
||||
.as_deref()
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid credentials".to_string()))?;
|
||||
|
||||
if !infra::auth::verify_password(payload.password.as_ref(), hash) {
|
||||
return Err(ApiError::Unauthorized("Invalid credentials".to_string()));
|
||||
}
|
||||
|
||||
let token = create_jwt(&user, &state)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Register a new local user → JWT token
|
||||
pub(super) async fn register(
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<RegisterRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
if !state.config.allow_registration {
|
||||
return Err(ApiError::Forbidden("Registration is disabled".to_string()));
|
||||
}
|
||||
|
||||
let password_hash = infra::auth::hash_password(payload.password.as_ref());
|
||||
|
||||
let user = state
|
||||
.user_service
|
||||
.create_local(payload.email.as_ref(), &password_hash)
|
||||
.await?;
|
||||
|
||||
let token = create_jwt(&user, &state)?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
/// Logout — JWT is stateless; instruct the client to drop the token
|
||||
pub(super) async fn logout() -> impl IntoResponse {
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
/// Get current user info from JWT
|
||||
pub(super) async fn me(CurrentUser(user): CurrentUser) -> Result<impl IntoResponse, ApiError> {
|
||||
Ok(Json(UserResponse {
|
||||
id: user.id,
|
||||
email: user.email.into_inner(),
|
||||
created_at: user.created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Issue a new JWT for the currently authenticated user (OIDC→JWT exchange or token refresh)
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub(super) async fn get_token(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let token = create_jwt(&user, &state)?;
|
||||
|
||||
Ok(Json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
}))
|
||||
}
|
||||
47
k-tv-backend/api/src/routes/auth/mod.rs
Normal file
47
k-tv-backend/api/src/routes/auth/mod.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
//! Authentication routes
|
||||
//!
|
||||
//! Provides login, register, logout, token, and OIDC endpoints.
|
||||
//! All authentication is JWT-based. OIDC state is stored in an encrypted cookie.
|
||||
|
||||
use axum::{Router, routing::{get, post}};
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
mod local;
|
||||
mod oidc;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
let r = Router::new()
|
||||
.route("/login", post(local::login))
|
||||
.route("/register", post(local::register))
|
||||
.route("/logout", post(local::logout))
|
||||
.route("/me", get(local::me));
|
||||
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
let r = r.route("/token", post(local::get_token));
|
||||
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
let r = r
|
||||
.route("/login/oidc", get(oidc::oidc_login))
|
||||
.route("/callback", get(oidc::oidc_callback));
|
||||
|
||||
r
|
||||
}
|
||||
|
||||
/// Helper: create JWT for a user
|
||||
#[cfg(feature = "auth-jwt")]
|
||||
pub(super) fn create_jwt(user: &domain::User, state: &AppState) -> Result<String, ApiError> {
|
||||
let validator = state
|
||||
.jwt_validator
|
||||
.as_ref()
|
||||
.ok_or_else(|| ApiError::Internal("JWT not configured".to_string()))?;
|
||||
|
||||
validator
|
||||
.create_token(user)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to create token: {}", e)))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "auth-jwt"))]
|
||||
pub(super) fn create_jwt(_user: &domain::User, _state: &AppState) -> Result<String, ApiError> {
|
||||
Err(ApiError::Internal("JWT feature not enabled".to_string()))
|
||||
}
|
||||
124
k-tv-backend/api/src/routes/auth/oidc.rs
Normal file
124
k-tv-backend/api/src/routes/auth/oidc.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::header,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
use crate::{
|
||||
dto::TokenResponse,
|
||||
error::ApiError,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
use super::create_jwt;
|
||||
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
#[derive(serde::Deserialize)]
|
||||
pub(super) struct CallbackParams {
|
||||
pub code: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
/// Start OIDC login: generate authorization URL and store state in encrypted cookie
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
pub(super) async fn oidc_login(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::PrivateCookieJar,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
|
||||
let service = state
|
||||
.oidc_service
|
||||
.as_ref()
|
||||
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
|
||||
|
||||
let (auth_data, oidc_state) = service.get_authorization_url();
|
||||
|
||||
let state_json = serde_json::to_string(&oidc_state)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to serialize OIDC state: {}", e)))?;
|
||||
|
||||
let cookie = Cookie::build(("oidc_state", state_json))
|
||||
.max_age(time::Duration::minutes(5))
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.secure(state.config.secure_cookie)
|
||||
.path("/")
|
||||
.build();
|
||||
|
||||
let updated_jar = jar.add(cookie);
|
||||
|
||||
let redirect = axum::response::Redirect::to(auth_data.url.as_str()).into_response();
|
||||
let (mut parts, body) = redirect.into_parts();
|
||||
parts.headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
"no-cache, no-store, must-revalidate".parse().unwrap(),
|
||||
);
|
||||
parts
|
||||
.headers
|
||||
.insert(header::PRAGMA, "no-cache".parse().unwrap());
|
||||
parts.headers.insert(header::EXPIRES, "0".parse().unwrap());
|
||||
|
||||
Ok((updated_jar, Response::from_parts(parts, body)))
|
||||
}
|
||||
|
||||
/// Handle OIDC callback: verify state cookie, complete exchange, issue JWT, clear cookie
|
||||
#[cfg(feature = "auth-oidc")]
|
||||
pub(super) async fn oidc_callback(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::PrivateCookieJar,
|
||||
axum::extract::Query(params): axum::extract::Query<CallbackParams>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
use infra::auth::oidc::OidcState;
|
||||
|
||||
let service = state
|
||||
.oidc_service
|
||||
.as_ref()
|
||||
.ok_or(ApiError::Internal("OIDC not configured".into()))?;
|
||||
|
||||
// Read and decrypt OIDC state from cookie
|
||||
let cookie = jar
|
||||
.get("oidc_state")
|
||||
.ok_or(ApiError::Validation("Missing OIDC state cookie".into()))?;
|
||||
|
||||
let oidc_state: OidcState = serde_json::from_str(cookie.value())
|
||||
.map_err(|_| ApiError::Validation("Invalid OIDC state cookie".into()))?;
|
||||
|
||||
// Verify CSRF token
|
||||
if params.state != oidc_state.csrf_token.as_ref() {
|
||||
return Err(ApiError::Validation("Invalid CSRF token".into()));
|
||||
}
|
||||
|
||||
// Complete OIDC exchange
|
||||
let oidc_user = service
|
||||
.resolve_callback(
|
||||
domain::AuthorizationCode::new(params.code),
|
||||
oidc_state.nonce,
|
||||
oidc_state.pkce_verifier,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
let user = state
|
||||
.user_service
|
||||
.find_or_create(&oidc_user.subject, &oidc_user.email)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
|
||||
// Clear the OIDC state cookie
|
||||
let cleared_jar = jar.remove(axum_extra::extract::cookie::Cookie::from("oidc_state"));
|
||||
|
||||
let token = create_jwt(&user, &state)?;
|
||||
|
||||
Ok((
|
||||
cleared_jar,
|
||||
Json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.config.jwt_expiry_hours * 3600,
|
||||
}),
|
||||
))
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
//! 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::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{DomainError, ScheduleEngineService};
|
||||
|
||||
use crate::{
|
||||
dto::{
|
||||
ChannelResponse, CreateChannelRequest, CurrentBroadcastResponse, ScheduleResponse,
|
||||
ScheduledSlotResponse, UpdateChannelRequest,
|
||||
},
|
||||
error::ApiError,
|
||||
extractors::CurrentUser,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(list_channels).post(create_channel))
|
||||
.route(
|
||||
"/{id}",
|
||||
get(get_channel).put(update_channel).delete(delete_channel),
|
||||
)
|
||||
.route(
|
||||
"/{id}/schedule",
|
||||
post(generate_schedule).get(get_active_schedule),
|
||||
)
|
||||
.route("/{id}/now", get(get_current_broadcast))
|
||||
.route("/{id}/epg", get(get_epg))
|
||||
.route("/{id}/stream", get(get_stream))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel CRUD
|
||||
// ============================================================================
|
||||
|
||||
async fn list_channels(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let channels = state.channel_service.find_all().await?;
|
||||
let response: Vec<ChannelResponse> = channels.into_iter().map(Into::into).collect();
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn create_channel(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Json(payload): Json<CreateChannelRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let mut channel = state
|
||||
.channel_service
|
||||
.create(user.id, &payload.name, &payload.timezone)
|
||||
.await?;
|
||||
|
||||
if let Some(desc) = payload.description {
|
||||
channel.description = Some(desc);
|
||||
channel = state.channel_service.update(channel).await?;
|
||||
}
|
||||
|
||||
Ok((StatusCode::CREATED, Json(ChannelResponse::from(channel))))
|
||||
}
|
||||
|
||||
async fn get_channel(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
require_owner(&channel, user.id)?;
|
||||
Ok(Json(ChannelResponse::from(channel)))
|
||||
}
|
||||
|
||||
async fn update_channel(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateChannelRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let mut channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
require_owner(&channel, user.id)?;
|
||||
|
||||
if let Some(name) = payload.name {
|
||||
channel.name = name;
|
||||
}
|
||||
if let Some(desc) = payload.description {
|
||||
channel.description = Some(desc);
|
||||
}
|
||||
if let Some(tz) = payload.timezone {
|
||||
channel.timezone = tz;
|
||||
}
|
||||
if let Some(sc) = payload.schedule_config {
|
||||
channel.schedule_config = sc;
|
||||
}
|
||||
if let Some(rp) = payload.recycle_policy {
|
||||
channel.recycle_policy = rp;
|
||||
}
|
||||
channel.updated_at = Utc::now();
|
||||
|
||||
let channel = state.channel_service.update(channel).await?;
|
||||
Ok(Json(ChannelResponse::from(channel)))
|
||||
}
|
||||
|
||||
async fn delete_channel(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// ChannelService::delete enforces ownership internally
|
||||
state.channel_service.delete(channel_id, user.id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schedule generation + retrieval
|
||||
// ============================================================================
|
||||
|
||||
/// Trigger 48-hour schedule generation for a channel, starting from now.
|
||||
/// Replaces any existing schedule for the same window.
|
||||
async fn generate_schedule(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
require_owner(&channel, user.id)?;
|
||||
|
||||
let schedule = state
|
||||
.schedule_engine
|
||||
.generate_schedule(channel_id, Utc::now())
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(ScheduleResponse::from(schedule))))
|
||||
}
|
||||
|
||||
/// Return the currently active 48-hour schedule for a channel.
|
||||
/// 404 if no schedule has been generated yet — call POST /:id/schedule first.
|
||||
async fn get_active_schedule(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
require_owner(&channel, user.id)?;
|
||||
|
||||
let schedule = state
|
||||
.schedule_engine
|
||||
.get_active_schedule(channel_id, Utc::now())
|
||||
.await?
|
||||
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
|
||||
|
||||
Ok(Json(ScheduleResponse::from(schedule)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Live broadcast endpoints
|
||||
// ============================================================================
|
||||
|
||||
/// What is currently playing right now on this channel.
|
||||
/// Returns 204 No Content when the channel is in a gap between blocks (no-signal).
|
||||
async fn get_current_broadcast(
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let _channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let schedule = state
|
||||
.schedule_engine
|
||||
.get_active_schedule(channel_id, now)
|
||||
.await?
|
||||
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// EPG: return scheduled slots that overlap a time window.
|
||||
///
|
||||
/// Query params (both RFC3339, both optional):
|
||||
/// - `from` — start of window (default: now)
|
||||
/// - `until` — end of window (default: now + 4 hours)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EpgQuery {
|
||||
from: Option<String>,
|
||||
until: Option<String>,
|
||||
}
|
||||
|
||||
async fn get_epg(
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
Query(params): Query<EpgQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let _channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let from = parse_optional_dt(params.from, now)?;
|
||||
let until = parse_optional_dt(params.until, now + chrono::Duration::hours(4))?;
|
||||
|
||||
if until <= from {
|
||||
return Err(ApiError::validation("'until' must be after 'from'"));
|
||||
}
|
||||
|
||||
let schedule = state
|
||||
.schedule_engine
|
||||
.get_active_schedule(channel_id, from)
|
||||
.await?
|
||||
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
|
||||
|
||||
let slots: Vec<ScheduledSlotResponse> = ScheduleEngineService::get_epg(&schedule, from, until)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
|
||||
Ok(Json(slots))
|
||||
}
|
||||
|
||||
/// Redirect to the stream URL for whatever is currently playing.
|
||||
/// Returns 307 Temporary Redirect so the client fetches from the media provider directly.
|
||||
/// Returns 204 No Content when the channel is in a gap (no-signal).
|
||||
async fn get_stream(
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let _channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let schedule = state
|
||||
.schedule_engine
|
||||
.get_active_schedule(channel_id, now)
|
||||
.await?
|
||||
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
|
||||
|
||||
let broadcast = match ScheduleEngineService::get_current_broadcast(&schedule, now) {
|
||||
None => return Ok(StatusCode::NO_CONTENT.into_response()),
|
||||
Some(b) => b,
|
||||
};
|
||||
|
||||
let url = state
|
||||
.schedule_engine
|
||||
.get_stream_url(&broadcast.slot.item.id)
|
||||
.await?;
|
||||
|
||||
Ok(Redirect::temporary(&url).into_response())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
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))),
|
||||
}
|
||||
}
|
||||
114
k-tv-backend/api/src/routes/channels/broadcast.rs
Normal file
114
k-tv-backend/api/src/routes/channels/broadcast.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::{DomainError, ScheduleEngineService};
|
||||
|
||||
use crate::{
|
||||
dto::{CurrentBroadcastResponse, ScheduledSlotResponse},
|
||||
error::ApiError,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use super::parse_optional_dt;
|
||||
|
||||
/// 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>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let _channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let schedule = state
|
||||
.schedule_engine
|
||||
.get_active_schedule(channel_id, now)
|
||||
.await?
|
||||
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// EPG: return scheduled slots that overlap a time window.
|
||||
///
|
||||
/// Query params (both RFC3339, both optional):
|
||||
/// - `from` — start of window (default: now)
|
||||
/// - `until` — end of window (default: now + 4 hours)
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct EpgQuery {
|
||||
from: Option<String>,
|
||||
until: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) async fn get_epg(
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
Query(params): Query<EpgQuery>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let _channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let from = parse_optional_dt(params.from, now)?;
|
||||
let until = parse_optional_dt(params.until, now + chrono::Duration::hours(4))?;
|
||||
|
||||
if until <= from {
|
||||
return Err(ApiError::validation("'until' must be after 'from'"));
|
||||
}
|
||||
|
||||
let schedule = state
|
||||
.schedule_engine
|
||||
.get_active_schedule(channel_id, from)
|
||||
.await?
|
||||
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
|
||||
|
||||
let slots: Vec<ScheduledSlotResponse> = ScheduleEngineService::get_epg(&schedule, from, until)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
|
||||
Ok(Json(slots))
|
||||
}
|
||||
|
||||
/// Redirect to the stream URL for whatever is currently playing.
|
||||
/// Returns 307 Temporary Redirect so the client fetches from the media provider directly.
|
||||
/// Returns 204 No Content when the channel is in a gap (no-signal).
|
||||
pub(super) async fn get_stream(
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let _channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let schedule = state
|
||||
.schedule_engine
|
||||
.get_active_schedule(channel_id, now)
|
||||
.await?
|
||||
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
|
||||
|
||||
let broadcast = match ScheduleEngineService::get_current_broadcast(&schedule, now) {
|
||||
None => return Ok(StatusCode::NO_CONTENT.into_response()),
|
||||
Some(b) => b,
|
||||
};
|
||||
|
||||
let url = state
|
||||
.schedule_engine
|
||||
.get_stream_url(&broadcast.slot.item.id)
|
||||
.await?;
|
||||
|
||||
Ok(Redirect::temporary(&url).into_response())
|
||||
}
|
||||
93
k-tv-backend/api/src/routes/channels/crud.rs
Normal file
93
k-tv-backend/api/src/routes/channels/crud.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
dto::{ChannelResponse, CreateChannelRequest, UpdateChannelRequest},
|
||||
error::ApiError,
|
||||
extractors::CurrentUser,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use super::require_owner;
|
||||
|
||||
pub(super) async fn list_channels(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let channels = state.channel_service.find_all().await?;
|
||||
let response: Vec<ChannelResponse> = channels.into_iter().map(Into::into).collect();
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
pub(super) async fn create_channel(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Json(payload): Json<CreateChannelRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let mut channel = state
|
||||
.channel_service
|
||||
.create(user.id, &payload.name, &payload.timezone)
|
||||
.await?;
|
||||
|
||||
if let Some(desc) = payload.description {
|
||||
channel.description = Some(desc);
|
||||
channel = state.channel_service.update(channel).await?;
|
||||
}
|
||||
|
||||
Ok((StatusCode::CREATED, Json(ChannelResponse::from(channel))))
|
||||
}
|
||||
|
||||
pub(super) async fn get_channel(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
require_owner(&channel, user.id)?;
|
||||
Ok(Json(ChannelResponse::from(channel)))
|
||||
}
|
||||
|
||||
pub(super) async fn update_channel(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateChannelRequest>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let mut channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
require_owner(&channel, user.id)?;
|
||||
|
||||
if let Some(name) = payload.name {
|
||||
channel.name = name;
|
||||
}
|
||||
if let Some(desc) = payload.description {
|
||||
channel.description = Some(desc);
|
||||
}
|
||||
if let Some(tz) = payload.timezone {
|
||||
channel.timezone = tz;
|
||||
}
|
||||
if let Some(sc) = payload.schedule_config {
|
||||
channel.schedule_config = sc;
|
||||
}
|
||||
if let Some(rp) = payload.recycle_policy {
|
||||
channel.recycle_policy = rp;
|
||||
}
|
||||
channel.updated_at = Utc::now();
|
||||
|
||||
let channel = state.channel_service.update(channel).await?;
|
||||
Ok(Json(ChannelResponse::from(channel)))
|
||||
}
|
||||
|
||||
pub(super) async fn delete_channel(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
// ChannelService::delete enforces ownership internally
|
||||
state.channel_service.delete(channel_id, user.id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
55
k-tv-backend/api/src/routes/channels/mod.rs
Normal file
55
k-tv-backend/api/src/routes/channels/mod.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! 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 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}/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(())
|
||||
}
|
||||
}
|
||||
|
||||
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))),
|
||||
}
|
||||
}
|
||||
56
k-tv-backend/api/src/routes/channels/schedule.rs
Normal file
56
k-tv-backend/api/src/routes/channels/schedule.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::DomainError;
|
||||
|
||||
use crate::{
|
||||
dto::ScheduleResponse,
|
||||
error::ApiError,
|
||||
extractors::CurrentUser,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
use super::require_owner;
|
||||
|
||||
/// Trigger 48-hour schedule generation for a channel, starting from now.
|
||||
/// Replaces any existing schedule for the same window.
|
||||
pub(super) async fn generate_schedule(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
require_owner(&channel, user.id)?;
|
||||
|
||||
let schedule = state
|
||||
.schedule_engine
|
||||
.generate_schedule(channel_id, Utc::now())
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(ScheduleResponse::from(schedule))))
|
||||
}
|
||||
|
||||
/// Return the currently active 48-hour schedule for a channel.
|
||||
/// 404 if no schedule has been generated yet — call POST /:id/schedule first.
|
||||
pub(super) async fn get_active_schedule(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let channel = state.channel_service.find_by_id(channel_id).await?;
|
||||
require_owner(&channel, user.id)?;
|
||||
|
||||
let schedule = state
|
||||
.schedule_engine
|
||||
.get_active_schedule(channel_id, Utc::now())
|
||||
.await?
|
||||
.ok_or(DomainError::NoActiveSchedule(channel_id))?;
|
||||
|
||||
Ok(Json(ScheduleResponse::from(schedule)))
|
||||
}
|
||||
Reference in New Issue
Block a user