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:
1 parent
4745a945fe
commit
40c33c41a6
5 files changed
+345
-29
No files matched your search
@@ -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/<hash>/<file> (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
|
||||
|
||||
+216
-26
@@ -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>))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Short-lived signed URLs for streaming an original video file.
|
||||
//!
|
||||
//! Native players (`<video src>`, the mobile player's direct-URL mode) can't
|
||||
//! attach an `Authorization` header. `POST /video/original` (authenticated)
|
||||
//! mints a URL whose token names one library + rel_path and an expiry;
|
||||
//! `GET /video/original/stream?token=` accepts only that token, so a leaked
|
||||
//! URL grants a single file for a few hours rather than the whole API.
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use log::debug;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::data::secret_key;
|
||||
|
||||
/// Long enough to watch (and seek around) a long video in one sitting;
|
||||
/// players keep issuing Range requests against the same URL the whole time.
|
||||
pub const ORIGINAL_URL_TTL_HOURS: i64 = 6;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct OriginalVideoClaims {
|
||||
pub sub: String,
|
||||
pub lib: i32,
|
||||
pub path: String,
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
/// Derived from SECRET_KEY but distinct from the login-token key, so a
|
||||
/// login JWT can't be replayed as a stream token or the reverse.
|
||||
fn signing_key() -> Vec<u8> {
|
||||
format!("{}:video-original", secret_key()).into_bytes()
|
||||
}
|
||||
|
||||
/// Returns the token and its expiry (unix seconds).
|
||||
pub fn sign(sub: &str, library_id: i32, rel_path: &str) -> (String, i64) {
|
||||
let exp = (Utc::now() + Duration::hours(ORIGINAL_URL_TTL_HOURS)).timestamp();
|
||||
let claims = OriginalVideoClaims {
|
||||
sub: sub.to_string(),
|
||||
lib: library_id,
|
||||
path: rel_path.to_string(),
|
||||
exp,
|
||||
};
|
||||
let token = encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(&signing_key()),
|
||||
)
|
||||
.expect("HS256 encoding of plain claims cannot fail");
|
||||
(token, exp)
|
||||
}
|
||||
|
||||
pub fn verify(token: &str) -> Option<OriginalVideoClaims> {
|
||||
decode::<OriginalVideoClaims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(&signing_key()),
|
||||
&Validation::new(Algorithm::HS256),
|
||||
)
|
||||
.map(|data| data.claims)
|
||||
.map_err(|e| debug!("Rejected original-video token: {}", e))
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::data::Claims;
|
||||
use std::str::FromStr;
|
||||
|
||||
#[test]
|
||||
fn round_trips_library_and_path() {
|
||||
let (token, exp) = sign("1", 3, "2024/clip.mp4");
|
||||
let claims = verify(&token).expect("fresh token verifies");
|
||||
assert_eq!(claims.lib, 3);
|
||||
assert_eq!(claims.path, "2024/clip.mp4");
|
||||
assert_eq!(claims.exp, exp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tampered_token() {
|
||||
let (token, _) = sign("1", 3, "2024/clip.mp4");
|
||||
let mut parts: Vec<&str> = token.split('.').collect();
|
||||
let (forged, _) = sign("1", 3, "2024/other.mp4");
|
||||
// Swap in another token's payload under the original signature.
|
||||
let forged_payload = forged.split('.').nth(1).unwrap().to_string();
|
||||
parts[1] = &forged_payload;
|
||||
assert!(verify(&parts.join(".")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_expired_token() {
|
||||
let claims = OriginalVideoClaims {
|
||||
sub: "1".into(),
|
||||
lib: 1,
|
||||
path: "clip.mp4".into(),
|
||||
exp: (Utc::now() - Duration::hours(1)).timestamp(),
|
||||
};
|
||||
let token = encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(&signing_key()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(verify(&token).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_not_interchangeable_with_login_tokens() {
|
||||
let login = encode(
|
||||
&Header::default(),
|
||||
&Claims::valid_user("1".into()),
|
||||
&EncodingKey::from_secret(secret_key().as_bytes()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(verify(&login).is_none());
|
||||
|
||||
let (stream_token, _) = sign("1", 1, "clip.mp4");
|
||||
assert!(Claims::from_str(&format!("Bearer {}", stream_token)).is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user