Compare commits

..

No commits in common. "main" and "config-page" have entirely different histories.

24 changed files with 315 additions and 1028 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. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 3
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@ -185,29 +185,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 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]] [[package]]
name = "deranged" name = "deranged"
version = "0.3.11" version = "0.3.11"
@ -217,17 +194,6 @@ dependencies = [
"powerfmt", "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]] [[package]]
name = "devise" name = "devise"
version = "0.4.2" version = "0.4.2"
@ -272,27 +238,6 @@ dependencies = [
"syn", "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]] [[package]]
name = "either" name = "either"
version = "1.13.0" version = "1.13.0"
@ -330,32 +275,16 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 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]] [[package]]
name = "fedirect" name = "fedirect"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bytes", "bytes",
"favicon-scraper",
"reqwest", "reqwest",
"rocket", "rocket",
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
"tokio",
"url", "url",
] ]
@ -403,16 +332,6 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "futures" name = "futures"
version = "0.3.31" version = "0.3.31"
@ -490,15 +409,6 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "fxhash"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "generator" name = "generator"
version = "0.7.5" version = "0.7.5"
@ -512,15 +422,6 @@ dependencies = [
"windows", "windows",
] ]
[[package]]
name = "getopts"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
dependencies = [
"unicode-width",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.15" version = "0.2.15"
@ -600,20 +501,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" 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]] [[package]]
name = "http" name = "http"
version = "0.2.12" version = "0.2.12"
@ -917,12 +804,6 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "imagesize"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.7.0" version = "2.7.0"
@ -1028,26 +909,6 @@ dependencies = [
"tracing-subscriber", "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]] [[package]]
name = "matchers" name = "matchers"
version = "0.1.0" version = "0.1.0"
@ -1125,12 +986,6 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.46.0" version = "0.46.0"
@ -1274,77 +1129,6 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 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]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.16" version = "0.2.16"
@ -1378,12 +1162,6 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.93" version = "1.0.93"
@ -1752,21 +1530,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "security-framework" name = "security-framework"
version = "2.11.1" version = "2.11.1"
@ -1790,25 +1553,6 @@ dependencies = [
"libc", "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]] [[package]]
name = "semver" name = "semver"
version = "1.0.24" version = "1.0.24"
@ -1868,15 +1612,6 @@ dependencies = [
"serde", "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]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@ -1901,18 +1636,6 @@ dependencies = [
"libc", "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]] [[package]]
name = "slab" name = "slab"
version = "0.4.9" version = "0.4.9"
@ -1968,32 +1691,6 @@ dependencies = [
"loom", "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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@ -2066,17 +1763,6 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.8" version = "1.1.8"
@ -2353,12 +2039,6 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]] [[package]]
name = "unicode-xid" name = "unicode-xid"
version = "0.2.6" version = "0.2.6"
@ -2382,12 +2062,6 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "utf16_iter" name = "utf16_iter"
version = "1.0.5" version = "1.0.5"

View file

@ -5,11 +5,9 @@ edition = "2021"
[dependencies] [dependencies]
bytes = "1.9.0" bytes = "1.9.0"
favicon-scraper = "0.3.1"
reqwest = { version = "0.12.12", features = ["stream"] } reqwest = { version = "0.12.12", features = ["stream"] }
rocket = { version = "0.5.1", features = ["json"] } rocket = { version = "0.5.1", features = ["json"] }
semver = "1.0.24" semver = "1.0.24"
serde = { version = "1.0.217", features = ["derive"] } serde = { version = "1.0.217", features = ["derive"] }
serde_json = "1.0.135" serde_json = "1.0.135"
tokio = { version = "1.43.0", features = ["process"] }
url = "2.5.4" url = "2.5.4"

View file

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

View file

@ -136,8 +136,7 @@
], ],
"groups": [ "groups": [
"misskey-compliant", "misskey-compliant",
"misskey-v13", "misskey-v13"
"mastodon-compliant-api"
], ],
"forkOf": "misskey" "forkOf": "misskey"
}, },

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 rocket::serde::json::Json;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
use crate::known_software::KNOWN_SOFTWARE_NODEINFO_NAMES; use crate::known_software::KNOWN_SOFTWARE_NODEINFO_NAMES;
const MINIMUM_ICON_SIZE: usize = 16;
#[derive(Serialize)] #[derive(Serialize)]
pub struct InstanceInfo { pub struct InstanceInfo {
name: String, name: String,
@ -17,6 +12,28 @@ pub struct InstanceInfo {
icon_url: Option<String>, 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)] #[derive(Deserialize)]
struct NodeInfoDiscovery { struct NodeInfoDiscovery {
links: Vec<NodeInfoLink>, links: Vec<NodeInfoLink>,
@ -45,59 +62,40 @@ struct NodeInfoMetadata {
name: Option<String>, 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 get_info_from_manifest(url: Url) -> Option<[Option<String>; 3]> {
async fn find_icon(host: &str) -> Option<String> { // FIXME: Iceshrimp.NET doesn't have a manifest...
let icons: Vec<Icon> = favicon_scraper::scrape(host) let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?;
.await let manifest: InstanceManifest = serde_json::from_str(&response).ok()?;
.ok()? Some([
.into_iter() manifest.name,
.filter(|i| i.size.width.min(i.size.height) >= MINIMUM_ICON_SIZE) manifest.short_name,
.collect(); manifest
.icons
let priority = |kind: &IconKind| match kind { .as_ref()
IconKind::LinkedInHTML => 0, .and_then(|icons| icons.iter().min_by_key(|icon| icon.get_size()))
IconKind::LinkedInManifest => 1, .map(|icon| icon.src.to_owned()),
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())
} }
#[get("/instance_info/<secure>/<host>")] #[get("/instance_info/<secure>/<host>")]
pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo>> { pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo>> {
let mut url = Url::parse(if secure { let mut url = Url::parse(&format!(
"https://temp.host/" "http{}://{host}/manifest.json",
} else { if secure { "s" } else { "" }
"http://temp.host/" ))
}) .ok()?;
.unwrap(); // I'm not sure if you can sneak in a path, but better safe than sorry
url.set_host(Some(host)).ok()?; // Using this to catch malformed hosts // I don't really care about username/password/port, those are fine
let host = url.host_str()?.to_owned(); // Shadow the original host in case things were filtered out if url.path() != "/manifest.json" {
// 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()
{
return None; return None;
} }
let [name, short_name, icon_url] = get_info_from_manifest(url.clone())
let icon_url = find_icon(url.as_str()).await; .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"); url.set_path("/.well-known/nodeinfo");
let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?; let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?;
let nodeinfo_discovery: NodeInfoDiscovery = serde_json::from_str(&response).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())) .and_then(|v| fork_map.get(v.build.as_str()))
.unwrap_or(software_name) .unwrap_or(software_name)
.to_owned(); .to_owned();
Some(Json(InstanceInfo { Some(Json(InstanceInfo {
name: nodeinfo name: name
.metadata .or(short_name)
.and_then(|m| m.name) .or(nodeinfo.metadata.and_then(|m| m.name))
.unwrap_or(host.to_owned()), .unwrap_or(url.host_str().unwrap().to_owned()),
software, software,
icon_url, icon_url,
})) }))

View file

@ -4,9 +4,5 @@ pub mod instance_info;
pub mod proxy; pub mod proxy;
pub fn get_routes() -> Vec<Route> { pub fn get_routes() -> Vec<Route> {
routes![ routes![instance_info::instance_info, proxy::proxy]
instance_info::instance_info,
// Proxy is temporarily disabled as it's not needed
// 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] #[macro_use]
extern crate rocket; extern crate rocket;

View file

@ -1,6 +1,5 @@
// This file handles the "Add an instance" dialog // This file handles the "Add an instance" dialog
import { FormDialog, ONCE } from "./dialog.mjs";
import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs"; import { findButtonOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
export function parseHost(host: string): { host: string, secure: boolean } | null { 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 = { export function initializeAddInstanceDialog(
dialog: HTMLDialogElement,
callback: (
host: string, host: string,
secure: boolean, secure: boolean,
autoQueryMetadata: boolean, autoQueryMetadata: boolean,
}; ) => void
): {
showAddInstanceDialog: () => void,
hideAddInstanceDialog: () => void,
} {
const showAddInstanceDialog = () => dialog.showModal();
const hideAddInstanceDialog = () => dialog.close();
export class AddInstanceDialog extends FormDialog { const form = findFormOrFail(dialog, ".addInstanceForm");
protected instanceHost: HTMLInputElement; const instanceHost = findInputOrFail(form, "#instanceHost");
protected autoQueryMetadata: HTMLInputElement; const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata");
protected closeButton: HTMLButtonElement; const closeButton = findButtonOrFail(form, ".close");
constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) { instanceHost.addEventListener("input", e => {
super(dialog, findFormOrFail(dialog, ".addInstanceForm")); if (parseHost(instanceHost.value) === null)
instanceHost.setCustomValidity("Invalid instance hostname or URL");
this.instanceHost = findInputOrFail(this.form, "#instanceHost"); else
this.autoQueryMetadata = findInputOrFail(this.form, "#autoQueryMetadata"); instanceHost.setCustomValidity("");
this.closeButton = findButtonOrFail(this.form, ".close");
if (initializeDOM) this.initializeDOM();
}
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();
}); });
}
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();
});
closeButton.addEventListener("click", e => hideAddInstanceDialog());
return {
showAddInstanceDialog,
hideAddInstanceDialog
};
} }

View file

@ -1,73 +1,69 @@
import { AddInstanceDialog } from "./add_an_instance.mjs"; import { initializeAddInstanceDialog } from "./add_an_instance.mjs";
import { dialogDetailsToInstance, InstanceDetailsDialog, InstanceDetailsDialogData } from "./confirm_instance_details.mjs"; import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
import { Dialog } from "./dialog.mjs";
import storageManager, { Instance } from "./storage_manager.mjs"; import storageManager, { Instance } from "./storage_manager.mjs";
export class AddInstanceFlow { export function initializeAddInstanceFlow(
addDialog: AddInstanceDialog; detailsDialog: HTMLDialogElement,
spinnerDialog: Dialog; addDialog: HTMLDialogElement
detailsDialog: InstanceDetailsDialog; ): {
showAddInstanceDialog: () => void,
constructor( hideAddInstanceDialog: () => void
addDialog: AddInstanceDialog | HTMLDialogElement, } {
spinnerDialog: HTMLDialogElement, const instanceDetailsDialogCallback = (
detailsDialog: InstanceDetailsDialog | HTMLDialogElement, name: string,
) { host: string,
if (addDialog instanceof AddInstanceDialog) hostSecure: boolean,
this.addDialog = addDialog; software: string,
else icon: string | null
this.addDialog = new AddInstanceDialog(addDialog, true); ) => {
const instance: Instance = {
this.spinnerDialog = new Dialog(spinnerDialog); name,
origin: `http${hostSecure ? "s" : ""}://${host}`,
if (detailsDialog instanceof InstanceDetailsDialog) software,
this.detailsDialog = detailsDialog; iconURL: icon ?? undefined
else };
this.detailsDialog = new InstanceDetailsDialog(detailsDialog, true); storageManager.storage.instances.push(instance);
} storageManager.save();
console.log("Successfully added new instance:", instance);
async start(autoSave: boolean) {
const {
autoQueryMetadata,
host,
secure,
} = await this.addDialog.present();
const detailsDialogData: InstanceDetailsDialogData = {
name: host,
host,
hostSecure: secure,
software: "",
iconURL: null,
preferredFor: []
}; };
const {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog
} = initializeInstanceDetailsDialog(detailsDialog, instanceDetailsDialogCallback);
const addInstanceDialogCallback = async (
host: string,
secure: boolean,
autoQueryMetadata: boolean,
) => {
try { try {
if (!autoQueryMetadata) throw null; // Skip to catch block if (!autoQueryMetadata) throw new Error("Don't");
this.spinnerDialog.open();
const { name, software, iconURL } = const { name, software, iconURL } =
await fetch(`/api/instance_info/${secure}/${encodeURIComponent(host)}`) await fetch(`/api/instance_info/${secure}/${encodeURIComponent(host)}`)
.then(r => r.json()); .then(r => r.json());
if ( if (
typeof name !== "string" typeof name !== "string"
|| typeof software !== "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"); throw new Error("Invalid API response");
populateInstanceDetailsDialog(name, host, secure, software, iconURL as string | null);
detailsDialogData.name = name; } catch {
detailsDialogData.software = software; populateInstanceDetailsDialog(host, host, secure, "", null);
detailsDialogData.iconURL = iconURL as string | null; } finally {
} catch { } showInstanceDetailsDialog();
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);
} }
} }
const {
showAddInstanceDialog,
hideAddInstanceDialog
} = initializeAddInstanceDialog(addDialog, addInstanceDialogCallback);
return {
showAddInstanceDialog,
hideAddInstanceDialog
};
}

View file

@ -6,11 +6,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FeDirect</title> <title>FeDirect</title>
<link rel="stylesheet" href="/static/main.css"> <link rel="stylesheet" href="/static/main.css">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *;">
</head> </head>
<body> <body>
<script type="module" src="/static/config.mjs"></script> <script type="module">
Object.assign(globalThis, await import("/static/config.mjs"));
getMainDialog().show(); // Don't show until the page is ready
</script>
<div class="flex-vcenter"> <div class="flex-vcenter">
<dialog id="mainDialog" class="half-width half-height"> <dialog id="mainDialog" class="half-width half-height">
<header class="separator-bottom margin-large-bottom"> <header class="separator-bottom margin-large-bottom">
@ -25,7 +27,7 @@
<center class="half-width"> <center class="half-width">
<ol id="instanceList" class="align-start wfit-content"></ol> <ol id="instanceList" class="align-start wfit-content"></ol>
<br> <br>
<button id="startAddInstanceFlow">Add an instance</button> <button onclick="showAddInstanceDialog()">Add an instance</button>
</center> </center>
</div> </div>
<div class="half-width align-self-start"> <div class="half-width align-self-start">
@ -50,8 +52,9 @@
<br> <br>
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked /> <input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
<label for="autoQueryMetadata"> <label for="autoQueryMetadata">
<abbr <abbr title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.
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 Automatically query metadata
</abbr> </abbr>
</label> </label>
@ -101,15 +104,14 @@ Unchecking this is not recommended, and this option only exists for exceptional
<br> <br>
<label for="defaultsList">Default option for:</label><br> <label for="defaultsList">Default option for:</label><br>
<select id="defaultsList" class="full-width" multiple> <select id="defaultsList" class="full-width" multiple>
<option id="noDefaults" value="" disabled>(None, use the "Redirect always" button to set!)</option> <option value="" disabled>(None, use the "Redirect always" button to set!)</option>
</select> </select>
<button id="removeDefaults" type="button" disabled>Remove</button> <button id="removeDefaults" disabled>Remove</button>
<br><br> <br><br>
<button type="submit">OK</button> <button type="submit">OK</button>
<button type="reset" class="close">Cancel</button> <button type="reset" class="close">Cancel</button>
</form> </form>
</dialog> </dialog>
<dialog id="spinner"><span class="spinner"></span></dialog>
</body> </body>
</html> </html>

View file

@ -1,34 +1,22 @@
import { AddInstanceFlow } from "./add_instance_flow.mjs"; import { parseHost } from "./add_an_instance.mjs";
import { dialogDetailsFromInstance, dialogDetailsToInstance, InstanceDetailsDialog } from "./confirm_instance_details.mjs"; import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs";
import { findButtonOrFail, findDialogOrFail, findOlOrFail } from "./dom.mjs"; import { findButtonOrFail, findDialogOrFail, findOlOrFail } from "./dom.mjs";
import storageManager, { Instance } from "./storage_manager.mjs"; import storageManager from "./storage_manager.mjs";
let reordering = false; let reordering = false;
let unsaved = false;
// Dragging code is a heavily modified version of https://stackoverflow.com/a/28962290 // Dragging code is a heavily modified version of https://stackoverflow.com/a/28962290
let elementBeingDragged: HTMLLIElement | undefined; 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 detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
const addDialog = findDialogOrFail(document.body, "#addInstance");
const instanceList = findOlOrFail(document.body, "#instanceList"); const instanceList = findOlOrFail(document.body, "#instanceList");
const saveButton = findButtonOrFail(document.body, "#save"); const saveButton = findButtonOrFail(document.body, "#save");
const reorderButton = findButtonOrFail(document.body, "#reorder"); const reorderButton = findButtonOrFail(document.body, "#reorder");
const resetButton = findButtonOrFail(document.body, "#reset");
let instanceDetailsDialog = new InstanceDetailsDialog(detailsDialog, true); saveButton.addEventListener("click", e => {
let addInstanceFlow = new AddInstanceFlow(addDialog, spinnerDialog, instanceDetailsDialog); storageManager.save();
startAddInstanceFlowButton.addEventListener("click", e => {
addInstanceFlow.start(false).then(_ => {
updateInstanceList();
unsavedChanges();
}); });
});
saveButton.addEventListener("click", e => saveChanges());
reorderButton.addEventListener("click", () => { reorderButton.addEventListener("click", () => {
reordering = !reordering; reordering = !reordering;
@ -37,47 +25,22 @@ reorderButton.addEventListener("click", () => {
reorderButton.innerText = reordering ? "Finish reordering" : "Reorder"; reorderButton.innerText = reordering ? "Finish reordering" : "Reorder";
}); });
resetButton.addEventListener("click", e => { export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog");
storageManager.reset();
updateInstanceList(); const {
unsavedChanges(); showInstanceDetailsDialog,
}); hideInstanceDetailsDialog,
populateInstanceDetailsDialog,
} = initializeInstanceDetailsDialog(detailsDialog, () => { });
export const {
showAddInstanceDialog,
hideAddInstanceDialog
} = initializeAddInstanceFlow(detailsDialog, addDialog);
updateInstanceList(); updateInstanceList();
storageManager.addSaveCallback(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() { function updateInstanceList() {
instanceList.replaceChildren(); // Erase all child nodes instanceList.replaceChildren(); // Erase all child nodes
instanceList.style.listStyleType = reordering ? "\"≡ \"" : "disc"; instanceList.style.listStyleType = reordering ? "\"≡ \"" : "disc";
@ -123,11 +86,26 @@ function updateInstanceList() {
const editLink = document.createElement("a"); const editLink = document.createElement("a");
editLink.innerText = `Edit`; editLink.innerText = `Edit`;
editLink.href = "#"; editLink.href = "#";
editLink.addEventListener("click", e => editInstance(instance)); editLink.addEventListener("click", e => {
const host = parseHost(instance.origin)!;
populateInstanceDetailsDialog(
instance.name,
host.host,
host.secure,
instance.software,
instance.iconURL ?? null
);
showInstanceDetailsDialog();
});
const deleteLink = document.createElement("a"); const deleteLink = document.createElement("a");
deleteLink.innerText = `Delete`; deleteLink.innerText = `Delete`;
deleteLink.href = "#"; deleteLink.href = "#";
deleteLink.addEventListener("click", e => deleteInstance(instance)); deleteLink.addEventListener("click", e => {
storageManager.storage.instances.splice(
storageManager.storage.instances.indexOf(instance)
);
updateInstanceList();
});
label.append(editLink, " ", deleteLink); label.append(editLink, " ", deleteLink);
} }
li.appendChild(label); li.appendChild(label);
@ -152,5 +130,4 @@ function applyReordering() {
indices.push(parseInt(option)); indices.push(parseInt(option));
} }
storageManager.storage.instances = indices.map(i => storageManager.storage.instances[i]); 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 // This file handles the "Confirm instance details" dialog
import { parseHost } from "./add_an_instance.mjs"; import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findSelectOrFail } from "./dom.mjs";
import { FormDialog, ONCE } from "./dialog.mjs"; import { resize } from "./image.mjs";
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findOptionOrFail, findSelectOrFail } from "./dom.mjs";
import knownSoftware from "./known_software.mjs"; import knownSoftware from "./known_software.mjs";
import { Instance } from "./storage_manager.mjs";
const blankImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; const blankImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
export function mergeHost(host: string, secure: boolean): string { export function initializeInstanceDetailsDialog(
return `http${secure ? "s" : ""}://${host}`; 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 = { const form = findFormOrFail(dialog, ".instanceDetailsForm");
name: string, const instanceName = findInputOrFail(form, "#instanceName");
host: string, const instanceHost = findInputOrFail(form, "#instanceHost");
hostSecure: boolean, const instanceHostSecure = findInputOrFail(form, "#instanceHostSecure");
software: string, const instanceSoftware = findSelectOrFail(form, "#instanceSoftware");
iconURL: string | null, const instanceIcon = findImageOrFail(form, "#instanceIcon");
preferredFor: string[], const closeButton = findButtonOrFail(form, ".close");
};
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,
};
}
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)) { for (const [name, software] of Object.entries(knownSoftware.software)) {
const option = new Option(software.name, name); const option = new Option(software.name, name);
this.instanceSoftware.appendChild(option); instanceSoftware.appendChild(option);
} }
this.instanceIcon.src = blankImage; instanceIcon.src = blankImage;
this.closeButton.addEventListener("click", e => this.close()); 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)}`;
};
if (this.defaultsList) { form.addEventListener("submit", e => {
this.defaultsList.list.addEventListener("change", e => this.#handleListSelectionChange()); let image: string | null = null;
this.defaultsList.removeButton.addEventListener("click", e => this.#removeSelectedListOptions()); if (instanceIcon.src !== blankImage) {
try {
image = resize(instanceIcon);
} catch { }
} }
} callback(
instanceName.value,
#getRemainingListOptions(): string[] { instanceHost.value,
if (!this.defaultsList) return []; instanceHostSecure.checked,
instanceSoftware.value,
const items: string[] = []; image
);
for (const option of this.defaultsList.list.options) { form.reset();
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();
}); });
}
closeButton.addEventListener("click", e => {
instanceIcon.src = blankImage;
hideInstanceDetailsDialog();
});
return {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog
};
} }

View file

@ -6,11 +6,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FeDirect</title> <title>FeDirect</title>
<link rel="stylesheet" href="/static/main.css"> <link rel="stylesheet" href="/static/main.css">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *;">
</head> </head>
<body> <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"> <div class="flex-vcenter">
<dialog id="mainDialog" class="half-width half-height"> <dialog id="mainDialog" class="half-width half-height">
<header class="separator-bottom margin-large-bottom"> <header class="separator-bottom margin-large-bottom">
@ -29,7 +31,7 @@
<p id="no-instance">You currently don't have any instances. You should add one!</p> <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> <form id="instanceSelectForm" class="align-start wfit-content"></form>
<br> <br>
<button id="startAddInstanceFlow">Add an instance</button> <button onclick="showAddInstanceDialog()">Add an instance</button>
</center> </center>
</div> </div>
<div class="half-width align-self-start"> <div class="half-width align-self-start">
@ -54,8 +56,9 @@
<br> <br>
<input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked /> <input id="autoQueryMetadata" type="checkbox" name="autoQueryMetadata" checked />
<label for="autoQueryMetadata"> <label for="autoQueryMetadata">
<abbr <abbr title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.
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 Automatically query metadata
</abbr> </abbr>
</label> </label>
@ -107,7 +110,6 @@ Unchecking this is not recommended, and this option only exists for exceptional
<button type="reset" class="close">Cancel</button> <button type="reset" class="close">Cancel</button>
</form> </form>
</dialog> </dialog>
<dialog id="spinner"><span class="spinner"></span></dialog>
</body> </body>
</html> </html>

View file

@ -1,37 +1,15 @@
import { AddInstanceFlow } from "./add_instance_flow.mjs"; import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
import { findButtonOrFail, findDialogOrFail, findFormOrFail, findInputOrFail, findParagraphOrFail, findPreOrFail } from "./dom.mjs"; import { findButtonOrFail, findDialogOrFail, findFormOrFail, findInputOrFail, findParagraphOrFail, findPreOrFail } from "./dom.mjs";
import knownSoftware from "./known_software.mjs"; import knownSoftware from "./known_software.mjs";
import storageManager from "./storage_manager.mjs"; import storageManager from "./storage_manager.mjs";
const RADIO_BUTTON_NAME = "instanceSelect"; const radioButtonName = "instanceSelect";
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 detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
const addDialog = findDialogOrFail(document.body, "#addInstance");
const instanceSelectForm = findFormOrFail(document.body, "#instanceSelectForm"); const instanceSelectForm = findFormOrFail(document.body, "#instanceSelectForm");
const redirectButton = findButtonOrFail(document.body, "#redirect"); const redirectButton = findButtonOrFail(document.body, "#redirect");
const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways"); const redirectAlwaysButton = findButtonOrFail(document.body, "#redirectAlways");
const pathText = findPreOrFail(document.body, "#path");
// 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 => { 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 // Can be assumed to not fail because the button is disabled if there are no options and the first one is selected by default
@ -45,6 +23,30 @@ redirectAlwaysButton.addEventListener("click", e => {
redirect(option); redirect(option);
}); });
export const getMainDialog = () => findDialogOrFail(document.body, "#mainDialog");
export const {
showAddInstanceDialog,
hideAddInstanceDialog
} = ((): {
showAddInstanceDialog: () => void,
hideAddInstanceDialog: () => void
} => {
// Don't bother initializing if we're performing autoredirect
if (autoRedirect()) return {
showAddInstanceDialog: () => { },
hideAddInstanceDialog: () => { }
}
createInstanceSelectOptions();
storageManager.addSaveCallback(createInstanceSelectOptions);
updateNoInstanceHint();
storageManager.addSaveCallback(updateNoInstanceHint);
findPreOrFail(document.body, "#path").innerText = getTargetPath();
return initializeAddInstanceFlow(detailsDialog, addDialog);
})();
function updateNoInstanceHint() { function updateNoInstanceHint() {
findParagraphOrFail(document.body, "#no-instance").style.display = findParagraphOrFail(document.body, "#no-instance").style.display =
storageManager.storage.instances.length > 0 storageManager.storage.instances.length > 0
@ -61,7 +63,7 @@ function createInstanceSelectOptions() {
radio.id = instance.origin; radio.id = instance.origin;
radio.value = instance.origin; radio.value = instance.origin;
radio.type = "radio"; radio.type = "radio";
radio.name = RADIO_BUTTON_NAME; radio.name = radioButtonName;
const label = document.createElement("label"); const label = document.createElement("label");
label.htmlFor = instance.origin; label.htmlFor = instance.origin;
label.innerText = instance.name + " "; label.innerText = instance.name + " ";
@ -108,7 +110,7 @@ function getTargetPath(): string {
function getSelectedOption(): string | null { function getSelectedOption(): string | null {
try { try {
return findInputOrFail(instanceSelectForm, `input[name="${RADIO_BUTTON_NAME}"]:checked`).value; return findInputOrFail(instanceSelectForm, `input[name="${radioButtonName}"]:checked`).value;
} catch { } catch {
return null; return null;
} }
@ -127,6 +129,7 @@ function autoRedirect(): boolean {
function setAutoRedirect(option: string) { function setAutoRedirect(option: string) {
const instance = storageManager.storage.instances.find(e => e.origin === option); const instance = storageManager.storage.instances.find(e => e.origin === option);
if (!instance) throw new Error("Invalid argument"); if (!instance) throw new Error("Invalid argument");
instance.preferredFor ??= [];
instance.preferredFor.push(getTargetSoftwareOrGroup()); instance.preferredFor.push(getTargetSoftwareOrGroup());
storageManager.save(); storageManager.save();
} }
@ -137,3 +140,5 @@ function redirect(to: string) {
url.pathname = getTargetPath(); url.pathname = getTargetPath();
location.href = url.toString(); location.href = url.toString();
} }
export { storageManager };

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,13 +1,6 @@
// I would've LOVED to use generics for this but unfortunately that's not possible. // I would've LOVED to use generics for this but unfortunately that's not possible.
// Type safety, but at what cost... >~< thanks TypeScript // 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 { export function findOlOrFail(on: Element, selector: string): HTMLOListElement {
const element = on.querySelector(selector); const element = on.querySelector(selector);
if (!(element instanceof HTMLOListElement)) if (!(element instanceof HTMLOListElement))

View file

@ -1,5 +1,3 @@
@import url("/static/spinner.css");
:root { :root {
--red: #cb0b0b; --red: #cb0b0b;
--blue: #2081c3; --blue: #2081c3;
@ -13,7 +11,6 @@ html,
body { body {
background: linear-gradient(300deg, var(--red), var(--blue)); background: linear-gradient(300deg, var(--red), var(--blue));
background-size: 100vw 100vh; background-size: 100vw 100vh;
background-attachment: fixed;
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
height: 100vh; height: 100vh;
@ -185,17 +182,3 @@ abbr[title] {
.buttonPanel>* { .buttonPanel>* {
margin-top: min(var(--xl), 6vh); 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

@ -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 = { export type Instance = {
/** /**
* The instance's (nick)name * The instance's (nick)name
@ -23,19 +21,18 @@ export type Instance = {
software: string, software: string,
/** /**
* The instance's icon URL * The instance's icon URL
* @example undefined *
* @example "https://void.lgbt/favicon.png" * Make sure to sanitize this! Could lead to XSS
*/ */
iconURL?: string, iconURL?: string,
/** /**
* The list of software names and groups the user prefers to autoredirect to this instance * The list of software names and groups the user prefers to autoredirect to this instance
* @example ["sharkey", "misskey-compliant"] * @example ["sharkey", "misskey-compliant"]
*/ */
preferredFor: string[], preferredFor?: string[],
} }
export type LocalStorage = { type LocalStorage = {
version: number,
instances: Instance[], instances: Instance[],
} }
@ -49,14 +46,12 @@ export default new class StorageManager {
default(): LocalStorage { default(): LocalStorage {
return { return {
version: 1,
instances: [] instances: []
} }
} }
load() { load() {
const data = JSON.parse(window.localStorage.getItem("storage") ?? "null") ?? this.default(); this.storage = JSON.parse(window.localStorage.getItem("storage") ?? "null") ?? this.default();
this.storage = migrate(data);
} }
save() { save() {
@ -67,8 +62,4 @@ export default new class StorageManager {
addSaveCallback(callback: () => void) { addSaveCallback(callback: () => void) {
this.saveCallbacks.push(callback); this.saveCallbacks.push(callback);
} }
reset() {
this.storage = this.default();
}
}(); }();

View file

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