mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-02 10:03:28 +00:00
Stop App Freezing (#1257)
* Drop Tauri custom URI handler
Me no likely but it has to be done.
* fix app startup with location
* fix "Add Location" button on web
* Serve correct content range
* Backport changes from 08ba4f91
* none of my homies like panics
* minor fixes
* fmt with new Rust version
This commit is contained in:
parent
39549ef74c
commit
f821bb9a0f
27
Cargo.lock
generated
27
Cargo.lock
generated
|
@ -3154,22 +3154,6 @@ version = "1.0.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "httpz"
|
||||
version = "0.0.3"
|
||||
source = "git+https://github.com/oscartbeaumont/httpz?rev=a5185f2ed2fdefeb2f582dce38a692a1bf76d1d6#a5185f2ed2fdefeb2f582dce38a692a1bf76d1d6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"form_urlencoded",
|
||||
"futures",
|
||||
"http",
|
||||
"hyper",
|
||||
"percent-encoding",
|
||||
"tauri",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpz"
|
||||
version = "0.0.4"
|
||||
|
@ -5591,9 +5575,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.12"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12cc1b0bf1727a77a54b6654e7b5f1af8604923edc8b81885f8ec92f9e3f0a05"
|
||||
checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
|
@ -6622,7 +6606,7 @@ dependencies = [
|
|||
"futures",
|
||||
"futures-channel",
|
||||
"futures-locks",
|
||||
"httpz 0.0.4",
|
||||
"httpz",
|
||||
"nougat",
|
||||
"pin-project",
|
||||
"serde",
|
||||
|
@ -6932,6 +6916,7 @@ dependencies = [
|
|||
"async-channel",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.21.2",
|
||||
"blake3",
|
||||
"chrono",
|
||||
|
@ -6944,8 +6929,8 @@ dependencies = [
|
|||
"globset",
|
||||
"hex",
|
||||
"hostname",
|
||||
"http-body",
|
||||
"http-range",
|
||||
"httpz 0.0.3",
|
||||
"image",
|
||||
"include_dir",
|
||||
"int-enum",
|
||||
|
@ -7048,7 +7033,6 @@ dependencies = [
|
|||
"axum",
|
||||
"futures",
|
||||
"http",
|
||||
"httpz 0.0.3",
|
||||
"opener",
|
||||
"percent-encoding",
|
||||
"prisma-client-rust",
|
||||
|
@ -7228,7 +7212,6 @@ dependencies = [
|
|||
"axum",
|
||||
"ctrlc",
|
||||
"http",
|
||||
"httpz 0.0.3",
|
||||
"include_dir",
|
||||
"mime_guess",
|
||||
"rspc",
|
||||
|
|
|
@ -43,7 +43,6 @@ tracing-appender = { git = "https://github.com/tokio-rs/tracing", rev = "2914626
|
|||
|
||||
rspc = { version = "0.1.4" }
|
||||
specta = { version = "1.0.4" }
|
||||
httpz = { version = "0.0.3" }
|
||||
tauri-specta = { version = "1.0.2" }
|
||||
|
||||
swift-rs = { version = "1.0.5" }
|
||||
|
@ -61,7 +60,6 @@ if-watch = { git = "https://github.com/oscartbeaumont/if-watch", rev = "782eb7b2
|
|||
|
||||
mdns-sd = { git = "https://github.com/oscartbeaumont/mdns-sd", rev = "45515a98e9e408c102871abaa5a9bff3bee0cbe8" } # TODO: Do upstream PR
|
||||
|
||||
httpz = { git = "https://github.com/oscartbeaumont/httpz", rev = "a5185f2ed2fdefeb2f582dce38a692a1bf76d1d6" }
|
||||
specta = { git = "https://github.com/oscartbeaumont/specta", rev = "4bc6e46fc8747cd8d8a07597c1fe13c52aa16a41" }
|
||||
rspc = { git = "https://github.com/oscartbeaumont/rspc", rev = "adebce542049b982dd251466d4144f4d57e92177" }
|
||||
tauri-specta = { git = "https://github.com/oscartbeaumont/tauri-specta", rev = "c964bef228a90a66effc18cefcba6859c45a8e08" }
|
||||
|
|
|
@ -11,10 +11,6 @@ edition = { workspace = true }
|
|||
[dependencies]
|
||||
tauri = { version = "1.3.0", features = ["dialog-all", "linux-protocol-headers", "macos-private-api", "os-all", "path-all", "protocol-all", "shell-all", "window-all"] }
|
||||
rspc = { workspace = true, features = ["tauri"] }
|
||||
httpz = { workspace = true, features = [
|
||||
"axum",
|
||||
"tauri",
|
||||
] } # TODO: The `axum` feature should be only enabled on Linux but this currently can't be done: https://github.com/rust-lang/cargo/issues/1197
|
||||
sd-core = { path = "../../../core", features = [
|
||||
"ffmpeg",
|
||||
"location-watcher",
|
||||
|
@ -31,13 +27,13 @@ specta = { workspace = true }
|
|||
tauri-specta = { workspace = true, features = ["typescript"] }
|
||||
uuid = { version = "1.3.3", features = ["serde"] }
|
||||
futures = "0.3"
|
||||
axum = { version = "0.6.18", features = ["headers", "query"] }
|
||||
rand = "0.8.5"
|
||||
|
||||
prisma-client-rust = { workspace = true }
|
||||
sd-prisma = { path = "../../../crates/prisma" }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
axum = { version = "0.6.18", features = ["headers", "query"] }
|
||||
rand = "0.8.5"
|
||||
sd-desktop-linux = { path = "../crates/linux" }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
|
|
|
@ -1,99 +0,0 @@
|
|||
use std::net::{SocketAddr, TcpListener};
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State, TypedHeader},
|
||||
headers::authorization::{Authorization, Bearer},
|
||||
http::{Request, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::Response,
|
||||
routing::get,
|
||||
RequestPartsExt, Router,
|
||||
};
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use serde::Deserialize;
|
||||
use tauri::{async_runtime::Receiver, plugin::TauriPlugin, Builder, Runtime};
|
||||
use tracing::debug;
|
||||
|
||||
pub(super) async fn setup<R: Runtime>(
|
||||
app: Builder<R>,
|
||||
mut rx: Receiver<()>,
|
||||
router: Router<()>,
|
||||
) -> Builder<R> {
|
||||
let auth_token: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(10)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
let axum_app = axum::Router::new()
|
||||
.route("/", get(|| async { "Spacedrive Server!" }))
|
||||
.nest("/spacedrive", router)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
auth_token.clone(),
|
||||
auth_middleware,
|
||||
))
|
||||
.fallback(|| async { "404 Not Found: We're past the event horizon..." });
|
||||
|
||||
// Only allow current device to access it and randomise port
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("Error creating localhost server!");
|
||||
let listen_addr = listener
|
||||
.local_addr()
|
||||
.expect("Error getting localhost server listen addr!");
|
||||
|
||||
debug!("Localhost server listening on: http://{:?}", listen_addr);
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::Server::from_tcp(listener)
|
||||
.expect("error creating HTTP server!")
|
||||
.serve(axum_app.into_make_service())
|
||||
.with_graceful_shutdown(async {
|
||||
rx.recv().await;
|
||||
})
|
||||
.await
|
||||
.expect("Error with HTTP server!");
|
||||
});
|
||||
|
||||
app.plugin(tauri_plugin(&auth_token, listen_addr))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueryToken {
|
||||
token: String,
|
||||
}
|
||||
|
||||
async fn auth_middleware<B>(
|
||||
Query(query): Query<QueryToken>,
|
||||
State(auth_token): State<String>,
|
||||
request: Request<B>,
|
||||
next: Next<B>,
|
||||
) -> Result<Response, StatusCode>
|
||||
where
|
||||
B: Send,
|
||||
{
|
||||
let req = if query.token != auth_token {
|
||||
let (mut parts, body) = request.into_parts();
|
||||
|
||||
let auth: TypedHeader<Authorization<Bearer>> = parts
|
||||
.extract()
|
||||
.await
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
if auth.token() != auth_token {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
Request::from_parts(parts, body)
|
||||
} else {
|
||||
request
|
||||
};
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
fn tauri_plugin<R: Runtime>(auth_token: &str, listen_addr: SocketAddr) -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("spacedrive-linux")
|
||||
.js_init_script(format!(
|
||||
r#"window.__SD_CUSTOM_SERVER_AUTH_TOKEN__ = "{auth_token}"; window.__SD_CUSTOM_URI_SERVER__ = "http://{listen_addr}";"#
|
||||
))
|
||||
.build()
|
||||
}
|
|
@ -5,17 +5,14 @@
|
|||
|
||||
use std::{fs, path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
use sd_core::{custom_uri::create_custom_uri_endpoint, Node, NodeError};
|
||||
use sd_core::{Node, NodeError};
|
||||
|
||||
use tauri::{
|
||||
api::path, async_runtime::block_on, ipc::RemoteDomainAccessScope, plugin::TauriPlugin,
|
||||
AppHandle, Manager, RunEvent, Runtime,
|
||||
};
|
||||
use tokio::{task::block_in_place, time::sleep};
|
||||
use tracing::{debug, error};
|
||||
use tauri::{api::path, ipc::RemoteDomainAccessScope, AppHandle, Manager};
|
||||
use tauri_plugins::{sd_error_plugin, sd_server_plugin};
|
||||
use tokio::time::sleep;
|
||||
use tracing::error;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod app_linux;
|
||||
mod tauri_plugins;
|
||||
|
||||
mod theme;
|
||||
|
||||
|
@ -64,15 +61,7 @@ async fn open_logs_dir(node: tauri::State<'_, Arc<Node>>) -> Result<(), ()> {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn tauri_error_plugin<R: Runtime>(err: NodeError) -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("spacedrive")
|
||||
.js_init_script(format!(
|
||||
r#"window.__SD_ERROR__ = `{}`;"#,
|
||||
err.to_string().replace('`', "\"")
|
||||
))
|
||||
.build()
|
||||
}
|
||||
|
||||
// TODO(@Oscar): A helper like this should probs exist in tauri-specta
|
||||
macro_rules! tauri_handlers {
|
||||
($($name:path),+) => {{
|
||||
#[cfg(debug_assertions)]
|
||||
|
@ -87,9 +76,6 @@ async fn main() -> tauri::Result<()> {
|
|||
#[cfg(target_os = "linux")]
|
||||
sd_desktop_linux::normalize_environment();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(1);
|
||||
|
||||
let data_dir = path::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("./"))
|
||||
.join("spacedrive");
|
||||
|
@ -104,29 +90,17 @@ async fn main() -> tauri::Result<()> {
|
|||
};
|
||||
|
||||
let app = tauri::Builder::default();
|
||||
|
||||
let (node, app) = match result {
|
||||
Ok((node, router)) => {
|
||||
// This is a super cringe workaround for: https://github.com/tauri-apps/tauri/issues/3725 & https://bugs.webkit.org/show_bug.cgi?id=146351#c5
|
||||
#[cfg(target_os = "linux")]
|
||||
let app = app_linux::setup(app, rx, create_custom_uri_endpoint(node.clone()).axum()).await;
|
||||
|
||||
let app = app
|
||||
.register_uri_scheme_protocol(
|
||||
"spacedrive",
|
||||
create_custom_uri_endpoint(node.clone()).tauri_uri_scheme("spacedrive"),
|
||||
)
|
||||
.plugin(rspc::integrations::tauri::plugin(router, {
|
||||
let node = node.clone();
|
||||
move || node.clone()
|
||||
}))
|
||||
.manage(node.clone());
|
||||
|
||||
(Some(node), app)
|
||||
}
|
||||
let app = match result {
|
||||
Ok((node, router)) => app
|
||||
.plugin(rspc::integrations::tauri::plugin(router, {
|
||||
let node = node.clone();
|
||||
move || node.clone()
|
||||
}))
|
||||
.plugin(sd_server_plugin(node.clone()).unwrap()) // TODO: Handle `unwrap`
|
||||
.manage(node.clone()),
|
||||
Err(err) => {
|
||||
tracing::error!("Error starting up the node: {err}");
|
||||
(None, app.plugin(tauri_error_plugin(err)))
|
||||
error!("Error starting up the node: {err}");
|
||||
app.plugin(sd_error_plugin(err))
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -150,13 +124,12 @@ async fn main() -> tauri::Result<()> {
|
|||
let app = app.handle();
|
||||
|
||||
app.windows().iter().for_each(|(_, window)| {
|
||||
// window.hide().unwrap();
|
||||
|
||||
tokio::spawn({
|
||||
let window = window.clone();
|
||||
async move {
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
if !window.is_visible().unwrap_or(true) {
|
||||
// This happens if the JS bundle crashes and hence doesn't send ready event.
|
||||
println!(
|
||||
"Window did not emit `app_ready` event fast enough. Showing window..."
|
||||
);
|
||||
|
@ -183,8 +156,7 @@ async fn main() -> tauri::Result<()> {
|
|||
app.ipc_scope().configure_remote_access(
|
||||
RemoteDomainAccessScope::new("localhost")
|
||||
.allow_on_scheme("spacedrive")
|
||||
.add_window("main")
|
||||
.enable_tauri_api(),
|
||||
.add_window("main"),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
@ -203,29 +175,6 @@ async fn main() -> tauri::Result<()> {
|
|||
])
|
||||
.build(tauri::generate_context!())?;
|
||||
|
||||
app.run(move |app_handler, event| {
|
||||
if let RunEvent::ExitRequested { .. } = event {
|
||||
debug!("Closing all open windows...");
|
||||
app_handler
|
||||
.windows()
|
||||
.iter()
|
||||
.for_each(|(window_name, window)| {
|
||||
debug!("closing window: {window_name}");
|
||||
if let Err(e) = window.close() {
|
||||
error!("failed to close window '{}': {:#?}", window_name, e);
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(node) = &node {
|
||||
block_in_place(|| block_on(node.shutdown()));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
block_in_place(|| block_on(tx.send(()))).ok();
|
||||
|
||||
app_handler.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
app.run(|_, _| {});
|
||||
Ok(())
|
||||
}
|
||||
|
|
123
apps/desktop/src-tauri/src/tauri_plugins.rs
Normal file
123
apps/desktop/src-tauri/src/tauri_plugins.rs
Normal file
|
@ -0,0 +1,123 @@
|
|||
use std::{io, net::TcpListener, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State, TypedHeader},
|
||||
headers::authorization::{Authorization, Bearer},
|
||||
http::{Request, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::Response,
|
||||
RequestPartsExt,
|
||||
};
|
||||
use rand::{distributions::Alphanumeric, Rng};
|
||||
use sd_core::{custom_uri, Node, NodeError};
|
||||
use serde::Deserialize;
|
||||
use tauri::{async_runtime::block_on, plugin::TauriPlugin, Manager, RunEvent, Runtime};
|
||||
use tokio::task::block_in_place;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
/// Inject `window.__SD_ERROR__` so the frontend can render core startup errors.
|
||||
/// It's assumed the error happened prior or during settings up the core and rspc.
|
||||
pub fn sd_error_plugin<R: Runtime>(err: NodeError) -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("sd-error")
|
||||
.js_init_script(format!(
|
||||
r#"window.__SD_ERROR__ = `{}`;"#,
|
||||
err.to_string().replace('`', "\"")
|
||||
))
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Right now Tauri doesn't support async custom URI protocols so we ship an Axum server.
|
||||
/// I began the upstream work on this: https://github.com/tauri-apps/wry/pull/872
|
||||
/// Related to https://github.com/tauri-apps/tauri/issues/3725 & https://bugs.webkit.org/show_bug.cgi?id=146351#c5
|
||||
///
|
||||
/// The server is on a random port w/ a localhost bind address and requires a random on startup auth token which is injected into the webview so this *should* be secure enough.
|
||||
pub fn sd_server_plugin<R: Runtime>(node: Arc<Node>) -> io::Result<TauriPlugin<R>> {
|
||||
let auth_token: String = rand::thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(15)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
let app = custom_uri::router(node.clone())
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
auth_token.clone(),
|
||||
auth_middleware,
|
||||
))
|
||||
.fallback(|| async { "404 Not Found: We're past the event horizon..." });
|
||||
|
||||
// Only allow current device to access it and randomise port
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let listen_addr = listener.local_addr()?;
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
|
||||
|
||||
info!("Internal server listening on: http://{:?}", listen_addr);
|
||||
tokio::spawn(async move {
|
||||
axum::Server::from_tcp(listener)
|
||||
.expect("error creating HTTP server!")
|
||||
.serve(app.into_make_service())
|
||||
.with_graceful_shutdown(async {
|
||||
rx.recv().await;
|
||||
})
|
||||
.await
|
||||
.expect("Error with HTTP server!"); // TODO: Panic handling
|
||||
});
|
||||
|
||||
Ok(tauri::plugin::Builder::new("sd-server")
|
||||
.js_init_script(format!(
|
||||
r#"window.__SD_CUSTOM_SERVER_AUTH_TOKEN__ = "{auth_token}"; window.__SD_CUSTOM_URI_SERVER__ = "http://{listen_addr}";"#
|
||||
))
|
||||
.on_event(move |app, e| {
|
||||
if let RunEvent::Exit { .. } = e {
|
||||
debug!("Closing all open windows...");
|
||||
app
|
||||
.windows()
|
||||
.iter()
|
||||
.for_each(|(window_name, window)| {
|
||||
debug!("closing window: {window_name}");
|
||||
if let Err(e) = window.close() {
|
||||
error!("failed to close window '{}': {:#?}", window_name, e);
|
||||
}
|
||||
});
|
||||
|
||||
block_in_place(|| {
|
||||
block_on(node.shutdown());
|
||||
block_on(tx.send(())).ok();
|
||||
});
|
||||
}
|
||||
})
|
||||
.build())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueryParams {
|
||||
token: String,
|
||||
}
|
||||
|
||||
async fn auth_middleware<B>(
|
||||
Query(query): Query<QueryParams>,
|
||||
State(auth_token): State<String>,
|
||||
request: Request<B>,
|
||||
next: Next<B>,
|
||||
) -> Result<Response, StatusCode>
|
||||
where
|
||||
B: Send,
|
||||
{
|
||||
let req = if query.token != auth_token {
|
||||
let (mut parts, body) = request.into_parts();
|
||||
|
||||
let auth: TypedHeader<Authorization<Bearer>> = parts
|
||||
.extract()
|
||||
.await
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
if auth.token() != auth_token {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
Request::from_parts(parts, body)
|
||||
} else {
|
||||
request
|
||||
};
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
|
@ -48,28 +48,21 @@ let customUriServerUrl = (window as any).__SD_CUSTOM_URI_SERVER__ as string | un
|
|||
const customUriAuthToken = (window as any).__SD_CUSTOM_SERVER_AUTH_TOKEN__ as string | undefined;
|
||||
const startupError = (window as any).__SD_ERROR__ as string | undefined;
|
||||
|
||||
if (customUriServerUrl === undefined || customUriServerUrl === '')
|
||||
console.warn("'window.__SD_CUSTOM_URI_SERVER__' may have not been injected correctly!");
|
||||
if (customUriServerUrl && !customUriServerUrl?.endsWith('/')) {
|
||||
customUriServerUrl += '/';
|
||||
}
|
||||
const queryParams = customUriAuthToken ? `?token=${encodeURIComponent(customUriAuthToken)}` : '';
|
||||
|
||||
const platform: Platform = {
|
||||
platform: 'tauri',
|
||||
getThumbnailUrlByThumbKey: (keyParts) =>
|
||||
convertFileSrc(
|
||||
`thumbnail/${keyParts.map((i) => encodeURIComponent(i)).join('/')}`,
|
||||
'spacedrive'
|
||||
),
|
||||
getFileUrl: (libraryId, locationLocalId, filePathId, _linux_workaround) => {
|
||||
const path = `file/${libraryId}/${locationLocalId}/${filePathId}`;
|
||||
if (_linux_workaround && customUriServerUrl) {
|
||||
const queryParams = customUriAuthToken
|
||||
? `?token=${encodeURIComponent(customUriAuthToken)}`
|
||||
: '';
|
||||
return `${customUriServerUrl}spacedrive/${path}${queryParams}`;
|
||||
} else {
|
||||
return convertFileSrc(path, 'spacedrive');
|
||||
}
|
||||
},
|
||||
`${customUriServerUrl}thumbnail/${keyParts
|
||||
.map((i) => encodeURIComponent(i))
|
||||
.join('/')}.webp${queryParams}`,
|
||||
getFileUrl: (libraryId, locationLocalId, filePathId) =>
|
||||
`${customUriServerUrl}file/${libraryId}/${locationLocalId}/${filePathId}${queryParams}`,
|
||||
openLink: shell.open,
|
||||
getOs,
|
||||
openDirectoryPickerDialog: () => dialog.open({ directory: true }),
|
||||
|
|
|
@ -15,7 +15,6 @@ sd-core = { path = "../../core", features = [
|
|||
"heif",
|
||||
] }
|
||||
rspc = { workspace = true, features = ["axum"] }
|
||||
httpz = { workspace = true, features = ["axum"] }
|
||||
axum = "0.6.18"
|
||||
tokio = { workspace = true, features = ["sync", "rt-multi-thread", "signal"] }
|
||||
tracing = { workspace = true }
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use std::{env, net::SocketAddr, path::Path};
|
||||
|
||||
use axum::routing::get;
|
||||
use sd_core::{custom_uri::create_custom_uri_endpoint, Node};
|
||||
use sd_core::{custom_uri, Node};
|
||||
use tracing::info;
|
||||
|
||||
mod utils;
|
||||
|
@ -49,10 +49,7 @@ async fn main() {
|
|||
|
||||
let app = axum::Router::new()
|
||||
.route("/health", get(|| async { "OK" }))
|
||||
.nest(
|
||||
"/spacedrive",
|
||||
create_custom_uri_endpoint(node.clone()).axum(),
|
||||
)
|
||||
.nest("/spacedrive", custom_uri::router(node.clone()))
|
||||
.nest("/rspc", router.endpoint(move || node.clone()).axum());
|
||||
|
||||
#[cfg(feature = "assets")]
|
||||
|
|
|
@ -41,7 +41,6 @@ rspc = { workspace = true, features = [
|
|||
"alpha",
|
||||
"unstable",
|
||||
] }
|
||||
httpz = { workspace = true }
|
||||
prisma-client-rust = { workspace = true }
|
||||
specta = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
|
@ -54,7 +53,6 @@ tokio = { workspace = true, features = [
|
|||
] }
|
||||
kamadak-exif = "0.5.5"
|
||||
base64 = "0.21.2"
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
chrono = { version = "0.4.25", features = ["serde"] }
|
||||
serde_json = { workspace = true }
|
||||
|
@ -98,12 +96,14 @@ int-enum = "0.5.0"
|
|||
tokio-stream = "0.1.14"
|
||||
futures-concurrency = "7.3"
|
||||
async-channel = "1.9"
|
||||
tokio-util = "0.7"
|
||||
tokio-util = { version = "0.7.8", features = ["io"] }
|
||||
slotmap = "1.0.6"
|
||||
aovec = "1.1.0"
|
||||
flate2 = "1.0.26"
|
||||
tar = "0.4.40"
|
||||
tempfile = "^3.5.0"
|
||||
axum = "0.6.20"
|
||||
http-body = "0.4.5"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
plist = "1"
|
||||
|
|
|
@ -1,237 +1,347 @@
|
|||
use crate::{
|
||||
location::file_path_helper::{file_path_to_handle_custom_uri, IsolatedFilePathData},
|
||||
prisma::{file_path, location},
|
||||
util::{db::*, error::FileIOError},
|
||||
util::{db::*, InfallibleResponse},
|
||||
Node,
|
||||
};
|
||||
|
||||
use std::{
|
||||
cmp::min,
|
||||
io,
|
||||
mem::take,
|
||||
ffi::OsStr,
|
||||
fmt::Debug,
|
||||
fs::Metadata,
|
||||
io::{self, SeekFrom},
|
||||
panic::Location,
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
time::UNIX_EPOCH,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
body::{self, Body, BoxBody, Full, StreamBody},
|
||||
extract::{self, State},
|
||||
http::{self, request, HeaderValue, Method, Request, Response, StatusCode},
|
||||
middleware::{self, Next},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use http_range::HttpRange;
|
||||
use httpz::{
|
||||
http::{response::Builder, Method, Response, StatusCode},
|
||||
Endpoint, GenericEndpoint, HttpEndpoint, Request,
|
||||
};
|
||||
use mini_moka::sync::Cache;
|
||||
use once_cell::sync::Lazy;
|
||||
use prisma_client_rust::QueryError;
|
||||
use sd_file_ext::text::is_text;
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
fs::{self, File},
|
||||
io::{AsyncReadExt, AsyncSeekExt, SeekFrom},
|
||||
fs::File,
|
||||
io::{AsyncReadExt, AsyncSeekExt},
|
||||
};
|
||||
use tracing::error;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tracing::{debug, error};
|
||||
use uuid::Uuid;
|
||||
|
||||
// This LRU cache allows us to avoid doing a DB lookup on every request.
|
||||
// The main advantage of this LRU Cache is for video files. Video files are fetch in multiple chunks and the cache prevents a DB lookup on every chunk reducing the request time from 15-25ms to 1-10ms.
|
||||
type MetadataCacheKey = (Uuid, file_path::id::Type);
|
||||
type NameAndExtension = (PathBuf, String);
|
||||
static FILE_METADATA_CACHE: Lazy<Cache<MetadataCacheKey, NameAndExtension>> =
|
||||
Lazy::new(|| Cache::new(100));
|
||||
|
||||
static MAX_TEXT_READ_LENGHT: usize = 10 * 1024; // 10KB
|
||||
const MAX_TEXT_READ_LENGTH: usize = 10 * 1024; // 10KB
|
||||
|
||||
// TODO: We should listen to events when deleting or moving a location and evict the cache accordingly.
|
||||
// TODO: Probs use this cache in rspc queries too!
|
||||
#[derive(Clone)]
|
||||
struct LocalState {
|
||||
node: Arc<Node>,
|
||||
|
||||
async fn handler(node: Arc<Node>, req: Request) -> Result<Response<Vec<u8>>, HandleCustomUriError> {
|
||||
let path = req
|
||||
.uri()
|
||||
.path()
|
||||
.strip_prefix('/')
|
||||
.unwrap_or_else(|| req.uri().path())
|
||||
.split('/')
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match path.first() {
|
||||
Some(&"thumbnail") => handle_thumbnail(&node, &path, &req).await,
|
||||
Some(&"file") => handle_file(&node, &path, &req).await,
|
||||
_ => Err(HandleCustomUriError::BadRequest("Invalid operation!")),
|
||||
}
|
||||
// This LRU cache allows us to avoid doing a DB lookup on every request.
|
||||
// The main advantage of this LRU Cache is for video files. Video files are fetch in multiple chunks and the cache prevents a DB lookup on every chunk reducing the request time from 15-25ms to 1-10ms.
|
||||
// TODO: We should listen to events when deleting or moving a location and evict the cache accordingly.
|
||||
file_metadata_cache: Cache<MetadataCacheKey, NameAndExtension>,
|
||||
}
|
||||
|
||||
async fn read_file(mut file: File, length: u64, start: Option<u64>) -> io::Result<Vec<u8>> {
|
||||
let mut buf = Vec::with_capacity(length as usize);
|
||||
if let Some(start) = start {
|
||||
file.seek(SeekFrom::Start(start)).await?;
|
||||
file.take(length).read_to_end(&mut buf).await?;
|
||||
} else {
|
||||
file.read_to_end(&mut buf).await?;
|
||||
}
|
||||
// We are using Axum on all platforms because Tauri's custom URI protocols can't be async!
|
||||
// TODO(@Oscar): Long-term hopefully this can be moved into rspc but streaming files is a hard thing for rspc to solve (Eg. how does batching work, dyn-safe handler, etc).
|
||||
pub fn router(node: Arc<Node>) -> Router<()> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/thumbnail/*path",
|
||||
get(
|
||||
|State(state): State<LocalState>,
|
||||
extract::Path(path): extract::Path<String>,
|
||||
request: Request<Body>| async move {
|
||||
let thumbnail_path = state.node.config.data_directory().join("thumbnails");
|
||||
let path = thumbnail_path.join(path);
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
// Prevent directory traversal attacks (Eg. requesting `../../../etc/passwd`)
|
||||
// For now we only support `webp` thumbnails.
|
||||
(path.starts_with(&thumbnail_path) && path.extension() == Some(OsStr::new("webp"))).then_some(()).ok_or_else(|| not_found(()))?;
|
||||
|
||||
fn cors(
|
||||
method: &Method,
|
||||
builder: &mut Builder,
|
||||
) -> Option<Result<Response<Vec<u8>>, httpz::http::Error>> {
|
||||
*builder = take(builder).header("Access-Control-Allow-Origin", "*");
|
||||
if method == Method::OPTIONS {
|
||||
Some(
|
||||
take(builder)
|
||||
.header("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "*")
|
||||
.header("Access-Control-Max-Age", "86400")
|
||||
.status(StatusCode::OK)
|
||||
.body(vec![]),
|
||||
let file = File::open(&path).await.map_err(|err| {
|
||||
InfallibleResponse::builder()
|
||||
.status(if err.kind() == io::ErrorKind::NotFound {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})
|
||||
.body(body::boxed(Full::from("")))
|
||||
})?;
|
||||
let metadata = file.metadata().await;
|
||||
serve_file(
|
||||
file,
|
||||
metadata,
|
||||
request.into_parts().0,
|
||||
InfallibleResponse::builder().header("Content-Type", HeaderValue::from_static("image/webp")),
|
||||
)
|
||||
.await
|
||||
},
|
||||
),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.route(
|
||||
"/file/:lib_id/:loc_id/:path_id",
|
||||
get(
|
||||
|State(state): State<LocalState>,
|
||||
extract::Path((lib_id, loc_id, path_id)): extract::Path<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
)>,
|
||||
request: Request<Body>| async move {
|
||||
let library_id = Uuid::from_str(&lib_id).map_err(bad_request)?;
|
||||
let location_id = loc_id.parse::<location::id::Type>().map_err(bad_request)?;
|
||||
let file_path_id = path_id.parse::<file_path::id::Type>().map_err(bad_request)?;
|
||||
|
||||
let lru_cache_key = (library_id, file_path_id);
|
||||
|
||||
let (file_path_full_path, extension) = if let Some(entry) =
|
||||
state.file_metadata_cache.get(&lru_cache_key)
|
||||
{
|
||||
entry
|
||||
} else {
|
||||
let library = state.node.libraries.get_library(&library_id).await.ok_or_else(|| internal_server_error(()))?;
|
||||
|
||||
let file_path = library
|
||||
.db
|
||||
.file_path()
|
||||
.find_unique(file_path::id::equals(file_path_id))
|
||||
.select(file_path_to_handle_custom_uri::select())
|
||||
.exec()
|
||||
.await
|
||||
.map_err(internal_server_error)?
|
||||
.ok_or_else(|| not_found(()))?;
|
||||
|
||||
let location =
|
||||
maybe_missing(&file_path.location, "file_path.location").map_err(internal_server_error)?;
|
||||
let path =
|
||||
maybe_missing(&location.path, "file_path.location.path").map_err(internal_server_error)?;
|
||||
|
||||
let lru_entry = (
|
||||
Path::new(path).join(
|
||||
IsolatedFilePathData::try_from((location_id, &file_path)).map_err(not_found)?
|
||||
),
|
||||
maybe_missing(file_path.extension, "extension").map_err(not_found)?
|
||||
);
|
||||
|
||||
state
|
||||
.file_metadata_cache
|
||||
.insert(lru_cache_key, lru_entry.clone());
|
||||
|
||||
lru_entry
|
||||
};
|
||||
|
||||
let metadata = file_path_full_path.metadata().map_err(internal_server_error)?;
|
||||
(!metadata.is_dir()).then_some(()).ok_or_else(|| not_found(()))?;
|
||||
|
||||
let mut file = File::open(&file_path_full_path).await.map_err(|err| {
|
||||
InfallibleResponse::builder()
|
||||
.status(if err.kind() == io::ErrorKind::NotFound {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})
|
||||
.body(body::boxed(Full::from("")))
|
||||
})?;
|
||||
|
||||
let resp = InfallibleResponse::builder().header("Content-Type", HeaderValue::from_str(&plz_for_the_love_of_all_that_is_good_replace_this_with_the_db_instead_of_adding_variants_to_it(&extension, &mut file, &metadata).await?).map_err(|err| {
|
||||
error!("Error converting mime-type into header value: {}", err);
|
||||
internal_server_error(())
|
||||
})?);
|
||||
|
||||
serve_file(file, Ok(metadata), request.into_parts().0, resp).await
|
||||
},
|
||||
),
|
||||
)
|
||||
.route_layer(middleware::from_fn(cors_middleware))
|
||||
.with_state(LocalState {
|
||||
node,
|
||||
file_metadata_cache: Cache::new(100),
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_thumbnail(
|
||||
node: &Node,
|
||||
path: &[&str],
|
||||
req: &Request,
|
||||
) -> Result<Response<Vec<u8>>, HandleCustomUriError> {
|
||||
let method = req.method();
|
||||
let mut builder = Response::builder();
|
||||
if let Some(response) = cors(method, &mut builder) {
|
||||
return Ok(response?);
|
||||
#[track_caller]
|
||||
fn bad_request(err: impl Debug) -> http::Response<BoxBody> {
|
||||
debug!("400: Bad Request at {}: {err:?}", Location::caller());
|
||||
|
||||
InfallibleResponse::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(body::boxed(Full::from("")))
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn not_found(err: impl Debug) -> http::Response<BoxBody> {
|
||||
debug!("404: Not Found at {}: {err:?}", Location::caller());
|
||||
|
||||
InfallibleResponse::builder()
|
||||
.status(StatusCode::NOT_FOUND)
|
||||
.body(body::boxed(Full::from("")))
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn internal_server_error(err: impl Debug) -> http::Response<BoxBody> {
|
||||
debug!(
|
||||
"500 - Internal Server Error at {}: {err:?}",
|
||||
Location::caller()
|
||||
);
|
||||
|
||||
InfallibleResponse::builder()
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(body::boxed(Full::from("")))
|
||||
}
|
||||
|
||||
async fn cors_middleware<B>(req: Request<B>, next: Next<B>) -> Response<BoxBody> {
|
||||
if req.method() == Method::OPTIONS {
|
||||
return Response::builder()
|
||||
.header("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS")
|
||||
.header("Access-Control-Allow-Headers", "*")
|
||||
.header("Access-Control-Max-Age", "86400")
|
||||
.status(StatusCode::OK)
|
||||
.body(body::boxed(Full::from("")))
|
||||
.expect("Invalid static response!");
|
||||
}
|
||||
|
||||
if path.len() < 3 {
|
||||
return Err(HandleCustomUriError::BadRequest(
|
||||
"Invalid number of parameters!",
|
||||
));
|
||||
}
|
||||
let mut response = next.run(req).await;
|
||||
|
||||
let mut thumbnail_path = node.config.data_directory().join("thumbnails");
|
||||
// if we ever wish to support multiple levels of sharding, we need only supply more params here
|
||||
for path_part in &path[1..] {
|
||||
thumbnail_path = thumbnail_path.join(path_part);
|
||||
}
|
||||
let filename = thumbnail_path.with_extension("webp");
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Access-Control-Allow-Origin", HeaderValue::from_static("*"));
|
||||
|
||||
let file = File::open(&filename).await.map_err(|err| {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
HandleCustomUriError::NotFound("file")
|
||||
} else {
|
||||
FileIOError::from((&filename, err)).into()
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Connection
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Connection", HeaderValue::from_static("Keep-Alive"));
|
||||
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("Server", HeaderValue::from_static("Spacedrive"));
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Serve a Tokio file as a HTTP response.
|
||||
///
|
||||
/// This function takes care of:
|
||||
/// - 304 Not Modified using ETag's
|
||||
/// - Range requests for partial content
|
||||
///
|
||||
/// BE AWARE this function does not do any path traversal protection so that's up to the caller!
|
||||
async fn serve_file(
|
||||
mut file: File,
|
||||
metadata: io::Result<Metadata>,
|
||||
req: request::Parts,
|
||||
mut resp: InfallibleResponse,
|
||||
) -> Result<Response<BoxBody>, Response<BoxBody>> {
|
||||
if let Ok(metadata) = metadata {
|
||||
// We only accept range queries if `files.metadata() == Ok(_)`
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Ranges
|
||||
resp = resp.header("Accept-Ranges", HeaderValue::from_static("bytes"));
|
||||
|
||||
// Empty files
|
||||
if metadata.len() == 0 {
|
||||
return Ok(resp
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Length", HeaderValue::from_static("0"))
|
||||
.body(body::boxed(Full::from(""))));
|
||||
}
|
||||
})?;
|
||||
|
||||
let content_length = file
|
||||
.metadata()
|
||||
.await
|
||||
.map_err(|e| FileIOError::from((&filename, e)))?
|
||||
.len();
|
||||
|
||||
Ok(builder
|
||||
.header("Content-Type", "image/webp")
|
||||
.header("Content-Length", content_length)
|
||||
.status(StatusCode::OK)
|
||||
.body(if method == Method::HEAD {
|
||||
vec![]
|
||||
} else {
|
||||
read_file(file, content_length, None)
|
||||
.await
|
||||
.map_err(|e| FileIOError::from((&filename, e)))?
|
||||
})?)
|
||||
}
|
||||
|
||||
async fn handle_file(
|
||||
node: &Node,
|
||||
path: &[&str],
|
||||
req: &Request,
|
||||
) -> Result<Response<Vec<u8>>, HandleCustomUriError> {
|
||||
let method = req.method();
|
||||
let mut builder = Response::builder();
|
||||
if let Some(response) = cors(method, &mut builder) {
|
||||
return Ok(response?);
|
||||
}
|
||||
|
||||
let library_id = path
|
||||
.get(1)
|
||||
.and_then(|id| Uuid::from_str(id).ok())
|
||||
.ok_or_else(|| {
|
||||
HandleCustomUriError::BadRequest("Invalid number of parameters. Missing library_id!")
|
||||
})?;
|
||||
|
||||
let location_id = path
|
||||
.get(2)
|
||||
.and_then(|id| id.parse::<location::id::Type>().ok())
|
||||
.ok_or_else(|| {
|
||||
HandleCustomUriError::BadRequest("Invalid number of parameters. Missing location_id!")
|
||||
})?;
|
||||
|
||||
let file_path_id = path
|
||||
.get(3)
|
||||
.and_then(|id| id.parse::<file_path::id::Type>().ok())
|
||||
.ok_or_else(|| {
|
||||
HandleCustomUriError::BadRequest("Invalid number of parameters. Missing file_path_id!")
|
||||
})?;
|
||||
|
||||
let lru_cache_key = (library_id, file_path_id);
|
||||
|
||||
let (file_path_full_path, extension) =
|
||||
if let Some(entry) = FILE_METADATA_CACHE.get(&lru_cache_key) {
|
||||
entry
|
||||
} else {
|
||||
let library = node
|
||||
.libraries
|
||||
.get_library(&library_id)
|
||||
.await
|
||||
.ok_or_else(|| HandleCustomUriError::NotFound("library"))?;
|
||||
|
||||
let file_path = library
|
||||
.db
|
||||
.file_path()
|
||||
.find_unique(file_path::id::equals(file_path_id))
|
||||
.select(file_path_to_handle_custom_uri::select())
|
||||
.exec()
|
||||
.await?
|
||||
.ok_or_else(|| HandleCustomUriError::NotFound("object"))?;
|
||||
|
||||
let location = maybe_missing(&file_path.location, "file_path.location")?;
|
||||
let path = maybe_missing(&location.path, "file_path.location.path")?;
|
||||
|
||||
let lru_entry = (
|
||||
Path::new(path).join(IsolatedFilePathData::try_from((location_id, &file_path))?),
|
||||
maybe_missing(file_path.extension, "extension")?,
|
||||
// ETag
|
||||
if let Ok(time) = metadata.modified() {
|
||||
let etag_header = format!(
|
||||
r#""{}""#,
|
||||
// The ETag's can be any value so we just use the modified time to make it easy.
|
||||
time.duration_since(UNIX_EPOCH)
|
||||
.expect("are you a time traveller? cause that's the only explanation for this error")
|
||||
.as_millis()
|
||||
);
|
||||
|
||||
FILE_METADATA_CACHE.insert(lru_cache_key, lru_entry.clone());
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
|
||||
if let Ok(etag_header) = HeaderValue::from_str(&etag_header) {
|
||||
resp = resp.header("etag", etag_header);
|
||||
} else {
|
||||
error!("Failed to convert ETag into header value!");
|
||||
}
|
||||
|
||||
lru_entry
|
||||
};
|
||||
if let Some(etag) = req.headers.get("If-None-Match") {
|
||||
if etag.as_bytes() == etag_header.as_bytes() {
|
||||
return Ok(resp
|
||||
.status(StatusCode::NOT_MODIFIED)
|
||||
.body(body::boxed(Full::from(""))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let metadata = fs::metadata(&file_path_full_path)
|
||||
.await
|
||||
.map_err(|e| FileIOError::from((&file_path_full_path, e)))?;
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
|
||||
if req.method == Method::GET {
|
||||
if let Some(range) = req.headers.get("range") {
|
||||
// TODO: Error handling
|
||||
let ranges = HttpRange::parse(range.to_str().map_err(bad_request)?, metadata.len())
|
||||
.map_err(bad_request)?;
|
||||
|
||||
if metadata.is_dir() {
|
||||
return Err(HandleCustomUriError::BadRequest(
|
||||
"Tried to query a directory",
|
||||
));
|
||||
// TODO: Multipart requests are not support, yet
|
||||
if ranges.len() != 1 {
|
||||
todo!(); // TODO: Error handling
|
||||
}
|
||||
let range = ranges.first().expect("checked above");
|
||||
|
||||
file.seek(SeekFrom::Start(range.start))
|
||||
.await
|
||||
.map_err(internal_server_error)?;
|
||||
|
||||
// TODO: Serve using streaming body instead of loading the entire chunk. - Right now my impl is not working correctly
|
||||
let mut buf = Vec::with_capacity(range.length as usize);
|
||||
file.take(range.length)
|
||||
.read_to_end(&mut buf)
|
||||
.await
|
||||
.map_err(internal_server_error)?;
|
||||
|
||||
return Ok(resp
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(
|
||||
"Content-Range",
|
||||
HeaderValue::from_str(&format!(
|
||||
"bytes {}-{}/{}",
|
||||
range.start,
|
||||
range.start + range.length - 1,
|
||||
metadata.len()
|
||||
))
|
||||
.map_err(internal_server_error)?,
|
||||
)
|
||||
.header(
|
||||
"Content-Length",
|
||||
HeaderValue::from_str(&range.length.to_string())
|
||||
.map_err(internal_server_error)?,
|
||||
)
|
||||
.body(body::boxed(Full::from(buf))));
|
||||
// TODO: Serve as stream instead of fixed set of bytes -> Show allow only loading part in the chunk into memory at a time. This will also be probs be required or P2P over custom URI.
|
||||
// .body(body::boxed(Limited::new(
|
||||
// StreamBody::new(ReaderStream::new(file)),
|
||||
// range.length.try_into().expect("integer overflow"),
|
||||
// )));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content_lenght = metadata.len();
|
||||
Ok(resp.body(body::boxed(StreamBody::new(ReaderStream::new(file)))))
|
||||
}
|
||||
|
||||
let mut file = File::open(&file_path_full_path).await.map_err(|err| {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
HandleCustomUriError::NotFound("file")
|
||||
} else {
|
||||
FileIOError::from((&file_path_full_path, err)).into()
|
||||
}
|
||||
})?;
|
||||
|
||||
let extension = extension.as_str();
|
||||
|
||||
// TODO: This should be determined from magic bytes when the file is indexed and stored it in the DB on the file path
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
|
||||
let mime_type = match extension {
|
||||
// TODO: This should be determined from magic bytes when the file is indexed and stored it in the DB on the file path
|
||||
async fn plz_for_the_love_of_all_that_is_good_replace_this_with_the_db_instead_of_adding_variants_to_it(
|
||||
ext: &str,
|
||||
file: &mut File,
|
||||
metadata: &Metadata,
|
||||
) -> Result<String, Response<BoxBody>> {
|
||||
let mime_type = match ext {
|
||||
// AAC audio
|
||||
"aac" => "audio/aac",
|
||||
// Musical Instrument Digital Interface (MIDI)
|
||||
|
@ -252,10 +362,13 @@ async fn handle_file(
|
|||
"avi" => "video/x-msvideo",
|
||||
// MP4 video
|
||||
"mp4" | "m4v" => "video/mp4",
|
||||
// TODO: Bruh
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
// TODO: Bruh
|
||||
// FIX-ME: This media types break macOS video rendering
|
||||
// MPEG transport stream
|
||||
"ts" => "video/mp2t",
|
||||
// TODO: Bruh
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
// FIX-ME: This media types break macOS video rendering
|
||||
// MPEG Video
|
||||
|
@ -296,22 +409,28 @@ async fn handle_file(
|
|||
_ => "text/plain",
|
||||
};
|
||||
|
||||
let mime_type = if mime_type == "text/plain" {
|
||||
let mut text_buf = vec![0; min(content_lenght as usize, MAX_TEXT_READ_LENGHT)];
|
||||
Ok(if mime_type == "text/plain" {
|
||||
let mut text_buf = vec![
|
||||
0;
|
||||
min(
|
||||
metadata.len().try_into().unwrap_or(usize::MAX),
|
||||
MAX_TEXT_READ_LENGTH
|
||||
)
|
||||
];
|
||||
if !text_buf.is_empty() {
|
||||
file.read_exact(&mut text_buf)
|
||||
.await
|
||||
.map_err(|e| FileIOError::from((&file_path_full_path, e)))?;
|
||||
.map_err(internal_server_error)?;
|
||||
file.seek(SeekFrom::Start(0))
|
||||
.await
|
||||
.map_err(|e| FileIOError::from((&file_path_full_path, e)))?;
|
||||
.map_err(internal_server_error)?;
|
||||
}
|
||||
|
||||
let charset = is_text(&text_buf, text_buf.len() == (content_lenght as usize)).unwrap_or("");
|
||||
let charset = is_text(&text_buf, text_buf.len() == (metadata.len() as usize)).unwrap_or("");
|
||||
|
||||
// Only browser recognized types, everything else should be text/plain
|
||||
// https://www.iana.org/assignments/media-types/media-types.xhtml#table-text
|
||||
let mime_type = match extension {
|
||||
let mime_type = match ext {
|
||||
// HyperText Markup Language
|
||||
"html" | "htm" => "text/html",
|
||||
// Cascading Style Sheets
|
||||
|
@ -332,9 +451,8 @@ async fn handle_file(
|
|||
"txt" => "text/plain",
|
||||
_ => {
|
||||
if charset.is_empty() {
|
||||
return Err(HandleCustomUriError::BadRequest(
|
||||
"TODO: This filetype is not supported because of the missing mime type!",
|
||||
));
|
||||
todo!();
|
||||
// "TODO: This filetype is not supported because of the missing mime type!",
|
||||
};
|
||||
mime_type
|
||||
}
|
||||
|
@ -342,178 +460,6 @@ async fn handle_file(
|
|||
|
||||
format!("{mime_type}; charset={charset}")
|
||||
} else {
|
||||
mime_type.to_owned()
|
||||
};
|
||||
|
||||
// GET is the only method for which range handling is defined, according to the spec
|
||||
// https://httpwg.org/specs/rfc9110.html#field.range
|
||||
let range = if method == Method::GET && content_lenght > 0 {
|
||||
if let Some(range) = req.headers().get("range") {
|
||||
range
|
||||
.to_str()
|
||||
.ok()
|
||||
.and_then(|range| HttpRange::parse(range, content_lenght).ok())
|
||||
.ok_or_else(|| {
|
||||
HandleCustomUriError::RangeNotSatisfiable("Error decoding range header!")
|
||||
})
|
||||
.and_then(|range| {
|
||||
// Let's support only 1 range for now
|
||||
if range.len() > 1 {
|
||||
Err(HandleCustomUriError::RangeNotSatisfiable(
|
||||
"Multiple ranges are not supported!",
|
||||
))
|
||||
} else {
|
||||
Ok(range.first().cloned())
|
||||
}
|
||||
})?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut status_code = 200;
|
||||
let buf = match range {
|
||||
Some(range) => {
|
||||
let file_size = content_lenght;
|
||||
let cropped_length = range.length;
|
||||
|
||||
// TODO: For some reason webkit2gtk doesn't like this at all.
|
||||
// It causes it to only stream random pieces of any given audio file.
|
||||
// TODO: This causes macOS to freeze streaming mp4
|
||||
#[cfg(windows)]
|
||||
let cropped_length = {
|
||||
// prevent max_length;
|
||||
// specially on webview2
|
||||
if mime_type != "application/pdf" && range.length > file_size / 3 {
|
||||
// max size sent (400kb / request)
|
||||
// as it's local file system we can afford to read more often
|
||||
min(file_size - range.start, 1024 * 400)
|
||||
} else {
|
||||
cropped_length
|
||||
}
|
||||
};
|
||||
|
||||
// last byte we are reading, the length of the range include the last byte
|
||||
// who should be skipped on the header
|
||||
let last_byte = range.start + cropped_length - 1;
|
||||
|
||||
// if the webview sent a range header, we need to send a 206 in return
|
||||
status_code = 206;
|
||||
|
||||
// macOS and Windows supports audio and video, linux only supports audio
|
||||
builder = builder
|
||||
.header("Connection", "Keep-Alive")
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.header(
|
||||
"Content-Range",
|
||||
format!("bytes {}-{}/{}", range.start, last_byte, file_size),
|
||||
);
|
||||
|
||||
// FIXME: Add ETag support (caching on the webview)
|
||||
|
||||
read_file(file, cropped_length, Some(range.start))
|
||||
.await
|
||||
.map_err(|e| FileIOError::from((&file_path_full_path, e)))?
|
||||
}
|
||||
_ if method == Method::HEAD => {
|
||||
builder = builder.header("Accept-Ranges", "bytes");
|
||||
vec![]
|
||||
}
|
||||
_ if content_lenght > 0 => read_file(file, content_lenght, None)
|
||||
.await
|
||||
.map_err(|e| FileIOError::from((&file_path_full_path, e)))?,
|
||||
_ => {
|
||||
// Empty file
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
Ok(builder
|
||||
.header("Content-type", mime_type)
|
||||
.header("Content-Length", content_lenght)
|
||||
.status(status_code)
|
||||
.body(buf)?)
|
||||
}
|
||||
|
||||
pub fn create_custom_uri_endpoint(node: Arc<Node>) -> Endpoint<impl HttpEndpoint> {
|
||||
GenericEndpoint::new(
|
||||
"/*any",
|
||||
[Method::HEAD, Method::OPTIONS, Method::GET, Method::POST],
|
||||
move |req: Request| {
|
||||
let node = node.clone();
|
||||
async move { handler(node, req).await.unwrap_or_else(Into::into) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum HandleCustomUriError {
|
||||
#[error("HandleCustomUriError::Http - {0}")]
|
||||
Http(#[from] httpz::http::Error),
|
||||
#[error("HandleCustomUriError::FileIO - {0}")]
|
||||
FileIO(#[from] FileIOError),
|
||||
#[error("HandleCustomUriError::QueryError - {0}")]
|
||||
QueryError(#[from] QueryError),
|
||||
#[error("HandleCustomUriError::BadRequest - {0}")]
|
||||
BadRequest(&'static str),
|
||||
#[error("HandleCustomUriError::RangeNotSatisfiable - invalid range {0}")]
|
||||
RangeNotSatisfiable(&'static str),
|
||||
#[error("HandleCustomUriError::NotFound - resource '{0}'")]
|
||||
NotFound(&'static str),
|
||||
#[error("HandleCustomUriError::MissingField - '{0}'")]
|
||||
MissingField(#[from] MissingFieldError),
|
||||
}
|
||||
|
||||
impl From<HandleCustomUriError> for Response<Vec<u8>> {
|
||||
fn from(value: HandleCustomUriError) -> Self {
|
||||
let builder = Response::builder().header("Content-Type", "text/plain");
|
||||
|
||||
(match value {
|
||||
HandleCustomUriError::Http(err) => {
|
||||
error!("Error creating http request/response: {:#?}", err);
|
||||
builder
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(b"Internal Server Error".to_vec())
|
||||
}
|
||||
HandleCustomUriError::FileIO(err) => {
|
||||
error!("IO error: {:#?}", err);
|
||||
builder
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(b"Internal Server Error".to_vec())
|
||||
}
|
||||
HandleCustomUriError::QueryError(err) => {
|
||||
error!("Query error: {:#?}", err);
|
||||
builder
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(b"Internal Server Error".to_vec())
|
||||
}
|
||||
HandleCustomUriError::BadRequest(msg) => {
|
||||
error!("Bad request: {}", msg);
|
||||
builder
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body(msg.as_bytes().to_vec())
|
||||
}
|
||||
HandleCustomUriError::RangeNotSatisfiable(msg) => {
|
||||
error!("Invalid Range header in request: {}", msg);
|
||||
builder
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.body(msg.as_bytes().to_vec())
|
||||
}
|
||||
HandleCustomUriError::NotFound(resource) => builder.status(StatusCode::NOT_FOUND).body(
|
||||
format!("Resource '{resource}' not found")
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
),
|
||||
HandleCustomUriError::MissingField(id) => {
|
||||
error!("Location <id = {id}> has no path");
|
||||
builder
|
||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
.body(b"Internal Server Error".to_vec())
|
||||
}
|
||||
})
|
||||
// SAFETY: This unwrap is ok as we have an hardcoded the response builders.
|
||||
.expect("internal error building hardcoded HTTP error response")
|
||||
}
|
||||
mime_type.to_string()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -116,12 +116,9 @@ impl Node {
|
|||
init_data.apply(&node.libraries, &node).await?;
|
||||
}
|
||||
|
||||
// It's import these are run after libraries are loaded!
|
||||
// Be REALLY careful about ordering here or you'll get unreliable deadlock's!
|
||||
locations_actor.start(node.clone());
|
||||
|
||||
// Finally load the libraries from disk into the library manager
|
||||
node.libraries.init(&node).await?;
|
||||
|
||||
jobs_actor.start(node.clone());
|
||||
node.p2p.start(p2p_stream, node.clone());
|
||||
|
||||
|
|
28
core/src/util/infallible_request.rs
Normal file
28
core/src/util/infallible_request.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
//! A HTTP response builder similar to the [http] crate but designed to be infallible.
|
||||
|
||||
use axum::http::{
|
||||
self, header::IntoHeaderName, response::Parts, HeaderValue, Response, StatusCode,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InfallibleResponse(Parts);
|
||||
|
||||
impl InfallibleResponse {
|
||||
pub fn builder() -> Self {
|
||||
Self(Response::new(()).into_parts().0)
|
||||
}
|
||||
|
||||
pub fn status(mut self, status: StatusCode) -> Self {
|
||||
self.0.status = status;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn header<K: IntoHeaderName>(mut self, key: K, val: HeaderValue) -> Self {
|
||||
self.0.headers.insert(key, val);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn body<B>(self, body: B) -> http::Response<B> {
|
||||
Response::from_parts(self.0, body)
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ pub mod db;
|
|||
#[cfg(debug_assertions)]
|
||||
pub mod debug_initializer;
|
||||
pub mod error;
|
||||
mod infallible_request;
|
||||
mod maybe_undefined;
|
||||
pub mod migrator;
|
||||
pub mod mpscrr;
|
||||
|
@ -10,5 +11,6 @@ mod observable;
|
|||
pub mod version_manager;
|
||||
|
||||
pub use abort_on_drop::*;
|
||||
pub use infallible_request::*;
|
||||
pub use maybe_undefined::*;
|
||||
pub use observable::*;
|
||||
|
|
|
@ -103,15 +103,7 @@ export const FileThumb = memo((props: ThumbProps) => {
|
|||
'id' in filePath &&
|
||||
(itemData.extension !== 'pdf' || pdfViewerEnabled())
|
||||
) {
|
||||
setSrc(
|
||||
platform.getFileUrl(
|
||||
library.uuid,
|
||||
locationId,
|
||||
filePath.id,
|
||||
// Workaround Linux webview not supporting playing video and audio through custom protocol urls
|
||||
itemData.kind === 'Video' || itemData.kind === 'Audio'
|
||||
)
|
||||
);
|
||||
setSrc(platform.getFileUrl(library.uuid, locationId, filePath.id));
|
||||
} else {
|
||||
setThumbType(ThumbType.Thumbnail);
|
||||
}
|
||||
|
|
|
@ -41,13 +41,11 @@ export const AddLocationButton = ({ path, className, ...props }: AddLocationButt
|
|||
className={clsx('w-full', className)}
|
||||
onClick={async () => {
|
||||
if (!path) {
|
||||
try {
|
||||
path = (await openDirectoryPickerDialog(platform)) ?? undefined;
|
||||
} catch (error) {
|
||||
showAlertDialog({ title: 'Error', value: String(error) });
|
||||
}
|
||||
path = (await openDirectoryPickerDialog(platform)) ?? undefined;
|
||||
}
|
||||
if (path)
|
||||
|
||||
// Remember `path` will be `undefined` on web cause the user has to provide it in the modal
|
||||
if (path !== '')
|
||||
dialogManager.create((dp) => (
|
||||
<AddLocationDialog path={path ?? ''} {...dp} />
|
||||
));
|
||||
|
|
|
@ -7,12 +7,7 @@ export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unkno
|
|||
export type Platform = {
|
||||
platform: 'web' | 'tauri'; // This represents the specific platform implementation
|
||||
getThumbnailUrlByThumbKey: (thumbKey: string[]) => string;
|
||||
getFileUrl: (
|
||||
libraryId: string,
|
||||
locationLocalId: number,
|
||||
filePathId: number,
|
||||
_linux_workaround?: boolean
|
||||
) => string;
|
||||
getFileUrl: (libraryId: string, locationLocalId: number, filePathId: number) => string;
|
||||
openLink: (url: string) => void;
|
||||
// Tauri patches `window.confirm` to return `Promise` not `bool`
|
||||
confirm(msg: string, cb: (result: boolean) => void): void;
|
||||
|
|
Loading…
Reference in a new issue