Merge pull request 'Finish Add Instance Flow' (#1) from feat/add-instance-2 into main

Reviewed-on: https://git.gay/Nekomata/FeDirect/pulls/1
This commit is contained in:
CenTdemeern1 2025-01-20 05:30:18 +00:00 committed by git.gay
commit 6a12b59f87
No known key found for this signature in database
GPG key ID: BDA2A7586B5E1432
13 changed files with 388 additions and 32 deletions

28
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

View file

@ -1,7 +1,8 @@
use rocket::Route;
pub mod instance_info;
pub mod proxy;
pub fn get_routes() -> Vec<Route> {
routes![instance_info::instance_info]
routes![instance_info::instance_info, proxy::proxy]
}

30
src/api/proxy.rs Normal file
View file

@ -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<T, E = Debug<reqwest::Error>> = std::result::Result<T, E>;
pub async fn get(url: &str) -> Result<ByteStream![Bytes]> {
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/<url>")]
pub async fn proxy(url: &str) -> req::Result<ByteStream![Bytes]> {
req::get(url).await
}

View file

@ -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 {

View file

@ -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
};
}

View file

@ -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
};
}

View file

@ -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 {
@ -84,3 +99,24 @@ 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%;
}

View file

@ -47,7 +47,7 @@
<br>
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
<label for="autoQueryMetadata">
<abbr title="This makes a single API request to FeDirect to automatically try to detect instance metadata, such as the name, software, and an icon.
<abbr title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.
We do this on the backend to avoid CORS problems.
We do not track or save any requests or data.">
Automatically query metadata
@ -58,6 +58,50 @@ We do not track or save any requests or data.">
<button type="reset" class="close">Cancel</button>
</form>
</dialog>
<dialog id="instanceDetails">
<h1>Confirm instance details</h1>
<form method="dialog" class="instanceDetailsForm">
<div class="flex-row">
<div class="half-width">
<label for="instanceName">Instance name</label>
<br>
<input id="instanceName" type="text" name="instanceName" required />
<br><br>
<label for="instanceHost">Instance hostname</label>
<br>
<input id="instanceHost" type="text" name="instanceHost" required />
<br>
<input type="checkbox" name="instanceHostSecure" id="instanceHostSecure" checked />
<label for="instanceHostSecure">
<abbr title="Whether to use HTTPS (as opposed to HTTP).
Unchecking this is not recommended, and this option only exists for exceptional cases">
Secure?
</abbr>
</label>
<br><br>
<label for="instanceSoftware">Instance software</label>
<br>
<select id="instanceSoftware" type="text" name="instanceSoftware" required>
<option value="">(Please select)</option>
</select>
</div>
<div class="half-width flex-row-reverse">
<div class="full-height flex-column-reverse">
<div>
<label for="iconContainer">Instance icon</label>
<div id="iconContainer" class="square iconContainer">
<!-- This data URI is for a transparent gif image -->
<img id="instanceIcon" alt="Icon for the selected instance" class="icon" />
</div>
</div>
</div>
</div>
</div>
<br>
<button type="submit">OK</button>
<button type="reset" class="close">Cancel</button>
</form>
</dialog>
</body>
</html>

View file

@ -1,15 +1,14 @@
import { initializeAddInstanceDialog } from "./add_an_instance.mjs";
import knownSoftware from "./known_software.mjs";
import storageManager from "./storage_manager.mjs";
console.log(knownSoftware);
console.log(storageManager.storage.instances);
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
import { findDialogOrFail } from "./dom.mjs";
export function getMainDialog(): HTMLDialogElement {
return document.getElementById('mainDialog') as HTMLDialogElement;
}
const dialog = document.querySelector("#addInstance");
if (!(dialog instanceof HTMLDialogElement))
throw new Error("Couldn't find addInstance dialog");
export const { showAddInstanceDialog, hideAddInstanceDialog } = initializeAddInstanceDialog(dialog);
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
const addDialog = findDialogOrFail(document.body, "#addInstance");
export const {
showAddInstanceDialog,
hideAddInstanceDialog
} = initializeAddInstanceFlow(detailsDialog, addDialog);

44
static/dom.mts Normal file
View file

@ -0,0 +1,44 @@
// I would've LOVED to use generics for this but unfortunately that's not possible.
// Type safety, but at what cost... >~< thanks TypeScript
export function findDialogOrFail(on: Element, selector: string): HTMLDialogElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLDialogElement))
throw new Error(`${selector} isn't a dialog`);
return element;
}
export function findFormOrFail(on: Element, selector: string): HTMLFormElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLFormElement))
throw new Error(`${selector} isn't a form`);
return element;
}
export function findInputOrFail(on: Element, selector: string): HTMLInputElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLInputElement))
throw new Error(`${selector} isn't an input`);
return element;
}
export function findButtonOrFail(on: Element, selector: string): HTMLButtonElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLButtonElement))
throw new Error(`${selector} isn't a button`);
return element;
}
export function findSelectOrFail(on: Element, selector: string): HTMLSelectElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLSelectElement))
throw new Error(`${selector} isn't a select`);
return element;
}
export function findImageOrFail(on: Element, selector: string): HTMLImageElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLImageElement))
throw new Error(`${selector} isn't an image`);
return element;
}

15
static/image.mts Normal file
View file

@ -0,0 +1,15 @@
export function resize(image: HTMLImageElement, width: number = 16, height: number = 16): string {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.style.display = "none";
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d");
if (ctx === null) throw Error("Resize failed");
const w = Math.min(image.width / image.height, 1) * width;
const h = Math.min(image.height / image.width, 1) * height;
ctx.drawImage(image, (width - w) / 2, (height - h) / 2, w, h);
const url = canvas.toDataURL();
document.body.removeChild(canvas);
return url;
}

View file

@ -1,4 +1,4 @@
type Instance = {
export type Instance = {
/**
* The instance's (nick)name
* @example "eepy.moe"