diff --git a/Cargo.lock b/Cargo.lock index 9369bf3..1b3bf92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" name = "fedirect" version = "0.1.0" dependencies = [ + "bytes", "reqwest", "rocket", "semver", @@ -367,6 +368,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -388,6 +400,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -1318,11 +1331,13 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", + "tokio-util", "tower", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "windows-registry", ] @@ -2159,6 +2174,19 @@ version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" +[[package]] +name = "wasm-streams" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.76" diff --git a/Cargo.toml b/Cargo.toml index 9df5c41..df36732 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,8 @@ version = "0.1.0" edition = "2021" [dependencies] -reqwest = "0.12.12" +bytes = "1.9.0" +reqwest = { version = "0.12.12", features = ["stream"] } rocket = { version = "0.5.1", features = ["json"] } semver = "1.0.24" serde = { version = "1.0.217", features = ["derive"] } diff --git a/src/api/mod.rs b/src/api/mod.rs index 2dd826b..38de871 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,7 +1,8 @@ use rocket::Route; pub mod instance_info; +pub mod proxy; pub fn get_routes() -> Vec { - routes![instance_info::instance_info] + routes![instance_info::instance_info, proxy::proxy] } diff --git a/src/api/proxy.rs b/src/api/proxy.rs new file mode 100644 index 0000000..fa17a37 --- /dev/null +++ b/src/api/proxy.rs @@ -0,0 +1,30 @@ +use bytes::Bytes; +use rocket::response::stream::ByteStream; + +/// Copy-pasted and modified from https://github.com/rwf2/Rocket/issues/1521 +mod req { + use super::*; + use rocket::response::Debug; + + pub type Result> = std::result::Result; + + pub async fn get(url: &str) -> Result { + let bytes_stream = reqwest::get(url).await?.bytes_stream(); + Ok(ByteStream! { + for await bytes in bytes_stream { + match bytes { + Ok(bytes) => yield bytes, + Err(e) => { + eprintln!("error while streaming: {}", e); + break; + } + } + } + }) + } +} + +#[get("/proxy/")] +pub async fn proxy(url: &str) -> req::Result { + req::get(url).await +} diff --git a/static/add_an_instance.mts b/static/add_an_instance.mts index e305637..c8130fc 100644 --- a/static/add_an_instance.mts +++ b/static/add_an_instance.mts @@ -1,29 +1,36 @@ // This file handles the "Add an instance" dialog +import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs"; + export function parseHost(host: string): { host: string, secure: boolean } | null { let parsedInstance = URL.parse(host); parsedInstance ??= URL.parse("https://" + host); if (!parsedInstance?.host) return null; + if (!/https?:/.test(parsedInstance.protocol)) return null; return { host: parsedInstance.host, secure: parsedInstance.protocol === "https:" }; } -export function initializeAddInstanceDialog(dialog: HTMLDialogElement): { +export function initializeAddInstanceDialog( + dialog: HTMLDialogElement, + callback: ( + host: string, + secure: boolean, + autoQueryMetadata: boolean, + ) => void +): { showAddInstanceDialog: () => void, hideAddInstanceDialog: () => void, } { const showAddInstanceDialog = () => dialog.showModal(); const hideAddInstanceDialog = () => dialog.close(); - const form = dialog.querySelector(".addInstanceForm"); - if (!(form instanceof HTMLFormElement)) - throw new Error(".addInstanceForm isn't a form"); - - const instanceHost = form.querySelector("#instanceHost"); - if (!(instanceHost instanceof HTMLInputElement)) - throw new Error("#instanceHost isn't an input"); + const form = findFormOrFail(dialog, ".addInstanceForm"); + const instanceHost = findInputOrFail(form, "#instanceHost"); + const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata"); + const closeButton = findButtonOrFail(form, ".close"); instanceHost.addEventListener("input", e => { if (parseHost(instanceHost.value) === null) @@ -32,19 +39,13 @@ export function initializeAddInstanceDialog(dialog: HTMLDialogElement): { instanceHost.setCustomValidity(""); }); - form.addEventListener("submit", async e => { + form.addEventListener("submit", e => { // A sane browser doesn't allow for submitting the form if the above validation fails const { host, secure } = parseHost(instanceHost.value)!; - console.log( - await fetch(`/api/instance_info/${secure}/${encodeURI(host)}`).then(r => r.json()) - ); + callback(host, secure, autoQueryMetadata.checked); form.reset(); }); - const closeButton = form.querySelector(".close"); - if (!(closeButton instanceof HTMLButtonElement)) - throw new Error(".close isn't a button"); - closeButton.addEventListener("click", e => hideAddInstanceDialog()); return { diff --git a/static/add_instance_flow.mts b/static/add_instance_flow.mts new file mode 100644 index 0000000..2703b55 --- /dev/null +++ b/static/add_instance_flow.mts @@ -0,0 +1,69 @@ +import { initializeAddInstanceDialog } from "./add_an_instance.mjs"; +import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs"; +import storageManager, { Instance } from "./storage_manager.mjs"; + +export function initializeAddInstanceFlow( + detailsDialog: HTMLDialogElement, + addDialog: HTMLDialogElement +): { + showAddInstanceDialog: () => void, + hideAddInstanceDialog: () => void +} { + const instanceDetailsDialogCallback = ( + name: string, + host: string, + hostSecure: boolean, + software: string, + icon: string | null + ) => { + const instance: Instance = { + name, + origin: `http${hostSecure ? "s" : ""}://${host}`, + software, + iconURL: icon ?? undefined + }; + storageManager.storage.instances.push(instance); + storageManager.save(); + console.log("Successfully added new instance:", instance); + }; + + const { + showInstanceDetailsDialog, + hideInstanceDetailsDialog, + populateInstanceDetailsDialog + } = initializeInstanceDetailsDialog(detailsDialog, instanceDetailsDialogCallback); + + const addInstanceDialogCallback = async ( + host: string, + secure: boolean, + autoQueryMetadata: boolean, + ) => { + try { + if (!autoQueryMetadata) throw new Error("Don't"); + const { name, software, iconURL } = + await fetch(`/api/instance_info/${secure}/${encodeURIComponent(host)}`) + .then(r => r.json()); + if ( + typeof name !== "string" + || typeof software !== "string" + || !(typeof iconURL === "string" || iconURL === null) + ) + throw new Error("Invalid API response"); + populateInstanceDetailsDialog(name, host, secure, software, iconURL as string | null); + } catch { + populateInstanceDetailsDialog(host, host, secure, "", null); + } finally { + showInstanceDetailsDialog(); + } + } + + const { + showAddInstanceDialog, + hideAddInstanceDialog + } = initializeAddInstanceDialog(addDialog, addInstanceDialogCallback); + + return { + showAddInstanceDialog, + hideAddInstanceDialog + }; +} diff --git a/static/confirm_instance_details.mts b/static/confirm_instance_details.mts new file mode 100644 index 0000000..7c01356 --- /dev/null +++ b/static/confirm_instance_details.mts @@ -0,0 +1,88 @@ +// This file handles the "Confirm instance details" dialog + +import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findSelectOrFail } from "./dom.mjs"; +import { resize } from "./image.mjs"; +import knownSoftware from "./known_software.mjs"; + +const blankImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + +export function initializeInstanceDetailsDialog( + dialog: HTMLDialogElement, + callback: ( + instanceName: string, + instanceHost: string, + instanceHostSecure: boolean, + instanceSoftware: string, + instanceIcon: string | null + ) => void +): { + showInstanceDetailsDialog: () => void, + hideInstanceDetailsDialog: () => void, + populateInstanceDetailsDialog: ( + instanceNameValue: string, + instanceHostValue: string, + instanceHostSecureValue: boolean, + instanceSoftwareValue: string, + instanceIconValue: string | null + ) => void +} { + const showInstanceDetailsDialog = () => dialog.showModal(); + const hideInstanceDetailsDialog = () => dialog.close(); + + const form = findFormOrFail(dialog, ".instanceDetailsForm"); + const instanceName = findInputOrFail(form, "#instanceName"); + const instanceHost = findInputOrFail(form, "#instanceHost"); + const instanceHostSecure = findInputOrFail(form, "#instanceHostSecure"); + const instanceSoftware = findSelectOrFail(form, "#instanceSoftware"); + const instanceIcon = findImageOrFail(form, "#instanceIcon"); + const closeButton = findButtonOrFail(form, ".close"); + + for (const [name, software] of Object.entries(knownSoftware.software)) { + const option = new Option(software.name, name); + instanceSoftware.appendChild(option); + } + + instanceIcon.src = blankImage; + + const populateInstanceDetailsDialog = ( + instanceNameValue: string, + instanceHostValue: string, + instanceHostSecureValue: boolean, + instanceSoftwareValue: string, + instanceIconValue: string | null + ) => { + instanceName.value = instanceNameValue; + instanceHost.value = instanceHostValue; + instanceHostSecure.checked = instanceHostSecureValue; + instanceSoftware.value = instanceSoftwareValue; + instanceIcon.src = instanceIconValue === null ? blankImage : `/api/proxy/${encodeURIComponent(instanceIconValue)}`; + }; + + form.addEventListener("submit", e => { + let image: string | null = null; + if (instanceIcon.src !== blankImage) { + try { + image = resize(instanceIcon); + } catch { } + } + callback( + instanceName.value, + instanceHost.value, + instanceHostSecure.checked, + instanceSoftware.value, + image + ); + form.reset(); + }); + + closeButton.addEventListener("click", e => { + instanceIcon.src = blankImage; + hideInstanceDetailsDialog(); + }); + + return { + showInstanceDetailsDialog, + hideInstanceDetailsDialog, + populateInstanceDetailsDialog + }; +} diff --git a/static/crossroad.css b/static/crossroad.css index a390c14..92bcd3c 100644 --- a/static/crossroad.css +++ b/static/crossroad.css @@ -1,6 +1,7 @@ :root { --red: #cb0b0b; --blue: #2081c3; + --transparent-black: #0008; --large: 2em; --medium: 1em; } @@ -25,7 +26,7 @@ dialog { dialog::backdrop { backdrop-filter: blur(5px); - background-color: #0008; + background-color: var(--transparent-black); transition: background-color 0.125s ease-out; @starting-style { @@ -65,6 +66,16 @@ abbr[title] { flex-direction: row; } +.flex-row-reverse { + display: flex; + flex-direction: row-reverse; +} + +.flex-column-reverse { + display: flex; + flex-direction: column-reverse; +} + .half-width { min-width: 50%; } @@ -73,8 +84,12 @@ abbr[title] { min-height: 50%; } +.full-height { + min-height: 100%; +} + .separator-bottom { - border-bottom: solid 1px #0008; + border-bottom: solid 1px var(--transparent-black); } .margin-auto-top { @@ -83,4 +98,25 @@ abbr[title] { .margin-large-bottom { margin-bottom: var(--large); +} + +.square { + aspect-ratio: 1; +} + +.iconContainer { + width: 64px; + height: 64px; + padding: 1px; + border: solid 1px var(--transparent-black); +} + +.icon { + position: relative; + max-width: 64px; + max-height: 64px; + margin: auto; + top: 50%; + left: 50%; + translate: -50% -50%; } \ No newline at end of file diff --git a/static/crossroad.html b/static/crossroad.html index 405032e..0ab8ba3 100644 --- a/static/crossroad.html +++ b/static/crossroad.html @@ -47,7 +47,7 @@