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:
Oscar Beaumont 2023-08-31 14:54:40 +08:00 committed by GitHub
parent 39549ef74c
commit f821bb9a0f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 517 additions and 620 deletions

27
Cargo.lock generated
View file

@ -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",

View file

@ -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" }

View file

@ -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]

View file

@ -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()
}

View file

@ -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(())
}

View 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)
}

View file

@ -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 }),

View file

@ -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 }

View file

@ -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")]

View file

@ -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"

View file

@ -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()
})
}

View file

@ -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());

View 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)
}
}

View file

@ -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::*;

View file

@ -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);
}

View file

@ -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} />
));

View file

@ -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;