//! HTTP server providing a browser-based library UI and download API. //! //! # Architecture //! //! All mutable server state lives in [`WebState`], wrapped in `Arc` //! and shared across axum handlers. Mutable fields use `Mutex` or `AtomicBool` //! so they can be updated by concurrent requests without blocking the async //! runtime. //! //! The server can be started in two ways: //! //! * **Standalone mode** — `serve(config)` blocks until the process exits. //! * **GUI-embedded mode** — `run_with_shutdown(config)` spawns a background //! Tokio runtime and returns a `Sender<()>` the GUI can use to stop the server. //! //! # AGPL §13 compliance //! //! This software is licensed under the GNU Affero General Public License v3. //! Any deployment that serves the web UI to network users must make the //! Corresponding Source available. Set `web.source_url` in `config.toml` to //! a URL where the source can be obtained; the URL is displayed in the UI footer //! and returned by `GET /api/settings`. use std::collections::{HashMap, HashSet}; use std::path::{Path as StdPath, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use axum::{ body::{Body, Bytes}, extract::{ConnectInfo, DefaultBodyLimit, Path, Query, Request, State}, http::{header, HeaderMap, HeaderValue, StatusCode}, middleware::{self, Next}, response::{IntoResponse, Response}, routing::{get, post}, Json, Router, }; use std::net::SocketAddr; use serde::{Deserialize, Serialize}; use tower_http::compression::CompressionLayer; use tower_http::services::ServeDir; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use argon2::password_hash::SaltString; use crate::util::LockExt; use crate::config::Config; use crate::database::Database; use crate::downloader::{DownloadQuality, Downloader, JobState}; use crate::platform::classify_url; use crate::library; use crate::maintenance; // ── Shared state ────────────────────────────────────────────────────────────── /// Serialisable snapshot of a single download job, sent to the browser. #[derive(Clone, Serialize)] pub struct JobSnapshot { pub label: String, pub url: String, /// One of `"running"`, `"done"`, `"failed"`, or `"cancelled"`. pub state: &'static str, pub progress: f32, pub last_line: String, /// Whether a manual "Retry" can re-issue this job (it has the captured /// inputs). False for running jobs, live recordings, and self-update. pub can_retry: bool, /// Classification of the failure, if `state == "failed"`. One of /// `rate-limited`, `members-only`, `geo-blocked`, `not-found`, /// `codec-missing`, `disk-full`, `network-error`, `bad-cookies`, `other`, /// or `null` while still running / on success. Drives the suggested /// action hint in the UI. #[serde(skip_serializing_if = "Option::is_none")] pub error_class: Option, /// Human-readable one-line suggested action paired with `error_class`. /// Empty when `error_class` is `Other` or `None`. #[serde(skip_serializing_if = "str::is_empty")] pub error_hint: &'static str, } /// All mutable state shared across axum handlers via `Arc`. pub struct WebState { /// Scanned channel/playlist/video tree, refreshed after each completed download. pub library: Mutex>, /// Active and recently finished yt-dlp jobs. pub downloader: Mutex, /// Set of video IDs the user has marked as watched (persisted in SQLite). pub watched: Mutex>, /// Bookmark / favourite / waiting / archive flag sets, hydrated from the /// `video_flags` SQLite table at startup. Each set holds the video IDs /// with the named flag enabled. Drives the smart-folder sidebar entries /// and the per-card action icons. pub flags: Mutex, /// Last known playback position per video ID in seconds (persisted in SQLite). pub positions: Mutex>, /// SQLite handle. Internally backed by an `r2d2` pool, so concurrent /// handlers each check out their own connection without serializing on /// an external mutex. pub db: Database, /// YouTube channels directory (the legacy `channels/` folder, kept for /// backward compat). Other platforms live as siblings under [`library_root`]. pub channels_root: PathBuf, /// Parent of `channels_root`. Backs the `/files/` static-file mount so /// non-YouTube platforms are reachable at `/files///...`. pub library_root: PathBuf, pub config_path: PathBuf, pub config: Mutex, /// Whether to transcode MKV→mp4 on the fly for playback (requires ffmpeg). pub transcode: AtomicBool, /// Active session tokens mapped to their issued-at UNIX time (seconds). /// Tokens older than [`SESSION_TTL`] are rejected and pruned lazily on each /// touch. Mirrored to the `sessions` DB table so logins survive a restart /// (the map is the runtime source of truth; the DB is its durable backing). pub sessions: Mutex>, /// When the last scheduled channel check ran; used to compute the next due time. pub last_scheduled_check: Mutex>, /// Cached "is password required" — refreshed when the password is changed. /// Avoids a DB hit on every request through `auth_middleware`. pub password_required_cache: AtomicBool, /// Monotonically-incremented version counter; serves as the ETag for /// `/api/library`. Bumped on any state change that would alter the /// JSON response (rescan, watched toggle, resume position, maintenance /// remove). Combined with `If-None-Match` short-circuits the megabytes /// of library JSON when nothing has changed. pub library_version: AtomicU64, /// Per-IP failure tracker for [`post_login`]. Each entry is the number of /// recent failures and the instant the lockout (if any) expires. pub login_attempts: Mutex>, /// Push channel for `/ws/progress` subscribers. A background tokio task /// ticks every 500 ms while jobs are active and broadcasts a fresh /// [`ProgressResponse`] snapshot here; the WebSocket handler forwards /// each message to its client. The `/api/progress` HTTP endpoint stays /// available as a fallback for clients that can't open a socket. pub progress_tx: tokio::sync::broadcast::Sender, /// Cached serialized body of `/api/library` keyed by the /// `library_version` it was built against. On hit, handlers ship /// the cached bytes without re-walking the channel tree or /// re-serializing. The Arc lets concurrent responses share one /// allocation. Cleared / replaced lazily on the next miss. pub library_body_cache: Mutex)>>, /// Background perceptual-dedup job state. The expensive fingerprint pass /// runs on its own thread; this carries live progress + the last result /// so `/api/maintenance/dedup/status` can be polled. `Arc` so the worker /// thread can hold a handle past the request that started it. pub dedup: std::sync::Arc, /// Read-only capability token for the podcast/RSS feeds. Podcast clients /// can't do the cookie login, so a tokenized feed URL (`?token=…`) grants /// GET access to `/feed*` and the media mounts even when a password is /// set. Persisted in the `feed_token` setting; stable until regenerated. pub feed_token: String, /// Federation peers (read-only remote libraries), built once from /// `config.remotes` at startup. Indexed by position for the `/api/remotes` /// endpoints. Empty when no `[[remote]]` is configured. pub remotes: Vec>, } /// Live state for the background perceptual-dedup job (see /// [`post_dedup_scan`]). One job runs at a time, guarded by `running`. #[derive(Default)] pub struct DedupState { running: AtomicBool, done: AtomicUsize, total: AtomicUsize, result: Mutex>>, error: Mutex>, } /// One video in a perceptual-similarity group, with everything the review UI /// needs to show it and (optionally) delete its files. #[derive(serde::Serialize, Clone)] pub struct SimilarVideo { video_id: String, title: String, channel: String, location: String, file_size: Option, /// Absolute paths of the video + its sidecars, for the delete action. files: Vec, /// The copy the UI recommends keeping (largest file in the group). recommended_keep: bool, } /// A cluster of videos that share visual content across different IDs. #[derive(serde::Serialize, Clone)] pub struct SimilarGroup { videos: Vec, } /// Failed-login tracking entry. After [`LOGIN_LOCKOUT_AFTER`] failures from /// the same IP, further attempts are rejected until [`LoginAttempt::until`]. pub struct LoginAttempt { pub failures: u32, pub until: Option, } /// How long a session token is valid for after login. pub const SESSION_TTL: std::time::Duration = std::time::Duration::from_secs(30 * 24 * 3600); /// Failures per IP before /api/login starts returning 429. pub const LOGIN_LOCKOUT_AFTER: u32 = 5; /// How long the lockout lasts once tripped. pub const LOGIN_LOCKOUT_DURATION: std::time::Duration = std::time::Duration::from_secs(60); impl WebState { fn job_snapshots(dl: &Downloader) -> Vec { dl.jobs .iter() .map(|j| JobSnapshot { label: j.label.clone(), url: j.url.clone(), state: match j.state { JobState::Running => "running", JobState::Done => "done", // A user cancel lands as Failed internally; surface it as a // distinct state so the UI doesn't show an error class. JobState::Failed if j.cancelled => "cancelled", JobState::Failed => "failed", }, progress: j.progress, last_line: j.log.back().cloned().unwrap_or_default(), can_retry: j.state != JobState::Running && j.has_retry_spec(), // Skip `Other` so the badge doesn't get a useless generic // label — the raw log line is still shown for that case. A // cancelled job has no meaningful error class either. error_class: if j.cancelled { None } else { j.failure_class.filter(|c| *c != crate::error_class::ErrorClass::Other ) }, error_hint: if j.cancelled { "" } else { j.failure_class.map(|c| c.hint()).unwrap_or("") }, }) .collect() } } // ── API types ───────────────────────────────────────────────────────────────── // These types are serialised to JSON and consumed by the browser UI. /// Response body for `GET /api/library`. #[derive(Serialize)] struct LibraryResponse { channels: Vec, folders: Vec, } /// JSON representation of a single channel sent to the browser. #[derive(Serialize)] struct ChannelInfo { name: String, /// dir_name() of the channel's source platform. Used by the UI to render /// the platform icon and group entries. platform: &'static str, /// Human-readable platform name (e.g. "YouTube", "TikTok"). platform_label: &'static str, /// Platform icon used in the sidebar. platform_icon: &'static str, /// Original URL the channel was downloaded from, if a `.source-url` sidecar /// exists. Used by the UI's "Check for new videos" action to avoid relying /// on a folder-name heuristic. source_url: Option, /// Folder id from the user's channel-organisation tree. `None` when the /// channel is "Unfiled" (no row in `channel_assignments`). folder_id: Option, total_videos: usize, size_bytes: u64, subscriber_count: Option, uploader: Option, channel_url: Option, /// Thumbnail URL for the channel overview grid — first available video thumbnail. thumb_url: Option, playlists: Vec, videos: Vec, } /// JSON representation of a playlist within a channel. #[derive(Serialize)] struct PlaylistInfo { name: String, videos: Vec, } /// JSON representation of a single video sent to the browser. /// /// `resume_pos` is only set when the user has played more than 3 seconds /// so that the "continue watching" list stays meaningful. #[derive(Serialize)] struct VideoInfo { id: String, title: String, duration_secs: Option, file_size: Option, /// Upload date as `YYYYMMDD` (yt-dlp's native format). upload_date: Option, /// Filesystem mtime as a UNIX timestamp. Drives the Recent-additions feed. mtime_unix: Option, has_video: bool, has_live_chat: bool, watched: bool, /// Smart-folder flags. Populated from the in-memory /// [`WebState::flags`] sets at response-build time. bookmark: bool, favourite: bool, waiting: bool, archive: bool, video_url: Option, thumb_url: Option, subtitles: Vec, has_chapters: bool, resume_pos: Option, } /// A single subtitle track URL for a video. #[derive(Serialize)] struct SubtitleInfo { lang: String, /// Human-readable label (e.g. "English"), shown in the track selector. label: String, url: String, } /// JSON representation of a single music track sent to the browser. #[derive(Serialize)] struct TrackInfo { id: String, title: String, artist: String, duration_secs: Option, file_size: Option, audio_url: Option, thumb_url: Option, } /// Request body for `POST /api/download`. #[derive(Deserialize)] struct StartDownloadRequest { url: String, /// When true, omits `--break-on-existing` so every video is checked /// individually — slower but fills gaps in partially-archived channels. #[serde(default)] full_scan: bool, /// Quality selector: "best" (default), "1080p", "720p", "480p", "360p", or "music". /// When "music", audio-only mode is used regardless of other settings. #[serde(default)] quality: String, /// Treat the URL as an ongoing live broadcast and record from the start. /// Adds `--live-from-start --wait-for-video 30` and timestamps the /// output filename so re-recordings don't collide. #[serde(default)] live: bool, } /// Response body for `GET /api/progress`. #[derive(Serialize)] struct ProgressResponse { jobs: Vec, queued: Vec, max_concurrent: usize, } #[derive(Serialize)] struct QueuedSnapshot { label: String, url: String, } #[derive(Serialize, Deserialize)] struct SettingsPayload { transcode: bool, /// URL of the source repository, shown in the footer for AGPL §13 compliance. /// Editable via the settings UI; empty string on POST clears it. #[serde(default)] source_url: Option, /// Current binding address and port, sent by server only. #[serde(skip_deserializing, default)] current_bind: Option, /// List of available bind options, sent by server only. #[serde(skip_deserializing, default)] available_binds: Option>, /// Selected bind mode (localhost, tailscale, lan, all). Clients can send this on POST to change. #[serde(default)] bind_mode: Option, /// Whether a password is required for downloads (sent by server only). #[serde(skip_deserializing, default)] download_password_required: bool, /// New plaintext password to set for downloads. Clients send this on POST; server does not return it. #[serde(skip_serializing, default)] new_download_password: Option, /// Plex library path, readable and writable by both client and server. #[serde(default)] plex_library_path: Option, /// Whether the background scheduler is enabled. #[serde(default)] scheduler_enabled: bool, /// Hours between automatic channel checks (1–168). Ignored if 0 on POST. #[serde(default)] scheduler_interval_hours: u32, /// Seconds until the next scheduled check. `None` if scheduler is disabled or last check unknown. #[serde(skip_deserializing, default)] scheduler_next_check_secs: Option, /// Maximum simultaneous yt-dlp processes. 0 = unlimited. Ignored if 0 on POST. #[serde(default)] max_concurrent: usize, /// If true, invoke the bundled yt-dlp under `~/.local/share/catacomb/bin/` /// instead of the system PATH yt-dlp. #[serde(default)] use_bundled_ytdlp: bool, /// Whether the bundled yt-dlp binary is installed on disk (sent by server only). #[serde(skip_deserializing, default)] bundled_ytdlp_installed: bool, /// If true and use_bundled_ytdlp is on, spawn the bgutil-pot HTTP /// server and pass its extractor-args to yt-dlp. See pot_provider.rs. #[serde(default)] use_pot_provider: bool, /// Whether the bgutil-pot binary is installed on disk (sent by server only). #[serde(skip_deserializing, default)] pot_provider_installed: bool, /// Global subtitle defaults (the `[subtitles]` config section). /// Round-trips on both GET and POST. Per-channel overrides live in /// each channel's DownloadOptions, not here. #[serde(default)] subtitles_enabled: bool, #[serde(default)] subtitles_auto: bool, #[serde(default)] subtitles_embed: bool, #[serde(default)] subtitle_format: String, #[serde(default)] subtitle_langs: String, /// YouTube player clients (comma-separated). Empty = yt-dlp defaults. #[serde(default)] youtube_player_clients: String, /// SponsorBlock mode: "off" / "mark" / "remove". #[serde(default)] sponsorblock_mode: String, /// Global comment fetching (`--write-comments`). Per-channel overrides /// live in each channel's DownloadOptions, not here. #[serde(default)] fetch_comments: bool, /// Whether the perceptual "similar content" dedup scan is enabled. #[serde(default = "crate::config::default_true")] dedup_enabled: bool, /// Global [convert] settings, round-tripped on GET + POST. #[serde(default)] convert_mode: String, #[serde(default)] convert_crf: u8, #[serde(default)] convert_preset: String, #[serde(default)] convert_audio_format: String, #[serde(default)] convert_keep_original: bool, } #[derive(Serialize, Deserialize, Clone)] pub struct BindOption { pub id: String, pub label: String, pub address: String, } // Build a `/files/` URL from an absolute path, percent-encoding each segment. // // `library_root` is the parent of channels_root so that paths like // `/channels/handle/video.mkv` become `/files/channels/handle/video.mkv` // and `/tiktok/user/clip.mp4` becomes `/files/tiktok/user/clip.mp4`. fn file_url(library_root: &StdPath, full: &StdPath) -> Option { let rel = full.strip_prefix(library_root).ok()?; let mut parts: Vec = Vec::new(); for c in rel.components() { if let std::path::Component::Normal(s) = c { parts.push(percent_encode_segment(s.to_str()?)); } } if parts.is_empty() { return None; } Some(format!("/files/{}", parts.join("/"))) } /// Like `file_url` but targets the `/api/sub-vtt/…` endpoint. All subtitle /// tracks are routed through that handler (not the raw `/files/` mount) so /// their cue position/alignment settings get stripped before the player /// renders them — otherwise auto-generated captions render left-aligned. fn sub_vtt_url(library_root: &StdPath, full: &StdPath) -> Option { let rel = full.strip_prefix(library_root).ok()?; let mut parts: Vec = Vec::new(); for c in rel.components() { if let std::path::Component::Normal(s) = c { parts.push(percent_encode_segment(s.to_str()?)); } } if parts.is_empty() { return None; } Some(format!("/api/sub-vtt/{}", parts.join("/"))) } fn percent_encode_segment(s: &str) -> String { use std::fmt::Write; let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), // `write!` reuses `out`'s buffer — avoids the intermediate // `String` allocation that `format!` would produce per byte. _ => { let _ = write!(out, "%{:02X}", b); } } } out } fn lang_label(code: &str) -> String { let base = code.split('-').next().unwrap_or(code); let name = match base { "en" => "English", "es" => "Spanish", "fr" => "French", "de" => "German", "ja" => "Japanese", "ko" => "Korean", "zh" => "Chinese", "pt" => "Portuguese", "ru" => "Russian", "it" => "Italian", "ar" => "Arabic", "hi" => "Hindi", "nl" => "Dutch", "pl" => "Polish", "tr" => "Turkish", "sv" => "Swedish", "id" => "Indonesian", "vi" => "Vietnamese", "th" => "Thai", _ => return code.to_string(), }; if code.ends_with("-orig") || code.contains("auto") { format!("{name} (auto)") } else { name.to_string() } } fn find_video_info_path(library: &[library::Channel], id: &str) -> Option { library::find_video(library, id).and_then(|(v, _)| v.info_path.clone()) } fn find_video_path(library: &[library::Channel], id: &str) -> Option { library::find_video(library, id).and_then(|(v, _)| v.video_path.clone()) } fn detect_tailscale_ip() -> Option { if std::path::Path::new("/proc/net/if_inet6").exists() || std::path::Path::new("/etc/tailscale").exists() { if let Ok(output) = std::process::Command::new("hostname") .arg("-I") .output() { let ip_str = String::from_utf8_lossy(&output.stdout); ip_str .split_whitespace() .find(|ip| ip.starts_with("100.")) .map(|s| s.to_string()) } else { None } } else { None } } fn detect_lan_ip() -> Option { if let Ok(output) = std::process::Command::new("hostname") .arg("-I") .output() { let ip_str = String::from_utf8_lossy(&output.stdout); ip_str .split_whitespace() .find(|ip| !ip.starts_with("127.") && !ip.starts_with("100.")) .map(|s| s.to_string()) } else { None } } pub fn get_available_binds(port: u16) -> Vec { let mut opts = vec![ BindOption { id: "localhost".to_string(), label: "Localhost only".to_string(), address: format!("127.0.0.1:{port}"), }, ]; if let Some(ts_ip) = detect_tailscale_ip() { opts.push(BindOption { id: "tailscale".to_string(), label: format!("Tailscale ({})", ts_ip), address: format!("{ts_ip}:{port}"), }); } if let Some(lan_ip) = detect_lan_ip() { if lan_ip != "127.0.0.1" { opts.push(BindOption { id: "lan".to_string(), label: format!("LAN ({})", lan_ip), address: format!("{lan_ip}:{port}"), }); } } opts.push(BindOption { id: "all".to_string(), label: "All interfaces (0.0.0.0)".to_string(), address: format!("0.0.0.0:{port}"), }); opts } pub fn resolve_bind_mode(mode: &str) -> String { match mode { "tailscale" => detect_tailscale_ip().unwrap_or_else(|| "127.0.0.1".to_string()), "lan" => detect_lan_ip().unwrap_or_else(|| "127.0.0.1".to_string()), "all" => "0.0.0.0".to_string(), _ => "127.0.0.1".to_string(), } } /// Infer the bind-mode id (`localhost`/`tailscale`/`lan`/`all`) from a stored bind address. pub fn bind_mode_of(addr: &str) -> &'static str { match addr { "127.0.0.1" | "localhost" => "localhost", "0.0.0.0" => "all", a if a.starts_with("100.") => "tailscale", _ => "lan", } } // ── Cookies ───────────────────────────────────────────────────────────────────── /// Convert SubRip (SRT) subtitle text to WebVTT. /// /// The only structural differences are the `WEBVTT` header and the timestamp /// decimal separator (SRT uses `,`, VTT uses `.`). fn srt_to_vtt(srt: &str) -> String { let mut out = String::from("WEBVTT\n\n"); for line in srt.lines() { if line.contains("-->") { out.push_str(&strip_cue_settings(&line.replace(',', "."))); } else { out.push_str(line); } out.push('\n'); } out } /// Drop the per-cue position/alignment settings ("align:start position:0%", /// "line:…", "size:…") that yt-dlp's auto-generated captions append to every /// cue timing line. Browsers honor them and left-align/offset the text; /// removing them restores the default centered, bottom rendering. Only the /// settings *after* the "start --> end" timestamps are removed — the two /// timestamps are preserved verbatim. fn strip_cue_settings(timing_line: &str) -> String { match timing_line.find("-->") { Some(arrow) => { let start = timing_line[..arrow].trim_end(); let after = timing_line[arrow + 3..].trim_start(); // The end timestamp is the first whitespace-delimited token; any // cue settings follow it and are dropped. let end = after.split_whitespace().next().unwrap_or(""); format!("{start} --> {end}") } None => timing_line.to_string(), } } /// Strip cue settings from an already-WebVTT document (leaves the header, /// cue identifiers, and text untouched — only timing lines are normalized). fn normalize_vtt(vtt: &str) -> String { let mut out = String::with_capacity(vtt.len()); for line in vtt.lines() { if line.contains("-->") { out.push_str(&strip_cue_settings(line)); } else { out.push_str(line); } out.push('\n'); } out } /// Path to the `cookies.txt` yt-dlp reads, resolved against the process working /// directory (the same place `config.toml` lives and where the downloader's /// relative `--cookies cookies.txt` resolves). pub fn cookies_path() -> PathBuf { std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join("cookies.txt") } /// Count cookie entries (Netscape lines with 7 tab-separated fields). fn count_cookies(text: &str) -> usize { text.lines().filter(|l| l.split('\t').count() >= 7).count() } /// Whether a cookies file exists and how many cookie entries it holds. pub fn cookies_status() -> (bool, usize) { match std::fs::read_to_string(cookies_path()) { Ok(s) => (true, count_cookies(&s)), Err(_) => (false, 0), } } /// Cookie-freshness assessment for the UI. /// /// `expires_at` is the **earliest** non-session expiry among the /// auth-relevant YouTube/Google cookies (the ones that actually gate /// logged-in access — `SID`, `SAPISID`, `__Secure-*`, `LOGIN_INFO`). /// `None` means none had a parseable future expiry (all session cookies, /// or already expired). `expired` is true when that earliest expiry is in /// the past — a strong "refresh your cookies" signal, since stale auth /// cookies make YouTube's bot-detection *worse* than none at all. #[derive(serde::Serialize, Default)] pub struct CookieFreshness { /// Earliest auth-cookie expiry as a UNIX timestamp, if any. pub expires_at: Option, /// True when the earliest auth-cookie expiry is already past. pub expired: bool, /// Days until `expires_at` (negative if expired). `None` if unknown. pub days_left: Option, /// True when the jar has cookies but NONE of the login/auth cookies — /// i.e. it's an anonymous/visitor session, not a signed-in one. This /// is the worst case for YouTube bot-detection (anonymous requests get /// captcha'd most aggressively), so we flag it distinctly. pub no_auth_cookies: bool, } /// Names whose expiry actually matters for staying logged in. Other /// cookies (prefs, consent) expiring doesn't break auth, so we ignore /// them to avoid false "stale" warnings. const AUTH_COOKIE_NAMES: &[&str] = &[ "SID", "HSID", "SSID", "APISID", "SAPISID", "__Secure-1PSID", "__Secure-3PSID", "__Secure-1PAPISID", "__Secure-3PAPISID", "LOGIN_INFO", ]; /// Parse the cookies file and assess freshness. See [`CookieFreshness`]. pub fn cookies_freshness() -> CookieFreshness { let text = std::fs::read_to_string(cookies_path()).unwrap_or_default(); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); cookies_freshness_from(&text, now) } /// Pure freshness assessment from cookie-jar text + "now". Split out for /// testability (the public fn reads the file + clock). fn cookies_freshness_from(text: &str, now: i64) -> CookieFreshness { // Netscape format: domain \t flag \t path \t secure \t expiry \t name \t value let mut earliest: Option = None; let mut saw_auth_cookie = false; let mut saw_any_cookie = false; for line in text.lines() { if line.starts_with('#') || line.trim().is_empty() { continue; } let f: Vec<&str> = line.split('\t').collect(); if f.len() < 7 { continue; } saw_any_cookie = true; let name = f[5]; if !AUTH_COOKIE_NAMES.contains(&name) { continue; } saw_auth_cookie = true; // Expiry 0 = session cookie (no fixed expiry); skip — it's not // a staleness signal on its own. let Ok(exp) = f[4].parse::() else { continue }; if exp == 0 { continue; } earliest = Some(earliest.map_or(exp, |e| e.min(exp))); } CookieFreshness { expires_at: earliest, expired: earliest.is_some_and(|exp| exp < now), days_left: earliest.map(|exp| (exp - now) / 86_400), // Has cookies but no login cookies = anonymous jar. no_auth_cookies: saw_any_cookie && !saw_auth_cookie, } } /// Validate that `text` looks like a Netscape cookie jar and write it to /// [`cookies_path`]. Returns the number of cookie entries written, or an error /// message if the content doesn't look like a cookies.txt. pub fn write_cookies(text: &str) -> Result { if text.trim().is_empty() { return Err("no cookies provided".to_string()); } let has_cookie_line = text.lines().any(|l| l.split('\t').count() >= 7); let has_header = text.trim_start().starts_with("# Netscape") || text.trim_start().starts_with("# HTTP Cookie File"); if !has_cookie_line && !has_header { return Err( "doesn't look like a Netscape cookies.txt (expected tab-separated fields)".to_string(), ); } let mut content = text.to_string(); if !content.ends_with('\n') { content.push('\n'); } let path = cookies_path(); std::fs::write(&path, &content).map_err(|e| e.to_string())?; // cookies.txt carries live session credentials — tighten the mode so it // isn't world-readable on multi-user systems. Best-effort, like the // similar guard on catacomb.db. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Ok(meta) = std::fs::metadata(&path) { let mut perms = meta.permissions(); perms.set_mode(0o600); let _ = std::fs::set_permissions(&path, perms); } } Ok(count_cookies(&content)) } #[cfg(test)] mod tests { use super::*; #[test] fn cookie_freshness_flags_expired_auth_cookie() { let now = 1_700_000_000; // SID expired (past), LOGIN_INFO valid (future). Earliest = SID → expired. let jar = format!( "# Netscape HTTP Cookie File\n\ .youtube.com\tTRUE\t/\tTRUE\t{past}\tSID\tabc\n\ .youtube.com\tTRUE\t/\tTRUE\t{future}\tLOGIN_INFO\txyz\n", past = now - 86_400, future = now + 30 * 86_400, ); let f = cookies_freshness_from(&jar, now); assert!(f.expired, "earliest auth cookie is in the past → expired"); assert_eq!(f.expires_at, Some(now - 86_400)); assert_eq!(f.days_left, Some(-1)); } #[test] fn cookie_freshness_fresh_when_all_future() { let now = 1_700_000_000; let jar = format!( ".youtube.com\tTRUE\t/\tTRUE\t{f}\tSID\ta\n\ .youtube.com\tTRUE\t/\tTRUE\t{f}\tSAPISID\tb\n", f = now + 10 * 86_400, ); let r = cookies_freshness_from(&jar, now); assert!(!r.expired); assert_eq!(r.days_left, Some(10)); } #[test] fn cookie_freshness_ignores_non_auth_and_session_cookies() { let now = 1_700_000_000; // A non-auth cookie expired + a session (0) auth cookie → no signal. let jar = format!( ".youtube.com\tTRUE\t/\tFALSE\t{past}\tPREF\tx\n\ .youtube.com\tTRUE\t/\tTRUE\t0\tSID\ta\n", past = now - 86_400, ); let r = cookies_freshness_from(&jar, now); assert_eq!(r.expires_at, None); assert!(!r.expired); // SID present (even as session) → not an anonymous jar. assert!(!r.no_auth_cookies); } #[test] fn cookie_freshness_detects_anonymous_jar() { let now = 1_700_000_000; // Only visitor/pref cookies, no login cookies → anonymous. let jar = format!( ".youtube.com\tTRUE\t/\tFALSE\t{f}\tPREF\tx\n\ .youtube.com\tTRUE\t/\tTRUE\t{f}\tVISITOR_INFO1_LIVE\ty\n\ .youtube.com\tTRUE\t/\tTRUE\t{f}\tYSC\tz\n", f = now + 100 * 86_400, ); let r = cookies_freshness_from(&jar, now); assert!(r.no_auth_cookies, "no login cookies → anonymous jar flagged"); assert!(!r.expired); } #[test] fn srt_to_vtt_replaces_comma_in_timestamps_only() { let srt = "1\n00:00:01,500 --> 00:00:03,000\nHello, world\n"; let vtt = srt_to_vtt(srt); assert!(vtt.starts_with("WEBVTT\n\n")); // Comma in body preserved; comma in timestamp converted to dot. assert!(vtt.contains("00:00:01.500 --> 00:00:03.000")); assert!(vtt.contains("Hello, world")); } #[test] fn strip_cue_settings_drops_alignment_keeps_timestamps() { let line = "00:00:01.000 --> 00:00:03.000 align:start position:0%"; assert_eq!(strip_cue_settings(line), "00:00:01.000 --> 00:00:03.000"); // A plain timing line is unchanged. let plain = "00:00:01.000 --> 00:00:03.000"; assert_eq!(strip_cue_settings(plain), plain); // Non-timing lines pass through untouched. assert_eq!(strip_cue_settings("Hello align:start"), "Hello align:start"); } #[test] fn normalize_vtt_strips_settings_only_on_timing_lines() { let vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000 align:start position:10%\nHello\n"; let out = normalize_vtt(vtt); assert!(out.contains("00:00:01.000 --> 00:00:03.000\n")); assert!(!out.contains("align:start")); assert!(out.contains("Hello")); assert!(out.starts_with("WEBVTT")); } #[test] fn count_cookies_only_counts_seven_field_lines() { let body = "# Netscape HTTP Cookie File\n\ .youtube.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n\ not a cookie line\n\ .example.com\tTRUE\t/\tFALSE\t0\tn2\tv2\n"; assert_eq!(count_cookies(body), 2); } #[test] fn percent_encode_segment_passes_safe_chars() { assert_eq!(percent_encode_segment("abcXYZ0-9._~"), "abcXYZ0-9._~"); } #[test] fn percent_encode_segment_escapes_space_and_slash() { assert_eq!(percent_encode_segment("a b/c"), "a%20b%2Fc"); } #[test] fn percent_encode_segment_escapes_non_ascii() { // 'é' in UTF-8 is 0xC3 0xA9. assert_eq!(percent_encode_segment("é"), "%C3%A9"); } #[test] fn bind_mode_of_recognizes_loopback_and_wildcard() { assert_eq!(bind_mode_of("127.0.0.1"), "localhost"); assert_eq!(bind_mode_of("localhost"), "localhost"); assert_eq!(bind_mode_of("0.0.0.0"), "all"); assert_eq!(bind_mode_of("100.64.1.2"), "tailscale"); assert_eq!(bind_mode_of("192.168.1.10"), "lan"); } #[test] fn hash_password_verify_roundtrip() { let h = hash_password("hunter2").unwrap(); assert!(verify_password("hunter2", &h)); assert!(!verify_password("wrong", &h)); } #[test] fn lang_label_known_codes() { assert_eq!(lang_label("en"), "English"); assert_eq!(lang_label("ja"), "Japanese"); assert_eq!(lang_label("en-orig"), "English (auto)"); // Unknown: returned as-is. assert_eq!(lang_label("zz"), "zz"); } } pub fn hash_password(password: &str) -> Option { use rand::thread_rng; let salt = SaltString::generate(thread_rng()); let argon2 = Argon2::default(); argon2 .hash_password(password.as_bytes(), &salt) .ok() .map(|hash| hash.to_string()) } fn verify_password(password: &str, hash: &str) -> bool { if let Ok(parsed_hash) = PasswordHash::new(hash) { Argon2::default() .verify_password(password.as_bytes(), &parsed_hash) .is_ok() } else { false } } // ── Session auth ──────────────────────────────────────────────────────────────── /// Generate a 256-bit random session token, hex-encoded. fn generate_session_token() -> String { use rand::RngCore; let mut bytes = [0u8; 32]; rand::thread_rng().fill_bytes(&mut bytes); bytes.iter().map(|b| format!("{:02x}", b)).collect() } /// Extract the `session` cookie value from request headers, if present. fn session_token_from_headers(headers: &HeaderMap) -> Option { let cookie = headers.get(header::COOKIE)?.to_str().ok()?; cookie .split(';') .filter_map(|p| p.trim().strip_prefix("session=")) .next() .map(|s| s.to_string()) } /// True if the request carries a valid, non-expired session cookie. /// /// Expired tokens are removed from the in-memory set as a side effect so the /// `sessions` map doesn't grow without bound for users who never log out. fn is_authed(state: &WebState, headers: &HeaderMap) -> bool { let Some(token) = session_token_from_headers(headers) else { return false }; let mut sessions = state.sessions.lock_recover(); let now = crate::stats::now_unix(); let ttl = SESSION_TTL.as_secs(); // Lazy prune: drop anything older than the TTL (in-memory only; the DB rows // are pruned at startup in load_sessions and on the next login). sessions.retain(|_, issued| now.saturating_sub(*issued) < ttl); sessions.contains_key(&token) } /// Whether a download/access password is configured. Backed by an atomic /// cache to avoid a SQLite hit on every request (especially for static files). fn password_required(state: &WebState) -> bool { state.password_required_cache.load(Ordering::Relaxed) } /// Bump the library-version counter. Callers should invoke this after any /// state change that would alter `/api/library`'s response (watched flip, /// resume position, rescan, maintenance remove). The counter is consumed /// as an `ETag` so well-behaved clients can short-circuit unchanged GETs /// with `If-None-Match`. fn bump_library_version(state: &WebState) { state.library_version.fetch_add(1, Ordering::Relaxed); } /// Bring the full-text search index in line with the current library. Cheap /// after the first build (only new/changed videos re-read a description), so /// it's fine to call inline after every (re)scan. Errors are logged, not /// fatal — search degrading is better than a scan failing. fn refresh_search_index(db: &Database, lib: &[library::Channel]) { if let Err(e) = db.sync_search_index(&library::build_search_entries(lib)) { eprintln!("search index sync failed: {e}"); } } /// Re-read the password setting from the DB and update the cache. Called /// after any change that could affect whether a password exists. fn refresh_password_cache(state: &WebState) { let present = state.db .get_setting("password_hash").ok().flatten().is_some(); state.password_required_cache.store(present, Ordering::Relaxed); } #[derive(Deserialize)] struct LoginRequest { password: String, } /// Build the `Set-Cookie` header value for a session token. /// /// `Secure` is added when the request arrived over HTTPS — detected either /// by a forwarding proxy (`X-Forwarded-Proto: https`) or by the request URI /// scheme. Setting `Secure` unconditionally would break logins on plain-HTTP /// LAN deployments since the browser would refuse to send the cookie back. fn session_cookie(token: &str, headers: &HeaderMap, max_age_secs: u64) -> String { let secure = headers.get("x-forwarded-proto") .and_then(|h| h.to_str().ok()) .map(|v| v.eq_ignore_ascii_case("https")) .unwrap_or(false); let secure_attr = if secure { "; Secure" } else { "" }; format!("session={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age={max_age_secs}{secure_attr}") } /// `POST /api/login` — verify the password and issue a session cookie. /// /// Rate-limited per source IP: after [`LOGIN_LOCKOUT_AFTER`] failed attempts, /// further attempts return 429 for [`LOGIN_LOCKOUT_DURATION`]. Successful /// logins reset the counter for that IP. async fn post_login( State(state): State>, ConnectInfo(addr): ConnectInfo, headers: HeaderMap, Json(body): Json, ) -> Response { let ip = addr.ip(); let now = std::time::Instant::now(); // Check lockout first. { let mut attempts = state.login_attempts.lock_recover(); // GC entries whose lockout has elapsed. attempts.retain(|_, a| a.until.map_or(true, |u| u > now)); if let Some(a) = attempts.get(&ip) { if let Some(until) = a.until { if until > now { return (StatusCode::TOO_MANY_REQUESTS, "too many failed attempts — try again shortly").into_response(); } } } } let hash = state.db.get_setting("password_hash").ok().flatten(); let Some(hash) = hash else { // No password configured; nothing to authenticate against. return (StatusCode::OK, "no password set").into_response(); }; if !verify_password(&body.password, &hash) { let mut attempts = state.login_attempts.lock_recover(); let entry = attempts.entry(ip).or_insert(LoginAttempt { failures: 0, until: None }); entry.failures += 1; if entry.failures >= LOGIN_LOCKOUT_AFTER { entry.until = Some(now + LOGIN_LOCKOUT_DURATION); entry.failures = 0; } return (StatusCode::UNAUTHORIZED, "invalid password").into_response(); } // Success: reset the failure counter for this IP. state.login_attempts.lock_recover().remove(&ip); let token = generate_session_token(); let issued = crate::stats::now_unix(); state.sessions.lock_recover().insert(token.clone(), issued); // Mirror to the DB so the session survives a restart. Best-effort: a failed // write only means this token won't outlive a restart, not that login fails. if let Err(e) = state.db.insert_session(&token, issued) { eprintln!("warning: could not persist session: {e}"); } let cookie = session_cookie(&token, &headers, SESSION_TTL.as_secs()); ([(header::SET_COOKIE, cookie)], StatusCode::OK).into_response() } /// `POST /api/logout` — invalidate the current session and clear the cookie. async fn post_logout( State(state): State>, headers: HeaderMap, ) -> Response { if let Some(token) = session_token_from_headers(&headers) { state.sessions.lock_recover().remove(&token); let _ = state.db.delete_session(&token); } let cookie = session_cookie("", &headers, 0); ([(header::SET_COOKIE, cookie)], StatusCode::OK).into_response() } /// Middleware that attaches conservative security headers to every response. /// /// The Content-Security-Policy permits inline JS and styles (the embedded UI /// is one big inline script tag) but forbids loading code from third-party /// origins, blocks plugin / object embedding, and prevents the page from /// being framed. This caps the blast radius of any future XSS slip-up in /// the embedded UI strings. async fn security_headers(req: Request, next: Next) -> Response { let mut resp = next.run(req).await; let headers = resp.headers_mut(); // SAFETY: every value here is a fixed compile-time ASCII string. let csp = "default-src 'self'; \ script-src 'self' 'unsafe-inline'; \ style-src 'self' 'unsafe-inline'; \ img-src 'self' data: blob: https:; \ media-src 'self' blob:; \ connect-src 'self'; \ font-src 'self' data:; \ object-src 'none'; \ base-uri 'self'; \ frame-ancestors 'none'"; headers.insert(header::CONTENT_SECURITY_POLICY, HeaderValue::from_static(csp)); headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); headers.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer")); resp } /// Middleware gating every route behind a session cookie when a password is set. /// With no password configured, all requests pass through unchanged (preserves /// the localhost-only default). `/api/login` is always reachable so users can /// authenticate; unauthenticated `GET /` is served a login page instead of the app. async fn auth_middleware( State(state): State>, req: Request, next: Next, ) -> Response { if !password_required(&state) { return next.run(req).await; } let path = req.uri().path(); if path == "/api/login" { return next.run(req).await; } // PWA static assets. Browsers fetch the manifest/icons during install // (not always with credentials) and must reach the service worker script // to register it from the login page's origin. All of these are // compile-time constants with nothing user- or library-specific, so // serving them pre-auth leaks nothing. if req.method().as_str() == "GET" && (path == "/manifest.webmanifest" || path == "/sw.js" || path == "/apple-touch-icon.png" || path.starts_with("/icons/")) { return next.run(req).await; } if is_authed(&state, req.headers()) { return next.run(req).await; } // Podcast clients can't perform the cookie login, so a tokenized GET to a // feed or the media mounts is allowed when the query carries the // read-only feed token. Scoped to reads of feeds + media only — never // `/api/*` mutations. if req.method().as_str() == "GET" && (path.starts_with("/feed") || path.starts_with("/files/") || path.starts_with("/music-files/") // Read-only media streams a federated peer needs when transcode is // on (/api/transcode) or for subtitle playback (/api/sub-vtt). Both // are GET-only re-reads of already-token-readable media. || path.starts_with("/api/transcode/") || path.starts_with("/api/sub-vtt/")) && req.uri().query().is_some_and(|q| query_has_token(q, &state.feed_token)) { return next.run(req).await; } if path == "/" { return ( [(header::CONTENT_TYPE, "text/html; charset=utf-8")], LOGIN_HTML, ) .into_response(); } (StatusCode::UNAUTHORIZED, "authentication required").into_response() } /// True if the query string carries `token=` (the feed capability /// token). Empty `expected` never matches. The token is compared in constant /// time so response latency doesn't leak how many leading bytes matched. fn query_has_token(query: &str, expected: &str) -> bool { if expected.is_empty() { return false; } query.split('&').any(|kv| { kv.strip_prefix("token=").is_some_and(|v| ct_eq(v.as_bytes(), expected.as_bytes())) }) } /// Constant-time byte-slice equality. Avoids the early-exit of `==` so a /// network attacker can't binary-search a capability token by timing. fn ct_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } let mut diff = 0u8; for (x, y) in a.iter().zip(b.iter()) { diff |= x ^ y; } diff == 0 } // ── Handlers ────────────────────────────────────────────────────────────────── async fn get_index() -> impl IntoResponse { // No-store on the HTML body: the binary upgrades change the embedded // markup without changing the URL, and we don't want a long-lived // browser tab serving a months-old UI against a fresh API. The JSON // endpoints still ETag their own bodies so library data remains // efficiently cached. ( [ (header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-store"), ], HTML_UI, ) } async fn get_library( State(state): State>, headers: HeaderMap, ) -> Response { // Conditional GET: short-circuit with 304 when the client's cached // ETag matches our current library_version. Saves megabytes for // large libraries on every poll. let version = state.library_version.load(Ordering::Relaxed); let etag = format!("\"{}\"", version); if let Some(client_etag) = headers.get(header::IF_NONE_MATCH).and_then(|v| v.to_str().ok()) { if client_etag == etag { return ([ (header::ETAG, etag.clone()), (header::CACHE_CONTROL, "no-cache".to_string()), ], StatusCode::NOT_MODIFIED).into_response(); } } // Body cache: if we already serialized the payload for this version, // hand out the cached String. The 304 above catches clients with a // matching ETag; the body cache catches clients without one (curl, // freshly-opened tabs, mobile WebViews that don't store ETags // reliably). Serializing a multi-MB library JSON for every reload // burns measurable CPU on large installations. { let cache = state.library_body_cache.lock_recover(); if let Some((cached_ver, body)) = cache.as_ref() { if *cached_ver == version { let body = body.clone(); drop(cache); return ( [ (header::ETAG, etag), (header::CACHE_CONTROL, "no-cache".to_string()), (header::CONTENT_TYPE, "application/json".to_string()), ], body.as_str().to_string(), ).into_response(); } } } let payload = build_library_payload(&state).await; // Re-check the version: if the library changed while we were // building (a download finished, watched toggled, etc.), the body // we just produced is stale for the *new* version. Don't cache // anything in that case — we'll serialize again next time. let post_version = state.library_version.load(Ordering::Relaxed); let body = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string()); if post_version == version { let body_arc = std::sync::Arc::new(body.clone()); *state.library_body_cache.lock_recover() = Some((version, body_arc)); } ( [ (header::ETAG, etag), (header::CACHE_CONTROL, "no-cache".to_string()), (header::CONTENT_TYPE, "application/json".to_string()), ], body, ).into_response() } async fn build_library_payload(state: &Arc) -> LibraryResponse { let lib = state.library.lock_recover(); let watched = state.watched.lock_recover(); let positions = state.positions.lock_recover(); let flags = state.flags.lock_recover(); // file_url() now resolves relative to library_root (= parent of // channels_root) so non-YouTube platforms are reachable at // `/files///