//! Video-related endpoints: HLS playlist generation, segment streaming, //! and the short-clip preview pipeline. use crate::error::ApiError; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Mutex; use tracing_opentelemetry::OpenTelemetrySpanExt; use actix_files::NamedFile; use actix_web::{ HttpMessage, HttpRequest, HttpResponse, Responder, body::MessageBody, dev::{ServiceRequest, ServiceResponse}, get, middleware::Next, post, web::{self, Data}, }; use log::{debug, error, info, warn}; use opentelemetry::KeyValue; use opentelemetry::trace::Status; use serde::Serialize; use crate::content_hash; use crate::data::{ Claims, PreviewClipRequest, PreviewStatusItem, PreviewStatusRequest, PreviewStatusResponse, ThumbnailRequest, }; use crate::database::{ExifDao, PreviewDao}; use crate::file_types; use crate::files::is_valid_full_path; use crate::libraries; use crate::state::AppState; use crate::video::actors::{ GeneratePreviewClipMessage, QueueVideosMessage, VideoToQueue, probe_video_stream_meta, }; use crate::video::hls_paths; use crate::video::original_url; /// Response body for `POST /video/generate`. Clients consume /// `playlist_url` (hash-keyed, stable across libraries and renames) /// and poll for readiness via the URL itself. #[derive(Serialize, Debug)] struct GenerateVideoResponse { /// Hash-keyed URL to the HLS playlist. Resolves to /// `$VIDEO_PATH///playlist.m3u8` server-side. Relative /// segment refs inside the playlist resolve correctly because the /// browser appends to this URL's path. playlist_url: String, /// blake3 content hash of the source video. Stable per byte content, /// so duplicate uploads / archive ingests share one set of HLS /// output. content_hash: String, /// `true` iff the playlist file is already on disk. `false` means a /// transcode was queued; clients should retry the URL after a short /// delay (or rely on HLS.js's own retry policy). ready: bool, /// Source-video frame rate in Hz, probed via ffprobe. `None` when the /// probe failed or ffprobe couldn't parse either rate field — clients /// fall back to their own default (typically 30) for frame stepping. #[serde(skip_serializing_if = "Option::is_none")] frame_rate: Option, } #[post("/video/generate")] #[tracing::instrument(name = "generate_video", skip_all)] pub async fn generate_video( _claims: Claims, app_state: Data, exif_dao: Data>>, body: web::Json, ) -> impl Responder { let span = tracing::Span::current(); let resolved = resolve_request_path(&app_state, &body.path, body.library.as_deref()); let Some((resolved_library_id, resolved_root, full_path)) = resolved else { span.set_status(Status::error(format!("invalid path {:?}", &body.path))); return invalid_path().into_response(); }; // Build the rel_path used to look up the row. Forward-slash normalized // so the lookup matches DB rows on Windows — see `rel_path_for_lookup`. let full_path_str = full_path.to_string_lossy().to_string(); let rel_path = rel_path_for_lookup(&full_path_str, &resolved_root); // DB lookup first. Cheap and avoids re-reading the file off disk for // already-ingested videos. let hash_from_db: Option = { let mut dao = exif_dao.lock().expect("Unable to lock ExifDao"); match dao.get_exif_batch(Some(resolved_library_id), std::slice::from_ref(&rel_path)) { Ok(rows) => rows.into_iter().next().and_then(|r| r.content_hash), Err(e) => { warn!( "exif_dao.get_exif_batch failed for {} (lib {}): {:?}", rel_path, resolved_library_id, e ); None } } }; // Best-effort fallback: compute on-the-fly when the DB row hasn't // been written or is mid-backfill. Read-only — no library mutation. let content_hash_str = match hash_from_db { Some(h) => h, None => match content_hash::compute(&full_path) { Ok(id) => id.content_hash, Err(e) => { error!( "Failed to compute content_hash for {}: {}", full_path.display(), e ); span.set_status(Status::error(format!("hash compute failed: {}", e))); return ApiError::internal("internal error").into_response(); } }, }; let video_dir = std::path::Path::new(&app_state.video_path); let playlist_path = hls_paths::playlist_for_hash(video_dir, &content_hash_str); let sentinel_path = hls_paths::sentinel_for_hash(video_dir, &content_hash_str); let ready = playlist_path.exists(); if !ready && !sentinel_path.exists() { // Kick off generation via the existing actor pipeline. Fire-and- // forget — the playlist appears at `playlist_path` once ffmpeg // + rename complete. The client polls the URL. info!( "/video/generate: queueing playlist for {} (hash={})", full_path.display(), &content_hash_str[..content_hash_str.len().min(16)] ); app_state.playlist_manager.do_send(QueueVideosMessage { videos: vec![VideoToQueue { video_path: full_path.clone(), content_hash: content_hash_str.clone(), }], }); span.add_event( "playlist_queued", vec![KeyValue::new("content_hash", content_hash_str.clone())], ); } else if ready { span.add_event( "playlist_already_present", vec![KeyValue::new("content_hash", content_hash_str.clone())], ); } else { // Sentinel present — past transcode attempt failed. Return the // URL anyway (it'll 404 / 5xx at fetch time) so the client gets // a deterministic answer. Operator must delete the sentinel to // force a retry. warn!( "/video/generate: unsupported sentinel present for {} (hash={}); not re-queueing", full_path.display(), &content_hash_str[..content_hash_str.len().min(16)] ); } let playlist_url = format!( "/video/hls/{}/{}", content_hash_str, hls_paths::PLAYLIST_FILENAME ); // Probe the source for frame rate so the mobile scrubber can step at // the right interval. Cheap (~tens of ms) and only runs once per video // open. Probe failures degrade silently — clients have a fallback. let frame_rate = probe_video_stream_meta(&full_path.to_string_lossy()) .await .frame_rate; span.set_status(Status::Ok); HttpResponse::Ok().json(GenerateVideoResponse { playlist_url, content_hash: content_hash_str, ready, frame_rate, }) } /// Serve HLS playlist or segment files under the hash-keyed layout /// `$VIDEO_PATH///`. The matched `{file}` must be /// either `playlist.m3u8` or a `segment_NNN.ts` style segment; any other /// shape is 400'd to defend against operators stashing other content in /// the hash dir. #[get("/video/hls/{hash}/{file}")] #[tracing::instrument(name = "stream_hls_file", skip_all)] pub async fn stream_hls_file( request: HttpRequest, _: Claims, path: web::Path<(String, String)>, app_state: Data, ) -> impl Responder { let span = tracing::Span::current(); let (hash, file) = path.into_inner(); if !is_valid_hash(&hash) { span.set_status(Status::error("invalid hash")); return ApiError::bad_request("invalid_path", "Invalid video stream address") .into_response(); } if !is_allowed_hls_filename(&file) { span.set_status(Status::error("invalid file")); return ApiError::bad_request("invalid_path", "Invalid video stream address") .into_response(); } let shard = &hash[..2]; let file_path = PathBuf::from(&app_state.video_path) .join(shard) .join(&hash) .join(&file); // Path-traversal guard: canonicalize both sides and require the file // to live under `app_state.video_path`. `is_valid_hash` / // `is_allowed_hls_filename` already block dangerous strings, but // belt-and-suspenders here is cheap. let canonical_base = match std::fs::canonicalize(&app_state.video_path) { Ok(p) => p, Err(e) => { error!("Failed to canonicalize VIDEO_PATH: {:?}", e); span.set_status(Status::error("VIDEO_PATH not canonicalisable")); return ApiError::internal("internal error").into_response(); } }; let canonical_file = match std::fs::canonicalize(&file_path) { Ok(p) => p, Err(_) => { debug!("HLS file not found: {}", file_path.display()); span.set_status(Status::error("not found")); return stream_not_found().into_response(); } }; if !canonical_file.starts_with(&canonical_base) { warn!( "Path traversal attempt: {} resolved outside VIDEO_PATH", file_path.display() ); span.set_status(Status::error("traversal")); return ApiError::forbidden("invalid_path", "Invalid video stream address").into_response(); } match NamedFile::open(&canonical_file) { Ok(f) => { span.set_status(Status::Ok); f.into_response(&request) } Err(_) => { span.set_status(Status::error("not found")); stream_not_found().into_response() } } } /// 64 lowercase-or-upper hex chars. Strict so we don't accept arbitrary /// strings that might canonicalize into trouble. fn is_valid_hash(s: &str) -> bool { s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit()) } /// Compute the forward-slash `rel_path` used to look up a video's /// `image_exif` row, from its absolute path string and the library root. /// /// Normalizing to forward slashes is essential on Windows: `file_scan` /// stores rel_paths forward-slash regardless of OS, but a raw strip of a /// backslash Windows path (`Z:\...\pic\album\clip.mp4`) yields /// `album\clip.mp4`. `get_exif_batch` does an exact match with no /// normalization, so the backslash form misses and the handler falls back /// to re-hashing the entire file on every request. /// Response body for `POST /video/original`. #[derive(Serialize, Debug)] struct OriginalVideoUrlResponse { /// `/video/original/stream?token=…` — works without an Authorization /// header, so it can go straight into a native player. url: String, /// Unix seconds. Clients should mint a fresh URL rather than reuse one /// past this. expires_at: i64, } /// Mint a short-lived URL for streaming the original bytes of a video. /// HLS (`/video/generate`) stays the default playback path; this is the /// opt-in full-quality path for clients on a fast network. #[post("/video/original")] #[tracing::instrument(name = "original_video_url", skip_all)] pub async fn original_video_url( claims: Claims, app_state: Data, body: web::Json, ) -> impl Responder { let span = tracing::Span::current(); let Some((library_id, root, full_path)) = resolve_request_path(&app_state, &body.path, body.library.as_deref()) else { span.set_status(Status::error(format!("invalid path {:?}", &body.path))); return invalid_path().into_response(); }; if !file_types::is_video_file(&full_path) { span.set_status(Status::error("not a video")); return ApiError::bad_request("not_a_video", "That file isn't a video").into_response(); } let rel_path = rel_path_for_lookup(&full_path.to_string_lossy(), &root); let (token, expires_at) = original_url::sign(&claims.sub, library_id, &rel_path); span.set_status(Status::Ok); HttpResponse::Ok().json(OriginalVideoUrlResponse { url: format!("{}?token={}", ORIGINAL_STREAM_PATH, token), expires_at, }) } const ORIGINAL_STREAM_PATH: &str = "/video/original/stream"; /// A stream token lifted out of the request URI by /// [`redact_original_stream_token`]. struct OriginalStreamToken(String); /// Moves `token=` off `/video/original/stream` requests into request /// extensions and rewrites the URI to `token=redacted`, so neither the /// access log nor the tracing span's `http.target` (exported to OTel) /// records a credential for a possibly sensitive file. Must be the /// outermost `.wrap()` so it runs before both loggers; without it the /// stream handler finds no token and 401s (fails closed). pub async fn redact_original_stream_token( mut req: ServiceRequest, next: Next, ) -> Result, actix_web::Error> { if req.path() == ORIGINAL_STREAM_PATH && let Some((token, redacted_query)) = take_token_param(req.query_string()) && let Ok(uri) = format!("{}?{}", ORIGINAL_STREAM_PATH, redacted_query).parse() { req.head_mut().uri = uri; req.extensions_mut().insert(OriginalStreamToken(token)); } next.call(req).await } /// Split the first `token=` value out of a query string, returning it and /// the query with that value replaced by `redacted`. fn take_token_param(query: &str) -> Option<(String, String)> { let mut token = None; let redacted: Vec<&str> = query .split('&') .map(|pair| match pair.strip_prefix("token=") { Some(value) if token.is_none() => { token = Some(value.to_string()); "token=redacted" } _ => pair, }) .collect(); token.map(|t| (t, redacted.join("&"))) } /// Stream an original video named by a token from `POST /video/original`. /// Authenticated by the token alone (no Claims), which /// [`redact_original_stream_token`] has already moved out of the URI. /// `NamedFile` honours Range, so players can seek. #[get("/video/original/stream")] #[tracing::instrument(name = "stream_original_video", skip_all)] pub async fn stream_original_video( request: HttpRequest, app_state: Data, ) -> impl Responder { let span = tracing::Span::current(); let claims = request .extensions() .get::() .and_then(|t| original_url::verify(&t.0)); let Some(claims) = claims else { span.set_status(Status::error("bad token")); return ApiError::unauthorized( "stream_link_expired", "This video link has expired. Open the video again.", ) .into_response(); }; let Some(path) = app_state .libraries .iter() .find(|lib| lib.id == claims.lib) .and_then(|lib| is_valid_full_path(&lib.root_path, &claims.path, false)) .filter(|p| p.is_file()) else { span.set_status(Status::error("not found")); return file_not_found().into_response(); }; match NamedFile::open(&path) { Ok(f) => { span.set_status(Status::Ok); f.use_etag(true) .use_last_modified(true) .into_response(&request) } Err(_) => { span.set_status(Status::error("not found")); stream_not_found().into_response() } } } /// Resolve a client `path` + optional `library` to `(library_id, root, /// full_path)`. Tries the requested library first, then falls back to any /// other library that actually contains the file — handles union-mode /// requests where the mobile client passes no library but the file lives /// in a non-primary library. fn resolve_request_path( app_state: &AppState, path: &str, library: Option<&str>, ) -> Option<(i32, String, PathBuf)> { let preferred_library = libraries::resolve_library_param_state(app_state, library) .ok() .flatten() .unwrap_or_else(|| app_state.primary_library()); is_valid_full_path(&preferred_library.root_path, &path.to_string(), false) .filter(|p| p.exists()) .map(|p| (preferred_library.id, preferred_library.root_path.clone(), p)) .or_else(|| { app_state.libraries.iter().find_map(|lib| { if lib.id == preferred_library.id { return None; } is_valid_full_path(&lib.root_path, &path.to_string(), false) .filter(|p| p.exists()) .map(|p| (lib.id, lib.root_path.clone(), p)) }) }) } fn rel_path_for_lookup(full_path_str: &str, resolved_root: &str) -> String { full_path_str .strip_prefix(resolved_root) .unwrap_or(full_path_str) .trim_start_matches(['/', '\\']) .replace('\\', "/") } /// Allowed file names inside a hash dir. `playlist.m3u8` plus segment /// files matching the `segment_NNN.ts` template that `PlaylistGenerator` /// writes via `hls_paths::SEGMENT_TEMPLATE`. Anything else (including /// `.tmp`, `.unsupported`, dotfiles) returns 400 — these are internal /// artifacts the client should never request. fn is_allowed_hls_filename(name: &str) -> bool { if name == hls_paths::PLAYLIST_FILENAME { return true; } if let Some(rest) = name.strip_prefix("segment_") && let Some(num) = rest.strip_suffix(".ts") && !num.is_empty() && num.bytes().all(|b| b.is_ascii_digit()) { return true; } false } #[get("/video/preview")] #[tracing::instrument(name = "get_video_preview", skip_all)] pub async fn get_video_preview( _claims: Claims, request: HttpRequest, req: web::Query, app_state: Data, preview_dao: Data>>, ) -> impl Responder { let span = tracing::Span::current(); // Validate path let full_path = match is_valid_full_path(&app_state.base_path, &req.path, true) { Some(path) => path, None => { span.set_status(Status::error("Invalid path")); return invalid_path().into_response(); } }; let full_path_str = full_path.to_string_lossy().to_string(); // Use relative path (from BASE_PATH) for DB storage, consistent with EXIF convention let relative_path = full_path_str .strip_prefix(&app_state.base_path) .unwrap_or(&full_path_str) .trim_start_matches(['/', '\\']) .to_string(); // Check preview status in DB let preview = { let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao"); dao.get_preview(&relative_path) }; match preview { Ok(Some(clip)) => match clip.status.as_str() { "complete" => { let preview_path = PathBuf::from(&app_state.preview_clips_path) .join(&relative_path) .with_extension("mp4"); match NamedFile::open(&preview_path) { Ok(file) => { span.set_status(Status::Ok); file.into_response(&request) } Err(_) => { // File missing on disk but DB says complete - reset and regenerate let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao"); let _ = dao.update_status(&relative_path, "pending", None, None, None); app_state .preview_clip_generator .do_send(GeneratePreviewClipMessage { video_path: full_path_str, }); span.set_status(Status::Ok); HttpResponse::Accepted().json(serde_json::json!({ "status": "processing", "path": req.path })) } } } "processing" => { span.set_status(Status::Ok); HttpResponse::Accepted().json(serde_json::json!({ "status": "processing", "path": req.path })) } "failed" => { let error_msg = clip .error_message .unwrap_or_else(|| "Unknown error".to_string()); span.set_status(Status::error(format!("Generation failed: {}", error_msg))); ApiError::internal(format!("Generation failed: {}", error_msg)).into_response() } _ => { // pending or unknown status - trigger generation app_state .preview_clip_generator .do_send(GeneratePreviewClipMessage { video_path: full_path_str, }); span.set_status(Status::Ok); HttpResponse::Accepted().json(serde_json::json!({ "status": "processing", "path": req.path })) } }, Ok(None) => { // No record exists - insert as pending and trigger generation { let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao"); let _ = dao.insert_preview(&relative_path, "pending"); } app_state .preview_clip_generator .do_send(GeneratePreviewClipMessage { video_path: full_path_str, }); span.set_status(Status::Ok); HttpResponse::Accepted().json(serde_json::json!({ "status": "processing", "path": req.path })) } Err(_) => { span.set_status(Status::error("Database error")); ApiError::internal("Database error").into_response() } } } #[post("/video/preview/status")] #[tracing::instrument(name = "get_preview_status", skip_all)] pub async fn get_preview_status( _claims: Claims, body: web::Json, app_state: Data, preview_dao: Data>>, ) -> impl Responder { let span = tracing::Span::current(); // Limit to 200 paths per request if body.paths.len() > 200 { span.set_status(Status::error("Too many paths")); return ApiError::bad_request("too_many_files", "At most 200 videos per request") .with_details(serde_json::json!({ "max": 200 })) .into_response(); } let previews = { let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao"); dao.get_previews_batch(&body.paths) }; match previews { Ok(clips) => { // Build a map of file_path -> VideoPreviewClip for quick lookup let clip_map: HashMap = clips .into_iter() .map(|clip| (clip.file_path.clone(), clip)) .collect(); let mut items: Vec = Vec::with_capacity(body.paths.len()); for path in &body.paths { if let Some(clip) = clip_map.get(path) { // Re-queue generation for stale pending/failed records if clip.status == "pending" || clip.status == "failed" { let full_path = format!( "{}/{}", app_state.base_path.trim_end_matches(['/', '\\']), path.trim_start_matches(['/', '\\']) ); app_state .preview_clip_generator .do_send(GeneratePreviewClipMessage { video_path: full_path, }); } items.push(PreviewStatusItem { path: path.clone(), status: clip.status.clone(), preview_url: if clip.status == "complete" { Some(format!("/video/preview?path={}", urlencoding::encode(path))) } else { None }, }); } else { // No record exists — insert as pending and trigger generation { let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao"); let _ = dao.insert_preview(path, "pending"); } // Build full path for ffmpeg (actor needs the absolute path for input) let full_path = format!( "{}/{}", app_state.base_path.trim_end_matches(['/', '\\']), path.trim_start_matches(['/', '\\']) ); info!("Triggering preview generation for '{}'", path); app_state .preview_clip_generator .do_send(GeneratePreviewClipMessage { video_path: full_path, }); items.push(PreviewStatusItem { path: path.clone(), status: "pending".to_string(), preview_url: None, }); } } span.set_status(Status::Ok); HttpResponse::Ok().json(PreviewStatusResponse { previews: items }) } Err(_) => { span.set_status(Status::error("Database error")); ApiError::internal("Database error").into_response() } } } /// The path names no file in the library. fn file_not_found() -> ApiError { ApiError::not_found("file_not_found", "That file doesn't exist") } /// The path isn't a file in the library (400, as it has always been here). fn invalid_path() -> ApiError { ApiError::bad_request("invalid_path", "That file isn't in the library") } /// An HLS playlist/segment that isn't (or is no longer) generated. fn stream_not_found() -> ApiError { ApiError::not_found("stream_not_found", "This video isn't ready to stream yet") } #[cfg(test)] mod tests { use super::*; use crate::data::Claims; use crate::database::PreviewDao; use crate::testhelpers::TestPreviewDao; use actix_web::App; #[test] fn is_valid_hash_requires_64_ascii_hex() { assert!(is_valid_hash(&"a".repeat(64))); assert!(is_valid_hash(&"F".repeat(64))); assert!(is_valid_hash(&format!("ab{}", "0".repeat(62)))); assert!(!is_valid_hash(&"a".repeat(63))); assert!(!is_valid_hash(&"a".repeat(65))); // Anything outside the hex alphabet — including '/', '.', '..' — // is rejected up front so the path-traversal canonicalisation // never has to defend the boundary alone. assert!(!is_valid_hash(&format!("/{}", "a".repeat(63)))); assert!(!is_valid_hash(&format!("..{}", "a".repeat(62)))); assert!(!is_valid_hash(&"g".repeat(64))); } #[test] fn is_allowed_hls_filename_accepts_only_playlist_and_segments() { assert!(is_allowed_hls_filename("playlist.m3u8")); assert!(is_allowed_hls_filename("segment_000.ts")); assert!(is_allowed_hls_filename("segment_999.ts")); assert!(is_allowed_hls_filename("segment_0.ts")); // Internal artifacts the client should never request. assert!(!is_allowed_hls_filename("playlist.m3u8.tmp")); assert!(!is_allowed_hls_filename("playlist.unsupported")); // Traversal / path components — defence in depth alongside // the actix path matcher itself. assert!(!is_allowed_hls_filename("..")); assert!(!is_allowed_hls_filename("../etc/passwd")); assert!(!is_allowed_hls_filename("segment_abc.ts")); assert!(!is_allowed_hls_filename("segment_.ts")); assert!(!is_allowed_hls_filename("")); } #[test] fn rel_path_for_lookup_normalizes_windows_separators() { // Windows: backslash root + backslash full path. The stored row is // forward-slash (`album/clip.mp4`), so without normalization the // lookup misses and the handler re-hashes the whole file. assert_eq!( rel_path_for_lookup(r"Z:\Media\pic\album\clip.mp4", r"Z:\Media\pic"), "album/clip.mp4" ); } #[test] fn rel_path_for_lookup_handles_unix_separators() { assert_eq!( rel_path_for_lookup("/media/pic/album/clip.mp4", "/media/pic"), "album/clip.mp4" ); } #[test] fn rel_path_for_lookup_file_at_root_has_no_separator() { // A file directly in the library root has no internal separator, so // the bug never manifested here — guard against a regression anyway. assert_eq!( rel_path_for_lookup(r"Z:\Media\pic\clip.mp4", r"Z:\Media\pic"), "clip.mp4" ); assert_eq!( rel_path_for_lookup("/media/pic/clip.mp4", "/media/pic"), "clip.mp4" ); } #[test] fn rel_path_for_lookup_strips_leading_separators() { // Both separator styles are trimmed from the front after the root // is stripped, regardless of which form the join produced. assert_eq!( rel_path_for_lookup(r"Z:\Media\pic\sub\a.mp4", r"Z:\Media\pic"), "sub/a.mp4" ); assert_eq!( rel_path_for_lookup("/media/pic//sub/a.mp4", "/media/pic"), "sub/a.mp4" ); } #[test] fn rel_path_for_lookup_falls_back_when_root_does_not_match() { // If the root doesn't prefix the path (e.g. a stale mount), we keep // the whole path but still normalize separators rather than panic. assert_eq!( rel_path_for_lookup(r"D:\other\album\clip.mp4", r"Z:\Media\pic"), "D:/other/album/clip.mp4" ); } fn make_token() -> String { let claims = Claims::valid_user("1".to_string()); jsonwebtoken::encode( &jsonwebtoken::Header::default(), &claims, &jsonwebtoken::EncodingKey::from_secret(b"test_key"), ) .unwrap() } fn write_library_file(app_state: &AppState, rel: &str, bytes: &[u8]) { let path = std::path::Path::new(&app_state.primary_library().root_path).join(rel); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, bytes).unwrap(); } #[actix_rt::test] async fn original_video_url_streams_with_range_and_no_auth_header() { let app_state = Data::new(AppState::test_state()); write_library_file(&app_state, "clips/a.mp4", b"0123456789"); let app = actix_web::test::init_service( App::new() .wrap(actix_web::middleware::from_fn(redact_original_stream_token)) .service(original_video_url) .service(stream_original_video) .app_data(app_state), ) .await; let req = actix_web::test::TestRequest::post() .uri("/video/original") .insert_header(("Authorization", format!("Bearer {}", make_token()))) .set_json(serde_json::json!({"path": "clips/a.mp4"})) .to_request(); let resp = actix_web::test::call_service(&app, req).await; assert_eq!(resp.status(), 200); let body: serde_json::Value = actix_web::test::read_body_json(resp).await; let url = body["url"].as_str().unwrap().to_string(); assert!(url.starts_with("/video/original/stream?token=")); assert!(body["expires_at"].as_i64().unwrap() > chrono::Utc::now().timestamp()); // No Authorization header: the token alone grants access. let req = actix_web::test::TestRequest::get() .uri(&url) .insert_header(("Range", "bytes=2-5")) .to_request(); let resp = actix_web::test::call_service(&app, req).await; assert_eq!(resp.status(), 206); assert_eq!(actix_web::test::read_body(resp).await.as_ref(), b"2345"); } #[actix_rt::test] async fn original_video_url_rejects_non_video_and_unauthenticated() { let app_state = Data::new(AppState::test_state()); write_library_file(&app_state, "notes.txt", b"hi"); let app = actix_web::test::init_service( App::new().service(original_video_url).app_data(app_state), ) .await; let req = actix_web::test::TestRequest::post() .uri("/video/original") .insert_header(("Authorization", format!("Bearer {}", make_token()))) .set_json(serde_json::json!({"path": "notes.txt"})) .to_request(); assert_eq!(actix_web::test::call_service(&app, req).await.status(), 400); let req = actix_web::test::TestRequest::post() .uri("/video/original") .set_json(serde_json::json!({"path": "notes.txt"})) .to_request(); assert_eq!(actix_web::test::call_service(&app, req).await.status(), 401); } #[test] fn take_token_param_redacts_only_the_token() { assert_eq!( take_token_param("token=abc.def.ghi"), Some(("abc.def.ghi".into(), "token=redacted".into())) ); assert_eq!( take_token_param("x=1&token=abc&y=2"), Some(("abc".into(), "x=1&token=redacted&y=2".into())) ); assert_eq!(take_token_param("x=1&tokens=abc"), None); assert_eq!(take_token_param(""), None); } /// What the loggers (and handler) see after the middleware runs. #[get("/video/original/stream")] async fn echo_uri_and_token(request: HttpRequest) -> HttpResponse { let token = request .extensions() .get::() .map(|t| t.0.clone()) .unwrap_or_default(); HttpResponse::Ok().body(format!("{} {}", request.uri(), token)) } #[actix_rt::test] async fn redact_middleware_strips_token_from_uri() { let app = actix_web::test::init_service( App::new() .wrap(actix_web::middleware::from_fn(redact_original_stream_token)) .service(echo_uri_and_token), ) .await; let req = actix_web::test::TestRequest::get() .uri("/video/original/stream?token=secret.jwt.value") .to_request(); let body = actix_web::test::call_and_read_body(&app, req).await; assert_eq!( std::str::from_utf8(&body).unwrap(), "/video/original/stream?token=redacted secret.jwt.value" ); } #[actix_rt::test] async fn stream_original_video_fails_closed_without_middleware() { let app_state = Data::new(AppState::test_state()); write_library_file(&app_state, "clips/a.mp4", b"0123456789"); let app = actix_web::test::init_service( App::new() .service(stream_original_video) .app_data(app_state), ) .await; let (token, _) = original_url::sign("1", crate::libraries::PRIMARY_LIBRARY_ID, "clips/a.mp4"); let req = actix_web::test::TestRequest::get() .uri(&format!("/video/original/stream?token={}", token)) .to_request(); assert_eq!(actix_web::test::call_service(&app, req).await.status(), 401); } #[actix_rt::test] async fn stream_original_video_rejects_bad_tokens() { let app_state = Data::new(AppState::test_state()); write_library_file(&app_state, "clips/a.mp4", b"0123456789"); let app = actix_web::test::init_service( App::new() .wrap(actix_web::middleware::from_fn(redact_original_stream_token)) .service(stream_original_video) .app_data(app_state), ) .await; // A login JWT is not a stream token. let req = actix_web::test::TestRequest::get() .uri(&format!("/video/original/stream?token={}", make_token())) .to_request(); assert_eq!(actix_web::test::call_service(&app, req).await.status(), 401); // A valid token for a path that escapes the library root. let (token, _) = original_url::sign( "1", crate::libraries::PRIMARY_LIBRARY_ID, "../../etc/passwd", ); let req = actix_web::test::TestRequest::get() .uri(&format!("/video/original/stream?token={}", token)) .to_request(); assert_eq!(actix_web::test::call_service(&app, req).await.status(), 404); } fn make_preview_dao(dao: TestPreviewDao) -> Data>> { Data::new(Mutex::new(Box::new(dao) as Box)) } #[actix_rt::test] async fn test_get_preview_status_returns_pending_for_unknown() { let dao = TestPreviewDao::new(); let preview_dao = make_preview_dao(dao); let app_state = Data::new(AppState::test_state()); let token = make_token(); let app = actix_web::test::init_service( App::new() .service(get_preview_status) .app_data(app_state) .app_data(preview_dao.clone()), ) .await; let req = actix_web::test::TestRequest::post() .uri("/video/preview/status") .insert_header(("Authorization", format!("Bearer {}", token))) .set_json(serde_json::json!({"paths": ["photos/new_video.mp4"]})) .to_request(); let resp = actix_web::test::call_service(&app, req).await; assert_eq!(resp.status(), 200); let body: serde_json::Value = actix_web::test::read_body_json(resp).await; let previews = body["previews"].as_array().unwrap(); assert_eq!(previews.len(), 1); assert_eq!(previews[0]["status"], "pending"); // Verify the DAO now has a pending record let mut dao_lock = preview_dao.lock().unwrap(); let clip = dao_lock.get_preview("photos/new_video.mp4").unwrap(); assert!(clip.is_some()); assert_eq!(clip.unwrap().status, "pending"); } #[actix_rt::test] async fn test_get_preview_status_returns_complete_with_url() { let mut dao = TestPreviewDao::new(); dao.insert_preview("photos/done.mp4", "pending").unwrap(); dao.update_status("photos/done.mp4", "complete", Some(9.5), Some(500000), None) .unwrap(); let preview_dao = make_preview_dao(dao); let app_state = Data::new(AppState::test_state()); let token = make_token(); let app = actix_web::test::init_service( App::new() .service(get_preview_status) .app_data(app_state) .app_data(preview_dao), ) .await; let req = actix_web::test::TestRequest::post() .uri("/video/preview/status") .insert_header(("Authorization", format!("Bearer {}", token))) .set_json(serde_json::json!({"paths": ["photos/done.mp4"]})) .to_request(); let resp = actix_web::test::call_service(&app, req).await; assert_eq!(resp.status(), 200); let body: serde_json::Value = actix_web::test::read_body_json(resp).await; let previews = body["previews"].as_array().unwrap(); assert_eq!(previews.len(), 1); assert_eq!(previews[0]["status"], "complete"); assert!( previews[0]["preview_url"] .as_str() .unwrap() .contains("photos%2Fdone.mp4") ); } #[actix_rt::test] async fn test_get_preview_status_rejects_over_200_paths() { let dao = TestPreviewDao::new(); let preview_dao = make_preview_dao(dao); let app_state = Data::new(AppState::test_state()); let token = make_token(); let app = actix_web::test::init_service( App::new() .service(get_preview_status) .app_data(app_state) .app_data(preview_dao), ) .await; let paths: Vec = (0..201).map(|i| format!("video_{}.mp4", i)).collect(); let req = actix_web::test::TestRequest::post() .uri("/video/preview/status") .insert_header(("Authorization", format!("Bearer {}", token))) .set_json(serde_json::json!({"paths": paths})) .to_request(); let resp = actix_web::test::call_service(&app, req).await; assert_eq!(resp.status(), 400); } #[actix_rt::test] async fn test_get_preview_status_mixed_statuses() { let mut dao = TestPreviewDao::new(); dao.insert_preview("a.mp4", "pending").unwrap(); dao.insert_preview("b.mp4", "pending").unwrap(); dao.update_status("b.mp4", "complete", Some(10.0), Some(100000), None) .unwrap(); let preview_dao = make_preview_dao(dao); let app_state = Data::new(AppState::test_state()); let token = make_token(); let app = actix_web::test::init_service( App::new() .service(get_preview_status) .app_data(app_state) .app_data(preview_dao), ) .await; let req = actix_web::test::TestRequest::post() .uri("/video/preview/status") .insert_header(("Authorization", format!("Bearer {}", token))) .set_json(serde_json::json!({"paths": ["a.mp4", "b.mp4", "c.mp4"]})) .to_request(); let resp = actix_web::test::call_service(&app, req).await; assert_eq!(resp.status(), 200); let body: serde_json::Value = actix_web::test::read_body_json(resp).await; let previews = body["previews"].as_array().unwrap(); assert_eq!(previews.len(), 3); // a.mp4 is pending assert_eq!(previews[0]["path"], "a.mp4"); assert_eq!(previews[0]["status"], "pending"); // b.mp4 is complete with URL assert_eq!(previews[1]["path"], "b.mp4"); assert_eq!(previews[1]["status"], "complete"); assert!(previews[1]["preview_url"].is_string()); // c.mp4 was not found — handler inserts pending assert_eq!(previews[2]["path"], "c.mp4"); assert_eq!(previews[2]["status"], "pending"); } /// Verifies that the status endpoint re-queues generation for stale /// "pending" and "failed" records (e.g., after a server restart or /// when clip files were deleted). The do_send to the actor exercises /// the re-queue code path; the actor runs against temp dirs so it /// won't panic. #[actix_rt::test] async fn test_get_preview_status_requeues_pending_and_failed() { let mut dao = TestPreviewDao::new(); // Simulate stale records left from a previous server run dao.insert_preview("stale/pending.mp4", "pending").unwrap(); dao.insert_preview("stale/failed.mp4", "pending").unwrap(); dao.update_status( "stale/failed.mp4", "failed", None, None, Some("ffmpeg error"), ) .unwrap(); let preview_dao = make_preview_dao(dao); let app_state = Data::new(AppState::test_state()); let token = make_token(); let app = actix_web::test::init_service( App::new() .service(get_preview_status) .app_data(app_state) .app_data(preview_dao), ) .await; let req = actix_web::test::TestRequest::post() .uri("/video/preview/status") .insert_header(("Authorization", format!("Bearer {}", token))) .set_json(serde_json::json!({ "paths": ["stale/pending.mp4", "stale/failed.mp4"] })) .to_request(); let resp = actix_web::test::call_service(&app, req).await; assert_eq!(resp.status(), 200); let body: serde_json::Value = actix_web::test::read_body_json(resp).await; let previews = body["previews"].as_array().unwrap(); assert_eq!(previews.len(), 2); // Both records are returned with their current status assert_eq!(previews[0]["path"], "stale/pending.mp4"); assert_eq!(previews[0]["status"], "pending"); assert!(previews[0].get("preview_url").is_none()); assert_eq!(previews[1]["path"], "stale/failed.mp4"); assert_eq!(previews[1]["status"], "failed"); assert!(previews[1].get("preview_url").is_none()); } }