Added video preview and http range support
Build and Publish / Build and Publish Docker Image (push) Successful in 5m11s

This commit is contained in:
Ultradesu
2026-08-08 10:19:47 +01:00
parent 1bee7a7940
commit f7a89b431d
8 changed files with 253 additions and 58 deletions
+22 -20
View File
@@ -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",
+23 -21
View File
@@ -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<Response> {
@@ -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",
+146
View File
@@ -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<Vec<u8>> {
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::<u64>() 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::<u64>() 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::<u64>() 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<Response> {
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::<u8>::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
));
}
}