use crate::{path::SafePathBuf, scope, webview::UriSchemeProtocolHandler};
use http::{Request, Response, header::*, status::StatusCode};
use http_range::HttpRange;
use std::fs::File;
use std::io::{Read, Seek, Write};
use std::{borrow::Cow, io::SeekFrom};
use tauri_utils::mime_type::MimeType;
pub fn get(scope: scope::fs::Scope, window_origin: String) -> UriSchemeProtocolHandler {
Box::new(
move |_, request, responder| match get_response(request, &scope, &window_origin) {
Ok(response) => responder.respond(response),
Err(e) => responder.respond(
http::Response::builder()
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
.header(CONTENT_TYPE, mime::TEXT_PLAIN.essence_str())
.header("Access-Control-Allow-Origin", &window_origin)
.body(e.to_string().into_bytes())
.unwrap(),
),
},
)
}
fn get_response(
request: Request<Vec<u8>>,
scope: &scope::fs::Scope,
window_origin: &str,
) -> Result<Response<Cow<'static, [u8]>>, Box<dyn std::error::Error>> {
let path = percent_encoding::percent_decode(&request.uri().path().as_bytes()[1..])
.decode_utf8_lossy()
.to_string();
let mut resp = Response::builder().header("Access-Control-Allow-Origin", window_origin);
if let Err(e) = SafePathBuf::new(path.clone().into()) {
log::error!("asset protocol path \"{path}\" is not valid: {e}");
return resp.status(403).body(Vec::new().into()).map_err(Into::into);
}
if !scope.is_allowed(&path) {
log::error!("asset protocol not configured to allow the path: {path}");
return resp.status(403).body(Vec::new().into()).map_err(Into::into);
}
let mut file = match File::open(path.clone()) {
Ok(file) => file,
Err(e) => {
#[cfg(target_os = "android")]
{
if path.starts_with("/storage/emulated/0/Android/data/") {
log::error!(
"Failed to open Android external storage file '{path}': {e}. This may be due to missing storage permissions."
);
}
}
return if e.kind() == std::io::ErrorKind::NotFound {
log::error!("File does not exist at path: {path}");
return resp.status(404).body(Vec::new().into()).map_err(Into::into);
} else if e.kind() == std::io::ErrorKind::PermissionDenied {
log::error!("Missing OS permission to access path \"{path}\": {e}");
return resp.status(403).body(Vec::new().into()).map_err(Into::into);
} else {
Err(e.into())
};
}
};
let len = file.metadata()?.len();
let (mime_type, read_bytes) = {
let nbytes = len.min(8192);
let mut magic_buf = Vec::with_capacity(nbytes as usize);
(&mut file).take(nbytes).read_to_end(&mut magic_buf)?;
file.rewind()?;
(
MimeType::parse(&magic_buf, &path),
if len < 8192 { Some(magic_buf) } else { None },
)
};
resp = resp.header(CONTENT_TYPE, &mime_type);
let response = if let Some(range_header) = request
.headers()
.get("range")
.and_then(|r| r.to_str().map(|r| r.to_string()).ok())
{
resp = resp.header(ACCEPT_RANGES, "bytes");
resp = resp.header(ACCESS_CONTROL_EXPOSE_HEADERS, "content-range");
let not_satisfiable = || {
Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(CONTENT_RANGE, format!("bytes */{len}"))
.body(vec![].into())
.map_err(Into::into)
};
let ranges = if let Ok(ranges) = HttpRange::parse(&range_header, len) {
ranges
.iter()
.map(|r| (r.start, r.start + r.length - 1))
.collect::<Vec<_>>()
} else {
return not_satisfiable();
};
const MAX_LEN: u64 = 1000 * 1024;
if ranges.len() == 1 {
let &(start, mut end) = ranges.first().unwrap();
if start >= len || end >= len || end < start {
return not_satisfiable();
}
end = start + (end - start).min(len - start).min(MAX_LEN - 1);
let nbytes = end + 1 - start;
let buf = {
let mut buf = Vec::with_capacity(nbytes as usize);
file.seek(SeekFrom::Start(start))?;
file.take(nbytes).read_to_end(&mut buf)?;
buf
};
resp = resp.header(CONTENT_RANGE, format!("bytes {start}-{end}/{len}"));
resp = resp.header(CONTENT_LENGTH, end + 1 - start);
resp = resp.status(StatusCode::PARTIAL_CONTENT);
resp.body(buf.into())
} else {
let ranges = ranges
.iter()
.filter_map(|&(start, mut end)| {
if start >= len || end >= len || end < start {
None
} else {
end = start + (end - start).min(len - start).min(MAX_LEN - 1);
Some((start, end))
}
})
.collect::<Vec<_>>();
let boundary = random_boundary();
let boundary_sep = format!("\r\n--{boundary}\r\n");
let boundary_closer = format!("\r\n--{boundary}--\r\n");
if let Some(headers) = resp.headers_mut() {
headers.insert(
CONTENT_TYPE,
HeaderValue::from_str(&format!("multipart/byteranges; boundary={boundary}"))?,
);
}
let buf = {
let mut buf = Vec::new();
for (start, end) in ranges {
buf.write_all(boundary_sep.as_bytes())?;
buf.write_all(format!("{CONTENT_TYPE}: {mime_type}\r\n").as_bytes())?;
buf.write_all(format!("{CONTENT_RANGE}: bytes {start}-{end}/{len}\r\n").as_bytes())?;
buf.write_all("\r\n".as_bytes())?;
let nbytes = end + 1 - start;
let mut local_buf = Vec::with_capacity(nbytes as usize);
file.seek(SeekFrom::Start(start))?;
(&mut file).take(nbytes).read_to_end(&mut local_buf)?;
buf.extend_from_slice(&local_buf);
}
buf.write_all(boundary_closer.as_bytes())?;
buf
};
resp = resp.status(StatusCode::PARTIAL_CONTENT);
resp.body(buf.into())
}
} else if request.method() == http::Method::HEAD {
resp = resp.header(CONTENT_LENGTH, len);
resp.body(Vec::new().into())
} else {
let buf = if let Some(b) = read_bytes {
b
} else {
let mut local_buf = Vec::with_capacity(len as usize);
file.read_to_end(&mut local_buf)?;
local_buf
};
resp = resp.header(CONTENT_LENGTH, len);
resp.body(buf.into())
};
response.map_err(Into::into)
}
fn random_boundary() -> String {
let mut x = [0_u8; 30];
getrandom::fill(&mut x).expect("failed to get random bytes");
(x[..])
.iter()
.map(|&x| format!("{x:x}"))
.fold(String::new(), |mut a, x| {
a.push_str(x.as_str());
a
})
}
#[cfg(test)]
mod tests {
use super::get_response;
use crate::scope::fs::Scope;
use http::{Request, header::CONTENT_TYPE, status::StatusCode};
use tauri_utils::config::FsScope;
#[test]
fn multi_range_request() {
let app = crate::test::mock_app();
let path = std::env::temp_dir().join(format!(
"tauri-asset-protocol-multi-range-{}.bin",
std::process::id()
));
std::fs::write(&path, vec![b'a'; 1000]).unwrap();
let scope = Scope::new(&app, &FsScope::default()).unwrap();
scope.allow_file(&path).unwrap();
let encoded_path = percent_encoding::percent_encode(
path.to_string_lossy().as_bytes(),
percent_encoding::NON_ALPHANUMERIC,
)
.to_string();
let request = Request::builder()
.uri(format!("asset://localhost/{encoded_path}"))
.header("range", "bytes=0-9, 20-29")
.body(Vec::new())
.unwrap();
let response = get_response(request, &scope, "http://tauri.localhost").unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
let content_types = response
.headers()
.get_all(CONTENT_TYPE)
.iter()
.map(|v| v.to_str().unwrap().to_string())
.collect::<Vec<_>>();
assert_eq!(content_types.len(), 1);
let boundary = content_types[0]
.strip_prefix("multipart/byteranges; boundary=")
.unwrap();
assert!(
response
.body()
.ends_with(format!("\r\n--{boundary}--\r\n").as_bytes())
);
}
}