//! 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, 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::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"`, or `"failed"`. pub state: &'static str, pub progress: f32, pub last_line: String, /// 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 `Instant`. Tokens older /// than [`SESSION_TTL`] are rejected and pruned lazily on each touch. 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)>>, } /// 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", JobState::Failed => "failed", }, progress: j.progress, last_line: j.log.back().cloned().unwrap_or_default(), // Skip `Other` so the badge doesn't get a useless generic // label — the raw log line is still shown for that case. error_class: j.failure_class.filter(|c| *c != crate::error_class::ErrorClass::Other ), error_hint: 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(skip_deserializing, 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/yt-offline/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, } #[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("/"))) } 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(&line.replace(',', ".")); } 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), } } /// 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 yt-offline.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 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 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().unwrap(); let now = std::time::Instant::now(); // Lazy prune: drop anything older than the TTL. sessions.retain(|_, issued| now.duration_since(*issued) < SESSION_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); } /// 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().unwrap(); // 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().unwrap(); 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().unwrap().remove(&ip); let token = generate_session_token(); state.sessions.lock().unwrap().insert(token.clone(), now); 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().unwrap().remove(&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'; \ 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; } if is_authed(&state, req.headers()) { 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() } // ── 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().unwrap(); 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().unwrap() = 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().unwrap(); let watched = state.watched.lock().unwrap(); let positions = state.positions.lock().unwrap(); let flags = state.flags.lock().unwrap(); // file_url() now resolves relative to library_root (= parent of // channels_root) so non-YouTube platforms are reachable at // `/files///