diff --git a/CLAUDE.md b/CLAUDE.md index 72d25b6..e3edd51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -525,9 +525,13 @@ GET /photos?path=...&recursive=true&sort=DateTakenDesc&camera_make=Canon&gps_lat // ExifDao::query_by_exif — shared by /photos, /photos/exif and unified search. // A calendar-day range = that day's bounds in the owner's display zone. -// Video streaming (HLS) -POST /video/generate (creates .m3u8 playlist + .ts segments) -GET /video/stream?path=... (serves playlist) +// Video streaming (HLS, the default playback path) +POST /video/generate (queues .m3u8 playlist + .ts segments, returns playlist_url) +GET /video/hls// (serves playlist / segments) + +// Original-quality video (opt-in; short-lived signed URL, no auth header needed) +POST /video/original (body: {path, library}) -> {url, expires_at} +GET /video/original/stream?token=... (Range-capable original bytes) // Tags GET /image/tags/all diff --git a/src/handlers/video.rs b/src/handlers/video.rs index ec14880..4506c12 100644 --- a/src/handlers/video.rs +++ b/src/handlers/video.rs @@ -14,7 +14,7 @@ use actix_web::{ use log::{debug, error, info, warn}; use opentelemetry::KeyValue; use opentelemetry::trace::Status; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::content_hash; use crate::data::{ @@ -22,6 +22,7 @@ use crate::data::{ ThumbnailRequest, }; use crate::database::{ExifDao, PreviewDao}; +use crate::file_types; use crate::files::is_valid_full_path; use crate::libraries; use crate::state::AppState; @@ -29,6 +30,7 @@ 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) @@ -65,31 +67,7 @@ pub async fn generate_video( ) -> impl Responder { let span = tracing::Span::current(); - let preferred_library = - libraries::resolve_library_param_state(&app_state, body.library.as_deref()) - .ok() - .flatten() - .unwrap_or_else(|| app_state.primary_library()); - - // Try the resolved library first, then fall 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. Track which library won so the DB lookup is - // scoped correctly. - let resolved = is_valid_full_path(&preferred_library.root_path, &body.path, 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, &body.path, false) - .filter(|p| p.exists()) - .map(|p| (lib.id, lib.root_path.clone(), p)) - }) - }); - + 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 HttpResponse::BadRequest().finish(); @@ -284,6 +262,126 @@ fn is_valid_hash(s: &str) -> bool { /// `Melissa\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 HttpResponse::BadRequest().finish(); + }; + if !file_types::is_video_file(&full_path) { + span.set_status(Status::error("not a video")); + return HttpResponse::BadRequest().body("not a video"); + } + + 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!("/video/original/stream?token={}", token), + expires_at, + }) +} + +#[derive(Deserialize)] +struct OriginalStreamQuery { + token: String, +} + +/// Stream an original video named by a token from `POST /video/original`. +/// Authenticated by the token alone (no Claims). `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, + query: web::Query, + app_state: Data, +) -> impl Responder { + let span = tracing::Span::current(); + + let Some(claims) = original_url::verify(&query.token) else { + span.set_status(Status::error("bad token")); + return HttpResponse::Unauthorized().finish(); + }; + 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 HttpResponse::NotFound().finish(); + }; + + 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")); + HttpResponse::NotFound().finish() + } + } +} + +/// 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) @@ -635,6 +733,98 @@ mod tests { .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() + .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); + } + + #[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() + .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)) } diff --git a/src/main.rs b/src/main.rs index 9203d65..9b89ed3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -352,6 +352,8 @@ fn main() -> std::io::Result<()> { .service(handlers::image::upload_image) .service(handlers::video::generate_video) .service(handlers::video::stream_hls_file) + .service(handlers::video::original_video_url) + .service(handlers::video::stream_original_video) .service(handlers::video::get_video_preview) .service(handlers::video::get_preview_status) .service(hls_stats::hls_stats_handler) diff --git a/src/video/mod.rs b/src/video/mod.rs index 0a4a072..380b179 100644 --- a/src/video/mod.rs +++ b/src/video/mod.rs @@ -10,6 +10,7 @@ pub mod actors; pub mod ffmpeg; pub mod hls_paths; pub mod legacy_migration; +pub mod original_url; #[allow(dead_code)] pub async fn generate_video_gifs() { diff --git a/src/video/original_url.rs b/src/video/original_url.rs new file mode 100644 index 0000000..1627ef7 --- /dev/null +++ b/src/video/original_url.rs @@ -0,0 +1,119 @@ +//! Short-lived signed URLs for streaming an original video file. +//! +//! Native players (`