feat: range requests

This commit is contained in:
kieran 2024-12-16 10:39:28 +00:00
parent 4aa51fa41f
commit 021dc59382
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
3 changed files with 261 additions and 30 deletions

View File

@ -15,7 +15,7 @@ path = "src/bin/main.rs"
name = "route96" name = "route96"
[features] [features]
default = ["nip96", "blossom", "analytics"] default = ["nip96", "blossom", "analytics", "ranges"]
media-compression = ["dep:ffmpeg-rs-raw", "dep:libc"] media-compression = ["dep:ffmpeg-rs-raw", "dep:libc"]
labels = ["nip96", "dep:candle-core", "dep:candle-nn", "dep:candle-transformers"] labels = ["nip96", "dep:candle-core", "dep:candle-nn", "dep:candle-transformers"]
nip96 = ["media-compression"] nip96 = ["media-compression"]
@ -24,6 +24,7 @@ bin-void-cat-migrate = ["dep:sqlx-postgres"]
torrent-v2 = [] torrent-v2 = []
analytics = [] analytics = []
void-cat-redirects = ["dep:sqlx-postgres"] void-cat-redirects = ["dep:sqlx-postgres"]
ranges = ["dep:http-range-header"]
[dependencies] [dependencies]
log = "0.4.21" log = "0.4.21"
@ -44,6 +45,7 @@ url = "2.5.0"
serde_with = { version = "3.8.1", features = ["hex"] } serde_with = { version = "3.8.1", features = ["hex"] }
reqwest = "0.12.8" reqwest = "0.12.8"
clap = { version = "4.5.18", features = ["derive"] } clap = { version = "4.5.18", features = ["derive"] }
mime2ext = "0.1.53"
libc = { version = "0.2.153", optional = true } libc = { version = "0.2.153", optional = true }
ffmpeg-rs-raw = { git = "https://git.v0l.io/Kieran/ffmpeg-rs-raw.git", rev = "b358b3e4209da827e021d979c7d35876594d0285", optional = true } ffmpeg-rs-raw = { git = "https://git.v0l.io/Kieran/ffmpeg-rs-raw.git", rev = "b358b3e4209da827e021d979c7d35876594d0285", optional = true }
@ -51,5 +53,5 @@ candle-core = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1"
candle-nn = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true } candle-nn = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true }
candle-transformers = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true } candle-transformers = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true }
sqlx-postgres = { version = "0.8.2", optional = true, features = ["chrono", "uuid"] } sqlx-postgres = { version = "0.8.2", optional = true, features = ["chrono", "uuid"] }
mime2ext = "0.1.53" http-range-header = { version = "0.4.2", optional = true }
http-range-header = "0.4.2"

184
index.html Normal file
View File

@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>route96</title>
<style>
html {
background-color: black;
color: white;
font-size: 15px;
font-weight: 400;
font-family: Arial, serif;
}
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.gap-2 {
gap: 0.5rem;
}
</style>
<script>
async function dumpToLog(rsp) {
console.debug(rsp);
const text = await rsp.text();
if (rsp.ok) {
document.querySelector("#log").append(JSON.stringify(JSON.parse(text), undefined, 2));
} else {
document.querySelector("#log").append(text);
}
document.querySelector("#log").append("\n");
}
async function listFiles() {
try {
const auth_event = await window.nostr.signEvent({
kind: 27235,
created_at: Math.floor(new Date().getTime() / 1000),
content: "",
tags: [
["u", `${window.location.protocol}//${window.location.host}/n96`],
["method", "GET"]
]
});
const rsp = await fetch("/n96?page=0&count=100", {
method: "GET",
headers: {
accept: "application/json",
authorization: `Nostr ${btoa(JSON.stringify(auth_event))}`,
},
});
await dumpToLog(rsp);
} catch (e) {
}
}
async function uploadFiles(e) {
try {
const input = document.querySelector("#file");
const file = input.files[0];
console.debug(file);
const r_nip96 = document.querySelector("#method-nip96").checked;
const r_blossom = document.querySelector("#method-blossom").checked;
if (r_nip96) {
await uploadFilesNip96(file)
} else if (r_blossom) {
await uploadBlossom(file);
}
} catch (ex) {
if (ex instanceof Error) {
alert(ex.message);
}
}
}
function buf2hex(buffer) { // buffer is an ArrayBuffer
return [...new Uint8Array(buffer)]
.map(x => x.toString(16).padStart(2, '0'))
.join('');
}
async function uploadBlossom(file) {
const hash = await window.crypto.subtle.digest("SHA-256", await file.arrayBuffer());
const now = Math.floor(new Date().getTime() / 1000);
const auth_event = await window.nostr.signEvent({
kind: 24242,
created_at: now,
content: `Upload ${file.name}`,
tags: [
["t", "upload"],
["u", `${window.location.protocol}//${window.location.host}/upload`],
["x", buf2hex(hash)],
["method", "PUT"],
["expiration", (now + 10).toString()]
]
});
const rsp = await fetch("/upload", {
body: file,
method: "PUT",
headers: {
accept: "application/json",
authorization: `Nostr ${btoa(JSON.stringify(auth_event))}`,
},
});
await dumpToLog(rsp);
}
async function uploadFilesNip96(file) {
const fd = new FormData();
fd.append("size", file.size.toString());
fd.append("caption", file.name);
fd.append("media_type", file.type);
fd.append("file", file);
fd.append("no_transform", document.querySelector("#no_transform").checked.toString())
const auth_event = await window.nostr.signEvent({
kind: 27235,
created_at: Math.floor(new Date().getTime() / 1000),
content: "",
tags: [
["u", `${window.location.protocol}//${window.location.host}/n96`],
["method", "POST"]
]
});
const rsp = await fetch("/n96", {
body: fd,
method: "POST",
headers: {
accept: "application/json",
authorization: `Nostr ${btoa(JSON.stringify(auth_event))}`,
},
});
await dumpToLog(rsp);
}
</script>
</head>
<body>
<h1>
Welcome to route96
</h1>
<div class="flex flex-col gap-2">
<div>
<label>
NIP-96
<input type="radio" name="method" id="method-nip96"/>
</label>
<label>
Blossom
<input type="radio" name="method" id="method-blossom"/>
</label>
</div>
<div style="color: #ff8383;">
You must have a nostr extension for this to work
</div>
<input type="file" id="file">
<div>
<input type="checkbox" id="no_transform">
<label for="no_transform">
Disable compression (images)
</label>
</div>
<div>
<button type="submit" onclick="uploadFiles(event)">
Upload
</button>
</div>
<div>
<button type="submit" onclick="listFiles()">
List Uploads
</button>
</div>
</div>
<pre id="log"></pre>
</body>
</html>

View File

@ -12,6 +12,7 @@ use anyhow::Error;
use http_range_header::{ use http_range_header::{
parse_range_header, EndPosition, StartPosition, SyntacticallyCorrectRange, parse_range_header, EndPosition, StartPosition, SyntacticallyCorrectRange,
}; };
use log::{debug, warn};
use nostr::Event; use nostr::Event;
use rocket::fs::NamedFile; use rocket::fs::NamedFile;
use rocket::http::{ContentType, Header, Status}; use rocket::http::{ContentType, Header, Status};
@ -100,13 +101,27 @@ impl Nip94Event {
} }
} }
/// Range request handler over file handle
struct RangeBody { struct RangeBody {
pub file: File, file: File,
pub file_size: u64, file_size: u64,
pub ranges: Vec<SyntacticallyCorrectRange>, ranges: Vec<SyntacticallyCorrectRange>,
current_range_index: usize, current_range_index: usize,
current_offset: u64, current_offset: u64,
poll_complete: bool,
}
impl RangeBody {
pub fn new(file: File, file_size: u64, ranges: Vec<SyntacticallyCorrectRange>) -> Self {
Self {
file,
file_size,
ranges,
current_offset: 0,
current_range_index: 0,
poll_complete: false,
}
}
} }
impl AsyncRead for RangeBody { impl AsyncRead for RangeBody {
@ -138,23 +153,32 @@ impl AsyncRead for RangeBody {
return self.poll_read(cx, buf); return self.poll_read(cx, buf);
} }
let pinned = pin!(&mut self.file); if !self.poll_complete {
pinned.start_seek(SeekFrom::Start(range_start))?; // start seeking to our read position
let pinned = pin!(&mut self.file);
pinned.start_seek(SeekFrom::Start(range_start))?;
self.poll_complete = true;
}
let pinned = pin!(&mut self.file); if self.poll_complete {
match pinned.poll_complete(cx) { let pinned = pin!(&mut self.file);
Poll::Ready(Ok(_)) => {} match pinned.poll_complete(cx) {
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), Poll::Ready(Ok(_)) => {
Poll::Pending => return Poll::Pending, self.poll_complete = false;
}
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Pending => return Poll::Pending,
}
} }
// Read data from the file // Read data from the file
let pinned = pin!(&mut self.file); let pinned = pin!(&mut self.file);
let n = pinned.poll_read(cx, &mut buf.take(bytes_to_read as usize)); let n = pinned.poll_read(cx, buf);
if let Poll::Ready(Ok(())) = n { if let Poll::Ready(Ok(())) = n {
self.current_offset += bytes_to_read; self.current_offset += bytes_to_read;
Poll::Ready(Ok(())) Poll::Ready(Ok(()))
} else { } else {
self.poll_complete = true;
Poll::Pending Poll::Pending
} }
} }
@ -170,30 +194,51 @@ impl<'r> Responder<'r, 'static> for FilePayload {
response.set_header(Header::new("accept-ranges", "bytes")); response.set_header(Header::new("accept-ranges", "bytes"));
if let Some(r) = request.headers().get("range").next() { if let Some(r) = request.headers().get("range").next() {
if let Ok(ranges) = parse_range_header(r) { if let Ok(ranges) = parse_range_header(r) {
let r_body = RangeBody { if ranges.ranges.len() > 1 {
file_size: self.info.size, // TODO: handle filesize mismatch warn!("Multipart ranges are not supported, fallback to non-range request");
file: self.file, response.set_streamed_body(self.file);
ranges: ranges.ranges, } else {
current_range_index: 0, let single_range = ranges.ranges.first().unwrap();
current_offset: 0, let range_start = match single_range.start {
}; StartPosition::Index(i) => i,
response.set_streamed_body(Box::pin(r_body)); StartPosition::FromLast(i) => self.info.size - i,
};
let range_end = match single_range.end {
EndPosition::Index(i) => i,
EndPosition::LastByte => self.info.size,
};
debug!("Range: {:?} {:?}", range_start..range_end, single_range);
let r_len = range_end - range_start;
let r_body = RangeBody::new(self.file, self.info.size, ranges.ranges);
response.set_status(Status::PartialContent);
response.set_header(Header::new("content-length", r_len.to_string()));
response.set_header(Header::new(
"content-range",
format!("bytes {}-{}/{}", range_start, range_end, self.info.size),
));
response.set_streamed_body(Box::pin(r_body));
}
} }
} else { } else {
response.set_streamed_body(self.file); response.set_streamed_body(self.file);
} }
} }
#[cfg(not(feature = "ranges"))] #[cfg(not(feature = "ranges"))]
response.set_streamed_body(self.file); {
response.set_header(Header::new("content-length", self.info.size.to_string())); response.set_streamed_body(self.file);
response.set_header(Header::new("content-length", self.info.size.to_string()));
}
if let Ok(ct) = ContentType::from_str(&self.info.mime_type) { if let Ok(ct) = ContentType::from_str(&self.info.mime_type) {
response.set_header(ct); response.set_header(ct);
} }
response.set_header(Header::new( if !self.info.name.is_empty() {
"content-disposition", response.set_header(Header::new(
format!("inline; filename=\"{}\"", self.info.name), "content-disposition",
)); format!("inline; filename=\"{}\"", self.info.name),
));
}
Ok(response) Ok(response)
} }
} }
@ -247,7 +292,7 @@ async fn delete_file(
#[rocket::get("/")] #[rocket::get("/")]
pub async fn root() -> Result<NamedFile, Status> { pub async fn root() -> Result<NamedFile, Status> {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
let index = "./ui_src/dist/index.html"; let index = "./index.html";
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
let index = "./ui/index.html"; let index = "./ui/index.html";
if let Ok(f) = NamedFile::open(index).await { if let Ok(f) = NamedFile::open(index).await {