feat(video): short-lived signed URLs for original-quality playback

POST /video/original mints a URL for one video's original bytes, signed
with a key derived from SECRET_KEY (distinct from the login-token key)
and valid for 6 hours. GET /video/original/stream?token= serves it via
NamedFile, so Range/seek works, without an Authorization header — native
players can use it directly. HLS stays the default path.

Also extracts generate_video's library fallback into resolve_request_path
and fixes the stale /video/stream entry in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron CordesandClaude Opus 5.5 committed 2026-10-13 20:42:28 -04:00
1 parent 4745a945fe
commit 40c33c41a6
5 files changed
+345 -29

No files matched your search

+216 -26
View File
@@ -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<AppState>,
body: web::Json<ThumbnailRequest>,
) -> 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<OriginalStreamQuery>,
app_state: Data<AppState>,
) -> 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<Mutex<Box<dyn PreviewDao>>> {
Data::new(Mutex::new(Box::new(dao) as Box<dyn PreviewDao>))
}