Merge pull request 'feat(video): short-lived signed URLs for original-quality playback' (#168) from feat/signed-original-video into master

Reviewed-on: #168
This commit was merged in pull request #168.
This commit is contained in:
cameron committed 2026-10-14 01:55:34 +00:00
commit 40d9dcdbb6
5 files changed
+345 -29

No files matched your search

+7 -3
View File
@@ -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
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>))
}
+2
View File
@@ -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)
+1
View File
@@ -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() {
+119
View File
@@ -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());
}
}