feat: range requests
This commit is contained in:
parent
4aa51fa41f
commit
021dc59382
@ -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
184
index.html
Normal 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>
|
@ -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 {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user