diff --git a/Cargo.lock b/Cargo.lock index cbb85572f..bda84ce62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,11 @@ dependencies = [ "syntex_syntax 0.33.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "base32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "bigint" version = "1.0.0" @@ -409,6 +414,7 @@ dependencies = [ name = "ethcore-dapps" version = "1.6.0" dependencies = [ + "base32 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", "clippy 0.0.103 (registry+https://github.com/rust-lang/crates.io-index)", "env_logger 0.3.3 (registry+https://github.com/rust-lang/crates.io-index)", "ethcore-devtools 1.6.0", @@ -2463,6 +2469,7 @@ dependencies = [ "checksum app_dirs 1.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "b7d1c0d48a81bbb13043847f957971f4d87c81542d80ece5e84ba3cba4058fd4" "checksum arrayvec 0.3.16 (registry+https://github.com/rust-lang/crates.io-index)" = "16e3bdb2f54b3ace0285975d59a97cf8ed3855294b2b6bc651fcf22a9c352975" "checksum aster 0.17.0 (registry+https://github.com/rust-lang/crates.io-index)" = "07d344974f0a155f091948aa389fb1b912d3a58414fbdb9c8d446d193ee3496a" +"checksum base32 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "1b9605ba46d61df0410d8ac686b0007add8172eba90e8e909c347856fe794d8c" "checksum bigint 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "2311bcd71b281e142a095311c22509f0d6bcd87b3000d7dbaa810929b9d6f6ae" "checksum bit-set 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d9bf6104718e80d7b26a68fdbacff3481cfc05df670821affc7e9cbc1884400c" "checksum bit-vec 0.4.3 (registry+https://github.com/rust-lang/crates.io-index)" = "5b97c2c8e8bbb4251754f559df8af22fb264853c7d009084a576cdf12565089d" diff --git a/dapps/Cargo.toml b/dapps/Cargo.toml index 09c72cb47..459a5ea70 100644 --- a/dapps/Cargo.toml +++ b/dapps/Cargo.toml @@ -23,6 +23,7 @@ serde = "0.8" serde_json = "0.8" linked-hash-map = "0.3" parity-dapps-glue = "1.4" +base32 = "0.3" mime = "0.2" mime_guess = "1.6.1" time = "0.1.35" diff --git a/dapps/src/api/api.rs b/dapps/src/api/api.rs index 305d9bc83..b521c0ba1 100644 --- a/dapps/src/api/api.rs +++ b/dapps/src/api/api.rs @@ -123,6 +123,7 @@ impl server::Handler for RestApiRouter { return Next::write(); } + // TODO [ToDr] Consider using `path.app_params` instead let url = extract_url(&request); if url.is_none() { // Just return 404 if we can't parse URL diff --git a/dapps/src/apps/mod.rs b/dapps/src/apps/mod.rs index 1959940e7..b85f0dde9 100644 --- a/dapps/src/apps/mod.rs +++ b/dapps/src/apps/mod.rs @@ -32,8 +32,8 @@ pub mod manifest; extern crate parity_ui; -pub const HOME_PAGE: &'static str = "home"; -pub const DAPPS_DOMAIN: &'static str = ".parity"; +pub const HOME_PAGE: &'static str = "parity"; +pub const DAPPS_DOMAIN: &'static str = ".web3.site"; pub const RPC_PATH: &'static str = "rpc"; pub const API_PATH: &'static str = "api"; pub const UTILS_PATH: &'static str = "parity-utils"; diff --git a/dapps/src/endpoint.rs b/dapps/src/endpoint.rs index 5bfa1c3d5..648d82ff8 100644 --- a/dapps/src/endpoint.rs +++ b/dapps/src/endpoint.rs @@ -22,6 +22,7 @@ use std::collections::BTreeMap; #[derive(Debug, PartialEq, Default, Clone)] pub struct EndpointPath { pub app_id: String, + pub app_params: Vec, pub host: String, pub port: u16, pub using_dapps_domains: bool, diff --git a/dapps/src/lib.rs b/dapps/src/lib.rs index 4cf9c333f..9ae163f88 100644 --- a/dapps/src/lib.rs +++ b/dapps/src/lib.rs @@ -19,6 +19,7 @@ #![warn(missing_docs)] #![cfg_attr(feature="nightly", plugin(clippy))] +extern crate base32; extern crate hyper; extern crate time; extern crate url as url_lib; @@ -91,11 +92,11 @@ impl SyncStatus for F where F: Fn() -> bool + Send + Sync { /// Validates Web Proxy tokens pub trait WebProxyTokens: Send + Sync { /// Should return true if token is a valid web proxy access token. - fn is_web_proxy_token_valid(&self, token: &String) -> bool; + fn is_web_proxy_token_valid(&self, token: &str) -> bool; } impl WebProxyTokens for F where F: Fn(String) -> bool + Send + Sync { - fn is_web_proxy_token_valid(&self, token: &String) -> bool { self(token.to_owned()) } + fn is_web_proxy_token_valid(&self, token: &str) -> bool { self(token.to_owned()) } } /// Webapps HTTP+RPC server build. @@ -409,6 +410,6 @@ mod util_tests { // then assert_eq!(none, Vec::::new()); - assert_eq!(some, vec!["http://home.parity".to_owned(), "http://127.0.0.1:18180".into()]); + assert_eq!(some, vec!["http://parity.web3.site".to_owned(), "http://127.0.0.1:18180".into()]); } } diff --git a/dapps/src/page/handler.rs b/dapps/src/page/handler.rs index 31e5da072..c2b68f750 100644 --- a/dapps/src/page/handler.rs +++ b/dapps/src/page/handler.rs @@ -252,6 +252,7 @@ fn should_extract_path_with_appid() { prefix: None, path: EndpointPath { app_id: "app".to_owned(), + app_params: vec![], host: "".to_owned(), port: 8080, using_dapps_domains: true, diff --git a/dapps/src/router/mod.rs b/dapps/src/router/mod.rs index 116ac7d8e..dbaf4dbb0 100644 --- a/dapps/src/router/mod.rs +++ b/dapps/src/router/mod.rs @@ -97,9 +97,7 @@ impl server::Handler for Router { => { trace!(target: "dapps", "Redirecting to correct web request: {:?}", referer_url); - // TODO [ToDr] Some nice util for this! - let using_domain = if referer.using_dapps_domains { 0 } else { 1 }; - let len = cmp::min(referer_url.path.len(), using_domain + 3); // token + protocol + hostname + let len = cmp::min(referer_url.path.len(), 2); // /web// let base = referer_url.path[..len].join("/"); let requested = url.map(|u| u.path.join("/")).unwrap_or_default(); Redirection::boxed(&format!("/{}/{}", base, requested)) @@ -262,20 +260,27 @@ fn extract_endpoint(url: &Option) -> (Option, SpecialEndpoint match *url { Some(ref url) => match url.host { Host::Domain(ref domain) if domain.ends_with(DAPPS_DOMAIN) => { - let len = domain.len() - DAPPS_DOMAIN.len(); - let id = domain[0..len].to_owned(); + let id = &domain[0..(domain.len() - DAPPS_DOMAIN.len())]; + let (id, params) = if let Some(split) = id.rfind('.') { + let (params, id) = id.split_at(split); + (id[1..].to_owned(), [params.to_owned()].into_iter().chain(&url.path).cloned().collect()) + } else { + (id.to_owned(), url.path.clone()) + }; (Some(EndpointPath { app_id: id, + app_params: params, host: domain.clone(), port: url.port, using_dapps_domains: true, }), special_endpoint(url)) }, _ if url.path.len() > 1 => { - let id = url.path[0].clone(); + let id = url.path[0].to_owned(); (Some(EndpointPath { - app_id: id.clone(), + app_id: id, + app_params: url.path[1..].to_vec(), host: format!("{}", url.host), port: url.port, using_dapps_domains: false, @@ -296,6 +301,7 @@ fn should_extract_endpoint() { extract_endpoint(&Url::parse("http://localhost:8080/status/index.html").ok()), (Some(EndpointPath { app_id: "status".to_owned(), + app_params: vec!["index.html".to_owned()], host: "localhost".to_owned(), port: 8080, using_dapps_domains: false, @@ -307,6 +313,7 @@ fn should_extract_endpoint() { extract_endpoint(&Url::parse("http://localhost:8080/rpc/").ok()), (Some(EndpointPath { app_id: "rpc".to_owned(), + app_params: vec!["".to_owned()], host: "localhost".to_owned(), port: 8080, using_dapps_domains: false, @@ -314,10 +321,11 @@ fn should_extract_endpoint() { ); assert_eq!( - extract_endpoint(&Url::parse("http://my.status.parity/parity-utils/inject.js").ok()), + extract_endpoint(&Url::parse("http://my.status.web3.site/parity-utils/inject.js").ok()), (Some(EndpointPath { - app_id: "my.status".to_owned(), - host: "my.status.parity".to_owned(), + app_id: "status".to_owned(), + app_params: vec!["my".to_owned(), "parity-utils".into(), "inject.js".into()], + host: "my.status.web3.site".to_owned(), port: 80, using_dapps_domains: true, }), SpecialEndpoint::Utils) @@ -325,10 +333,11 @@ fn should_extract_endpoint() { // By Subdomain assert_eq!( - extract_endpoint(&Url::parse("http://my.status.parity/test.html").ok()), + extract_endpoint(&Url::parse("http://status.web3.site/test.html").ok()), (Some(EndpointPath { - app_id: "my.status".to_owned(), - host: "my.status.parity".to_owned(), + app_id: "status".to_owned(), + app_params: vec!["test.html".to_owned()], + host: "status.web3.site".to_owned(), port: 80, using_dapps_domains: true, }), SpecialEndpoint::None) @@ -336,10 +345,11 @@ fn should_extract_endpoint() { // RPC by subdomain assert_eq!( - extract_endpoint(&Url::parse("http://my.status.parity/rpc/").ok()), + extract_endpoint(&Url::parse("http://my.status.web3.site/rpc/").ok()), (Some(EndpointPath { - app_id: "my.status".to_owned(), - host: "my.status.parity".to_owned(), + app_id: "status".to_owned(), + app_params: vec!["my".to_owned(), "rpc".into(), "".into()], + host: "my.status.web3.site".to_owned(), port: 80, using_dapps_domains: true, }), SpecialEndpoint::Rpc) @@ -347,10 +357,11 @@ fn should_extract_endpoint() { // API by subdomain assert_eq!( - extract_endpoint(&Url::parse("http://my.status.parity/api/").ok()), + extract_endpoint(&Url::parse("http://my.status.web3.site/api/").ok()), (Some(EndpointPath { - app_id: "my.status".to_owned(), - host: "my.status.parity".to_owned(), + app_id: "status".to_owned(), + app_params: vec!["my".to_owned(), "api".into(), "".into()], + host: "my.status.web3.site".to_owned(), port: 80, using_dapps_domains: true, }), SpecialEndpoint::Api) diff --git a/dapps/src/tests/api.rs b/dapps/src/tests/api.rs index 3c0ae528a..05e285264 100644 --- a/dapps/src/tests/api.rs +++ b/dapps/src/tests/api.rs @@ -143,7 +143,7 @@ fn should_return_signer_port_cors_headers_for_home_parity() { "\ POST /api/ping HTTP/1.1\r\n\ Host: localhost:8080\r\n\ - Origin: http://home.parity\r\n\ + Origin: http://parity.web3.site\r\n\ Connection: close\r\n\ \r\n\ {} @@ -153,8 +153,8 @@ fn should_return_signer_port_cors_headers_for_home_parity() { // then assert_eq!(response.status, "HTTP/1.1 200 OK".to_owned()); assert!( - response.headers_raw.contains("Access-Control-Allow-Origin: http://home.parity"), - "CORS header for home.parity missing: {:?}", + response.headers_raw.contains("Access-Control-Allow-Origin: http://parity.web3.site"), + "CORS header for parity.web3.site missing: {:?}", response.headers ); } diff --git a/dapps/src/tests/fetch.rs b/dapps/src/tests/fetch.rs index 8f2e22e5c..4f343b3a3 100644 --- a/dapps/src/tests/fetch.rs +++ b/dapps/src/tests/fetch.rs @@ -31,7 +31,7 @@ fn should_resolve_dapp() { let response = request(server, "\ GET / HTTP/1.1\r\n\ - Host: 1472a9e190620cdf6b31f383373e45efcfe869a820c91f9ccd7eb9fb45e4985d.parity\r\n\ + Host: 1472a9e190620cdf6b31f383373e45efcfe869a820c91f9ccd7eb9fb45e4985d.web3.site\r\n\ Connection: close\r\n\ \r\n\ " @@ -52,7 +52,7 @@ fn should_return_503_when_syncing_but_should_make_the_calls() { let response = request(server, "\ GET / HTTP/1.1\r\n\ - Host: 1472a9e190620cdf6b31f383373e45efcfe869a820c91f9ccd7eb9fb45e4985d.parity\r\n\ + Host: 1472a9e190620cdf6b31f383373e45efcfe869a820c91f9ccd7eb9fb45e4985d.web3.site\r\n\ Connection: close\r\n\ \r\n\ " @@ -81,7 +81,7 @@ fn should_return_502_on_hash_mismatch() { let response = request(server, "\ GET / HTTP/1.1\r\n\ - Host: 94f093625c06887d94d9fee0d5f9cc4aaa46f33d24d1c7e4b5237e7c37d547dd.parity\r\n\ + Host: 94f093625c06887d94d9fee0d5f9cc4aaa46f33d24d1c7e4b5237e7c37d547dd.web3.site\r\n\ Connection: close\r\n\ \r\n\ " @@ -112,7 +112,7 @@ fn should_return_error_for_invalid_dapp_zip() { let response = request(server, "\ GET / HTTP/1.1\r\n\ - Host: 2be00befcf008bc0e7d9cdefc194db9c75352e8632f48498b5a6bfce9f02c88e.parity\r\n\ + Host: 2be00befcf008bc0e7d9cdefc194db9c75352e8632f48498b5a6bfce9f02c88e.web3.site\r\n\ Connection: close\r\n\ \r\n\ " @@ -144,7 +144,7 @@ fn should_return_fetched_dapp_content() { let response1 = http_client::request(server.addr(), "\ GET /index.html HTTP/1.1\r\n\ - Host: 9c94e154dab8acf859b30ee80fc828fb1d38359d938751b65db71d460588d82a.parity\r\n\ + Host: 9c94e154dab8acf859b30ee80fc828fb1d38359d938751b65db71d460588d82a.web3.site\r\n\ Connection: close\r\n\ \r\n\ " @@ -152,7 +152,7 @@ fn should_return_fetched_dapp_content() { let response2 = http_client::request(server.addr(), "\ GET /manifest.json HTTP/1.1\r\n\ - Host: 9c94e154dab8acf859b30ee80fc828fb1d38359d938751b65db71d460588d82a.parity\r\n\ + Host: 9c94e154dab8acf859b30ee80fc828fb1d38359d938751b65db71d460588d82a.web3.site\r\n\ Connection: close\r\n\ \r\n\ " @@ -207,7 +207,7 @@ fn should_return_fetched_content() { let response = request(server, "\ GET / HTTP/1.1\r\n\ - Host: 2be00befcf008bc0e7d9cdefc194db9c75352e8632f48498b5a6bfce9f02c88e.parity\r\n\ + Host: 2be00befcf008bc0e7d9cdefc194db9c75352e8632f48498b5a6bfce9f02c88e.web3.site\r\n\ Connection: close\r\n\ \r\n\ " @@ -234,7 +234,7 @@ fn should_cache_content() { ); let request_str = "\ GET / HTTP/1.1\r\n\ - Host: 2be00befcf008bc0e7d9cdefc194db9c75352e8632f48498b5a6bfce9f02c88e.parity\r\n\ + Host: 2be00befcf008bc0e7d9cdefc194db9c75352e8632f48498b5a6bfce9f02c88e.web3.site\r\n\ Connection: close\r\n\ \r\n\ "; @@ -265,7 +265,7 @@ fn should_not_request_content_twice() { ); let request_str = "\ GET / HTTP/1.1\r\n\ - Host: 2be00befcf008bc0e7d9cdefc194db9c75352e8632f48498b5a6bfce9f02c88e.parity\r\n\ + Host: 2be00befcf008bc0e7d9cdefc194db9c75352e8632f48498b5a6bfce9f02c88e.web3.site\r\n\ Connection: close\r\n\ \r\n\ "; @@ -298,6 +298,17 @@ fn should_not_request_content_twice() { response2.assert_status("HTTP/1.1 200 OK"); } +#[test] +fn should_encode_and_decode_base32() { + use base32; + + let encoded = base32::encode(base32::Alphabet::Crockford, "token+https://parity.io".as_bytes()); + assert_eq!("EHQPPSBE5DM78X3GECX2YBVGC5S6JX3S5SMPY", &encoded); + + let data = base32::decode(base32::Alphabet::Crockford, "EHQPPSBE5DM78X3GECX2YBVGC5S6JX3S5SMPY").unwrap(); + assert_eq!("token+https://parity.io", &String::from_utf8(data).unwrap()); +} + #[test] fn should_stream_web_content() { // given @@ -306,8 +317,8 @@ fn should_stream_web_content() { // when let response = request(server, "\ - GET /web/token/https/parity.io/ HTTP/1.1\r\n\ - Host: localhost:8080\r\n\ + GET / HTTP/1.1\r\n\ + Host: EHQPPSBE5DM78X3GECX2YBVGC5S6JX3S5SMPY.web.web3.site\r\n\ Connection: close\r\n\ \r\n\ " @@ -322,20 +333,90 @@ fn should_stream_web_content() { } #[test] -fn should_return_error_on_invalid_token() { +fn should_support_base32_encoded_web_urls() { // given let (server, fetch) = serve_with_fetch("token"); // when let response = request(server, "\ - GET /web/invalidtoken/https/parity.io/ HTTP/1.1\r\n\ + GET /styles.css?test=123 HTTP/1.1\r\n\ + Host: EHQPPSBE5DM78X3GECX2YBVGC5S6JX3S5SMPY.web.web3.site\r\n\ + Connection: close\r\n\ + \r\n\ + " + ); + + // then + response.assert_status("HTTP/1.1 200 OK"); + assert_security_headers_for_embed(&response.headers); + + fetch.assert_requested("https://parity.io/styles.css?test=123"); + fetch.assert_no_more_requests(); +} + +#[test] +fn should_correctly_handle_long_label_when_splitted() { + // given + let (server, fetch) = serve_with_fetch("xolrg9fePeQyKLnL"); + + // when + let response = request(server, + "\ + GET /styles.css?test=123 HTTP/1.1\r\n\ + Host: f1qprwk775k6am35a5wmpk3e9gnpgx3me1sk.mbsfcdqpwx3jd5h7ax39dxq2wvb5dhqpww3fe9t2wrvfdm.web.web3.site\r\n\ + Connection: close\r\n\ + \r\n\ + " + ); + + // then + response.assert_status("HTTP/1.1 200 OK"); + assert_security_headers_for_embed(&response.headers); + + fetch.assert_requested("https://contribution.melonport.com/styles.css?test=123"); + fetch.assert_no_more_requests(); +} + + +#[test] +fn should_support_base32_encoded_web_urls_as_path() { + // given + let (server, fetch) = serve_with_fetch("token"); + + // when + let response = request(server, + "\ + GET /web/EHQPPSBE5DM78X3GECX2YBVGC5S6JX3S5SMPY/styles.css?test=123 HTTP/1.1\r\n\ Host: localhost:8080\r\n\ Connection: close\r\n\ \r\n\ " ); + // then + response.assert_status("HTTP/1.1 200 OK"); + assert_security_headers_for_embed(&response.headers); + + fetch.assert_requested("https://parity.io/styles.css?test=123"); + fetch.assert_no_more_requests(); +} + +#[test] +fn should_return_error_on_invalid_token() { + // given + let (server, fetch) = serve_with_fetch("test"); + + // when + let response = request(server, + "\ + GET / HTTP/1.1\r\n\ + Host: EHQPPSBE5DM78X3GECX2YBVGC5S6JX3S5SMPY.web.web3.site\r\n\ + Connection: close\r\n\ + \r\n\ + " + ); + // then response.assert_status("HTTP/1.1 400 Bad Request"); assert_security_headers_for_embed(&response.headers); @@ -365,28 +446,6 @@ fn should_return_error_on_invalid_protocol() { fetch.assert_no_more_requests(); } -#[test] -fn should_redirect_if_trailing_slash_is_missing() { - // given - let (server, fetch) = serve_with_fetch("token"); - - // when - let response = request(server, - "\ - GET /web/token/https/parity.io HTTP/1.1\r\n\ - Host: localhost:8080\r\n\ - Connection: close\r\n\ - \r\n\ - " - ); - - // then - response.assert_status("HTTP/1.1 302 Found"); - response.assert_header("Location", "/web/token/https/parity.io/"); - - fetch.assert_no_more_requests(); -} - #[test] fn should_disallow_non_get_requests() { // given @@ -395,8 +454,8 @@ fn should_disallow_non_get_requests() { // when let response = request(server, "\ - POST /token/https/parity.io/ HTTP/1.1\r\n\ - Host: web.parity\r\n\ + POST / HTTP/1.1\r\n\ + Host: EHQPPSBE5DM78X3GECX2YBVGC5S6JX3S5SMPY.web.web3.site\r\n\ Content-Type: application/json\r\n\ Connection: close\r\n\ \r\n\ @@ -423,14 +482,37 @@ fn should_fix_absolute_requests_based_on_referer() { GET /styles.css HTTP/1.1\r\n\ Host: localhost:8080\r\n\ Connection: close\r\n\ - Referer: http://localhost:8080/web/token/https/parity.io/\r\n\ + Referer: http://localhost:8080/web/EHQPPSBE5DM78X3GECX2YBVGC5S6JX3S5SMPY/\r\n\ \r\n\ " ); // then response.assert_status("HTTP/1.1 302 Found"); - response.assert_header("Location", "/web/token/https/parity.io/styles.css"); + response.assert_header("Location", "/web/EHQPPSBE5DM78X3GECX2YBVGC5S6JX3S5SMPY/styles.css"); + + fetch.assert_no_more_requests(); +} + +#[test] +fn should_fix_absolute_requests_based_on_referer_in_url() { + // given + let (server, fetch) = serve_with_fetch("token"); + + // when + let response = request(server, + "\ + GET /styles.css HTTP/1.1\r\n\ + Host: localhost:8080\r\n\ + Connection: close\r\n\ + Referer: http://localhost:8080/?__referer=web/EHQPPSBE5DM78X3GECX2YBVGC5S6JX3S5SMPY/\r\n\ + \r\n\ + " + ); + + // then + response.assert_status("HTTP/1.1 302 Found"); + response.assert_header("Location", "/web/EHQPPSBE5DM78X3GECX2YBVGC5S6JX3S5SMPY/styles.css"); fetch.assert_no_more_requests(); } diff --git a/dapps/src/tests/redirection.rs b/dapps/src/tests/redirection.rs index a19888620..8b529a851 100644 --- a/dapps/src/tests/redirection.rs +++ b/dapps/src/tests/redirection.rs @@ -105,7 +105,7 @@ fn should_display_404_on_invalid_dapp_with_domain() { let response = request(server, "\ GET / HTTP/1.1\r\n\ - Host: invaliddapp.parity\r\n\ + Host: invaliddapp.web3.site\r\n\ Connection: close\r\n\ \r\n\ " @@ -179,7 +179,7 @@ fn should_serve_proxy_pac() { // then assert_eq!(response.status, "HTTP/1.1 200 OK".to_owned()); - assert_eq!(response.body, "D5\n\nfunction FindProxyForURL(url, host) {\n\tif (shExpMatch(host, \"home.parity\"))\n\t{\n\t\treturn \"PROXY 127.0.0.1:18180\";\n\t}\n\n\tif (shExpMatch(host, \"*.parity\"))\n\t{\n\t\treturn \"PROXY 127.0.0.1:8080\";\n\t}\n\n\treturn \"DIRECT\";\n}\n\n0\n\n".to_owned()); + assert_eq!(response.body, "DD\n\nfunction FindProxyForURL(url, host) {\n\tif (shExpMatch(host, \"parity.web3.site\"))\n\t{\n\t\treturn \"PROXY 127.0.0.1:18180\";\n\t}\n\n\tif (shExpMatch(host, \"*.web3.site\"))\n\t{\n\t\treturn \"PROXY 127.0.0.1:8080\";\n\t}\n\n\treturn \"DIRECT\";\n}\n\n0\n\n".to_owned()); assert_security_headers(&response.headers); } diff --git a/dapps/src/tests/validation.rs b/dapps/src/tests/validation.rs index e43621313..afeb7b5ef 100644 --- a/dapps/src/tests/validation.rs +++ b/dapps/src/tests/validation.rs @@ -66,7 +66,7 @@ fn should_serve_dapps_domains() { let response = request(server, "\ GET / HTTP/1.1\r\n\ - Host: ui.parity\r\n\ + Host: ui.web3.site\r\n\ Connection: close\r\n\ \r\n\ {} diff --git a/dapps/src/web.rs b/dapps/src/web.rs index 8287a8585..0e872daf2 100644 --- a/dapps/src/web.rs +++ b/dapps/src/web.rs @@ -20,6 +20,7 @@ use std::sync::Arc; use fetch::{self, Fetch}; use parity_reactor::Remote; +use base32; use hyper::{self, server, net, Next, Encoder, Decoder}; use hyper::status::StatusCode; @@ -27,7 +28,7 @@ use apps; use endpoint::{Endpoint, Handler, EndpointPath}; use handlers::{ ContentFetcherHandler, ContentHandler, ContentValidator, ValidatorResponse, - StreamingHandler, Redirection, extract_url, + StreamingHandler, extract_url, }; use url::Url; use WebProxyTokens; @@ -86,9 +87,10 @@ impl ContentValidator for WebInstaller { ); if is_html { handler.set_initial_content(&format!( - r#""#, + r#""#, apps::UTILS_PATH, apps::URL_REFERER, + apps::WEB_PATH, &self.referer, )); } @@ -99,7 +101,6 @@ impl ContentValidator for WebInstaller { enum State { Initial, Error(ContentHandler), - Redirecting(Redirection), Fetching(ContentFetcherHandler), } @@ -114,25 +115,26 @@ struct WebHandler { } impl WebHandler { - fn extract_target_url(&self, url: Option) -> Result<(String, String), State> { - let (path, query) = match url { - Some(url) => (url.path, url.query), - None => { - return Err(State::Error(ContentHandler::error( - StatusCode::BadRequest, "Invalid URL", "Couldn't parse URL", None, self.embeddable_on.clone() - ))); - } - }; + fn extract_target_url(&self, url: Option) -> Result> { + let token_and_url = self.path.app_params.get(0) + .map(|encoded| encoded.replace('.', "")) + .and_then(|encoded| base32::decode(base32::Alphabet::Crockford, &encoded.to_uppercase())) + .and_then(|data| String::from_utf8(data).ok()) + .ok_or_else(|| State::Error(ContentHandler::error( + StatusCode::BadRequest, + "Invalid parameter", + "Couldn't parse given parameter:", + self.path.app_params.get(0).map(String::as_str), + self.embeddable_on.clone() + )))?; - // Support domain based routing. - let idx = match path.get(0).map(|m| m.as_ref()) { - Some(apps::WEB_PATH) => 1, - _ => 0, - }; + let mut token_it = token_and_url.split('+'); + let token = token_it.next(); + let target_url = token_it.next(); // Check if token supplied in URL is correct. - match path.get(idx) { - Some(ref token) if self.web_proxy_tokens.is_web_proxy_token_valid(token) => {}, + match token { + Some(token) if self.web_proxy_tokens.is_web_proxy_token_valid(token) => {}, _ => { return Err(State::Error(ContentHandler::error( StatusCode::BadRequest, "Invalid Access Token", "Invalid or old web proxy access token supplied.", Some("Try refreshing the page."), self.embeddable_on.clone() @@ -141,9 +143,8 @@ impl WebHandler { } // Validate protocol - let protocol = match path.get(idx + 1).map(|a| a.as_str()) { - Some("http") => "http", - Some("https") => "https", + let mut target_url = match target_url { + Some(url) if url.starts_with("http://") || url.starts_with("https://") => url.to_owned(), _ => { return Err(State::Error(ContentHandler::error( StatusCode::BadRequest, "Invalid Protocol", "Invalid protocol used.", None, self.embeddable_on.clone() @@ -151,28 +152,35 @@ impl WebHandler { } }; - // Redirect if address to main page does not end with / - if let None = path.get(idx + 3) { - return Err(State::Redirecting( - Redirection::new(&format!("/{}/", path.join("/"))) - )); + if !target_url.ends_with("/") { + target_url = format!("{}/", target_url); } - let query = match query { - Some(query) => format!("?{}", query), + // TODO [ToDr] Should just use `path.app_params` + let (path, query) = match (&url, self.path.using_dapps_domains) { + (&Some(ref url), true) => (&url.path[..], &url.query), + (&Some(ref url), false) => (&url.path[2..], &url.query), + _ => { + return Err(State::Error(ContentHandler::error( + StatusCode::BadRequest, "Invalid URL", "Couldn't parse URL", None, self.embeddable_on.clone() + ))); + } + }; + + let query = match *query { + Some(ref query) => format!("?{}", query), None => "".into(), }; - Ok((format!("{}://{}{}", protocol, path[idx + 2..].join("/"), query), path[0..].join("/"))) + Ok(format!("{}{}{}", target_url, path.join("/"), query)) } } impl server::Handler for WebHandler { fn on_request(&mut self, request: server::Request) -> Next { let url = extract_url(&request); - // First extract the URL (reject invalid URLs) - let (target_url, referer) = match self.extract_target_url(url) { + let target_url = match self.extract_target_url(url) { Ok(url) => url, Err(error) => { self.state = error; @@ -186,7 +194,9 @@ impl server::Handler for WebHandler { self.control.clone(), WebInstaller { embeddable_on: self.embeddable_on.clone(), - referer: referer, + referer: self.path.app_params.get(0) + .expect("`target_url` is valid; app_params is not empty;qed") + .to_owned(), }, self.embeddable_on.clone(), self.remote.clone(), @@ -202,7 +212,6 @@ impl server::Handler for WebHandler { match self.state { State::Initial => Next::end(), State::Error(ref mut handler) => handler.on_request_readable(decoder), - State::Redirecting(ref mut handler) => handler.on_request_readable(decoder), State::Fetching(ref mut handler) => handler.on_request_readable(decoder), } } @@ -211,7 +220,6 @@ impl server::Handler for WebHandler { match self.state { State::Initial => Next::end(), State::Error(ref mut handler) => handler.on_response(res), - State::Redirecting(ref mut handler) => handler.on_response(res), State::Fetching(ref mut handler) => handler.on_response(res), } } @@ -220,7 +228,6 @@ impl server::Handler for WebHandler { match self.state { State::Initial => Next::end(), State::Error(ref mut handler) => handler.on_response_writable(encoder), - State::Redirecting(ref mut handler) => handler.on_response_writable(encoder), State::Fetching(ref mut handler) => handler.on_response_writable(encoder), } } diff --git a/devtools/src/http_client.rs b/devtools/src/http_client.rs index f2b8c7931..de59a7a71 100644 --- a/devtools/src/http_client.rs +++ b/devtools/src/http_client.rs @@ -87,7 +87,7 @@ pub fn request(address: &SocketAddr, request: &str) -> Response { let _ = req.read_to_string(&mut response); let mut lines = response.lines(); - let status = lines.next().unwrap().to_owned(); + let status = lines.next().expect("Expected a response").to_owned(); let headers_raw = read_block(&mut lines, false); let headers = headers_raw.split('\n').map(|v| v.to_owned()).collect(); let body = read_block(&mut lines, true); diff --git a/js/package.json b/js/package.json index a702f2795..b10c180a0 100644 --- a/js/package.json +++ b/js/package.json @@ -140,6 +140,7 @@ "yargs": "6.6.0" }, "dependencies": { + "base32.js": "0.1.0", "bignumber.js": "3.0.1", "blockies": "0.0.2", "brace": "0.9.0", diff --git a/js/src/ui/Form/DappUrlInput/dappUrlInput.js b/js/src/ui/Form/DappUrlInput/dappUrlInput.js new file mode 100644 index 000000000..a513059df --- /dev/null +++ b/js/src/ui/Form/DappUrlInput/dappUrlInput.js @@ -0,0 +1,61 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import keycode from 'keycode'; +import React, { Component, PropTypes } from 'react'; + +export default class DappUrlInput extends Component { + static propTypes = { + className: PropTypes.string, + onChange: PropTypes.func.isRequired, + onGoto: PropTypes.func.isRequired, + onRestore: PropTypes.func.isRequired, + url: PropTypes.string.isRequired + } + + render () { + const { className, url } = this.props; + + return ( + + ); + } + + onChange = (event) => { + this.props.onChange(event.target.value); + }; + + onKeyDown = (event) => { + switch (keycode(event)) { + case 'esc': + this.props.onRestore(); + break; + + case 'enter': + this.props.onGoto(); + break; + + default: + break; + } + }; +} diff --git a/js/src/ui/Form/DappUrlInput/dappUrlInput.spec.js b/js/src/ui/Form/DappUrlInput/dappUrlInput.spec.js new file mode 100644 index 000000000..23a352ae6 --- /dev/null +++ b/js/src/ui/Form/DappUrlInput/dappUrlInput.spec.js @@ -0,0 +1,70 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import { shallow } from 'enzyme'; +import React from 'react'; +import sinon from 'sinon'; + +import DappUrlInput from './'; + +let component; +let onChange; +let onGoto; +let onRestore; + +function render (props = { url: 'http://some.url' }) { + onChange = sinon.stub(); + onGoto = sinon.stub(); + onRestore = sinon.stub(); + + component = shallow( + + ); + + return component; +} + +describe('ui/Form/DappUrlInput', () => { + it('renders defaults', () => { + expect(render()).to.be.ok; + }); + + describe('events', () => { + describe('onChange', () => { + it('calls the onChange callback as provided', () => { + component.simulate('change', { target: { value: 'testing' } }); + expect(onChange).to.have.been.calledWith('testing'); + }); + }); + + describe('onKeyDown', () => { + it('calls the onGoto callback on enter', () => { + component.simulate('keyDown', { keyCode: 13 }); + expect(onGoto).to.have.been.called; + }); + + it('calls the onRestor callback on esc', () => { + component.simulate('keyDown', { keyCode: 27 }); + expect(onRestore).to.have.been.called; + }); + }); + }); +}); diff --git a/js/src/ui/Form/DappUrlInput/index.js b/js/src/ui/Form/DappUrlInput/index.js new file mode 100644 index 000000000..a30c4dbe5 --- /dev/null +++ b/js/src/ui/Form/DappUrlInput/index.js @@ -0,0 +1,17 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +export default from './dappUrlInput'; diff --git a/js/src/ui/Form/index.js b/js/src/ui/Form/index.js index 9e0823934..21fd2986c 100644 --- a/js/src/ui/Form/index.js +++ b/js/src/ui/Form/index.js @@ -15,6 +15,7 @@ // along with Parity. If not, see . import AddressSelect from './AddressSelect'; +import DappUrlInput from './DappUrlInput'; import FormWrap from './FormWrap'; import Input from './Input'; import InputAddress from './InputAddress'; @@ -31,6 +32,7 @@ import TypedInput from './TypedInput'; export default from './form'; export { AddressSelect, + DappUrlInput, FormWrap, Input, InputAddress, diff --git a/js/src/ui/index.js b/js/src/ui/index.js index 967465165..41903adf1 100644 --- a/js/src/ui/index.js +++ b/js/src/ui/index.js @@ -35,7 +35,7 @@ import DappIcon from './DappIcon'; import Editor from './Editor'; import Errors from './Errors'; import Features, { FEATURES, FeaturesStore } from './Features'; -import Form, { AddressSelect, FormWrap, Input, InputAddress, InputAddressSelect, InputChip, InputDate, InputInline, InputTime, Label, RadioButtons, Select, TypedInput } from './Form'; +import Form, { AddressSelect, DappUrlInput, FormWrap, Input, InputAddress, InputAddressSelect, InputChip, InputDate, InputInline, InputTime, Label, RadioButtons, Select, TypedInput } from './Form'; import GasPriceEditor from './GasPriceEditor'; import GasPriceSelector from './GasPriceSelector'; import Icons from './Icons'; @@ -80,8 +80,9 @@ export { ContextProvider, CopyToClipboard, CurrencySymbol, - DappIcon, DappCard, + DappIcon, + DappUrlInput, Editor, Errors, FEATURES, diff --git a/js/src/util/dapplink.js b/js/src/util/dapplink.js new file mode 100644 index 000000000..1a73d184f --- /dev/null +++ b/js/src/util/dapplink.js @@ -0,0 +1,60 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import base32 from 'base32.js'; + +const BASE_URL = '.web.web3.site'; +const ENCODER_OPTS = { type: 'crockford' }; + +export function encodePath (token, url) { + const encoder = new base32.Encoder(ENCODER_OPTS); + const chars = `${token}+${url}` + .split('') + .map((char) => char.charCodeAt(0)); + + return encoder + .write(chars) // add the characters to encode + .finalize(); // create the encoded string +} + +export function encodeUrl (token, url) { + const encoded = encodePath(token, url) + .match(/.{1,63}/g) // split into 63-character chunks, max length is 64 for URLs parts + .join('.'); // add '.' between URL parts + + return `${encoded}${BASE_URL}`; +} + +// TODO: This export is really more a helper along the way of verifying the actual +// encoding (being able to decode test values from the node layer), than meant to +// be used as-is. Should the need arrise to decode URLs as well (instead of just +// producing), it would make sense to further split the output into the token/URL +export function decode (encoded) { + const decoder = new base32.Decoder(ENCODER_OPTS); + const sanitized = encoded + .replace(BASE_URL, '') // remove the BASE URL + .split('.') // split the string on the '.' (63-char boundaries) + .join(''); // combine without the '.' + + return decoder + .write(sanitized) // add the string to decode + .finalize() // create the decoded buffer + .toString(); // create string from buffer +} + +export { + BASE_URL +}; diff --git a/js/src/util/dapplink.spec.js b/js/src/util/dapplink.spec.js new file mode 100644 index 000000000..49a3aa197 --- /dev/null +++ b/js/src/util/dapplink.spec.js @@ -0,0 +1,83 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import { BASE_URL, decode, encodePath, encodeUrl } from './dapplink'; + +const TEST_TOKEN = 'token'; +const TEST_URL = 'https://parity.io'; +const TEST_URL_LONG = 'http://some.very.very.very.long.long.long.domain.example.com'; +const TEST_PREFIX = 'EHQPPSBE5DM78X3GECX2YBVGC5S6JX3S5SMPY'; +const TEST_PREFIX_LONG = [ + 'EHQPPSBE5DM78X3G78QJYWVFDNJJWXK5E9WJWXK5E9WJWXK5E9WJWV3FDSKJWV3', 'FDSKJWV3FDSKJWS3FDNGPJVHECNW62VBGDHJJWRVFDM' +].join('.'); +const TEST_RESULT = `${TEST_PREFIX}${BASE_URL}`; +const TEST_ENCODED = `${TEST_TOKEN}+${TEST_URL}`; + +describe('util/ethlink', () => { + describe('decode', () => { + it('decodes into encoded url', () => { + expect(decode(TEST_PREFIX)).to.equal(TEST_ENCODED); + }); + + it('decodes full into encoded url', () => { + expect(decode(TEST_RESULT)).to.equal(TEST_ENCODED); + }); + }); + + describe('encodePath', () => { + it('encodes a url/token combination', () => { + expect(encodePath(TEST_TOKEN, TEST_URL)).to.equal(TEST_PREFIX); + }); + + it('changes when token changes', () => { + expect(encodePath('test-token-2', TEST_URL)).not.to.equal(TEST_PREFIX); + }); + + it('changes when url changes', () => { + expect(encodePath(TEST_TOKEN, 'http://other.example.com')).not.to.equal(TEST_PREFIX); + }); + }); + + describe('encodeUrl', () => { + it('encodes a url/token combination', () => { + expect(encodeUrl(TEST_TOKEN, TEST_URL)).to.equal(TEST_RESULT); + }); + + it('changes when token changes', () => { + expect(encodeUrl('test-token-2', TEST_URL)).not.to.equal(TEST_RESULT); + }); + + it('changes when url changes', () => { + expect(encodeUrl(TEST_TOKEN, 'http://other.example.com')).not.to.equal(TEST_RESULT); + }); + + describe('splitting', () => { + let encoded; + + beforeEach(() => { + encoded = encodeUrl(TEST_TOKEN, TEST_URL_LONG); + }); + + it('splits long values into boundary parts', () => { + expect(encoded).to.equal(`${TEST_PREFIX_LONG}${BASE_URL}`); + }); + + it('first part 63 characters', () => { + expect(encoded.split('.')[0].length).to.equal(63); + }); + }); + }); +}); diff --git a/js/src/views/Web/AddressBar/addressBar.js b/js/src/views/Web/AddressBar/addressBar.js index 54dc6c85c..af02f3272 100644 --- a/js/src/views/Web/AddressBar/addressBar.js +++ b/js/src/views/Web/AddressBar/addressBar.js @@ -14,101 +14,62 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . +import { observer } from 'mobx-react'; import React, { Component, PropTypes } from 'react'; -import Refresh from 'material-ui/svg-icons/navigation/refresh'; -import Close from 'material-ui/svg-icons/navigation/close'; import Subdirectory from 'material-ui/svg-icons/navigation/subdirectory-arrow-left'; -import { Button } from '~/ui'; - -const KEY_ESC = 27; -const KEY_ENTER = 13; +import { Button, DappUrlInput } from '~/ui'; +import { CloseIcon, RefreshIcon } from '~/ui/Icons'; +@observer export default class AddressBar extends Component { static propTypes = { className: PropTypes.string, - isLoading: PropTypes.bool.isRequired, - onChange: PropTypes.func.isRequired, - onRefresh: PropTypes.func.isRequired, - url: PropTypes.string.isRequired + store: PropTypes.object.isRequired }; - state = { - currentUrl: this.props.url - }; - - componentWillReceiveProps (nextProps) { - if (this.props.url === nextProps.url) { - return; - } - - this.setState({ - currentUrl: nextProps.url - }); - } - - isPristine () { - return this.state.currentUrl === this.props.url; - } - render () { - const { isLoading } = this.props; - const { currentUrl } = this.state; - const isPristine = this.isPristine(); + const { isLoading, isPristine, nextUrl } = this.props.store; return (
); } - onUpdateUrl = (ev) => { - this.setState({ - currentUrl: ev.target.value - }); - }; + onRefreshUrl = () => { + this.props.store.reload(); + } - onKey = (ev) => { - const key = ev.which; + onChangeUrl = (url) => { + this.props.store.setNextUrl(url); + } - if (key === KEY_ESC) { - this.setState({ - currentUrl: this.props.url - }); - return; - } + onGotoUrl = () => { + this.props.store.gotoUrl(); + } - if (key === KEY_ENTER) { - this.onGo(); - return; - } - }; - - onGo = () => { - if (this.isPristine()) { - this.props.onRefresh(); - } else { - this.props.onChange(this.state.currentUrl); - } - }; + onRestoreUrl = () => { + this.props.store.restoreUrl(); + } } diff --git a/js/src/views/Web/AddressBar/addressBar.spec.js b/js/src/views/Web/AddressBar/addressBar.spec.js new file mode 100644 index 000000000..7208d0f7b --- /dev/null +++ b/js/src/views/Web/AddressBar/addressBar.spec.js @@ -0,0 +1,48 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import { shallow } from 'enzyme'; +import React from 'react'; + +import AddressBar from './'; + +let component; +let store; + +function createStore () { + store = { + nextUrl: 'https://parity.io' + }; + + return store; +} + +function render (props = {}) { + component = shallow( + + ); + + return component; +} + +describe('views/Web/AddressBar', () => { + it('renders defaults', () => { + expect(render()).to.be.ok; + }); +}); diff --git a/js/src/views/Web/store.js b/js/src/views/Web/store.js new file mode 100644 index 000000000..542b47a7b --- /dev/null +++ b/js/src/views/Web/store.js @@ -0,0 +1,158 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import { action, computed, observable, transaction } from 'mobx'; +import localStore from 'store'; +import { parse as parseUrl } from 'url'; + +import { encodePath, encodeUrl } from '~/util/dapplink'; + +const DEFAULT_URL = 'https://mkr.market'; +const LS_LAST_ADDRESS = '_parity::webLastAddress'; + +const hasProtocol = /^https?:\/\//; + +let instance = null; + +export default class Store { + @observable counter = Date.now(); + @observable currentUrl = null; + @observable history = []; + @observable isLoading = false; + @observable parsedUrl = null; + @observable nextUrl = null; + @observable token = null; + + constructor (api) { + this._api = api; + + this.nextUrl = this.currentUrl = this.loadLastUrl(); + } + + @computed get encodedPath () { + return `${this._api.dappsUrl}/web/${encodePath(this.token, this.currentUrl)}?t=${this.counter}`; + } + + @computed get encodedUrl () { + return `http://${encodeUrl(this.token, this.currentUrl)}:${this._api.dappsPort}?t=${this.counter}`; + } + + @computed get frameId () { + return `_web_iframe_${this.counter}`; + } + + @computed get isPristine () { + return this.currentUrl === this.nextUrl; + } + + @action gotoUrl = (_url) => { + transaction(() => { + let url = (_url || this.nextUrl).trim().replace(/\/+$/, ''); + + if (!hasProtocol.test(url)) { + url = `https://${url}`; + } + + this.setNextUrl(url); + this.setCurrentUrl(this.nextUrl); + }); + } + + @action reload = () => { + transaction(() => { + this.setLoading(true); + this.counter = Date.now(); + }); + } + + @action restoreUrl = () => { + this.setNextUrl(this.currentUrl); + } + + @action setHistory = (history) => { + this.history = history; + } + + @action setLoading = (isLoading) => { + this.isLoading = isLoading; + } + + @action setToken = (token) => { + this.token = token; + } + + @action setCurrentUrl = (_url) => { + const url = _url || this.currentUrl; + + transaction(() => { + this.currentUrl = url; + this.parsedUrl = parseUrl(url); + + this.saveLastUrl(); + + this.reload(); + }); + } + + @action setNextUrl = (url) => { + this.nextUrl = url; + } + + generateToken = () => { + this.setToken(null); + + return this._api.signer + .generateWebProxyAccessToken() + .then((token) => { + this.setToken(token); + }) + .catch((error) => { + console.warn('generateToken', error); + }); + } + + loadHistory = () => { + return this._api.parity + .listRecentDapps() + .then((apps) => { + this.setHistory(apps); + }) + .catch((error) => { + console.warn('loadHistory', error); + }); + } + + loadLastUrl = () => { + return localStore.get(LS_LAST_ADDRESS) || DEFAULT_URL; + } + + saveLastUrl = () => { + return localStore.set(LS_LAST_ADDRESS, this.currentUrl); + } + + static get (api) { + if (!instance) { + instance = new Store(api); + } + + return instance; + } +} + +export { + DEFAULT_URL, + LS_LAST_ADDRESS +}; diff --git a/js/src/views/Web/store.spec.js b/js/src/views/Web/store.spec.js new file mode 100644 index 000000000..8a8dd268c --- /dev/null +++ b/js/src/views/Web/store.spec.js @@ -0,0 +1,202 @@ +// Copyright 2015-2017 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import sinon from 'sinon'; + +import Store from './store'; + +const TEST_HISTORY = ['somethingA', 'somethingB']; +const TEST_TOKEN = 'testing-123'; +const TEST_URL1 = 'http://some.test.domain.com'; +const TEST_URL2 = 'http://something.different.com'; + +let api; +let store; + +function createApi () { + api = { + dappsPort: 8080, + dappsUrl: 'http://home.web3.site:8080', + parity: { + listRecentDapps: sinon.stub().resolves(TEST_HISTORY) + }, + signer: { + generateWebProxyAccessToken: sinon.stub().resolves(TEST_TOKEN) + } + }; + + return api; +} + +function create () { + store = new Store(createApi()); + + return store; +} + +describe('views/Web/Store', () => { + beforeEach(() => { + create(); + }); + + describe('@action', () => { + describe('gotoUrl', () => { + it('uses the nextUrl when none specified', () => { + store.setNextUrl('https://parity.io'); + store.gotoUrl(); + + expect(store.currentUrl).to.equal('https://parity.io'); + }); + + it('adds https when no protocol', () => { + store.gotoUrl('google.com'); + + expect(store.currentUrl).to.equal('https://google.com'); + }); + }); + + describe('restoreUrl', () => { + it('sets the nextUrl to the currentUrl', () => { + store.setCurrentUrl(TEST_URL1); + store.setNextUrl(TEST_URL2); + store.restoreUrl(); + + expect(store.nextUrl).to.equal(TEST_URL1); + }); + }); + + describe('setCurrentUrl', () => { + beforeEach(() => { + store.setCurrentUrl(TEST_URL1); + }); + + it('sets the url', () => { + expect(store.currentUrl).to.equal(TEST_URL1); + }); + }); + + describe('setHistory', () => { + it('sets the history', () => { + store.setHistory(TEST_HISTORY); + expect(store.history.peek()).to.deep.equal(TEST_HISTORY); + }); + }); + + describe('setLoading', () => { + beforeEach(() => { + store.setLoading(true); + }); + + it('sets the loading state (true)', () => { + expect(store.isLoading).to.be.true; + }); + + it('sets the loading state (false)', () => { + store.setLoading(false); + + expect(store.isLoading).to.be.false; + }); + }); + + describe('setNextUrl', () => { + it('sets the url', () => { + store.setNextUrl(TEST_URL1); + + expect(store.nextUrl).to.equal(TEST_URL1); + }); + }); + + describe('setToken', () => { + it('sets the token', () => { + store.setToken(TEST_TOKEN); + + expect(store.token).to.equal(TEST_TOKEN); + }); + }); + }); + + describe('@computed', () => { + describe('encodedUrl', () => { + describe('encodedPath', () => { + it('encodes current', () => { + store.setCurrentUrl(TEST_URL1); + expect(store.encodedPath).to.match( + /http:\/\/home\.web3\.site:8080\/web\/DSTPRV1BD1T78W1T5WQQ6VVDCMQ78SBKEGQ68VVDC5MPWBK3DXPG\?t=[0-9]*$/ + ); + }); + }); + + it('encodes current', () => { + store.setCurrentUrl(TEST_URL1); + expect(store.encodedUrl).to.match( + /^http:\/\/DSTPRV1BD1T78W1T5WQQ6VVDCMQ78SBKEGQ68VVDC5MPWBK3DXPG\.web\.web3\.site:8080\?t=[0-9]*$/ + ); + }); + }); + + describe('frameId', () => { + it('creates an id', () => { + expect(store.frameId).to.be.ok; + }); + }); + + describe('isPristine', () => { + it('is true when current === next', () => { + store.setCurrentUrl(TEST_URL1); + store.setNextUrl(TEST_URL1); + + expect(store.isPristine).to.be.true; + }); + + it('is false when current !== next', () => { + store.setCurrentUrl(TEST_URL1); + store.setNextUrl(TEST_URL2); + + expect(store.isPristine).to.be.false; + }); + }); + }); + + describe('operations', () => { + describe('generateToken', () => { + beforeEach(() => { + return store.generateToken(); + }); + + it('calls signer_generateWebProxyAccessToken', () => { + expect(api.signer.generateWebProxyAccessToken).to.have.been.calledOnce; + }); + + it('sets the token as retrieved', () => { + expect(store.token).to.equal(TEST_TOKEN); + }); + }); + + describe('loadHistory', () => { + beforeEach(() => { + return store.loadHistory(); + }); + + it('calls parity_listRecentDapps', () => { + expect(api.parity.listRecentDapps).to.have.been.calledOnce; + }); + + it('sets the history as retrieved', () => { + expect(store.history.peek()).to.deep.equal(TEST_HISTORY); + }); + }); + }); +}); diff --git a/js/src/views/Web/web.js b/js/src/views/Web/web.js index 124fd8bb5..2d94b1b16 100644 --- a/js/src/views/Web/web.js +++ b/js/src/views/Web/web.js @@ -14,19 +14,16 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . +import { observer } from 'mobx-react'; import React, { Component, PropTypes } from 'react'; -import store from 'store'; -import { parse as parseUrl, format as formatUrl } from 'url'; -import { parse as parseQuery } from 'querystring'; +import { FormattedMessage } from 'react-intl'; import AddressBar from './AddressBar'; +import Store from './store'; import styles from './web.css'; -const LS_LAST_ADDRESS = '_parity::webLastAddress'; - -const hasProtocol = /^https?:\/\//; - +@observer export default class Web extends Component { static contextTypes = { api: PropTypes.object.isRequired @@ -36,120 +33,62 @@ export default class Web extends Component { params: PropTypes.object.isRequired } - state = { - displayedUrl: null, - isLoading: true, - token: null, - url: null - }; + store = Store.get(this.context.api); componentDidMount () { - const { api } = this.context; - const { params } = this.props; - - api - .signer - .generateWebProxyAccessToken() - .then((token) => { - this.setState({ token }); - }); - - this.setUrl(params.url); + this.store.gotoUrl(this.props.params.url); + return this.store.generateToken(); } componentWillReceiveProps (props) { - this.setUrl(props.params.url); + this.store.gotoUrl(props.params.url); } - setUrl = (url) => { - url = url || store.get(LS_LAST_ADDRESS) || 'https://mkr.market'; - if (!hasProtocol.test(url)) { - url = `https://${url}`; - } - - this.setState({ url, displayedUrl: url }); - }; - render () { - const { displayedUrl, isLoading, token } = this.state; + const { currentUrl, token } = this.store; if (!token) { return (

- Requesting access token... +

); } - const { dappsUrl } = this.context.api; - const { url } = this.state; + return currentUrl + ? this.renderFrame() + : null; + } - if (!url || !token) { - return null; - } - - const parsed = parseUrl(url); - const { protocol, host, path } = parsed; - const address = `${dappsUrl}/web/${token}/${protocol.slice(0, -1)}/${host}${path}`; + renderFrame () { + const { encodedPath, frameId } = this.store; return (