diff --git a/Cargo.lock b/Cargo.lock index 532a728..bd856c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1485,6 +1485,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1823,6 +1833,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", ] [[package]] @@ -3377,6 +3388,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -3420,6 +3437,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + [[package]] name = "utf8parse" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index a34afd7..abb68cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ boilerplate = { version = "1.0.0" } html-escaper = "0.2.0" tower-http = { version = "0.5.2", features = ["fs"] } readonly = "0.2.12" +url = "2.5.0" [patch.crates-io] diff --git a/src/html/page/mod.rs b/src/html/page/mod.rs index 765ec18..9384efe 100644 --- a/src/html/page/mod.rs +++ b/src/html/page/mod.rs @@ -1,4 +1,5 @@ pub mod block; pub mod not_found; +pub mod redirect_qs_to_path; pub mod root; pub mod utxo; diff --git a/src/html/page/redirect_qs_to_path.rs b/src/html/page/redirect_qs_to_path.rs new file mode 100644 index 0000000..f3265b8 --- /dev/null +++ b/src/html/page/redirect_qs_to_path.rs @@ -0,0 +1,116 @@ +use axum::extract::RawQuery; +use axum::extract::State; +use axum::response::Redirect; +use axum::response::Response; +use std::sync::Arc; +// use axum::routing::get; +// use axum::routing::Router; +use super::not_found::not_found_html_response; +use axum::response::IntoResponse; +use std::collections::HashSet; +// use super::root::root; +// use super::utxo::utxo_page; +use crate::model::app_state::AppState; +// use neptune_explorer::model::config::Config; + +/// This converts a query string into a path and redirects browser. +/// +/// Purpose: enable a javascript-free website. +/// +/// Problem being solved: +/// +/// Our axum routes are all paths, however an HTML form submits user input as a query-string. +/// We need to convert that query string into a path. +/// +/// We also want the end-user to see a nice path in the browser url +/// for purposes of copy/paste, etc. +/// +/// Browser form submits: We want: +/// /utxo?utxo=5&l=Submit /utxo/5 +/// /block?height=15&l=Submit /block/height/15 +/// +/// Solution: +/// +/// 1. We submit all browser forms to /rqs with method=get. +/// (note: rqs is short for redirect-query-string) +/// 2. /rqs calls this redirect_query_string_to_path() handler. +/// 3. query-string is transformed to path as follows: +/// a) if _ig key is present, the value is split by ',' +/// to obtain a list of query-string keys to ignore. +/// b. each key/val is converted to: +/// key (if val is empty) +/// key/val (if val is not empty) +/// c) any keys in the _ig list are ignored. +/// d) keys and vals are url encoded +/// e) each resulting /key or /key/val is appended to the path. +/// 4. a 301 redirect to the new path is sent to the browser. +/// +/// An html form might look like: +/// +///
+/// +/// +/// +/// Block height or digest: +/// +/// +///
+/// +/// note that the submit with name "l" is ignored because +/// of _ig=l. We could ignore a list of fields also +/// eg _ig=field1,field2,field3,etc. +/// +/// Order of keys in the query-string (and form) is important. +/// +/// Any keys that are not ignored are translated into a +/// path in the order received. Eg: +/// /rqs?block=&height_or_digest=10 --> /block/height_or_digest/10 +/// /rqs?height_or_digest=10&block= --> /height_or_digest/10/block +/// +/// A future enhancement could be to add an optional field for specifying the +/// path order. That would enable re-ordering of inputs in a form without +/// altering the resulting path. For now, our forms are so simple, that is not +/// needed. +#[axum::debug_handler] +pub async fn redirect_query_string_to_path( + RawQuery(raw_query_option): RawQuery, + State(state): State>, +) -> Result { + let not_found = || not_found_html_response(State(state.clone()), None); + + let raw_query = raw_query_option.ok_or_else(not_found)?; + + // note: we construct a fake-url so we can use Url::query_pairs(). + let fake_url = format!("http://127.0.0.1/?{}", raw_query); + let url = url::Url::parse(&fake_url).map_err(|_| not_found())?; + let query_vars: Vec<(String, _)> = url.query_pairs().into_owned().collect(); + + const IGNORE_QS_VAR: &str = "_ig"; + + let ignore_keys: HashSet<_> = match query_vars.iter().find(|(k, _)| k == IGNORE_QS_VAR) { + Some((_k, v)) => v.split(',').collect(), + None => Default::default(), + }; + + let mut new_path: String = Default::default(); + + for (key, val) in query_vars.iter() { + if key == IGNORE_QS_VAR || ignore_keys.contains(key as &str) { + continue; + } + let parts = match val.is_empty() { + false => format!("/{}/{}", url_encode(key), url_encode(val)), + true => format!("/{}", url_encode(key)), + }; + new_path += &parts; + } + + match new_path.is_empty() { + true => Err(not_found()), + false => Ok(Redirect::permanent(&new_path).into_response()), + } +} + +fn url_encode(s: &str) -> String { + url::form_urlencoded::byte_serialize(s.as_bytes()).collect() +} diff --git a/src/main.rs b/src/main.rs index a1e8b11..da3bde3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use neptune_core::rpc_server::RPCClient; use neptune_explorer::html::page::block::block_page; use neptune_explorer::html::page::block::block_page_with_value; use neptune_explorer::html::page::not_found::not_found_html_fallback; +use neptune_explorer::html::page::redirect_qs_to_path::redirect_query_string_to_path; use neptune_explorer::html::page::root::root; use neptune_explorer::html::page::utxo::utxo_page; use neptune_explorer::model::app_state::AppState; @@ -58,6 +59,8 @@ async fn main() -> Result<(), RpcError> { .route("/block/:selector", get(block_page)) .route("/block/:selector/:value", get(block_page_with_value)) .route("/utxo/:value", get(utxo_page)) + // -- Rewrite query-strings to path -- + .route("/rqs", get(redirect_query_string_to_path)) // -- Static files -- .nest_service( "/css", diff --git a/templates/web/html/page/root.html b/templates/web/html/page/root.html index 213c282..9957ee2 100644 --- a/templates/web/html/page/root.html +++ b/templates/web/html/page/root.html @@ -2,22 +2,6 @@ {{self.state.config.site_name}}: (network: {{self.state.network}}) {{ html_escaper::Trusted(include_str!( concat!(env!("CARGO_MANIFEST_DIR"), "/templates/web/html/components/head.html"))) }} -
@@ -35,7 +19,9 @@ The blockchain tip is at height: {{self.tip_height}} Block Lookup -
+ + + 🛈 Provide a numeric block height or hexadecimal digest identifier to lookup any block in the Neptune blockchain. @@ -44,7 +30,7 @@ The blockchain tip is at height: {{self.tip_height}} Block height or digest: - + Quick Lookup: @@ -56,7 +42,8 @@ Quick Lookup:
UTXO Lookup -
+ + 🛈 An Unspent Transaction Output (UTXO) index can be found in the output of neptune-cli wallet-status. Look for the field: aocl_leaf_index @@ -64,7 +51,7 @@ Quick Lookup: UTXO index: - +