Add direct URL aka path navigation

This commit is contained in:
NGnius (Graham) 2023-10-29 22:06:20 -04:00
parent 9e5494035a
commit bf08bf7000
13 changed files with 143 additions and 46 deletions

26
Cargo.lock generated
View file

@ -733,7 +733,7 @@ dependencies = [
"gloo-render",
"gloo-storage",
"gloo-timers",
"gloo-utils",
"gloo-utils 0.1.6",
"gloo-worker",
]
@ -743,7 +743,7 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f"
dependencies = [
"gloo-utils",
"gloo-utils 0.1.6",
"js-sys",
"serde",
"wasm-bindgen",
@ -789,7 +789,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ce5ae65c5d76e2bbd9f274d7dcc00a306a79964305efa275a0ac728caaeb792"
dependencies = [
"gloo-events",
"gloo-utils",
"gloo-utils 0.1.6",
"serde",
"serde-wasm-bindgen",
"serde_urlencoded",
@ -807,7 +807,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-sink",
"gloo-utils",
"gloo-utils 0.1.6",
"js-sys",
"pin-project",
"serde",
@ -834,7 +834,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480"
dependencies = [
"gloo-utils",
"gloo-utils 0.1.6",
"js-sys",
"serde",
"serde_json",
@ -866,6 +866,19 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gloo-utils"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "gloo-worker"
version = "0.2.1"
@ -875,7 +888,7 @@ dependencies = [
"anymap2",
"bincode",
"gloo-console",
"gloo-utils",
"gloo-utils 0.1.6",
"js-sys",
"serde",
"wasm-bindgen",
@ -2218,6 +2231,7 @@ dependencies = [
"bytes",
"clap",
"futures",
"gloo-utils 0.2.0",
"log",
"rand",
"reqwest",

View file

@ -30,6 +30,7 @@ yew_icons = {version = "0.7", features = [
"FeatherFile",
"FeatherRefreshCcw"
] }
log = "0.4"
[target.'cfg(target_arch = "wasm32")'.dependencies]
yew = { version = "0.20", features = [ "csr", "hydration" ] }
@ -42,9 +43,11 @@ web-sys = { version = "0.3", features = [
"RequestMode",
"Response",
"Window",
"Location",
"History",
] }
gloo-utils = "0.2"
wasm-logger = "0.2"
log = "0.4"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
yew = { version = "0.20", features = [ "ssr" ] }

View file

@ -54,7 +54,7 @@ pub async fn file(path: web::Path<String>, auth: BasicAuth, query: web::Query<Fi
let req_disposition = query.mode.unwrap_or_else(
|| DeliveryMode::default_from_ext(filepath.extension())
).disposition();
println!("file PATH: {}", filepath.display());
log::debug!("file PATH: {}", filepath.display());
Ok(
actix_files::NamedFile::open_async(&filepath).await?
.prefer_utf8(false)
@ -94,13 +94,25 @@ pub async fn dir(path: web::Path<String>, auth: BasicAuth) -> std::io::Result<im
if !args.authenticate(auth.user_id(), auth.password().unwrap_or("")).await {
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Basic Authentication failed"))
}
let root = args.dir.unwrap();
let domain = args.domain;
let filepath = root.join(&*path);
let entries = get_dir(&path, &args)?;
Ok(
web::Json(entries)
)
}
pub fn get_dir(end_path: &str, args: &super::CliArgs) -> std::io::Result<Vec<FileEntry>> {
let root = args.dir.as_ref().unwrap();
let domain = &args.domain;
let filepath = root.join(end_path);
let scheme = if args.ssl {"https"} else {"http"};
println!("dir PATH: {}", filepath.display());
let mut entries = Vec::new();
if !(path.is_empty() || &*path == "/") && filepath.parent().is_some() {
if let Ok(root_canon) = root.canonicalize() {
if !filepath.canonicalize()?.starts_with(root_canon) {
return Ok(entries);
}
}
if !(end_path.is_empty() || &*end_path == "/") && filepath.parent().is_some() {
let external_path = filepath.parent().expect("Path has no parent")
.strip_prefix(&root).expect("path does not start with root path!").to_path_buf();
let link = format!("{}://{}/api/listdir/{}", scheme, domain, external_path.to_string_lossy());
@ -111,7 +123,6 @@ pub async fn dir(path: web::Path<String>, auth: BasicAuth) -> std::io::Result<im
url: url_escape::encode_path(&link).to_owned().to_string(),
});
}
//let pseudo_root = std::path::PathBuf::from("/");
// build dir entry json
for e in filepath.read_dir()? {
let entry = e?;
@ -136,7 +147,5 @@ pub async fn dir(path: web::Path<String>, auth: BasicAuth) -> std::io::Result<im
}
// sort alphabetically
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(
web::Json(entries)
)
Ok(entries)
}

View file

@ -4,6 +4,13 @@ use actix_web_httpauth::extractors::basic::BasicAuth;
use bytes::Bytes;
use futures::stream::{self, Stream, StreamExt};
use serde::Deserialize;
#[derive(Deserialize, Default, Clone)]
pub struct IndexQuery {
path: Option<std::path::PathBuf>,
}
type BoxedError = Box<dyn std::error::Error + 'static>;
pub struct IndexPage {
@ -12,8 +19,11 @@ pub struct IndexPage {
}
impl IndexPage {
async fn render(&self) -> impl Stream<Item = Result<Bytes, BoxedError>> + Send {
let renderer = yew::ServerRenderer::<crate::ui::App>::new();
async fn render(&self, query: IndexQuery) -> impl Stream<Item = Result<Bytes, BoxedError>> + Send {
let renderer = yew::ServerRenderer::<crate::ui::App>::with_props(|| crate::ui::AppProps {
starting_path: query.path.clone(),
starting_files: query.path.map(|start| super::get_files::get_dir(&start.to_string_lossy(), &super::CliArgs::get()).ok()).flatten()
});
let before = self.before.clone();
let after = self.after.clone();
@ -36,17 +46,17 @@ impl IndexPage {
}
#[get("/")]
pub async fn index_auth(page: web::Data<IndexPage>, auth: BasicAuth) -> std::io::Result<impl Responder> {
pub async fn index_auth(page: web::Data<IndexPage>, query: web::Query<IndexQuery>, auth: BasicAuth) -> std::io::Result<impl Responder> {
let args = super::CliArgs::get();
if !args.authenticate(auth.user_id(), auth.password().unwrap_or("")).await {
return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Basic Authentication failed"))
}
Ok(HttpResponse::Ok()
.streaming(page.render().await))
.streaming(page.render((&*query).clone()).await))
}
#[get("/")]
pub async fn index_no_auth(page: web::Data<IndexPage>) -> std::io::Result<impl Responder> {
Ok(HttpResponse::Ok()
.streaming(page.render().await))
.streaming(page.render(IndexQuery::default()).await))
}

View file

@ -13,7 +13,7 @@ use yarrr::api::{
async fn main() -> std::io::Result<()> {
let args = yarrr::api::CliArgs::get();
println!("cli: {:?}", args);
log::info!("cli: {:?}", args);
if args.dir.is_some() {
HttpServer::new(|| {

View file

@ -1,8 +1,20 @@
use yarrr::ui::App;
#[cfg(target_arch = "wasm32")]
fn main() {
yew::Renderer::<App>::new().hydrate();
wasm_logger::init(wasm_logger::Config::new(log::Level::Info));
let url_str = gloo_utils::document()
.location()
.expect("Location init failed")
.href()
.expect("Location.href does not exist");
let url = reqwest::Url::parse(&url_str)
.expect("Failed to parse URL");
let props = url.query_pairs()
.filter(|(param, _)| param == "path")
.map(|(_, start_path)| yarrr::ui::AppProps { starting_path: Some(std::path::PathBuf::from(&*start_path)), starting_files: None, })
.next()
.unwrap_or_else(|| yarrr::ui::AppProps { starting_path: None, starting_files: None, });
log::debug!("Hydrating yew renderer");
yew::Renderer::<yarrr::ui::App>::with_props(props).hydrate();
}
#[cfg(not(target_arch = "wasm32"))]

View file

@ -1,10 +1,16 @@
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct Props {
pub starting_path: Option<std::path::PathBuf>,
pub starting_files: Option<Vec<crate::data::FileEntry>>,
}
#[function_component(App)]
pub fn app() -> Html {
pub fn app(props: &Props) -> Html {
html! {
<Suspense fallback={html!{"..."}}>
<super::Landing />
<super::Landing starting_path={props.starting_path.clone()} starting_files={props.starting_files.clone()}/>
</Suspense>
}
}

View file

@ -9,7 +9,8 @@ use crate::data::FileEntry;
type FileEntryVec = Vec<FileEntry>;
async fn listdir(scheme: &str, domain: &str, path: &str) -> reqwest::Result<FileEntryVec> {
let url = format!("{}://{}/api/listdir/{}", scheme, domain, path.trim_start_matches("/"));
let url = format!("{}://{}/api/listdir/{}", scheme, domain, path.trim_start_matches("/").trim_start_matches(".."));
log::debug!("Directory API ({}): what does {} contain?", url, path);
reqwest::get(&url)
.await?
.json()
@ -34,6 +35,8 @@ pub struct Props {
pub scheme: String,
pub domain: String,
pub path: String,
pub prepared_path: String,
pub prepared_files: Option<FileEntryVec>,
}
pub struct FileExplorer {
@ -45,10 +48,18 @@ impl Component for FileExplorer {
type Message = Msg;
type Properties = Props;
fn create(_ctx: &Context<Self>) -> Self {
Self {
files: FetchState::NotFetching,
cwd: "???".to_owned(),
fn create(ctx: &Context<Self>) -> Self {
log::debug!("prepared_path ({}) == path ({})? {}", ctx.props().prepared_path, ctx.props().path, ctx.props().prepared_path == ctx.props().path);
if ctx.props().prepared_path == ctx.props().path {
Self {
files: ctx.props().prepared_files.as_ref().map(|files| FetchState::Success(files.clone())).unwrap_or(FetchState::NotFetching),
cwd: ctx.props().prepared_path.clone(),
}
} else {
Self {
files: FetchState::NotFetching,
cwd: "???".to_owned(),
}
}
}
@ -107,6 +118,7 @@ impl Component for FileExplorer {
</div>
},
FetchState::Success(data) => {
log::debug!("cwd ({}) != path ({})? {}", self.cwd, ctx.props().path, self.cwd != ctx.props().path);
if self.cwd != ctx.props().path {
ctx.link().send_message(Msg::Load);
}

View file

@ -20,10 +20,20 @@ pub fn entry(props: &Props) -> Html {
} else {
html! { <Icon icon_id={IconId::FeatherFolder} width={icon_dimension.clone()} height={icon_dimension.clone()}/> }
};
let navigation_url = format!("{}://{}/?path={}",
if s_ctx.ssl { "https" } else { "http" },
s_ctx.domain,
props.entry.path.to_string_lossy());
let navigation_url2 = navigation_url.clone();
#[cfg(target_arch = "wasm32")]
let onclick_closure = move |event: web_sys::MouseEvent| {
event.prevent_default();
fs_ctx.dispatch(super::FilesystemCtxAction::NavigateTo(path.clone(), navigation_url.clone()));
};
#[cfg(not(target_arch = "wasm32"))]
let onclick_closure = move |_| fs_ctx.dispatch(super::FilesystemCtxAction::NavigateTo(path.clone(), navigation_url.clone()));
html! {
<a href={"#"} class={classes!("yarrr-file-entry-link")} onclick={
move |_| fs_ctx.dispatch(super::FilesystemCtxAction::NavigateTo(path.clone()))
}>
<a href={navigation_url2} class={classes!("yarrr-file-entry-link")} onclick={onclick_closure}>
<div class={classes!("yarrr-file-entry-dir")}>
<div class={classes!("yarrr-file-entry-icon")}>
{icon}

View file

@ -3,7 +3,7 @@ use std::path::PathBuf;
use yew::prelude::*;
pub enum FilesystemCtxAction {
NavigateTo(PathBuf),
NavigateTo(PathBuf, String),
}
#[derive(Clone, PartialEq, Eq)]
@ -14,9 +14,9 @@ pub struct FilesystemCtx {
pub type FilesystemContext = UseReducerHandle<FilesystemCtx>;
impl FilesystemCtx {
pub fn init() -> Self {
pub fn init(cwd: &Option<PathBuf>) -> Self {
Self {
cwd: PathBuf::from(""),
cwd: cwd.as_ref().map(|x| x.to_owned()).unwrap_or_else(|| PathBuf::from("")),
}
}
}
@ -26,7 +26,14 @@ impl Reducible for FilesystemCtx {
fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
let cwd_new = match action {
FilesystemCtxAction::NavigateTo(path) => path,
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
FilesystemCtxAction::NavigateTo(path, url) => {
#[cfg(target_arch = "wasm32")]
gloo_utils::history()
.push_state_with_url(&wasm_bindgen::JsValue::undefined(), &path.to_string_lossy(), Some(&url))
.unwrap_or(());
path
},
};
Rc::new(Self { cwd: cwd_new })
}

View file

@ -1,24 +1,34 @@
use yew::prelude::*;
use std::rc::Rc;
#[derive(Properties, PartialEq)]
pub struct Props {
pub starting_path: Option<std::path::PathBuf>,
pub starting_files: Option<Vec<crate::data::FileEntry>>,
}
#[function_component(Landing)]
pub fn landing() -> HtmlResult {
pub fn landing(props: &Props) -> HtmlResult {
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
let starting_path = props.starting_path.clone();
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))]
let starting_files = props.starting_files.clone();
let server_ctx = use_prepared_state!(
async move |_| -> super::ServerCtx { super::build_server_ctx().await }, ()
async move |_| -> super::ServerCtx { super::build_server_ctx(starting_path, starting_files).await }, ()
)?.expect("Missing server-provided context");
let fs_ctx = use_reducer(super::dir::FilesystemCtx::init);
let fs_ctx = use_reducer(|| super::dir::FilesystemCtx::init(&props.starting_path));
if server_ctx.root_dir.is_some() {
let scheme = if server_ctx.ssl {"https"} else {"http"}.to_owned();
let domain = server_ctx.domain.clone();
let prepared_files = server_ctx.files.clone();
let path = fs_ctx.cwd.to_string_lossy().to_string();
Ok(html! {
<ContextProvider<Rc<super::ServerCtx>> context={server_ctx}>
<img src="/banner.png" class={classes!("yarrr-proof-of-purchase")}/>
<ContextProvider<super::dir::FilesystemContext> context={fs_ctx}>
<super::dir::FileExplorer {scheme} {domain} {path}/>
<super::dir::FileExplorer {scheme} {domain} {path} prepared_path={props.starting_path.clone().unwrap_or("".into()).to_string_lossy().to_string()} {prepared_files}/>
</ContextProvider<super::dir::FilesystemContext>>
</ContextProvider<Rc<super::ServerCtx>>>
})

View file

@ -4,7 +4,7 @@ mod landing;
mod rant;
mod server_ctx;
pub use app::App;
pub use app::{App, Props as AppProps};
pub use landing::Landing;
pub use rant::Rant;
pub use server_ctx::ServerCtx;

View file

@ -5,14 +5,18 @@ pub struct ServerCtx {
pub domain: String,
pub root_dir: Option<std::path::PathBuf>,
pub ssl: bool,
pub path: Option<std::path::PathBuf>,
pub files: Option<Vec<crate::data::FileEntry>>,
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn build_server_ctx() -> ServerCtx {
pub async fn build_server_ctx(path: Option<std::path::PathBuf>, files: Option<Vec<crate::data::FileEntry>>) -> ServerCtx {
let args = crate::api::CliArgs::get();
ServerCtx {
domain: args.domain,
root_dir: args.dir,
ssl: args.ssl,
path,
files,
}
}