diff --git a/Cargo.lock b/Cargo.lock index 9b37ff6..b598d79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,6 +508,38 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core 0.3.4", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.11", + "http-body 0.4.5", + "hyper 0.14.27", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "axum" version = "0.7.4" @@ -558,6 +590,23 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.11", + "http-body 0.4.5", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + [[package]] name = "axum-core" version = "0.4.3" @@ -579,6 +628,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum_static" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187665a64f61ce283c0e33dcaf0a976ad77c30fea8b3e1129e5d4334dc6b2c04" +dependencies = [ + "axum 0.6.20", + "tower-http 0.3.5", +] + [[package]] name = "backtrace" version = "0.3.69" @@ -2015,6 +2074,7 @@ version = "0.0.0" dependencies = [ "anyhow", "axum 0.7.4", + "axum_static", "clerk-rs", "fuel-core", "fuel-core-client", @@ -2563,6 +2623,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" +[[package]] +name = "http-range-header" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ce4ef31cda248bbdb6e6820603b82dfcd9e833db65a43e997a0ccec777d11fe" + [[package]] name = "httparse" version = "1.8.0" @@ -4900,9 +4966,14 @@ dependencies = [ "futures-util", "http 0.2.11", "http-body 0.4.5", - "http-range-header", + "http-range-header 0.3.1", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -4917,10 +4988,18 @@ checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ "bitflags 2.4.1", "bytes", + "futures-util", "http 1.1.0", "http-body 1.0.0", "http-body-util", + "http-range-header 0.4.0", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", diff --git a/Cargo.toml b/Cargo.toml index dbab397..bd96dd3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ description = "A token faucet for onboarding fuel users" [dependencies] anyhow = "1.0" axum = "0.7.4" +axum_static = "1.2.3" clerk-rs = "0.2.3" fuel-core-client = "0.22.0" fuel-tx = "0.43.0" @@ -37,7 +38,12 @@ tower = { version = "0.4", features = [ "util", "timeout", ] } -tower-http = { version = "0.5.2", features = ["cors", "trace", "set-header"] } +tower-http = { version = "0.5.2", features = [ + "fs", + "cors", + "trace", + "set-header", +] } tower-sessions = "0.11.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } diff --git a/README.md b/README.md index 0fcdf09..70f4536 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ The faucet makes use of environment variables for configuration. | CAPTCHA_SECRET | The secret key used for enabling Google captcha authentication. | | CAPTCHA_KEY | The website key used for enabling Google captcha authentication. | | CLERK_PUB_KEY | The public key used for enabling clerk authentication. | -| CLERK_SECRET_KEY | The secret key used for enabling clerk authentication. | +| CLERK_SECRET_KEY | The secret key used for enabling clerk authentication. | | WALLET_SECRET_KEY | A hex formatted string of the wallet private key that owns some tokens. | | FUEL_NODE_URL | The GraphQL endpoint for connecting to fuel-core. | | PUBLIC_FUEL_NODE_URL | The public GraphQL endpoint for connecting to fuel-core. Ex.: https://node.fuel.network/graphql | diff --git a/build.rs b/build.rs index 1f66c0f..ec6190d 100644 --- a/build.rs +++ b/build.rs @@ -11,5 +11,4 @@ fn build(page: &str, raw: &[u8]) { fn main() { build("index.html", include_bytes!("./static/index.html")); - build("sign_in.html", include_bytes!("./static/sign_in.html")); } diff --git a/src/lib.rs b/src/lib.rs index 9f2061a..ecf1466 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,7 @@ mod constants; mod dispense_tracker; mod recaptcha; mod routes; +mod static_files; pub use dispense_tracker::{Clock, TokioTime}; pub use routes::THE_BIGGEST_AMOUNT; @@ -164,8 +165,8 @@ pub async fn start_server( let app = Router::new() .route("/", get(routes::main).route_layer(web_layer.clone())) .route( - "/sign-in", - get(routes::sign_in).route_layer(web_layer.clone()), + "/auth", + get(routes::auth).route_layer(web_layer.clone()), ) .route( "/api/validate-session", @@ -189,6 +190,7 @@ pub async fn start_server( .into_inner(), ), ) + .nest("/static", static_files::router("static")) .layer( ServiceBuilder::new() // Handle errors from middleware @@ -214,20 +216,19 @@ pub async fn start_server( .into_inner(), ); - // run the server let addr = SocketAddr::from(([0, 0, 0, 0], service_config.service_port)); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); let bound_addr = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); + Ok(()) + }); + + // run the server info!("listening on {}", bound_addr); - ( - bound_addr, - tokio::spawn(async move { - axum::serve(listener, app.into_make_service()) - .await - .unwrap(); - Ok(()) - }), - ) + (bound_addr, task) } async fn handle_error(error: BoxError) -> impl IntoResponse { diff --git a/src/routes.rs b/src/routes.rs index 305edea..36146c8 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -33,7 +33,7 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; use tower_sessions::Session; -use tracing::{error, info}; +use tracing::{debug, error, info}; // The amount to fetch the biggest input of the faucet. pub const THE_BIGGEST_AMOUNT: u64 = u32::MAX as u64; @@ -62,13 +62,14 @@ pub fn render_main( if let Some(captcha_key) = &captcha_key { data.insert("captcha_key", captcha_key.as_str()); } + data.insert("page", "faucet"); // render page handlebars.render("index", &data).unwrap() } #[memoize::memoize] -pub fn render_sign_in(clerk_pub_key: String) -> String { - let template = include_str!(concat!(env!("OUT_DIR"), "/sign_in.html")); +pub fn render_auth(clerk_pub_key: String) -> String { + let template = include_str!(concat!(env!("OUT_DIR"), "/index.html")); // sub in values let mut handlebars = Handlebars::new(); handlebars @@ -76,6 +77,7 @@ pub fn render_sign_in(clerk_pub_key: String) -> String { .unwrap(); let mut data = BTreeMap::new(); data.insert("clerk_public_key", clerk_pub_key.as_str()); + data.insert("page", "auth"); // render page handlebars.render("index", &data).unwrap() } @@ -86,16 +88,16 @@ pub async fn main( ) -> impl IntoResponse { let public_node_url = config.public_node_url.clone(); let captcha_key = config.captcha_key.clone(); - let clerk_pub_key = config.clerk_pub_key.clone().unwrap(); + let clerk_pub_key = config.clerk_pub_key.clone().unwrap_or("".to_string()); let jwt_token: Option = session.get("JWT_TOKEN").await.unwrap(); match jwt_token { Some(_) => Html(render_main(public_node_url, captcha_key, clerk_pub_key)).into_response(), - None => Redirect::temporary("/sign-in").into_response(), + None => Redirect::temporary("/auth").into_response(), } } -pub async fn sign_in( +pub async fn auth( Extension(config): Extension, session: Session, ) -> impl IntoResponse { @@ -104,7 +106,7 @@ pub async fn sign_in( match jwt_token { Some(_) => Redirect::temporary("/").into_response(), - None => Html(render_sign_in(clerk_pub_key.unwrap())).into_response(), + None => Html(render_auth(clerk_pub_key.unwrap_or("".to_string()))).into_response(), } } @@ -291,6 +293,8 @@ pub async fn dispense_tokens( Extension(dispense_tracker): Extension, Json(input): Json, ) -> Result { + println!("dispense_tokens"); + dbg!(&input); // parse deposit address let address = if let Ok(address) = Address::from_str(input.address.as_str()) { Ok(address) diff --git a/src/static_files.rs b/src/static_files.rs new file mode 100644 index 0000000..3d31cc3 --- /dev/null +++ b/src/static_files.rs @@ -0,0 +1,48 @@ +use axum::{ + body::Body, + http::Request, + middleware::{from_fn, Next}, + response::Response, + Router, +}; +use std::path::Path; +use tower_http::services::ServeDir; + +pub async fn content_type_middleware(req: Request, next: Next) -> Response { + let uri = req.uri().to_owned(); + let path = uri.path(); + let splited = path.split('.').collect::>(); + if let Some(extension) = splited.last() { + let mut response = next.run(req).await; + let extension = extension.to_owned().to_lowercase(); + let content_type = match extension.as_str() { + "html" => "text/html", + "css" => "text/css", + "js" => "text/javascript", + "ps" => "application/postscript", + _ => "application/octet-stream", + }; + + if let Ok(content_type) = content_type.parse() { + response.headers_mut().insert("Content-Type", content_type); + } + + response + } else { + let mut response = next.run(req).await; + + if let Ok(content_type) = "application/octet-stream".parse() { + response.headers_mut().insert("Content-Type", content_type); + } + + response + } +} + +pub fn router>(path: P) -> Router { + let serve_dir = ServeDir::new(path); + + Router::new() + .fallback_service(serve_dir) + .layer(from_fn(content_type_middleware)) +} diff --git a/static/components/captcha.js b/static/components/captcha.js new file mode 100644 index 0000000..59acb5a --- /dev/null +++ b/static/components/captcha.js @@ -0,0 +1,23 @@ +import { html } from "htm/preact"; + +export function Captcha({ captchaKey, isHidden }) { + if (isHidden) { + return null; + } + return html` +
+ ${ + captchaKey && + html` +
+
+
+ ` + } + +
+ `; +} diff --git a/static/components/checkbox.js b/static/components/checkbox.js new file mode 100644 index 0000000..01df270 --- /dev/null +++ b/static/components/checkbox.js @@ -0,0 +1,22 @@ +import { html } from "htm/preact"; + +export function Checkbox({ id, checked, onChange, children }) { + return html` +
+ + +
+ `; +} + +const styles = { + checkboxRow: "flex items-center gap-2", + checkbox: "w-4 h-4 text-green-600 bg-gray-100 border-gray-300 rounded", +}; diff --git a/static/components/faucet-form.js b/static/components/faucet-form.js new file mode 100644 index 0000000..02b18c9 --- /dev/null +++ b/static/components/faucet-form.js @@ -0,0 +1,197 @@ +import { html } from "htm/preact"; +import { Component } from "preact"; + +import { Captcha } from "components/captcha"; +import { Checkbox } from "components/checkbox"; + +const query = new URLSearchParams(document.location.search); +const address = query.get("address"); + +function AlertError({ error }) { + if (!error) return null; + return html``; +} +function AlertSuccess({ explorerLink, isSent }) { + if (isSent) { + return html` `; + } + return null; +} + +export class FaucetForm extends Component { + state = { + value: address, + formHidden: false, + hasAgreed1: false, + hasAgreed2: false, + hasAgreed3: false, + isSent: false, + explorerLink: "#", + error: null, + }; + + onSubmit = async (e) => { + e.preventDefault(); + + const payload = { + address: this.state.value, + captcha: "", + }; + + // if (this.hasCaptcha()) { + // const target = e.currentTarget; + // payload.captcha = target.querySelector(".g-recaptcha-response")?.value; + // } + + try { + const res = await fetch("/dispense", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + const data = await res.json(); + if (data.error) { + this.setState((state) => ({ + ...state, + error: data.error, + })); + return; + } + + const blockExplorer = "https://fuellabs.github.io/block-explorer-v2"; + const providerUrl = this.props.providerUrl; + const encodedProviderUrl = encodeURIComponent(providerUrl); + const { value: address } = this.state; + this.setState((state) => ({ + ...state, + inSent: true, + formHidden: true, + explorerLink: `${blockExplorer}/address/${address}?providerUrl=${encodedProviderUrl}`, + })); + } catch (e) { + console.log("error"); + this.setState((state) => ({ + ...state, + error: e.message, + })); + } + }; + + changeAgreement = (num) => { + return (e) => { + this.setState((state) => ({ + ...state, + [`hasAgreed${num}`]: e.currentTarget.checked, + })); + }; + }; + + onInput = (e) => { + this.setState((state) => ({ + ...state, + value: e.currentTarget.value, + })); + }; + + hasCaptcha = () => { + return !!document.getElementsByClassName("captcha-container")[0]; + }; + + render({ captchaKey }) { + const { formHidden } = this.state; + return html` +
+
+ ${this.formElement()} + +

+ This is a Test Ether faucet running on the${" "} + Test Fuel network. This faucet sends fake Ether assets + to the provided wallet address. +

+ + <${Captcha} captchaKey=${captchaKey} isHidden=${formHidden} /> + +
+ <${Checkbox} id="agreement1" onChange=${this.changeAgreement(1)}> + I acknowledge that this faucet is only used for testing. + + <${Checkbox} id="agreement2" onChange=${this.changeAgreement(2)}> + I acknowledge that there are no incentives to using this faucet. + + <${Checkbox} id="agreement3" onChange=${this.changeAgreement(3)}> + I agree not to spam this faucet, and know that I will be blocked if I do. + +
+ + <${AlertError} error=${this.state.error} /> +
+ +
+
+ <${AlertSuccess} + explorerLink=${this.state.explorerLink} + isSent=${this.state.isSent} + /> +
+ `; + } + + formElement = () => { + const { value, formHidden } = this.state; + if (formHidden) return null; + return html` +
+ + +
+ `; + }; +} + +const styles = { + formWrapper: "border p-4 mb-4 flex flex-col rounded-lg", + label: "mb-2 text-md text-gray-500", + input: + "border border-gray-300 text-gray-900 text-sm rounded focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5", + explorerLink: "underline underline-offset-2", + alertError: + "flex flex-col items-center py-2 px-4 border border-red-300 mt-6 gap-1 text-sm rounded-lg bg-red-50 text-red-800", + alertSuccess: + "flex flex-col items-center p-4 border border-gray-300 mt-6 gap-1 text-sm rounded-lg bg-gray-50", + submitButton: + "text-white bg-green-700 hover:bg-green-800 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 focus:outline-none disabled:bg-gray-300 disabled:text-gray-800 disabled:cursor-not-allowed", + agreements: + "flex flex-col gap-2 text-sm mt-6 py-4 border-t border-b border-gray-300 border-dashed [&_label>span]:font-bold", +}; diff --git a/static/components/fuel-logo.js b/static/components/fuel-logo.js new file mode 100644 index 0000000..7006705 --- /dev/null +++ b/static/components/fuel-logo.js @@ -0,0 +1,38 @@ +import { html } from "htm/preact"; +import { Component } from "preact"; + +export class FuelLogo extends Component { + render() { + return html` +
+ + + + + + + + + + +
+ `; + } +} diff --git a/static/css/index.css b/static/css/index.css new file mode 100644 index 0000000..dac24be --- /dev/null +++ b/static/css/index.css @@ -0,0 +1,76 @@ +body, +#root { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.background { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' width='32' height='32' fill='none' stroke='%230f172a' stroke-opacity='0.04'%3E%3Cpath d='M0 .5H31.5V32' /%3E%3C/svg%3E%0A"); + -webkit-mask-image: linear-gradient( + 180deg, + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0.3) + ); + mask-image: linear-gradient( + 180deg, + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 1), + rgba(255, 255, 255, 0.3) + ); +} + +.cl-userButton-root { + position: absolute; + top: 20px; + right: 20px; +} + +.inter-font { + font-family: "Inter", sans-serif; + font-optical-sizing: auto; + font-weight: ; + font-style: normal; + font-variation-settings: "slnt" 0; +} + +.loader { + border: 0.4rem solid #f3f3f3; + border-top: 0.4rem solid #00f58c; + border-radius: 50%; + width: 2rem; + height: 2rem; + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +#overlay { + position: fixed; + inset: 0 0 0 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.2); + z-index: 1000; + display: flex; + justify-content: center; + align-items: center; +} + +.container { + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/static/index.html b/static/index.html index 57862bc..8d84785 100644 --- a/static/index.html +++ b/static/index.html @@ -4,299 +4,35 @@ {{ page_title }} + + + + - - - -
-
-
- -
-
- - -
-

- This is a Test Ether faucet running on the - Test Fuel network. This faucet sends fake Ether - assets to the provided wallet address. -

-
- {{#if captcha_key}} -
-
-
- {{/if}} - -
-
- -
- -
- -
-
-
- -
-
-

Test Ether sent to the wallet

- See on Fuel Explorer -
-
-
Node url: {{ public_node_url }}
+ - - diff --git a/static/pages/auth.js b/static/pages/auth.js new file mode 100644 index 0000000..5e96739 --- /dev/null +++ b/static/pages/auth.js @@ -0,0 +1,54 @@ +import { html } from "htm/preact"; +import { Component, render } from "preact"; + +class App extends Component { + state = { + isLoading: true, + }; + + componentDidMount() { + this.loadClerk(); + } + + async loadClerk() { + await Clerk.load(); + + if (Clerk.user) { + const sessions = await Clerk.user.getSessions(); + if (sessions.length) { + const res = await fetch("/api/validate-session", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ value: sessions[0]?.id }), + }); + const data = await res.json(); + if (data?.id) { + window.location.reload(); + } + } + } else { + const body = document.querySelector("#root"); + const overlay = document.createElement("div"); + overlay.id = "overlay"; + body.appendChild(overlay); + Clerk.mountSignIn(overlay); + this.setState({ isLoading: false }); + } + } + + render() { + return html` +
+ ${this.state.isLoading ? html`Loading...` : ""} +
+ `; + } +} + +export default function renderSignIn(props) { + render(html`<${App} ...${props} />`, document.querySelector("#root")); +} diff --git a/static/pages/faucet.js b/static/pages/faucet.js new file mode 100644 index 0000000..a3ef3d4 --- /dev/null +++ b/static/pages/faucet.js @@ -0,0 +1,54 @@ +import { html } from "htm/preact"; +import { Component, render } from "preact"; + +import { FaucetForm } from "components/faucet-form"; +import { FuelLogo } from "components/fuel-logo"; + +window.addEventListener("load", async () => { + await Clerk.load(); + + if (Clerk.user) { + const userBtn = document.querySelector("#clerk-user"); + Clerk.mountUserButton(userBtn); + } + + Clerk.addListener(async (resources) => { + if (!resources.session) { + const res = await fetch("/api/remove-session", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + await res.json(); + window.location.reload(); + } + }); +}); + +class App extends Component { + render({ publicNodeUrl, captchaKey }) { + return html` +
+
+ <${FuelLogo} /> + <${FaucetForm} + providerUrl=${publicNodeUrl} + captchaKey=${captchaKey} + /> +
+
+ Node url: ${publicNodeUrl} +
+
+ `; + } +} + +export default function renderFaucet(props) { + render(html`<${App} ...${props} />`, document.querySelector("#root")); +} diff --git a/static/sign_in.html b/static/sign_in.html deleted file mode 100644 index 1feba1e..0000000 --- a/static/sign_in.html +++ /dev/null @@ -1,109 +0,0 @@ - - - - - {{ page_title }} - - - - - - -
-
Loading...
- - - - -