feat: initialize k-tv-frontend with Next.js and Tailwind CSS

- Added package.json with dependencies and scripts for development, build, and linting.
- Created postcss.config.mjs for Tailwind CSS integration.
- Added SVG assets for UI components including file, globe, next, vercel, and window icons.
- Configured TypeScript with tsconfig.json for strict type checking and module resolution.
This commit is contained in:
2026-03-11 19:13:21 +01:00
commit 01108aa23e
130 changed files with 29949 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
[package]
name = "api"
version = "0.1.0"
edition = "2024"
default-run = "api"
[features]
default = ["sqlite", "auth-jwt", "jellyfin"]
sqlite = ["infra/sqlite"]
postgres = ["infra/postgres"]
auth-oidc = ["infra/auth-oidc"]
auth-jwt = ["infra/auth-jwt"]
jellyfin = ["infra/jellyfin"]
[dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"logging",
"db-sqlx",
"sqlite",
"http",
] }
domain = { path = "../domain" }
infra = { path = "../infra", default-features = false, features = ["sqlite"] }
# Web framework
axum = { version = "0.8.8", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie-private", "cookie-key-expansion"] }
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
# Async runtime
tokio = { version = "1.48.0", features = ["full"] }
# Serialization
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0"
# Error handling
thiserror = "2.0.17"
anyhow = "1.0"
# Utilities
chrono = { version = "0.4.42", features = ["serde"] }
uuid = { version = "1.19.0", features = ["v4", "serde"] }
# Logging
tracing = "0.1"
async-trait = "0.1"
dotenvy = "0.15.7"
time = "0.3"

View File

@@ -0,0 +1,49 @@
[package]
name = "k-tv"
version = "0.1.0"
edition = "2024"
default-run = "k-tv"
[features]
default = ["sqlite", "auth-jwt"]
sqlite = ["infra/sqlite"]
postgres = ["infra/postgres"]
auth-oidc = ["infra/auth-oidc"]
auth-jwt = ["infra/auth-jwt"]
[dependencies]
k-core = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-core", features = [
"logging",
"db-sqlx",
"sqlite",
"http",
] }
domain = { path = "../domain" }
infra = { path = "../infra", default-features = false, features = ["sqlite"] }
# Web framework
axum = { version = "0.8.8", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie-private", "cookie-key-expansion"] }
tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["cors", "trace"] }
# Async runtime
tokio = { version = "1.48.0", features = ["full"] }
# Serialization
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0"
# Error handling
thiserror = "2.0.17"
anyhow = "1.0"
# Utilities
chrono = { version = "0.4.42", features = ["serde"] }
uuid = { version = "1.19.0", features = ["v4", "serde"] }
# Logging
tracing = "0.1"
dotenvy = "0.15.7"
time = "0.3"

View File

@@ -0,0 +1,131 @@
//! Application Configuration
//!
//! Loads configuration from environment variables.
use std::env;
/// Application configuration loaded from environment variables
#[derive(Debug, Clone)]
pub struct Config {
pub database_url: String,
pub cookie_secret: String,
pub cors_allowed_origins: Vec<String>,
pub port: u16,
pub host: String,
pub secure_cookie: bool,
pub db_max_connections: u32,
pub db_min_connections: u32,
// OIDC configuration
pub oidc_issuer: Option<String>,
pub oidc_client_id: Option<String>,
pub oidc_client_secret: Option<String>,
pub oidc_redirect_url: Option<String>,
pub oidc_resource_id: Option<String>,
// JWT configuration
pub jwt_secret: Option<String>,
pub jwt_issuer: Option<String>,
pub jwt_audience: Option<String>,
pub jwt_expiry_hours: u64,
/// Whether the application is running in production mode
pub is_production: bool,
// Jellyfin media provider
pub jellyfin_base_url: Option<String>,
pub jellyfin_api_key: Option<String>,
pub jellyfin_user_id: Option<String>,
}
impl Config {
pub fn from_env() -> Self {
let _ = dotenvy::dotenv();
let host = env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = env::var("PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(3000);
let database_url =
env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:data.db?mode=rwc".to_string());
// Cookie secret for PrivateCookieJar (OIDC state encryption).
// Must be at least 64 bytes in production.
let cookie_secret = env::var("COOKIE_SECRET").unwrap_or_else(|_| {
"k-template-cookie-secret-key-must-be-at-least-64-bytes-long!!".to_string()
});
let cors_origins_str = env::var("CORS_ALLOWED_ORIGINS")
.unwrap_or_else(|_| "http://localhost:5173".to_string());
let cors_allowed_origins = cors_origins_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let secure_cookie = env::var("SECURE_COOKIE")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(false);
let db_max_connections = env::var("DB_MAX_CONNECTIONS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(5);
let db_min_connections = env::var("DB_MIN_CONNECTIONS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
let oidc_issuer = env::var("OIDC_ISSUER").ok();
let oidc_client_id = env::var("OIDC_CLIENT_ID").ok();
let oidc_client_secret = env::var("OIDC_CLIENT_SECRET").ok();
let oidc_redirect_url = env::var("OIDC_REDIRECT_URL").ok();
let oidc_resource_id = env::var("OIDC_RESOURCE_ID").ok();
let jwt_secret = env::var("JWT_SECRET").ok();
let jwt_issuer = env::var("JWT_ISSUER").ok();
let jwt_audience = env::var("JWT_AUDIENCE").ok();
let jwt_expiry_hours = env::var("JWT_EXPIRY_HOURS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(24);
let is_production = env::var("PRODUCTION")
.or_else(|_| env::var("RUST_ENV"))
.map(|v| v.to_lowercase() == "production" || v == "1" || v == "true")
.unwrap_or(false);
let jellyfin_base_url = env::var("JELLYFIN_BASE_URL").ok();
let jellyfin_api_key = env::var("JELLYFIN_API_KEY").ok();
let jellyfin_user_id = env::var("JELLYFIN_USER_ID").ok();
Self {
host,
port,
database_url,
cookie_secret,
cors_allowed_origins,
secure_cookie,
db_max_connections,
db_min_connections,
oidc_issuer,
oidc_client_id,
oidc_client_secret,
oidc_redirect_url,
oidc_resource_id,
jwt_secret,
jwt_issuer,
jwt_audience,
jwt_expiry_hours,
is_production,
jellyfin_base_url,
jellyfin_api_key,
jellyfin_user_id,
}
}
}

184
k-tv-backend/api/src/dto.rs Normal file
View File

@@ -0,0 +1,184 @@
//! Request and Response DTOs
//!
//! Data Transfer Objects for the API.
//! Uses domain newtypes for validation instead of the validator crate.
use chrono::{DateTime, Utc};
use domain::{Email, Password};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Login request with validated email and password newtypes
#[derive(Debug, Deserialize)]
pub struct LoginRequest {
/// Email is validated on deserialization
pub email: Email,
/// Password is validated on deserialization (min 8 chars)
pub password: Password,
}
/// Register request with validated email and password newtypes
#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
/// Email is validated on deserialization
pub email: Email,
/// Password is validated on deserialization (min 8 chars)
pub password: Password,
}
/// User response DTO
#[derive(Debug, Serialize)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub created_at: DateTime<Utc>,
}
/// JWT token response
#[derive(Debug, Serialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: u64,
}
/// System configuration response
#[derive(Debug, Serialize)]
pub struct ConfigResponse {
pub allow_registration: bool,
}
// ============================================================================
// Channel DTOs
// ============================================================================
#[derive(Debug, Deserialize)]
pub struct CreateChannelRequest {
pub name: String,
pub description: Option<String>,
/// IANA timezone, e.g. "UTC" or "America/New_York"
pub timezone: String,
}
/// All fields are optional — only provided fields are updated.
#[derive(Debug, Deserialize)]
pub struct UpdateChannelRequest {
pub name: Option<String>,
pub description: Option<String>,
pub timezone: Option<String>,
/// Replace the entire schedule config (template import/edit)
pub schedule_config: Option<domain::ScheduleConfig>,
pub recycle_policy: Option<domain::RecyclePolicy>,
}
#[derive(Debug, Serialize)]
pub struct ChannelResponse {
pub id: Uuid,
pub owner_id: Uuid,
pub name: String,
pub description: Option<String>,
pub timezone: String,
pub schedule_config: domain::ScheduleConfig,
pub recycle_policy: domain::RecyclePolicy,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<domain::Channel> for ChannelResponse {
fn from(c: domain::Channel) -> Self {
Self {
id: c.id,
owner_id: c.owner_id,
name: c.name,
description: c.description,
timezone: c.timezone,
schedule_config: c.schedule_config,
recycle_policy: c.recycle_policy,
created_at: c.created_at,
updated_at: c.updated_at,
}
}
}
// ============================================================================
// EPG / playback DTOs
// ============================================================================
#[derive(Debug, Serialize)]
pub struct MediaItemResponse {
pub id: String,
pub title: String,
pub content_type: domain::ContentType,
pub duration_secs: u32,
pub genres: Vec<String>,
pub year: Option<u16>,
pub tags: Vec<String>,
}
impl From<domain::MediaItem> for MediaItemResponse {
fn from(i: domain::MediaItem) -> Self {
Self {
id: i.id.into_inner(),
title: i.title,
content_type: i.content_type,
duration_secs: i.duration_secs,
genres: i.genres,
year: i.year,
tags: i.tags,
}
}
}
#[derive(Debug, Serialize)]
pub struct ScheduledSlotResponse {
pub id: Uuid,
pub start_at: DateTime<Utc>,
pub end_at: DateTime<Utc>,
pub item: MediaItemResponse,
pub source_block_id: Uuid,
}
impl From<domain::ScheduledSlot> for ScheduledSlotResponse {
fn from(s: domain::ScheduledSlot) -> Self {
Self {
id: s.id,
start_at: s.start_at,
end_at: s.end_at,
item: s.item.into(),
source_block_id: s.source_block_id,
}
}
}
/// What is currently playing on a channel.
/// A 204 No Content response is returned instead when there is no active slot (no-signal).
#[derive(Debug, Serialize)]
pub struct CurrentBroadcastResponse {
pub slot: ScheduledSlotResponse,
/// Seconds elapsed since the start of the current item — use this as the
/// initial seek position for the player.
pub offset_secs: u32,
}
#[derive(Debug, Serialize)]
pub struct ScheduleResponse {
pub id: Uuid,
pub channel_id: Uuid,
pub valid_from: DateTime<Utc>,
pub valid_until: DateTime<Utc>,
pub generation: u32,
pub slots: Vec<ScheduledSlotResponse>,
}
impl From<domain::GeneratedSchedule> for ScheduleResponse {
fn from(s: domain::GeneratedSchedule) -> Self {
Self {
id: s.id,
channel_id: s.channel_id,
valid_from: s.valid_from,
valid_until: s.valid_until,
generation: s.generation,
slots: s.slots.into_iter().map(Into::into).collect(),
}
}
}

View File

@@ -0,0 +1,130 @@
//! API error handling
//!
//! Maps domain errors to HTTP responses
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use thiserror::Error;
use domain::DomainError;
/// API-level errors
#[derive(Debug, Error)]
pub enum ApiError {
#[error("{0}")]
Domain(#[from] DomainError),
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal server error")]
Internal(String),
#[error("Forbidden: {0}")]
Forbidden(String),
#[error("Unauthorized: {0}")]
Unauthorized(String),
}
/// Error response body
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<String>,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error_response) = match &self {
ApiError::Domain(domain_error) => {
let status = match domain_error {
DomainError::UserNotFound(_) | DomainError::ChannelNotFound(_) | DomainError::NoActiveSchedule(_) => {
StatusCode::NOT_FOUND
}
DomainError::UserAlreadyExists(_) => StatusCode::CONFLICT,
DomainError::ValidationError(_) | DomainError::TimezoneError(_) => {
StatusCode::BAD_REQUEST
}
// Unauthenticated = not logged in → 401
DomainError::Unauthenticated(_) => StatusCode::UNAUTHORIZED,
// Forbidden = not allowed to perform action → 403
DomainError::Forbidden(_) => StatusCode::FORBIDDEN,
DomainError::RepositoryError(_) | DomainError::InfrastructureError(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
(
status,
ErrorResponse {
error: domain_error.to_string(),
details: None,
},
)
}
ApiError::Validation(msg) => (
StatusCode::BAD_REQUEST,
ErrorResponse {
error: "Validation error".to_string(),
details: Some(msg.clone()),
},
),
ApiError::Internal(msg) => {
tracing::error!("Internal error: {}", msg);
(
StatusCode::INTERNAL_SERVER_ERROR,
ErrorResponse {
error: "Internal server error".to_string(),
details: None,
},
)
}
ApiError::Forbidden(msg) => (
StatusCode::FORBIDDEN,
ErrorResponse {
error: "Forbidden".to_string(),
details: Some(msg.clone()),
},
),
ApiError::Unauthorized(msg) => (
StatusCode::UNAUTHORIZED,
ErrorResponse {
error: "Unauthorized".to_string(),
details: Some(msg.clone()),
},
),
};
(status, Json(error_response)).into_response()
}
}
impl ApiError {
pub fn validation(msg: impl Into<String>) -> Self {
Self::Validation(msg.into())
}
pub fn internal(msg: impl Into<String>) -> Self {
Self::Internal(msg.into())
}
}
/// Result type alias for API handlers
pub type ApiResult<T> = Result<T, ApiError>;

View File

@@ -0,0 +1,89 @@
//! Auth extractors for API handlers
//!
//! Provides the `CurrentUser` extractor that validates JWT Bearer tokens.
use axum::{extract::FromRequestParts, http::request::Parts};
use domain::User;
use crate::error::ApiError;
use crate::state::AppState;
/// Extracted current user from the request.
///
/// Validates a JWT Bearer token from the `Authorization` header.
pub struct CurrentUser(pub User);
impl FromRequestParts<AppState> for CurrentUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
#[cfg(feature = "auth-jwt")]
{
return match try_jwt_auth(parts, state).await {
Ok(user) => Ok(CurrentUser(user)),
Err(e) => Err(e),
};
}
#[cfg(not(feature = "auth-jwt"))]
{
let _ = (parts, state);
Err(ApiError::Unauthorized(
"No authentication backend configured".to_string(),
))
}
}
}
/// Authenticate using JWT Bearer token
#[cfg(feature = "auth-jwt")]
async fn try_jwt_auth(parts: &mut Parts, state: &AppState) -> Result<User, ApiError> {
use axum::http::header::AUTHORIZATION;
let auth_header = parts
.headers
.get(AUTHORIZATION)
.ok_or_else(|| ApiError::Unauthorized("Missing Authorization header".to_string()))?;
let auth_str = auth_header
.to_str()
.map_err(|_| ApiError::Unauthorized("Invalid Authorization header encoding".to_string()))?;
let token = auth_str.strip_prefix("Bearer ").ok_or_else(|| {
ApiError::Unauthorized("Authorization header must use Bearer scheme".to_string())
})?;
let validator = state
.jwt_validator
.as_ref()
.ok_or_else(|| ApiError::Internal("JWT validator not configured".to_string()))?;
let claims = validator.validate_token(token).map_err(|e| {
tracing::debug!("JWT validation failed: {:?}", e);
match e {
infra::auth::jwt::JwtError::Expired => {
ApiError::Unauthorized("Token expired".to_string())
}
infra::auth::jwt::JwtError::InvalidFormat => {
ApiError::Unauthorized("Invalid token format".to_string())
}
_ => ApiError::Unauthorized("Token validation failed".to_string()),
}
})?;
let user_id: uuid::Uuid = claims
.sub
.parse()
.map_err(|_| ApiError::Unauthorized("Invalid user ID in token".to_string()))?;
let user = state
.user_service
.find_by_id(user_id)
.await
.map_err(|e| ApiError::Internal(format!("Failed to fetch user: {}", e)))?;
Ok(user)
}

View File

@@ -0,0 +1,168 @@
//! API Server Entry Point
//!
//! Configures and starts the HTTP server with JWT-based authentication.
use std::net::SocketAddr;
use std::time::Duration as StdDuration;
use axum::Router;
use std::sync::Arc;
use domain::{ChannelService, IMediaProvider, ScheduleEngineService, UserService};
use infra::factory::{build_channel_repository, build_schedule_repository, build_user_repository};
use infra::run_migrations;
use k_core::http::server::{ServerConfig, apply_standard_middleware};
use k_core::logging;
use tokio::net::TcpListener;
use tracing::info;
mod config;
mod dto;
mod error;
mod extractors;
mod routes;
mod state;
use crate::config::Config;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
logging::init("api");
let config = Config::from_env();
info!("Starting server on {}:{}", config.host, config.port);
// Setup database
tracing::info!("Connecting to database: {}", config.database_url);
#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
let db_type = k_core::db::DbType::Sqlite;
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
let db_type = k_core::db::DbType::Postgres;
// Both features enabled: fall back to URL inspection at runtime
#[cfg(all(feature = "sqlite", feature = "postgres"))]
let db_type = if config.database_url.starts_with("postgres") {
k_core::db::DbType::Postgres
} else {
k_core::db::DbType::Sqlite
};
let db_config = k_core::db::DatabaseConfig {
db_type,
url: config.database_url.clone(),
max_connections: config.db_max_connections,
min_connections: config.db_min_connections,
acquire_timeout: StdDuration::from_secs(30),
};
let db_pool = k_core::db::connect(&db_config).await?;
run_migrations(&db_pool).await?;
let user_repo = build_user_repository(&db_pool).await?;
let channel_repo = build_channel_repository(&db_pool).await?;
let schedule_repo = build_schedule_repository(&db_pool).await?;
let user_service = UserService::new(user_repo);
let channel_service = ChannelService::new(channel_repo.clone());
// Build media provider — Jellyfin if configured, no-op fallback otherwise.
let media_provider: Arc<dyn IMediaProvider> = build_media_provider(&config);
let schedule_engine = ScheduleEngineService::new(media_provider, channel_repo, schedule_repo);
let state = AppState::new(user_service, channel_service, schedule_engine, config.clone()).await?;
let server_config = ServerConfig {
cors_origins: config.cors_allowed_origins.clone(),
};
let app = Router::new()
.nest("/api/v1", routes::api_v1_router())
.with_state(state);
let app = apply_standard_middleware(app, &server_config);
let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?;
let listener = TcpListener::bind(addr).await?;
tracing::info!("🚀 API server running at http://{}", addr);
tracing::info!("🔒 Authentication mode: JWT (Bearer token)");
#[cfg(feature = "auth-jwt")]
tracing::info!(" ✓ JWT auth enabled");
#[cfg(feature = "auth-oidc")]
tracing::info!(" ✓ OIDC integration enabled (stateless cookie state)");
tracing::info!("📝 API endpoints available at /api/v1/...");
axum::serve(listener, app).await?;
Ok(())
}
/// Build the media provider from config.
/// Falls back to a no-op provider that returns an informative error when
/// Jellyfin env vars are not set, so other API features still work in dev.
fn build_media_provider(config: &Config) -> Arc<dyn IMediaProvider> {
#[cfg(feature = "jellyfin")]
if let (Some(base_url), Some(api_key), Some(user_id)) = (
&config.jellyfin_base_url,
&config.jellyfin_api_key,
&config.jellyfin_user_id,
) {
tracing::info!("Media provider: Jellyfin at {}", base_url);
return Arc::new(infra::JellyfinMediaProvider::new(infra::JellyfinConfig {
base_url: base_url.clone(),
api_key: api_key.clone(),
user_id: user_id.clone(),
}));
}
tracing::warn!(
"No media provider configured. Set JELLYFIN_BASE_URL, JELLYFIN_API_KEY, \
and JELLYFIN_USER_ID to enable schedule generation."
);
Arc::new(NoopMediaProvider)
}
/// Stand-in provider used when no real media source is configured.
/// Returns a descriptive error for every call so schedule endpoints fail
/// gracefully rather than panicking at startup.
struct NoopMediaProvider;
#[async_trait::async_trait]
impl IMediaProvider for NoopMediaProvider {
async fn fetch_items(
&self,
_: &domain::MediaFilter,
) -> domain::DomainResult<Vec<domain::MediaItem>> {
Err(domain::DomainError::InfrastructureError(
"No media provider configured. Set JELLYFIN_BASE_URL, JELLYFIN_API_KEY, \
and JELLYFIN_USER_ID."
.into(),
))
}
async fn fetch_by_id(
&self,
_: &domain::MediaItemId,
) -> domain::DomainResult<Option<domain::MediaItem>> {
Err(domain::DomainError::InfrastructureError(
"No media provider configured.".into(),
))
}
async fn get_stream_url(
&self,
_: &domain::MediaItemId,
) -> domain::DomainResult<String> {
Err(domain::DomainError::InfrastructureError(
"No media provider configured.".into(),
))
}
}

View File

@@ -0,0 +1,253 @@
//! 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> {
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,
}),
))
}

View File

@@ -0,0 +1,292 @@
//! Channel routes
//!
//! CRUD for channels and broadcast/EPG endpoints.
//!
//! All routes require authentication (Bearer JWT).
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>,
CurrentUser(user): CurrentUser,
) -> Result<impl IntoResponse, ApiError> {
let channels = state.channel_service.find_by_owner(user.id).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>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>,
) -> Result<Response, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
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>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>,
Query(params): Query<EpgQuery>,
) -> Result<impl IntoResponse, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
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>,
CurrentUser(user): CurrentUser,
Path(channel_id): Path<Uuid>,
) -> Result<Response, ApiError> {
let channel = state.channel_service.find_by_id(channel_id).await?;
require_owner(&channel, user.id)?;
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))),
}
}

View File

@@ -0,0 +1,13 @@
use axum::{Json, Router, routing::get};
use crate::dto::ConfigResponse;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new().route("/", get(get_config))
}
async fn get_config() -> Json<ConfigResponse> {
Json(ConfigResponse {
allow_registration: true, // Default to true for template
})
}

View File

@@ -0,0 +1,18 @@
//! API Routes
//!
//! Defines the API endpoints and maps them to handler functions.
use crate::state::AppState;
use axum::Router;
pub mod auth;
pub mod channels;
pub mod config;
/// Construct the API v1 router
pub fn api_v1_router() -> Router<AppState> {
Router::new()
.nest("/auth", auth::router())
.nest("/channels", channels::router())
.nest("/config", config::router())
}

View File

@@ -0,0 +1,125 @@
//! Application State
//!
//! Holds shared state for the application.
use axum::extract::FromRef;
use axum_extra::extract::cookie::Key;
#[cfg(feature = "auth-jwt")]
use infra::auth::jwt::{JwtConfig, JwtValidator};
#[cfg(feature = "auth-oidc")]
use infra::auth::oidc::OidcService;
use std::sync::Arc;
use crate::config::Config;
use domain::{ChannelService, ScheduleEngineService, UserService};
#[derive(Clone)]
pub struct AppState {
pub user_service: Arc<UserService>,
pub channel_service: Arc<ChannelService>,
pub schedule_engine: Arc<ScheduleEngineService>,
pub cookie_key: Key,
#[cfg(feature = "auth-oidc")]
pub oidc_service: Option<Arc<OidcService>>,
#[cfg(feature = "auth-jwt")]
pub jwt_validator: Option<Arc<JwtValidator>>,
pub config: Arc<Config>,
}
impl AppState {
pub async fn new(
user_service: UserService,
channel_service: ChannelService,
schedule_engine: ScheduleEngineService,
config: Config,
) -> anyhow::Result<Self> {
let cookie_key = Key::derive_from(config.cookie_secret.as_bytes());
#[cfg(feature = "auth-oidc")]
let oidc_service = if let (Some(issuer), Some(id), secret, Some(redirect), resource_id) = (
&config.oidc_issuer,
&config.oidc_client_id,
&config.oidc_client_secret,
&config.oidc_redirect_url,
&config.oidc_resource_id,
) {
tracing::info!("Initializing OIDC service with issuer: {}", issuer);
let issuer_url = domain::IssuerUrl::new(issuer)
.map_err(|e| anyhow::anyhow!("Invalid OIDC issuer URL: {}", e))?;
let client_id = domain::ClientId::new(id)
.map_err(|e| anyhow::anyhow!("Invalid OIDC client ID: {}", e))?;
let client_secret = secret.as_ref().map(|s| domain::ClientSecret::new(s));
let redirect_url = domain::RedirectUrl::new(redirect)
.map_err(|e| anyhow::anyhow!("Invalid OIDC redirect URL: {}", e))?;
let resource = resource_id
.as_ref()
.map(|r| domain::ResourceId::new(r))
.transpose()
.map_err(|e| anyhow::anyhow!("Invalid OIDC resource ID: {}", e))?;
Some(Arc::new(
OidcService::new(issuer_url, client_id, client_secret, redirect_url, resource)
.await?,
))
} else {
None
};
#[cfg(feature = "auth-jwt")]
let jwt_validator = {
let secret = match &config.jwt_secret {
Some(s) if !s.is_empty() => s.clone(),
_ => {
if config.is_production {
anyhow::bail!("JWT_SECRET is required in production");
}
tracing::warn!(
"⚠️ JWT_SECRET not set — using insecure development secret. DO NOT USE IN PRODUCTION!"
);
"k-template-dev-secret-not-for-production-use-only".to_string()
}
};
tracing::info!("Initializing JWT validator");
let jwt_config = JwtConfig::new(
secret,
config.jwt_issuer.clone(),
config.jwt_audience.clone(),
Some(config.jwt_expiry_hours),
config.is_production,
)?;
Some(Arc::new(JwtValidator::new(jwt_config)))
};
Ok(Self {
user_service: Arc::new(user_service),
channel_service: Arc::new(channel_service),
schedule_engine: Arc::new(schedule_engine),
cookie_key,
#[cfg(feature = "auth-oidc")]
oidc_service,
#[cfg(feature = "auth-jwt")]
jwt_validator,
config: Arc::new(config),
})
}
}
impl FromRef<AppState> for Arc<UserService> {
fn from_ref(input: &AppState) -> Self {
input.user_service.clone()
}
}
impl FromRef<AppState> for Arc<Config> {
fn from_ref(input: &AppState) -> Self {
input.config.clone()
}
}
impl FromRef<AppState> for Key {
fn from_ref(input: &AppState) -> Self {
input.cookie_key.clone()
}
}