Backend: add refresh JWT (30d, token_type claim), POST /auth/refresh endpoint (rotates token pair), remember_me on login, JWT_REFRESH_EXPIRY_DAYS env var. Extractors now reject refresh tokens on protected routes. Frontend: sessionStorage for non-remembered sessions, localStorage + refresh token for remembered sessions. Transparent 401 recovery in api.ts (retry once after refresh). Remember me checkbox on login page with security note when checked.
381 lines
12 KiB
Rust
381 lines
12 KiB
Rust
//! 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,
|
|
/// When true, a refresh token is also issued for persistent sessions
|
|
#[serde(default)]
|
|
pub remember_me: bool,
|
|
}
|
|
|
|
/// Refresh token request
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct RefreshRequest {
|
|
pub refresh_token: String,
|
|
}
|
|
|
|
/// 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>,
|
|
pub is_admin: bool,
|
|
}
|
|
|
|
/// JWT token response
|
|
#[derive(Debug, Serialize)]
|
|
pub struct TokenResponse {
|
|
pub access_token: String,
|
|
pub token_type: String,
|
|
pub expires_in: u64,
|
|
/// Only present when remember_me was true at login, or on token refresh
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub refresh_token: Option<String>,
|
|
}
|
|
|
|
/// Per-provider info returned by `GET /config`.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ProviderInfo {
|
|
pub id: String,
|
|
pub capabilities: domain::ProviderCapabilities,
|
|
}
|
|
|
|
/// System configuration response
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ConfigResponse {
|
|
pub allow_registration: bool,
|
|
/// All registered providers with their capabilities.
|
|
pub providers: Vec<ProviderInfo>,
|
|
/// Capabilities of the primary provider — kept for backward compatibility.
|
|
pub provider_capabilities: domain::ProviderCapabilities,
|
|
/// Provider type strings supported by this build (feature-gated).
|
|
pub available_provider_types: Vec<String>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Admin DTOs
|
|
// ============================================================================
|
|
|
|
/// An activity log entry returned by GET /admin/activity.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ActivityEventResponse {
|
|
pub id: Uuid,
|
|
pub timestamp: DateTime<Utc>,
|
|
pub event_type: String,
|
|
pub detail: String,
|
|
pub channel_id: Option<Uuid>,
|
|
}
|
|
|
|
impl From<domain::ActivityEvent> for ActivityEventResponse {
|
|
fn from(e: domain::ActivityEvent) -> Self {
|
|
Self {
|
|
id: e.id,
|
|
timestamp: e.timestamp,
|
|
event_type: e.event_type,
|
|
detail: e.detail,
|
|
channel_id: e.channel_id,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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,
|
|
pub access_mode: Option<domain::AccessMode>,
|
|
/// Plain-text password; hashed before storage.
|
|
pub access_password: Option<String>,
|
|
pub webhook_url: Option<String>,
|
|
pub webhook_poll_interval_secs: Option<u32>,
|
|
pub webhook_body_template: Option<String>,
|
|
pub webhook_headers: Option<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::ScheduleConfigCompat>,
|
|
pub recycle_policy: Option<domain::RecyclePolicy>,
|
|
pub auto_schedule: Option<bool>,
|
|
pub access_mode: Option<domain::AccessMode>,
|
|
/// Empty string clears the password; non-empty re-hashes.
|
|
pub access_password: Option<String>,
|
|
/// `Some(None)` = clear logo, `Some(Some(url))` = set logo, `None` = unchanged.
|
|
pub logo: Option<Option<String>>,
|
|
pub logo_position: Option<domain::LogoPosition>,
|
|
pub logo_opacity: Option<f32>,
|
|
/// `Some(None)` = clear, `Some(Some(url))` = set, `None` = unchanged.
|
|
pub webhook_url: Option<Option<String>>,
|
|
pub webhook_poll_interval_secs: Option<u32>,
|
|
/// `Some(None)` = clear, `Some(Some(tmpl))` = set, `None` = unchanged.
|
|
pub webhook_body_template: Option<Option<String>>,
|
|
/// `Some(None)` = clear, `Some(Some(json))` = set, `None` = unchanged.
|
|
pub webhook_headers: Option<Option<String>>,
|
|
}
|
|
|
|
#[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 auto_schedule: bool,
|
|
pub access_mode: domain::AccessMode,
|
|
pub logo: Option<String>,
|
|
pub logo_position: domain::LogoPosition,
|
|
pub logo_opacity: f32,
|
|
pub webhook_url: Option<String>,
|
|
pub webhook_poll_interval_secs: u32,
|
|
pub webhook_body_template: Option<String>,
|
|
pub webhook_headers: Option<String>,
|
|
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,
|
|
auto_schedule: c.auto_schedule,
|
|
access_mode: c.access_mode,
|
|
logo: c.logo,
|
|
logo_position: c.logo_position,
|
|
logo_opacity: c.logo_opacity,
|
|
webhook_url: c.webhook_url,
|
|
webhook_poll_interval_secs: c.webhook_poll_interval_secs,
|
|
webhook_body_template: c.webhook_body_template,
|
|
webhook_headers: c.webhook_headers,
|
|
created_at: c.created_at,
|
|
updated_at: c.updated_at,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Config history DTOs
|
|
// ============================================================================
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ConfigSnapshotResponse {
|
|
pub id: Uuid,
|
|
pub version_num: i64,
|
|
pub label: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl From<domain::ChannelConfigSnapshot> for ConfigSnapshotResponse {
|
|
fn from(s: domain::ChannelConfigSnapshot) -> Self {
|
|
Self {
|
|
id: s.id,
|
|
version_num: s.version_num,
|
|
label: s.label,
|
|
created_at: s.created_at,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct PatchSnapshotRequest {
|
|
pub label: Option<String>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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 description: Option<String>,
|
|
pub genres: Vec<String>,
|
|
pub year: Option<u16>,
|
|
pub tags: Vec<String>,
|
|
pub series_name: Option<String>,
|
|
pub season_number: Option<u32>,
|
|
pub episode_number: Option<u32>,
|
|
}
|
|
|
|
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,
|
|
description: i.description,
|
|
genres: i.genres,
|
|
year: i.year,
|
|
tags: i.tags,
|
|
series_name: i.series_name,
|
|
season_number: i.season_number,
|
|
episode_number: i.episode_number,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
#[serde(default)]
|
|
pub block_access_mode: domain::AccessMode,
|
|
}
|
|
|
|
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,
|
|
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
|
|
.all_blocks()
|
|
.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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
/// Access mode of the block currently playing. The stream is gated by this.
|
|
pub block_access_mode: domain::AccessMode,
|
|
}
|
|
|
|
#[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>,
|
|
}
|
|
|
|
// ============================================================================
|
|
// Transcode DTOs
|
|
// ============================================================================
|
|
|
|
#[cfg(feature = "local-files")]
|
|
#[derive(Debug, Serialize)]
|
|
pub struct TranscodeSettingsResponse {
|
|
pub cleanup_ttl_hours: u32,
|
|
}
|
|
|
|
#[cfg(feature = "local-files")]
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdateTranscodeSettingsRequest {
|
|
pub cleanup_ttl_hours: u32,
|
|
}
|
|
|
|
#[cfg(feature = "local-files")]
|
|
#[derive(Debug, Serialize)]
|
|
pub struct TranscodeStatsResponse {
|
|
pub cache_size_bytes: u64,
|
|
pub item_count: usize,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ScheduleHistoryEntry {
|
|
pub id: Uuid,
|
|
pub generation: u32,
|
|
pub valid_from: DateTime<Utc>,
|
|
pub valid_until: DateTime<Utc>,
|
|
pub slot_count: usize,
|
|
}
|
|
|
|
impl From<domain::GeneratedSchedule> for ScheduleHistoryEntry {
|
|
fn from(s: domain::GeneratedSchedule) -> Self {
|
|
Self {
|
|
id: s.id,
|
|
generation: s.generation,
|
|
valid_from: s.valid_from,
|
|
valid_until: s.valid_until,
|
|
slot_count: s.slots.len(),
|
|
}
|
|
}
|
|
}
|
|
|
|
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(),
|
|
}
|
|
}
|
|
}
|