Skip to content

Commit 021dc59

Browse files
committed
feat: range requests
1 parent 4aa51fa commit 021dc59

File tree

3 files changed

+261
-30
lines changed

3 files changed

+261
-30
lines changed

Cargo.toml

+5-3
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ path = "src/bin/main.rs"
1515
name = "route96"
1616

1717
[features]
18-
default = ["nip96", "blossom", "analytics"]
18+
default = ["nip96", "blossom", "analytics", "ranges"]
1919
media-compression = ["dep:ffmpeg-rs-raw", "dep:libc"]
2020
labels = ["nip96", "dep:candle-core", "dep:candle-nn", "dep:candle-transformers"]
2121
nip96 = ["media-compression"]
@@ -24,6 +24,7 @@ bin-void-cat-migrate = ["dep:sqlx-postgres"]
2424
torrent-v2 = []
2525
analytics = []
2626
void-cat-redirects = ["dep:sqlx-postgres"]
27+
ranges = ["dep:http-range-header"]
2728

2829
[dependencies]
2930
log = "0.4.21"
@@ -44,12 +45,13 @@ url = "2.5.0"
4445
serde_with = { version = "3.8.1", features = ["hex"] }
4546
reqwest = "0.12.8"
4647
clap = { version = "4.5.18", features = ["derive"] }
48+
mime2ext = "0.1.53"
4749

4850
libc = { version = "0.2.153", optional = true }
4951
ffmpeg-rs-raw = { git = "https://git.v0l.io/Kieran/ffmpeg-rs-raw.git", rev = "b358b3e4209da827e021d979c7d35876594d0285", optional = true }
5052
candle-core = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true }
5153
candle-nn = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true }
5254
candle-transformers = { git = "https://git.v0l.io/huggingface/candle.git", tag = "0.8.1", optional = true }
5355
sqlx-postgres = { version = "0.8.2", optional = true, features = ["chrono", "uuid"] }
54-
mime2ext = "0.1.53"
55-
http-range-header = "0.4.2"
56+
http-range-header = { version = "0.4.2", optional = true }
57+

index.html

+184
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>route96</title>
6+
<style>
7+
html {
8+
background-color: black;
9+
color: white;
10+
font-size: 15px;
11+
font-weight: 400;
12+
font-family: Arial, serif;
13+
}
14+
15+
.flex {
16+
display: flex;
17+
}
18+
19+
.flex-col {
20+
flex-direction: column;
21+
}
22+
23+
.gap-2 {
24+
gap: 0.5rem;
25+
}
26+
</style>
27+
<script>
28+
async function dumpToLog(rsp) {
29+
console.debug(rsp);
30+
const text = await rsp.text();
31+
if (rsp.ok) {
32+
document.querySelector("#log").append(JSON.stringify(JSON.parse(text), undefined, 2));
33+
} else {
34+
document.querySelector("#log").append(text);
35+
}
36+
document.querySelector("#log").append("\n");
37+
}
38+
39+
async function listFiles() {
40+
try {
41+
const auth_event = await window.nostr.signEvent({
42+
kind: 27235,
43+
created_at: Math.floor(new Date().getTime() / 1000),
44+
content: "",
45+
tags: [
46+
["u", `${window.location.protocol}//${window.location.host}/n96`],
47+
["method", "GET"]
48+
]
49+
});
50+
const rsp = await fetch("/n96?page=0&count=100", {
51+
method: "GET",
52+
headers: {
53+
accept: "application/json",
54+
authorization: `Nostr ${btoa(JSON.stringify(auth_event))}`,
55+
},
56+
});
57+
await dumpToLog(rsp);
58+
} catch (e) {
59+
60+
}
61+
}
62+
63+
async function uploadFiles(e) {
64+
try {
65+
const input = document.querySelector("#file");
66+
const file = input.files[0];
67+
console.debug(file);
68+
69+
const r_nip96 = document.querySelector("#method-nip96").checked;
70+
const r_blossom = document.querySelector("#method-blossom").checked;
71+
if (r_nip96) {
72+
await uploadFilesNip96(file)
73+
} else if (r_blossom) {
74+
await uploadBlossom(file);
75+
}
76+
} catch (ex) {
77+
if (ex instanceof Error) {
78+
alert(ex.message);
79+
}
80+
}
81+
}
82+
83+
function buf2hex(buffer) { // buffer is an ArrayBuffer
84+
return [...new Uint8Array(buffer)]
85+
.map(x => x.toString(16).padStart(2, '0'))
86+
.join('');
87+
}
88+
89+
async function uploadBlossom(file) {
90+
const hash = await window.crypto.subtle.digest("SHA-256", await file.arrayBuffer());
91+
92+
const now = Math.floor(new Date().getTime() / 1000);
93+
const auth_event = await window.nostr.signEvent({
94+
kind: 24242,
95+
created_at: now,
96+
content: `Upload ${file.name}`,
97+
tags: [
98+
["t", "upload"],
99+
["u", `${window.location.protocol}//${window.location.host}/upload`],
100+
["x", buf2hex(hash)],
101+
["method", "PUT"],
102+
["expiration", (now + 10).toString()]
103+
]
104+
});
105+
const rsp = await fetch("/upload", {
106+
body: file,
107+
method: "PUT",
108+
headers: {
109+
accept: "application/json",
110+
authorization: `Nostr ${btoa(JSON.stringify(auth_event))}`,
111+
},
112+
});
113+
await dumpToLog(rsp);
114+
}
115+
116+
async function uploadFilesNip96(file) {
117+
const fd = new FormData();
118+
fd.append("size", file.size.toString());
119+
fd.append("caption", file.name);
120+
fd.append("media_type", file.type);
121+
fd.append("file", file);
122+
fd.append("no_transform", document.querySelector("#no_transform").checked.toString())
123+
124+
const auth_event = await window.nostr.signEvent({
125+
kind: 27235,
126+
created_at: Math.floor(new Date().getTime() / 1000),
127+
content: "",
128+
tags: [
129+
["u", `${window.location.protocol}//${window.location.host}/n96`],
130+
["method", "POST"]
131+
]
132+
});
133+
const rsp = await fetch("/n96", {
134+
body: fd,
135+
method: "POST",
136+
headers: {
137+
accept: "application/json",
138+
authorization: `Nostr ${btoa(JSON.stringify(auth_event))}`,
139+
},
140+
});
141+
await dumpToLog(rsp);
142+
}
143+
</script>
144+
</head>
145+
<body>
146+
<h1>
147+
Welcome to route96
148+
</h1>
149+
<div class="flex flex-col gap-2">
150+
<div>
151+
<label>
152+
NIP-96
153+
<input type="radio" name="method" id="method-nip96"/>
154+
</label>
155+
<label>
156+
Blossom
157+
<input type="radio" name="method" id="method-blossom"/>
158+
</label>
159+
</div>
160+
<div style="color: #ff8383;">
161+
You must have a nostr extension for this to work
162+
</div>
163+
<input type="file" id="file">
164+
<div>
165+
<input type="checkbox" id="no_transform">
166+
<label for="no_transform">
167+
Disable compression (images)
168+
</label>
169+
</div>
170+
<div>
171+
<button type="submit" onclick="uploadFiles(event)">
172+
Upload
173+
</button>
174+
</div>
175+
176+
<div>
177+
<button type="submit" onclick="listFiles()">
178+
List Uploads
179+
</button>
180+
</div>
181+
</div>
182+
<pre id="log"></pre>
183+
</body>
184+
</html>

src/routes/mod.rs

+72-27
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use anyhow::Error;
1212
use http_range_header::{
1313
parse_range_header, EndPosition, StartPosition, SyntacticallyCorrectRange,
1414
};
15+
use log::{debug, warn};
1516
use nostr::Event;
1617
use rocket::fs::NamedFile;
1718
use rocket::http::{ContentType, Header, Status};
@@ -100,13 +101,27 @@ impl Nip94Event {
100101
}
101102
}
102103

104+
/// Range request handler over file handle
103105
struct RangeBody {
104-
pub file: File,
105-
pub file_size: u64,
106-
pub ranges: Vec<SyntacticallyCorrectRange>,
107-
106+
file: File,
107+
file_size: u64,
108+
ranges: Vec<SyntacticallyCorrectRange>,
108109
current_range_index: usize,
109110
current_offset: u64,
111+
poll_complete: bool,
112+
}
113+
114+
impl RangeBody {
115+
pub fn new(file: File, file_size: u64, ranges: Vec<SyntacticallyCorrectRange>) -> Self {
116+
Self {
117+
file,
118+
file_size,
119+
ranges,
120+
current_offset: 0,
121+
current_range_index: 0,
122+
poll_complete: false,
123+
}
124+
}
110125
}
111126

112127
impl AsyncRead for RangeBody {
@@ -138,23 +153,32 @@ impl AsyncRead for RangeBody {
138153
return self.poll_read(cx, buf);
139154
}
140155

141-
let pinned = pin!(&mut self.file);
142-
pinned.start_seek(SeekFrom::Start(range_start))?;
156+
if !self.poll_complete {
157+
// start seeking to our read position
158+
let pinned = pin!(&mut self.file);
159+
pinned.start_seek(SeekFrom::Start(range_start))?;
160+
self.poll_complete = true;
161+
}
143162

144-
let pinned = pin!(&mut self.file);
145-
match pinned.poll_complete(cx) {
146-
Poll::Ready(Ok(_)) => {}
147-
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
148-
Poll::Pending => return Poll::Pending,
163+
if self.poll_complete {
164+
let pinned = pin!(&mut self.file);
165+
match pinned.poll_complete(cx) {
166+
Poll::Ready(Ok(_)) => {
167+
self.poll_complete = false;
168+
}
169+
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
170+
Poll::Pending => return Poll::Pending,
171+
}
149172
}
150173

151174
// Read data from the file
152175
let pinned = pin!(&mut self.file);
153-
let n = pinned.poll_read(cx, &mut buf.take(bytes_to_read as usize));
176+
let n = pinned.poll_read(cx, buf);
154177
if let Poll::Ready(Ok(())) = n {
155178
self.current_offset += bytes_to_read;
156179
Poll::Ready(Ok(()))
157180
} else {
181+
self.poll_complete = true;
158182
Poll::Pending
159183
}
160184
}
@@ -170,30 +194,51 @@ impl<'r> Responder<'r, 'static> for FilePayload {
170194
response.set_header(Header::new("accept-ranges", "bytes"));
171195
if let Some(r) = request.headers().get("range").next() {
172196
if let Ok(ranges) = parse_range_header(r) {
173-
let r_body = RangeBody {
174-
file_size: self.info.size, // TODO: handle filesize mismatch
175-
file: self.file,
176-
ranges: ranges.ranges,
177-
current_range_index: 0,
178-
current_offset: 0,
179-
};
180-
response.set_streamed_body(Box::pin(r_body));
197+
if ranges.ranges.len() > 1 {
198+
warn!("Multipart ranges are not supported, fallback to non-range request");
199+
response.set_streamed_body(self.file);
200+
} else {
201+
let single_range = ranges.ranges.first().unwrap();
202+
let range_start = match single_range.start {
203+
StartPosition::Index(i) => i,
204+
StartPosition::FromLast(i) => self.info.size - i,
205+
};
206+
let range_end = match single_range.end {
207+
EndPosition::Index(i) => i,
208+
EndPosition::LastByte => self.info.size,
209+
};
210+
debug!("Range: {:?} {:?}", range_start..range_end, single_range);
211+
let r_len = range_end - range_start;
212+
let r_body = RangeBody::new(self.file, self.info.size, ranges.ranges);
213+
214+
response.set_status(Status::PartialContent);
215+
response.set_header(Header::new("content-length", r_len.to_string()));
216+
response.set_header(Header::new(
217+
"content-range",
218+
format!("bytes {}-{}/{}", range_start, range_end, self.info.size),
219+
));
220+
response.set_streamed_body(Box::pin(r_body));
221+
}
181222
}
182223
} else {
183224
response.set_streamed_body(self.file);
184225
}
185226
}
186227
#[cfg(not(feature = "ranges"))]
187-
response.set_streamed_body(self.file);
188-
response.set_header(Header::new("content-length", self.info.size.to_string()));
228+
{
229+
response.set_streamed_body(self.file);
230+
response.set_header(Header::new("content-length", self.info.size.to_string()));
231+
}
189232

190233
if let Ok(ct) = ContentType::from_str(&self.info.mime_type) {
191234
response.set_header(ct);
192235
}
193-
response.set_header(Header::new(
194-
"content-disposition",
195-
format!("inline; filename=\"{}\"", self.info.name),
196-
));
236+
if !self.info.name.is_empty() {
237+
response.set_header(Header::new(
238+
"content-disposition",
239+
format!("inline; filename=\"{}\"", self.info.name),
240+
));
241+
}
197242
Ok(response)
198243
}
199244
}
@@ -247,7 +292,7 @@ async fn delete_file(
247292
#[rocket::get("/")]
248293
pub async fn root() -> Result<NamedFile, Status> {
249294
#[cfg(debug_assertions)]
250-
let index = "./ui_src/dist/index.html";
295+
let index = "./index.html";
251296
#[cfg(not(debug_assertions))]
252297
let index = "./ui/index.html";
253298
if let Ok(f) = NamedFile::open(index).await {

0 commit comments

Comments
 (0)