Files
k-tv/k-tv-backend/api/src/dto.rs
Gabriel Kaszewski d2412da057 feat(auth): refresh tokens + remember me
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.
2026-03-19 22:24:26 +01:00

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(),
}
}
}