diff --git a/Cargo.lock b/Cargo.lock index fc4b4a5..ba6605d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3466,7 +3466,7 @@ dependencies = [ [[package]] name = "web-petting" -version = "1.0.0" +version = "1.0.1" dependencies = [ "base64", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 7ff6c1f..ef1f9c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "web-petting" -version = "1.0.1" +version = "1.0.2" edition = "2024" [dependencies] diff --git a/src/admin.rs b/src/admin.rs index c3f3d96..8adf93d 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -2285,26 +2285,28 @@ async fn serve_upload( None => return Html::new("404").into_response(), }; - match crate::uploads::read_db_file(&media.file_path).await { - Ok(data) => { - let content_type = match media.file_path.rsplit('.').next().unwrap_or("") { - "jpg" | "jpeg" => "image/jpeg", - "png" => "image/png", - "heic" | "heif" => "image/heic", - "webp" => "image/webp", - "mp4" => "video/mp4", - "mov" => "video/quicktime", - "avi" => "video/x-msvideo", - "mkv" => "video/x-matroska", - "webm" => "video/webm", - _ => "application/octet-stream", - }; - let body = cot::Body::fixed(data); - let mut resp = Response::new(body); - resp.headers_mut() - .insert("content-type", content_type.parse().unwrap()); - Ok(resp) - } + let range = request + .headers() + .get("range") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + + match { + let content_type = match media.file_path.rsplit('.').next().unwrap_or("") { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "heic" | "heif" => "image/heic", + "webp" => "image/webp", + "mp4" => "video/mp4", + "mov" => "video/quicktime", + "avi" => "video/x-msvideo", + "mkv" => "video/x-matroska", + "webm" => "video/webm", + _ => "application/octet-stream", + }; + crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await + } { + Ok(response) => Ok(response), Err(err) => { tracing::warn!( target: "uploads", diff --git a/src/public.rs b/src/public.rs index 73a02fa..a83aa3e 100644 --- a/src/public.rs +++ b/src/public.rs @@ -370,7 +370,7 @@ async fn submit_feedback( /// Serve media files for the client portal (no auth required, but only via token). async fn portal_media( - _request: Request, + request: Request, db: Database, Path((token, media_id)): Path<(String, i64)>, ) -> cot::Result { @@ -394,26 +394,28 @@ async fn portal_media( } } - match crate::uploads::read_db_file(&media.file_path).await { - Ok(data) => { - let content_type = match media.file_path.rsplit('.').next().unwrap_or("") { - "jpg" | "jpeg" => "image/jpeg", - "png" => "image/png", - "heic" | "heif" => "image/heic", - "webp" => "image/webp", - "mp4" => "video/mp4", - "mov" => "video/quicktime", - "avi" => "video/x-msvideo", - "mkv" => "video/x-matroska", - "webm" => "video/webm", - _ => "application/octet-stream", - }; - let body = cot::Body::fixed(data); - let mut resp = Response::new(body); - resp.headers_mut() - .insert("content-type", content_type.parse().unwrap()); - Ok(resp) - } + let range = request + .headers() + .get("range") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + + match { + let content_type = match media.file_path.rsplit('.').next().unwrap_or("") { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "heic" | "heif" => "image/heic", + "webp" => "image/webp", + "mp4" => "video/mp4", + "mov" => "video/quicktime", + "avi" => "video/x-msvideo", + "mkv" => "video/x-matroska", + "webm" => "video/webm", + _ => "application/octet-stream", + }; + crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await + } { + Ok(response) => Ok(response), Err(err) => { tracing::warn!( target: "uploads", diff --git a/src/uploads.rs b/src/uploads.rs index cddb65d..12bf609 100644 --- a/src/uploads.rs +++ b/src/uploads.rs @@ -1,5 +1,9 @@ use std::path::{Path, PathBuf}; +use cot::response::Response; +use cot::{Body, StatusCode}; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + const DEFAULT_UPLOAD_DIR: &str = "uploads"; const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR"; @@ -53,6 +57,148 @@ pub async fn read_db_file(db_path: &str) -> std::io::Result> { tokio::fs::read(resolve_db_path(db_path)).await } +enum ByteRange { + Full, + Partial { start: u64, end: u64 }, + Unsatisfiable, +} + +fn parse_byte_range(header: Option<&str>, file_len: u64) -> ByteRange { + let Some(value) = header else { + return ByteRange::Full; + }; + let Some(spec) = value.strip_prefix("bytes=") else { + return ByteRange::Unsatisfiable; + }; + if spec.contains(',') || file_len == 0 { + return ByteRange::Unsatisfiable; + } + + let Some((start, end)) = spec.split_once('-') else { + return ByteRange::Unsatisfiable; + }; + if start.is_empty() { + let Ok(suffix_len) = end.parse::() else { + return ByteRange::Unsatisfiable; + }; + if suffix_len == 0 { + return ByteRange::Unsatisfiable; + } + let start = file_len.saturating_sub(suffix_len); + return ByteRange::Partial { + start, + end: file_len - 1, + }; + } + + let Ok(start) = start.parse::() else { + return ByteRange::Unsatisfiable; + }; + if start >= file_len { + return ByteRange::Unsatisfiable; + } + let end = if end.is_empty() { + file_len - 1 + } else { + let Ok(end) = end.parse::() else { + return ByteRange::Unsatisfiable; + }; + end.min(file_len - 1) + }; + if end < start { + return ByteRange::Unsatisfiable; + } + + ByteRange::Partial { start, end } +} + +/// Read a file into an HTTP response, honoring a single `Range: bytes=...` request. +pub async fn ranged_file_response( + db_path: &str, + content_type: &str, + range_header: Option<&str>, +) -> std::io::Result { + let path = resolve_db_path(db_path); + let mut file = tokio::fs::File::open(path).await?; + let file_len = file.metadata().await?.len(); + let range = parse_byte_range(range_header, file_len); + + let (status, body, content_range) = match range { + ByteRange::Full => { + let mut data = Vec::with_capacity(file_len as usize); + file.read_to_end(&mut data).await?; + (StatusCode::OK, data, None) + } + ByteRange::Partial { start, end } => { + let range_len = end - start + 1; + let mut data = vec![0; range_len as usize]; + file.seek(std::io::SeekFrom::Start(start)).await?; + file.read_exact(&mut data).await?; + ( + StatusCode::PARTIAL_CONTENT, + data, + Some(format!("bytes {start}-{end}/{file_len}")), + ) + } + ByteRange::Unsatisfiable => { + let mut response = Response::new(Body::fixed(Vec::::new())); + *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; + response + .headers_mut() + .insert("accept-ranges", "bytes".parse().unwrap()); + response.headers_mut().insert( + "content-range", + format!("bytes */{file_len}").parse().unwrap(), + ); + return Ok(response); + } + }; + + let content_len = body.len(); + let mut response = Response::new(Body::fixed(body)); + *response.status_mut() = status; + response + .headers_mut() + .insert("content-type", content_type.parse().unwrap()); + response + .headers_mut() + .insert("accept-ranges", "bytes".parse().unwrap()); + response + .headers_mut() + .insert("content-length", content_len.to_string().parse().unwrap()); + if let Some(content_range) = content_range { + response + .headers_mut() + .insert("content-range", content_range.parse().unwrap()); + } + Ok(response) +} + pub async fn remove_db_file(db_path: &str) -> std::io::Result<()> { tokio::fs::remove_file(resolve_db_path(db_path)).await } + +#[cfg(test)] +mod tests { + use super::{ByteRange, parse_byte_range}; + + #[test] + fn parses_byte_ranges() { + assert!(matches!( + parse_byte_range(Some("bytes=10-19"), 100), + ByteRange::Partial { start: 10, end: 19 } + )); + assert!(matches!( + parse_byte_range(Some("bytes=90-"), 100), + ByteRange::Partial { start: 90, end: 99 } + )); + assert!(matches!( + parse_byte_range(Some("bytes=-10"), 100), + ByteRange::Partial { start: 90, end: 99 } + )); + assert!(matches!( + parse_byte_range(Some("bytes=100-"), 100), + ByteRange::Unsatisfiable + )); + } +} diff --git a/templates/admin/media.html b/templates/admin/media.html index fd72368..964a1ca 100644 --- a/templates/admin/media.html +++ b/templates/admin/media.html @@ -32,7 +32,10 @@ {% else %} -
🎬
+
+ + â–¶ +
{% endif %}
@@ -73,13 +76,27 @@ display: block; } .media-card .video-thumb { + position: relative; width: 100%; height: 160px; - display: flex; - align-items: center; - justify-content: center; - font-size: 3rem; - background: #f0f0f0; + background: #111; + } + .media-card .video-thumb video { + width: 100%; + height: 100%; + display: block; + object-fit: cover; + } + .media-card .video-play { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-size: 2.5rem; + line-height: 1; + text-shadow: 0 1px 5px #000; + pointer-events: none; } .media-info { padding: 0.6rem 0.75rem; diff --git a/templates/admin/schedule_edit.html b/templates/admin/schedule_edit.html index 057da29..e51b190 100644 --- a/templates/admin/schedule_edit.html +++ b/templates/admin/schedule_edit.html @@ -118,7 +118,10 @@ {% else %} -
🎬
+
+ + â–¶ +
{% endif %} {% if let Some(cap) = m.caption.as_deref() %} @@ -236,13 +239,27 @@ display: block; } .visit-media-item .video-thumb-sm { + position: relative; width: 100%; height: 80px; - display: flex; - align-items: center; - justify-content: center; - font-size: 2rem; - background: #f0f0f0; + background: #111; + } + .visit-media-item .video-thumb-sm video { + width: 100%; + height: 100%; + display: block; + object-fit: cover; + } + .visit-media-item .video-play { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-size: 1.6rem; + line-height: 1; + text-shadow: 0 1px 4px #000; + pointer-events: none; } .visit-media-item .media-cap { font-size: 0.7rem; diff --git a/templates/client_portal.html b/templates/client_portal.html index e4a4ea3..775b58a 100644 --- a/templates/client_portal.html +++ b/templates/client_portal.html @@ -52,8 +52,16 @@ width: 80px; height: 60px; object-fit: cover; border-radius: 6px; } .media-row .vid-thumb { - width: 80px; height: 60px; border-radius: 6px; background: #f0f0f0; - display: flex; align-items: center; justify-content: center; font-size: 1.5rem; + position: relative; width: 80px; height: 60px; border-radius: 6px; + overflow: hidden; background: #111; + } + .media-row .vid-thumb video { + width: 100%; height: 100%; display: block; object-fit: cover; + } + .media-row .video-play { + position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); + color: white; font-size: 1.35rem; line-height: 1; + text-shadow: 0 1px 4px #000; pointer-events: none; } .feedback-form { margin-top: 0.6rem; } .feedback-form textarea { @@ -152,7 +160,10 @@ {% else %} -
🎬
+
+ + â–¶ +
{% endif %} {% endfor %}