Compare commits

..

No commits in common. "main" and "feat/add-instance-2" have entirely different histories.

25 changed files with 262 additions and 1467 deletions

View file

@ -1,24 +0,0 @@
name: Build & Test
on: [push]
jobs:
build-run:
runs-on: docker
container:
image: rust
steps:
- name: Update package repos
run: apt update
- name: Install Node using apt
run: apt install nodejs -y
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Deno
uses: https://github.com/denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Build using Cargo
run: cargo build --verbose
- name: Run unit tests
run: cargo test --verbose

328
Cargo.lock generated
View file

@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
version = 3
[[package]]
name = "addr2line"
@ -185,29 +185,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cssparser"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3"
dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
"phf",
"smallvec",
]
[[package]]
name = "cssparser-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "deranged"
version = "0.3.11"
@ -217,17 +194,6 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derive_more"
version = "0.99.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "devise"
version = "0.4.2"
@ -272,27 +238,6 @@ dependencies = [
"syn",
]
[[package]]
name = "dtoa"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "ego-tree"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8"
[[package]]
name = "either"
version = "1.13.0"
@ -330,32 +275,16 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "favicon-scraper"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e2d1e19588bba8f650edac55fc88e1f2db227d18920d9e71322b46530f04a2"
dependencies = [
"futures",
"imagesize",
"reqwest",
"scraper",
"serde",
"url",
]
[[package]]
name = "fedirect"
version = "0.1.0"
dependencies = [
"bytes",
"favicon-scraper",
"reqwest",
"rocket",
"semver",
"serde",
"serde_json",
"tokio",
"url",
]
@ -403,16 +332,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
@ -490,15 +409,6 @@ dependencies = [
"slab",
]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]]
name = "generator"
version = "0.7.5"
@ -512,15 +422,6 @@ dependencies = [
"windows",
]
[[package]]
name = "getopts"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.15"
@ -600,20 +501,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc"
[[package]]
name = "html5ever"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e15626aaf9c351bc696217cbe29cb9b5e86c43f8a46b5e2f5c6c5cf7cb904ce"
dependencies = [
"log",
"mac",
"markup5ever",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "http"
version = "0.2.12"
@ -917,12 +804,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "imagesize"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
[[package]]
name = "indexmap"
version = "2.7.0"
@ -1028,26 +909,6 @@ dependencies = [
"tracing-subscriber",
]
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "markup5ever"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82c88c6129bd24319e62a0359cb6b958fa7e8be6e19bb1663bc396b90883aca5"
dependencies = [
"log",
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
"tendril",
]
[[package]]
name = "matchers"
version = "0.1.0"
@ -1125,12 +986,6 @@ dependencies = [
"tempfile",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@ -1274,77 +1129,6 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf_generator"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
dependencies = [
"phf_shared 0.10.0",
"rand",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "phf_shared"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"
dependencies = [
"siphasher 0.3.11",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher 1.0.1",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@ -1378,12 +1162,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "proc-macro2"
version = "1.0.93"
@ -1752,21 +1530,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "scraper"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15"
dependencies = [
"cssparser",
"ego-tree",
"getopts",
"html5ever",
"precomputed-hash",
"selectors",
"tendril",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@ -1790,25 +1553,6 @@ dependencies = [
"libc",
]
[[package]]
name = "selectors"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8"
dependencies = [
"bitflags",
"cssparser",
"derive_more",
"fxhash",
"log",
"new_debug_unreachable",
"phf",
"phf_codegen",
"precomputed-hash",
"servo_arc",
"smallvec",
]
[[package]]
name = "semver"
version = "1.0.24"
@ -1868,15 +1612,6 @@ dependencies = [
"serde",
]
[[package]]
name = "servo_arc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae65c4249478a2647db249fb43e23cec56a2c8974a427e7bd8cb5a1d0964921a"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@ -1901,18 +1636,6 @@ dependencies = [
"libc",
]
[[package]]
name = "siphasher"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "slab"
version = "0.4.9"
@ -1968,32 +1691,6 @@ dependencies = [
"loom",
]
[[package]]
name = "string_cache"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b"
dependencies = [
"new_debug_unreachable",
"once_cell",
"parking_lot",
"phf_shared 0.10.0",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988"
dependencies = [
"phf_generator 0.10.0",
"phf_shared 0.10.0",
"proc-macro2",
"quote",
]
[[package]]
name = "subtle"
version = "2.6.1"
@ -2066,17 +1763,6 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]]
name = "thread_local"
version = "1.1.8"
@ -2353,12 +2039,6 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-xid"
version = "0.2.6"
@ -2382,12 +2062,6 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf16_iter"
version = "1.0.5"

View file

@ -5,11 +5,9 @@ edition = "2021"
[dependencies]
bytes = "1.9.0"
favicon-scraper = "0.3.1"
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"] }
serde_json = "1.0.135"
tokio = { version = "1.43.0", features = ["process"] }
url = "2.5.4"

View file

@ -2,13 +2,11 @@
Fedi links that open on your preferred instance!
## Building and Running
## Building
To compile TypeScript, the build script assumes Deno is installed.
1. [Install Rust via rustup](https://rustup.rs/)
2. [Install Deno](https://deno.com/)
3. Use Cargo to build the project:
When you have Deno and Rust installed, simply use Cargo to build the project
```sh
# For example, to build for release, you can do

View file

@ -32,7 +32,6 @@
"name": "Akkoma",
"nodeinfoName": "akkoma",
"aliases": [
"akkoma",
"akko"
],
"groups": [
@ -94,7 +93,6 @@
"nodeinfoName": "mastodon",
"buildMetadata": "glitch",
"aliases": [
"glitch-soc",
"glitch"
],
"groups": [
@ -136,8 +134,7 @@
],
"groups": [
"misskey-compliant",
"misskey-v13",
"mastodon-compliant-api"
"misskey-v13"
],
"forkOf": "misskey"
},
@ -158,8 +155,7 @@
"name": "Iceshrimp.NET",
"nodeinfoName": "Iceshrimp.NET",
"aliases": [
"iceshrimp-dotnet",
"iceshrimp.net"
"iceshrimp-dotnet"
],
"groups": [
"misskey-compliant",

View file

@ -1,2 +0,0 @@
[toolchain]
channel = "nightly"

View file

@ -1,14 +1,9 @@
use std::net::ToSocketAddrs;
use favicon_scraper::{Icon, IconKind};
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::known_software::KNOWN_SOFTWARE_NODEINFO_NAMES;
const MINIMUM_ICON_SIZE: usize = 16;
#[derive(Serialize)]
pub struct InstanceInfo {
name: String,
@ -17,6 +12,28 @@ pub struct InstanceInfo {
icon_url: Option<String>,
}
#[derive(Deserialize)]
struct InstanceManifest {
name: Option<String>,
short_name: Option<String>,
icons: Option<Vec<InstanceIcon>>,
}
#[derive(Deserialize)]
struct InstanceIcon {
src: String,
sizes: String,
}
impl InstanceIcon {
fn get_size(&self) -> Option<usize> {
let (x, y) = self.sizes.split_once("x")?;
let x: usize = x.parse().ok()?;
let y: usize = y.parse().ok()?;
Some(x.max(y))
}
}
#[derive(Deserialize)]
struct NodeInfoDiscovery {
links: Vec<NodeInfoLink>,
@ -45,59 +62,40 @@ struct NodeInfoMetadata {
name: Option<String>,
}
/// Scrapes icons and returns the smallest one where the smallest axis is bigger than or equal to [MINIMUM_ICON_SIZE]
async fn find_icon(host: &str) -> Option<String> {
let icons: Vec<Icon> = favicon_scraper::scrape(host)
.await
.ok()?
.into_iter()
.filter(|i| i.size.width.min(i.size.height) >= MINIMUM_ICON_SIZE)
.collect();
let priority = |kind: &IconKind| match kind {
IconKind::LinkedInHTML => 0,
IconKind::LinkedInManifest => 1,
IconKind::HardcodedURL => 2,
_ => 3,
};
let preferred_kind = icons
.iter()
.map(|i| i.kind)
.min_by(|x, y| priority(x).cmp(&priority(y)))?; // None if icons is empty
icons
.into_iter()
.filter(|i| i.kind == preferred_kind)
.min_by_key(|i| i.size)
.map(|i| i.url.into())
async fn get_info_from_manifest(url: Url) -> Option<[Option<String>; 3]> {
// FIXME: Iceshrimp.NET doesn't have a manifest...
let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?;
let manifest: InstanceManifest = serde_json::from_str(&response).ok()?;
Some([
manifest.name,
manifest.short_name,
manifest
.icons
.as_ref()
.and_then(|icons| icons.iter().min_by_key(|icon| icon.get_size()))
.map(|icon| icon.src.to_owned()),
])
}
#[get("/instance_info/<secure>/<host>")]
pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo>> {
let mut url = Url::parse(if secure {
"https://temp.host/"
} else {
"http://temp.host/"
})
.unwrap();
url.set_host(Some(host)).ok()?; // Using this to catch malformed hosts
let host = url.host_str()?.to_owned(); // Shadow the original host in case things were filtered out
// Check if the host is globally routable.
// This should help filter out a bunch of invalid or potentially malicious requests
let host_with_port = format!("{host}:{}", url.port_or_known_default()?);
if !host_with_port
.to_socket_addrs()
.ok()?
.next()?
.ip()
.is_global()
{
let mut url = Url::parse(&format!(
"http{}://{host}/manifest.json",
if secure { "s" } else { "" }
))
.ok()?;
// I'm not sure if you can sneak in a path, but better safe than sorry
// I don't really care about username/password/port, those are fine
if url.path() != "/manifest.json" {
return None;
}
let icon_url = find_icon(url.as_str()).await;
let [name, short_name, icon_url] = get_info_from_manifest(url.clone())
.await
.unwrap_or_default();
let icon_url = icon_url
.and_then(|i| url.join(&i).ok())
.map(|u| u.to_string());
// FIXME: Iceshrimp.NET doesn't have a nodeinfo discovery file either.............
url.set_path("/.well-known/nodeinfo");
let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?;
let nodeinfo_discovery: NodeInfoDiscovery = serde_json::from_str(&response).ok()?;
@ -114,12 +112,11 @@ pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo
.and_then(|v| fork_map.get(v.build.as_str()))
.unwrap_or(software_name)
.to_owned();
Some(Json(InstanceInfo {
name: nodeinfo
.metadata
.and_then(|m| m.name)
.unwrap_or(host.to_owned()),
name: name
.or(short_name)
.or(nodeinfo.metadata.and_then(|m| m.name))
.unwrap_or(url.host_str().unwrap().to_owned()),
software,
icon_url,
}))

View file

@ -4,9 +4,5 @@ pub mod instance_info;
pub mod proxy;
pub fn get_routes() -> Vec<Route> {
routes![
instance_info::instance_info,
// Proxy is temporarily disabled as it's not needed
// proxy::proxy
]
routes![instance_info::instance_info, proxy::proxy]
}

View file

@ -98,17 +98,3 @@ impl<'r> FromParam<'r> for KnownInstanceSoftware<'r> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// If this test fails, known-software.json is invalid
#[test]
fn known_software_is_valid() {
assert!(!KNOWN_SOFTWARE.groups.is_empty());
assert!(!KNOWN_SOFTWARE.software.is_empty());
assert!(!KNOWN_SOFTWARE_NAMES.is_empty());
assert!(!KNOWN_SOFTWARE_NODEINFO_NAMES.is_empty());
}
}

View file

@ -1,4 +1,3 @@
#![feature(ip)]
#[macro_use]
extern crate rocket;
@ -25,11 +24,6 @@ async fn route_for_known_instance_software(
NamedFile::open("static/crossroad.html").await.ok()
}
#[get("/")]
async fn configure_page() -> Option<NamedFile> {
NamedFile::open("static/config.html").await.ok()
}
#[get("/<instance>/<route..>", rank = 2)]
fn route_for_unknown_instance_software(instance: &str, route: PathBuf) -> (ContentType, String) {
(
@ -52,8 +46,7 @@ fn rocket() -> _ {
routes![
known_software_json,
route_for_known_instance_software,
route_for_unknown_instance_software,
configure_page
route_for_unknown_instance_software
],
)
}

View file

@ -1,6 +1,5 @@
// This file handles the "Add an instance" dialog
import { FormDialog, ONCE } from "./dialog.mjs";
import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
export function parseHost(host: string): { host: string, secure: boolean } | null {
@ -14,67 +13,43 @@ export function parseHost(host: string): { host: string, secure: boolean } | nul
};
}
export type AddInstanceDialogData = {
host: string,
secure: boolean,
autoQueryMetadata: boolean,
};
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();
export class AddInstanceDialog extends FormDialog {
protected instanceHost: HTMLInputElement;
protected autoQueryMetadata: HTMLInputElement;
protected closeButton: HTMLButtonElement;
const form = findFormOrFail(dialog, ".addInstanceForm");
const instanceHost = findInputOrFail(form, "#instanceHost");
const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata");
const closeButton = findButtonOrFail(form, ".close");
constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
super(dialog, findFormOrFail(dialog, ".addInstanceForm"));
instanceHost.addEventListener("input", e => {
if (parseHost(instanceHost.value) === null)
instanceHost.setCustomValidity("Invalid instance hostname or URL");
else
instanceHost.setCustomValidity("");
});
this.instanceHost = findInputOrFail(this.form, "#instanceHost");
this.autoQueryMetadata = findInputOrFail(this.form, "#autoQueryMetadata");
this.closeButton = findButtonOrFail(this.form, ".close");
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)!;
callback(host, secure, autoQueryMetadata.checked);
form.reset();
});
if (initializeDOM) this.initializeDOM();
}
closeButton.addEventListener("click", e => hideAddInstanceDialog());
protected override initializeDOM() {
super.initializeDOM();
this.instanceHost.addEventListener("input", e => this.#getDataIfValid());
this.closeButton.addEventListener("click", e => this.close());
}
#getDataIfValid(): AddInstanceDialogData | null {
const parsedHost = parseHost(this.instanceHost.value);
if (parsedHost === null) {
this.instanceHost.setCustomValidity("Invalid instance hostname or URL");
return null;
}
this.instanceHost.setCustomValidity("");
return {
host: parsedHost.host,
secure: parsedHost.secure,
autoQueryMetadata: this.autoQueryMetadata.checked
};
}
#handleSubmit(resolve: (data: AddInstanceDialogData) => void) {
this.form.addEventListener("submit", e => {
const data = this.#getDataIfValid();
if (data === null) {
// Prevent the user from submitting the form if it's invalid and let them try again
e.preventDefault();
this.#handleSubmit(resolve);
return;
}
resolve(data);
this.close();
}, ONCE);
}
async present(): Promise<AddInstanceDialogData> {
return new Promise((resolve, reject) => {
this.cancelOnceClosed(reject);
this.#handleSubmit(resolve);
this.open();
});
}
return {
showAddInstanceDialog,
hideAddInstanceDialog
};
}

View file

@ -1,73 +1,69 @@
import { AddInstanceDialog } from "./add_an_instance.mjs";
import { dialogDetailsToInstance, InstanceDetailsDialog, InstanceDetailsDialogData } from "./confirm_instance_details.mjs";
import { Dialog } from "./dialog.mjs";
import { initializeAddInstanceDialog } from "./add_an_instance.mjs";
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
import storageManager, { Instance } from "./storage_manager.mjs";
export class AddInstanceFlow {
addDialog: AddInstanceDialog;
spinnerDialog: Dialog;
detailsDialog: InstanceDetailsDialog;
constructor(
addDialog: AddInstanceDialog | HTMLDialogElement,
spinnerDialog: HTMLDialogElement,
detailsDialog: InstanceDetailsDialog | HTMLDialogElement,
) {
if (addDialog instanceof AddInstanceDialog)
this.addDialog = addDialog;
else
this.addDialog = new AddInstanceDialog(addDialog, true);
this.spinnerDialog = new Dialog(spinnerDialog);
if (detailsDialog instanceof InstanceDetailsDialog)
this.detailsDialog = detailsDialog;
else
this.detailsDialog = new InstanceDetailsDialog(detailsDialog, true);
}
async start(autoSave: boolean) {
const {
autoQueryMetadata,
host,
secure,
} = await this.addDialog.present();
const detailsDialogData: InstanceDetailsDialogData = {
name: host,
host,
hostSecure: secure,
software: "",
iconURL: null,
preferredFor: []
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 null; // Skip to catch block
this.spinnerDialog.open();
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) // I guess TS is too stupid to understand this?
|| !(typeof iconURL === "string" || iconURL === null)
)
throw new Error("Invalid API response");
detailsDialogData.name = name;
detailsDialogData.software = software;
detailsDialogData.iconURL = iconURL as string | null;
} catch { }
this.spinnerDialog.close();
const finalData = await this.detailsDialog.present(detailsDialogData);
const instance = dialogDetailsToInstance(finalData, {});
storageManager.storage.instances.push(instance);
if (autoSave) storageManager.save();
console.log("Successfully added new instance:", instance);
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

@ -1,115 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FeDirect</title>
<link rel="stylesheet" href="/static/main.css">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *;">
</head>
<body>
<script type="module" src="/static/config.mjs"></script>
<div class="flex-vcenter">
<dialog id="mainDialog" class="half-width half-height">
<header class="separator-bottom margin-large-bottom">
<div class="flex-row">
<h1>FeDirect</h1>
<p class="margin-auto-top">&ThickSpace;By Nekomata</p>
</div>
<img src="/static/nekomata_small.png" alt="Nekomata Logo" class="logo" />
</header>
<div class="flex-row align-content-center">
<div class="flex-vcenter full-height">
<center class="half-width">
<ol id="instanceList" class="align-start wfit-content"></ol>
<br>
<button id="startAddInstanceFlow">Add an instance</button>
</center>
</div>
<div class="half-width align-self-start">
<div class="flex-hcenter">
<div class="flex-column buttonPanel">
<button id="save">Save</button>
<button id="reorder">Reorder</button>
<button id="reset">Destroy all data</button>
</div>
</div>
</div>
</div>
</dialog>
</div>
<dialog id="addInstance">
<h1>Add an instance</h1>
<form method="dialog" class="addInstanceForm">
<label for="instanceHost">Instance hostname or URL<br>
(for example <code>mastodon.social</code> or <code>https://kitsunes.club/</code>)<br>
</label>
<input id="instanceHost" type="text" name="instanceHost" />
<br>
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
<label for="autoQueryMetadata">
<abbr
title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.">
Automatically query metadata
</abbr>
</label>
<br><br>
<button type="submit">OK</button>
<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="" disabled>(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">
<img id="instanceIcon" alt="Icon for the selected instance" class="icon" />
</div>
</div>
</div>
</div>
</div>
<br>
<label for="defaultsList">Default option for:</label><br>
<select id="defaultsList" class="full-width" multiple>
<option id="noDefaults" value="" disabled>(None, use the "Redirect always" button to set!)</option>
</select>
<button id="removeDefaults" type="button" disabled>Remove</button>
<br><br>
<button type="submit">OK</button>
<button type="reset" class="close">Cancel</button>
</form>
</dialog>
<dialog id="spinner"><span class="spinner"></span></dialog>
</body>
</html>

View file

@ -1,156 +0,0 @@
import { AddInstanceFlow } from "./add_instance_flow.mjs";
import { dialogDetailsFromInstance, dialogDetailsToInstance, InstanceDetailsDialog } from "./confirm_instance_details.mjs";
import { findButtonOrFail, findDialogOrFail, findOlOrFail } from "./dom.mjs";
import storageManager, { Instance } from "./storage_manager.mjs";
let reordering = false;
let unsaved = false;
// Dragging code is a heavily modified version of https://stackoverflow.com/a/28962290
let elementBeingDragged: HTMLLIElement | undefined;
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
const startAddInstanceFlowButton = findButtonOrFail(document.body, "#startAddInstanceFlow");
const addDialog = findDialogOrFail(document.body, "#addInstance");
const spinnerDialog = findDialogOrFail(document.body, "#spinner");
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
const instanceList = findOlOrFail(document.body, "#instanceList");
const saveButton = findButtonOrFail(document.body, "#save");
const reorderButton = findButtonOrFail(document.body, "#reorder");
const resetButton = findButtonOrFail(document.body, "#reset");
let instanceDetailsDialog = new InstanceDetailsDialog(detailsDialog, true);
let addInstanceFlow = new AddInstanceFlow(addDialog, spinnerDialog, instanceDetailsDialog);
startAddInstanceFlowButton.addEventListener("click", e => {
addInstanceFlow.start(false).then(_ => {
updateInstanceList();
unsavedChanges();
});
});
saveButton.addEventListener("click", e => saveChanges());
reorderButton.addEventListener("click", () => {
reordering = !reordering;
if (!reordering) applyReordering();
updateInstanceList();
reorderButton.innerText = reordering ? "Finish reordering" : "Reorder";
});
resetButton.addEventListener("click", e => {
storageManager.reset();
updateInstanceList();
unsavedChanges();
});
updateInstanceList();
storageManager.addSaveCallback(updateInstanceList);
mainDialog.show();
function saveChanges() {
storageManager.save();
unsaved = false;
saveButton.classList.remove("pulse-red");
}
function unsavedChanges() {
if (!unsaved) {
unsaved = true;
saveButton.classList.add("pulse-red");
}
}
async function editInstance(instance: Instance) {
const data = dialogDetailsFromInstance(instance);
const newData = await instanceDetailsDialog.present(data);
dialogDetailsToInstance(newData, instance);
updateInstanceList();
unsavedChanges();
}
function deleteInstance(instance: Instance) {
storageManager.storage.instances.splice(
storageManager.storage.instances.indexOf(instance),
1
);
updateInstanceList();
unsavedChanges();
}
function updateInstanceList() {
instanceList.replaceChildren(); // Erase all child nodes
instanceList.style.listStyleType = reordering ? "\"≡ \"" : "disc";
for (let n = 0; n < storageManager.storage.instances.length; n++) {
const instance = storageManager.storage.instances[n];
const li = document.createElement("li");
li.setAttribute("x-option", n.toString());
const label = document.createElement("label");
label.htmlFor = instance.origin;
label.innerText = instance.name + " ";
label.style.cursor = "inherit";
if (instance.iconURL) {
const img = new Image();
img.src = instance.iconURL;
img.alt = `${instance.name} icon`;
img.className = "inlineIcon medium-height";
label.append(img, " ");
}
if (reordering) {
li.draggable = true;
li.addEventListener("dragstart", e => {
if (e.dataTransfer === null) return;
if (!(e.target instanceof HTMLLIElement)) return;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", "");
elementBeingDragged = e.target;
});
li.addEventListener("dragover", e => {
if (elementBeingDragged === undefined) return;
if (!(e.target instanceof HTMLElement)) return;
const listElement = e.target.closest("li");
if (listElement === null) return;
if (listElement.parentNode === null) return;
if (isBefore(elementBeingDragged, listElement))
listElement.parentNode.insertBefore(elementBeingDragged, listElement);
else
listElement.parentNode.insertBefore(elementBeingDragged, listElement.nextSibling);
e.preventDefault();
});
li.addEventListener("dragenter", e => e.preventDefault());
li.style.cursor = "grab";
} else {
const editLink = document.createElement("a");
editLink.innerText = `Edit`;
editLink.href = "#";
editLink.addEventListener("click", e => editInstance(instance));
const deleteLink = document.createElement("a");
deleteLink.innerText = `Delete`;
deleteLink.href = "#";
deleteLink.addEventListener("click", e => deleteInstance(instance));
label.append(editLink, " ", deleteLink);
}
li.appendChild(label);
instanceList.appendChild(li);
}
}
function isBefore(el1: HTMLLIElement, el2: HTMLLIElement) {
if (el2.parentNode === el1.parentNode)
for (let cur = el1.previousSibling; cur && cur.nodeType !== 9; cur = cur.previousSibling)
if (cur === el2)
return true;
return false;
}
function applyReordering() {
const indices: number[] = [];
for (const el of instanceList.children) {
if (!(el instanceof HTMLLIElement)) continue;
const option = el.getAttribute("x-option");
if (option === null) continue;
indices.push(parseInt(option));
}
storageManager.storage.instances = indices.map(i => storageManager.storage.instances[i]);
unsavedChanges();
}

View file

@ -1,184 +1,88 @@
// This file handles the "Confirm instance details" dialog
import { parseHost } from "./add_an_instance.mjs";
import { FormDialog, ONCE } from "./dialog.mjs";
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findOptionOrFail, findSelectOrFail } from "./dom.mjs";
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findSelectOrFail } from "./dom.mjs";
import { resize } from "./image.mjs";
import knownSoftware from "./known_software.mjs";
import { Instance } from "./storage_manager.mjs";
const blankImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
export function mergeHost(host: string, secure: boolean): string {
return `http${secure ? "s" : ""}://${host}`;
}
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();
export type InstanceDetailsDialogData = {
name: string,
host: string,
hostSecure: boolean,
software: string,
iconURL: string | null,
preferredFor: string[],
};
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();
});
export function dialogDetailsFromInstance(instance: Instance): InstanceDetailsDialogData {
const host = parseHost(instance.origin)!;
return {
name: instance.name,
host: host.host,
hostSecure: host.secure,
software: instance.software,
iconURL: instance.iconURL ?? null,
preferredFor: instance.preferredFor,
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog
};
}
export function dialogDetailsToInstance(data: InstanceDetailsDialogData, instance: Partial<Instance>): Instance {
instance.name = data.name;
instance.origin = mergeHost(data.host, data.hostSecure);
instance.software = data.software;
instance.iconURL = data.iconURL ?? undefined;
instance.preferredFor = data.preferredFor;
return instance as Instance;
}
export class InstanceDetailsDialog extends FormDialog {
protected instanceName: HTMLInputElement;
protected instanceHost: HTMLInputElement;
protected instanceHostSecure: HTMLInputElement;
protected instanceSoftware: HTMLSelectElement;
protected instanceIcon: HTMLImageElement;
protected closeButton: HTMLButtonElement;
protected defaultsList?: {
list: HTMLSelectElement,
removeButton: HTMLButtonElement,
noDefaultsOption: HTMLOptionElement,
};
constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
super(dialog, findFormOrFail(dialog, ".instanceDetailsForm"));
this.instanceName = findInputOrFail(this.form, "#instanceName");
this.instanceHost = findInputOrFail(this.form, "#instanceHost");
this.instanceHostSecure = findInputOrFail(this.form, "#instanceHostSecure");
this.instanceSoftware = findSelectOrFail(this.form, "#instanceSoftware");
this.instanceIcon = findImageOrFail(this.form, "#instanceIcon");
this.closeButton = findButtonOrFail(this.form, ".close");
try {
this.defaultsList = {
list: findSelectOrFail(this.form, "#defaultsList"),
removeButton: findButtonOrFail(this.form, "#removeDefaults"),
noDefaultsOption: findOptionOrFail(this.form, "#noDefaults"),
};
} catch {
this.defaultsList = undefined;
}
if (initializeDOM) this.initializeDOM();
}
protected override initializeDOM() {
super.initializeDOM();
for (const [name, software] of Object.entries(knownSoftware.software)) {
const option = new Option(software.name, name);
this.instanceSoftware.appendChild(option);
}
this.instanceIcon.src = blankImage;
this.closeButton.addEventListener("click", e => this.close());
if (this.defaultsList) {
this.defaultsList.list.addEventListener("change", e => this.#handleListSelectionChange());
this.defaultsList.removeButton.addEventListener("click", e => this.#removeSelectedListOptions());
}
}
#getRemainingListOptions(): string[] {
if (!this.defaultsList) return [];
const items: string[] = [];
for (const option of this.defaultsList.list.options) {
if (option == this.defaultsList.noDefaultsOption) continue;
items.push(option.value);
}
return items;
}
#removeSelectedListOptions() {
if (!this.defaultsList) return;
// Copy using spread because this breaks when the list changes mid-iteration
for (const option of [...this.defaultsList.list.selectedOptions]) {
option.remove();
}
this.#handleListChange();
}
#populateDefaultsList(items: string[]) {
if (!this.defaultsList) return;
while (this.defaultsList.list.children.length > 1)
this.defaultsList.list.removeChild(this.defaultsList.list.lastChild!);
for (const item of items) {
const option = document.createElement("option");
option.value = item;
option.innerText = knownSoftware.software[item]?.name ?? knownSoftware.groups[item]?.name ?? item;
this.defaultsList.list.appendChild(option);
}
this.#handleListChange();
}
#handleListChange() {
this.#handleListEmpty();
this.#handleListSelectionChange();
}
#handleListSelectionChange() {
if (!this.defaultsList) return;
this.defaultsList.removeButton.disabled = this.defaultsList.list.selectedOptions.length === 0;
}
#handleListEmpty() {
if (!this.defaultsList) return;
if (this.defaultsList.list.children.length == 1) {
this.defaultsList.noDefaultsOption.removeAttribute("hidden");
} else {
this.defaultsList.noDefaultsOption.setAttribute("hidden", "");
}
}
#handleSubmit(data: InstanceDetailsDialogData, resolve: (data: InstanceDetailsDialogData) => void) {
this.form.addEventListener("submit", e => {
data.name = this.instanceName.value;
data.host = this.instanceHost.value;
data.hostSecure = this.instanceHostSecure.checked;
data.software = this.instanceSoftware.value;
data.preferredFor = this.#getRemainingListOptions();
resolve(data);
this.close();
}, ONCE);
}
async present(data: InstanceDetailsDialogData): Promise<InstanceDetailsDialogData> {
this.instanceName.value = data.name;
this.instanceHost.value = data.host;
this.instanceHostSecure.checked = data.hostSecure;
this.instanceSoftware.value = data.software;
this.instanceIcon.src = data.iconURL ?? blankImage;
this.#populateDefaultsList(data.preferredFor);
return new Promise((resolve, reject) => {
this.cancelOnceClosed(reject);
this.#handleSubmit(data, resolve);
this.open();
});
}
}

View file

@ -1,10 +1,7 @@
@import url("/static/spinner.css");
:root {
--red: #cb0b0b;
--blue: #2081c3;
--transparent-black: #0008;
--xl: 4em;
--large: 2em;
--medium: 1em;
}
@ -13,7 +10,6 @@ html,
body {
background: linear-gradient(300deg, var(--red), var(--blue));
background-size: 100vw 100vh;
background-attachment: fixed;
margin: 0;
min-height: 100vh;
height: 100vh;
@ -57,23 +53,6 @@ abbr[title] {
text-decoration-color: var(--blue);
}
.align-start {
text-align: start;
}
.inline-block {
display: inline-block;
}
.align-content-center {
justify-content: center;
align-items: center;
}
.align-self-start {
align-self: start;
}
.flex-vcenter {
display: flex;
flex-direction: column;
@ -82,14 +61,6 @@ abbr[title] {
height: 100%;
}
.flex-hcenter {
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
height: 100%;
}
.flex-row {
display: flex;
flex-direction: row;
@ -100,27 +71,11 @@ abbr[title] {
flex-direction: row-reverse;
}
.flex-column {
display: flex;
flex-direction: column;
}
.flex-column-reverse {
display: flex;
flex-direction: column-reverse;
}
.flex-vevenly {
display: flex;
flex-direction: column;
justify-content: space-evenly;
height: 100%;
}
.wfit-content {
width: fit-content;
}
.half-width {
min-width: 50%;
}
@ -129,18 +84,10 @@ abbr[title] {
min-height: 50%;
}
.full-width {
min-width: 100%;
}
.full-height {
min-height: 100%;
}
.medium-height {
height: var(--medium);
}
.separator-bottom {
border-bottom: solid 1px var(--transparent-black);
}
@ -172,30 +119,4 @@ abbr[title] {
top: 50%;
left: 50%;
translate: -50% -50%;
}
.logo {
height: 4em;
}
.inlineIcon {
vertical-align: text-top;
}
.buttonPanel>* {
margin-top: min(var(--xl), 6vh);
}
.pulse-red {
animation: 1s ease-in-out 0s infinite alternate both running pulse-red-anim;
}
@keyframes pulse-red-anim {
0% {
box-shadow: 0px 0px 0px var(--red);
}
100% {
box-shadow: 0px 0px 20px var(--red);
}
}

View file

@ -5,12 +5,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FeDirect</title>
<link rel="stylesheet" href="/static/main.css">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *;">
<link rel="stylesheet" href="/static/crossroad.css">
</head>
<body>
<script type="module" src="/static/crossroad.mjs"></script>
<script type="module">
Object.assign(globalThis, await import("/static/crossroad.mjs"));
getMainDialog().show(); // Don't show until the page is ready
</script>
<div class="flex-vcenter">
<dialog id="mainDialog" class="half-width half-height">
<header class="separator-bottom margin-large-bottom">
@ -18,29 +20,20 @@
<h1>FeDirect</h1>
<p class="margin-auto-top">&ThickSpace;By Nekomata</p>
</div>
<img src="/static/nekomata_small.png" alt="Nekomata Logo" class="logo" />
<img src="/static/nekomata_small.png" alt="Nekomata Logo" style="height: 4em;" />
</header>
<div class="flex-row align-content-center">
<div class="flex-vcenter full-height">
<center class="half-width">
You're about to go to
<pre id="path" class="inline-block"></pre>.<br>
<img src="/static/down_arrow.svg" alt="" class="medium-height" />
<p id="no-instance">You currently don't have any instances. You should add one!</p>
<form id="instanceSelectForm" class="align-start wfit-content"></form>
<br>
<button id="startAddInstanceFlow">Add an instance</button>
</center>
</div>
<div class="half-width align-self-start">
<div class="flex-hcenter">
<div class="flex-column buttonPanel">
<button id="redirect">Redirect</button>
<button id="redirectAlways">Redirect always</button>
<a href="/">Manage instances</a>
</div>
</div>
<div class="flex-row">
<div class="half-width">
<form>
<input id="radio" type="radio" />
<label for="radio">
Instances and stuff go here!
</label>
</form>
<br>
<button onclick="showAddInstanceDialog()">Add an instance</button>
</div>
<div class="half-width"></div>
</div>
</dialog>
</div>
@ -54,8 +47,9 @@
<br>
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
<label for="autoQueryMetadata">
<abbr
title="This uses FeDirect's API 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
</abbr>
</label>
@ -88,7 +82,7 @@ Unchecking this is not recommended, and this option only exists for exceptional
<label for="instanceSoftware">Instance software</label>
<br>
<select id="instanceSoftware" type="text" name="instanceSoftware" required>
<option value="" disabled>(Please select)</option>
<option value="">(Please select)</option>
</select>
</div>
<div class="half-width flex-row-reverse">
@ -96,6 +90,7 @@ Unchecking this is not recommended, and this option only exists for exceptional
<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>
@ -107,7 +102,6 @@ Unchecking this is not recommended, and this option only exists for exceptional
<button type="reset" class="close">Cancel</button>
</form>
</dialog>
<dialog id="spinner"><span class="spinner"></span></dialog>
</body>
</html>

View file

@ -1,139 +1,14 @@
import { AddInstanceFlow } from "./add_instance_flow.mjs";
import { findButtonOrFail, findDialogOrFail, findFormOrFail, findInputOrFail, findParagraphOrFail, findPreOrFail } from "./dom.mjs";
import knownSoftware from "./known_software.mjs";
import storageManager from "./storage_manager.mjs";
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
import { findDialogOrFail } from "./dom.mjs";
const RADIO_BUTTON_NAME = "instanceSelect";
export function getMainDialog(): HTMLDialogElement {
return document.getElementById('mainDialog') as HTMLDialogElement;
}
let addInstanceFlow: AddInstanceFlow | undefined;
const mainDialog = findDialogOrFail(document.body, "#mainDialog");
const startAddInstanceFlowButton = findButtonOrFail(document.body, "#startAddInstanceFlow");
const addDialog = findDialogOrFail(document.body, "#addInstance");
const spinnerDialog = findDialogOrFail(document.body, "#spinner");
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
const instanceSelectForm = findFormOrFail(document.body, "#instanceSelectForm");
const redirectButton = findButtonOrFail(document.body, "#redirect");
const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways");
const pathText = findPreOrFail(document.body, "#path");
const addDialog = findDialogOrFail(document.body, "#addInstance");
// Don't bother initializing if we're performing autoredirect
if (!autoRedirect()) {
createInstanceSelectOptions();
storageManager.addSaveCallback(createInstanceSelectOptions);
updateNoInstanceHint();
storageManager.addSaveCallback(updateNoInstanceHint);
pathText.innerText = getTargetPath();
addInstanceFlow = new AddInstanceFlow(addDialog, spinnerDialog, detailsDialog);
mainDialog.show();
};
startAddInstanceFlowButton.addEventListener("click", e => addInstanceFlow?.start(true));
redirectButton.addEventListener("click", e => {
// Can be assumed to not fail because the button is disabled if there are no options and the first one is selected by default
redirect(getSelectedOption()!);
});
redirectAlwaysButton.addEventListener("click", e => {
// Can be assumed to not fail because the button is disabled if there are no options and the first one is selected by default
const option = getSelectedOption()!;
setAutoRedirect(option);
redirect(option);
});
function updateNoInstanceHint() {
findParagraphOrFail(document.body, "#no-instance").style.display =
storageManager.storage.instances.length > 0
? "none"
: "";
}
function createInstanceSelectOptions() {
instanceSelectForm.replaceChildren(); // Erase all child nodes
for (const instance of storageManager.storage.instances) {
const div = document.createElement("div");
div.setAttribute("x-option", instance.origin);
const radio = document.createElement("input");
radio.id = instance.origin;
radio.value = instance.origin;
radio.type = "radio";
radio.name = RADIO_BUTTON_NAME;
const label = document.createElement("label");
label.htmlFor = instance.origin;
label.innerText = instance.name + " ";
if (instance.iconURL) {
const img = new Image();
img.src = instance.iconURL;
img.alt = `${instance.name} icon`;
img.className = "inlineIcon medium-height";
label.append(img, " ");
}
const small = document.createElement("small");
const softwareName = knownSoftware.software[instance.software].name;
small.innerText = `(${softwareName})`;
label.appendChild(small);
div.appendChild(radio);
div.appendChild(label);
instanceSelectForm.appendChild(div);
}
const firstInput = instanceSelectForm.querySelector("input");
if (firstInput) firstInput.checked = true;
setRedirectButtonState(firstInput !== null);
}
function setRedirectButtonState(enabled: boolean) {
redirectButton.disabled = !enabled;
redirectAlwaysButton.disabled = !enabled;
}
function getTargetSoftwareOrGroup(): string {
const currentURL = URL.parse(location.href)!;
const target = currentURL.pathname.match(/\/+([^\/]*)\/?/)?.[1];
if (target == null) throw new Error("Crossroad was served on an invalid path (likely a backend routing mistake)");
const softwareName = Object.entries(knownSoftware.software).find(([name, software]) => software.aliases.includes(target))?.[0];
if (softwareName) return softwareName;
const groupName = Object.entries(knownSoftware.groups).find(([name, group]) => group.aliases.includes(target))?.[0];
if (groupName) return groupName;
throw new Error("Could not identify target software or group");
}
function getTargetPath(): string {
const currentURL = URL.parse(location.href)!;
return currentURL.pathname.replace(/\/+[^\/]*\/?/, "/");
}
function getSelectedOption(): string | null {
try {
return findInputOrFail(instanceSelectForm, `input[name="${RADIO_BUTTON_NAME}"]:checked`).value;
} catch {
return null;
}
}
function autoRedirect(): boolean {
const targetSoftware = getTargetSoftwareOrGroup();
const preferredFor = storageManager.storage.instances.find(instance => instance.preferredFor?.includes(targetSoftware));
if (preferredFor) {
redirect(preferredFor.origin);
return true;
}
return false;
}
function setAutoRedirect(option: string) {
const instance = storageManager.storage.instances.find(e => e.origin === option);
if (!instance) throw new Error("Invalid argument");
instance.preferredFor.push(getTargetSoftwareOrGroup());
storageManager.save();
}
function redirect(to: string) {
const url = URL.parse(to);
if (url === null) throw new Error("Couldn't parse destination");
url.pathname = getTargetPath();
location.href = url.toString();
}
export const {
showAddInstanceDialog,
hideAddInstanceDialog
} = initializeAddInstanceFlow(detailsDialog, addDialog);

View file

@ -1,37 +0,0 @@
import { LocalStorage } from "./storage_manager.mjs"
type InstanceV0 = {
name: string,
origin: string,
software: string,
iconURL?: string,
preferredFor?: string[],
}
type LocalStorageV0 = {
version: undefined,
instances: InstanceV0[],
}
type LocalStorageV1 = LocalStorage;
function migrate0to1(s: LocalStorageV0): LocalStorageV1 {
return {
version: 1,
instances: s.instances.map(i => ({
preferredFor: i.preferredFor ?? [],
...i
}))
};
}
type AnyLocalStorage = LocalStorageV0 | LocalStorageV1;
export default function migrate(storage: AnyLocalStorage): LocalStorage {
switch (storage.version) {
case undefined:
storage = migrate0to1(storage);
case 1:
default:
return storage;
}
}

View file

@ -1,46 +0,0 @@
export const ONCE = { once: true };
export const CANCELLED = Symbol("Cancelled");
export class Dialog {
protected dialog: HTMLDialogElement;
constructor(dialog: HTMLDialogElement) {
this.dialog = dialog;
}
/**
* A function that should only be called once that has permanent effects on the DOM
*/
protected initializeDOM() { }
open() {
this.dialog.showModal();
}
close() {
this.dialog.close();
}
protected cancelOnceClosed(reject: (reason?: any) => void) {
this.dialog.addEventListener("close", e => reject(CANCELLED), ONCE);
}
};
export class FormDialog extends Dialog {
protected form: HTMLFormElement;
constructor(dialog: HTMLDialogElement, form: HTMLFormElement) {
super(dialog);
this.form = form;
}
protected override initializeDOM() {
super.initializeDOM();
this.dialog.addEventListener("close", e => this.reset());
}
reset() {
this.form.reset();
}
}

View file

@ -1,34 +1,6 @@
// 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 findOptionOrFail(on: Element, selector: string): HTMLOptionElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLOptionElement))
throw new Error(`${selector} isn't an option`);
return element;
}
export function findOlOrFail(on: Element, selector: string): HTMLOListElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLOListElement))
throw new Error(`${selector} isn't an ol`);
return element;
}
export function findPreOrFail(on: Element, selector: string): HTMLPreElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLPreElement))
throw new Error(`${selector} isn't a pre`);
return element;
}
export function findParagraphOrFail(on: Element, selector: string): HTMLParagraphElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLParagraphElement))
throw new Error(`${selector} isn't a paragraph`);
return element;
}
export function findDialogOrFail(on: Element, selector: string): HTMLDialogElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLDialogElement))

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc. --><path d="M256 464a208 208 0 1 1 0-416 208 208 0 1 1 0 416zM256 0a256 256 0 1 0 0 512A256 256 0 1 0 256 0zM376.9 294.6c4.5-4.2 7.1-10.1 7.1-16.3c0-12.3-10-22.3-22.3-22.3L304 256l0-96c0-17.7-14.3-32-32-32l-32 0c-17.7 0-32 14.3-32 32l0 96-57.7 0C138 256 128 266 128 278.3c0 6.2 2.6 12.1 7.1 16.3l107.1 99.9c3.8 3.5 8.7 5.5 13.8 5.5s10.1-2 13.8-5.5l107.1-99.9z"/></svg>

Before

Width:  |  Height:  |  Size: 579 B

View file

@ -1,78 +0,0 @@
/* Sourced and modified from https://cssloaders.github.io/ */
.spinner {
display: block;
animation: rotate 1s infinite;
height: 50px;
width: 50px;
}
.spinner:before,
.spinner:after {
border-radius: 50%;
content: "";
display: block;
height: 20px;
width: 20px;
}
.spinner:before {
animation: ball1 1s infinite;
background-color: var(--red);
box-shadow: 30px 0 0 var(--blue);
margin-bottom: 10px;
}
.spinner:after {
animation: ball2 1s infinite;
background-color: var(--blue);
box-shadow: 30px 0 0 var(--red);
}
@keyframes rotate {
0% {
transform: rotate(0deg) scale(0.8)
}
50% {
transform: rotate(360deg) scale(1.2)
}
100% {
transform: rotate(720deg) scale(0.8)
}
}
@keyframes ball1 {
0% {
box-shadow: 30px 0 0 var(--blue);
}
50% {
box-shadow: 0 0 0 var(--blue);
margin-bottom: 0;
transform: translate(15px, 15px);
}
100% {
box-shadow: 30px 0 0 var(--blue);
margin-bottom: 10px;
}
}
@keyframes ball2 {
0% {
box-shadow: 30px 0 0 var(--red);
}
50% {
box-shadow: 0 0 0 var(--red);
margin-top: -20px;
transform: translate(15px, 15px);
}
100% {
box-shadow: 30px 0 0 var(--red);
margin-top: 0;
}
}

View file

@ -1,5 +1,3 @@
import migrate from "./data_migration.mjs";
export type Instance = {
/**
* The instance's (nick)name
@ -23,25 +21,18 @@ export type Instance = {
software: string,
/**
* The instance's icon URL
* @example undefined
* @example "https://void.lgbt/favicon.png"
*
* Make sure to sanitize this! Could lead to XSS
*/
iconURL?: string,
/**
* The list of software names and groups the user prefers to autoredirect to this instance
* @example ["sharkey", "misskey-compliant"]
*/
preferredFor: string[],
}
export type LocalStorage = {
version: number,
type LocalStorage = {
instances: Instance[],
}
export default new class StorageManager {
storage: LocalStorage;
saveCallbacks: (() => void)[] = [];
constructor() {
this.load();
@ -49,26 +40,15 @@ export default new class StorageManager {
default(): LocalStorage {
return {
version: 1,
instances: []
}
}
load() {
const data = JSON.parse(window.localStorage.getItem("storage") ?? "null") ?? this.default();
this.storage = migrate(data);
this.storage = JSON.parse(window.localStorage.getItem("storage") ?? "null") ?? this.default();
}
save() {
window.localStorage.setItem("storage", JSON.stringify(this.storage));
this.saveCallbacks.forEach(c => c());
}
addSaveCallback(callback: () => void) {
this.saveCallbacks.push(callback);
}
reset() {
this.storage = this.default();
}
}();

View file

@ -4,7 +4,6 @@
"allowJs": true,
"checkJs": true,
"strictNullChecks": true,
"noImplicitOverride": true,
},
"include": [
"static/**.mts",