Compare commits

...
Sign in to create a new pull request.

62 commits

Author SHA1 Message Date
becaf79690 Add managing preferredFor from editing
All checks were successful
Build & Test / build-run (push) Successful in 45s
2025-02-12 06:49:22 +01:00
68f54b341b This is no longer necessary with LSV1
All checks were successful
Build & Test / build-run (push) Successful in 42s
2025-02-11 23:08:20 +01:00
5e3817c1a7 Add migration
All checks were successful
Build & Test / build-run (push) Successful in 42s
2025-02-11 22:11:56 +01:00
f0617522e3 Implement "Destroy all data" button
All checks were successful
Build & Test / build-run (push) Successful in 49s
Which resets the storage manager, not the entire localstorage. Not that localstorage should be touched outside of the storage manager, but it means I can keep my backups for debugging in there.
2025-02-09 19:13:54 +01:00
7a39fbd418 Re-add setup deno (oops)
All checks were successful
Build & Test / build-run (push) Successful in 41s
2025-02-04 00:11:32 +01:00
8850e08f5d Try using the rust image again
Some checks failed
Build & Test / build-run (push) Failing after 23s
Manually install node this time
2025-02-04 00:08:38 +01:00
kio
9a4e2b3ca5 Remove unneccesary lines
All checks were successful
Build & Test / build-run (push) Successful in 58s
2025-02-03 22:45:31 +00:00
kio
9f5e6115e9 openssl-libs-static
All checks were successful
Build & Test / build-run (push) Successful in 58s
2025-02-03 22:41:02 +00:00
kio
22bbbf0955 add musl-dev
Some checks failed
Build & Test / build-run (push) Failing after 53s
2025-02-03 22:37:46 +00:00
kio
563462c1c5 Update .forgejo/workflows/ci.yaml
Some checks failed
Build & Test / build-run (push) Failing after 29s
2025-02-03 22:36:15 +00:00
kio
cfae51c43f Update .forgejo/workflows/ci.yaml
Some checks failed
Build & Test / build-run (push) Failing after 24s
2025-02-03 22:35:17 +00:00
kio
beaa76f996 Literal paths
Some checks failed
Build & Test / build-run (push) Failing after 36s
2025-02-03 22:28:00 +00:00
kio
3244cecc6a ingest cargo env vars
Some checks failed
Build & Test / build-run (push) Failing after 19s
2025-02-03 22:26:44 +00:00
kio
b4c70c0d16 automate the install
Some checks failed
Build & Test / build-run (push) Failing after 32s
2025-02-03 22:23:06 +00:00
kio
079b91a6c1 Update .forgejo/workflows/ci.yaml
Some checks failed
Build & Test / build-run (push) Failing after 5s
2025-02-03 22:21:58 +00:00
kio
aa3e1ab526 Change explicit install of Rust/Cargo to rustup
Some checks failed
Build & Test / build-run (push) Failing after 1s
2025-02-03 22:21:31 +00:00
kio
3fad4f8d6e add openssl-dev
Some checks failed
Build & Test / build-run (push) Failing after 32s
2025-02-03 22:17:16 +00:00
kio
93f8ba4226 add openssl
Some checks failed
Build & Test / build-run (push) Failing after 16s
2025-02-03 22:16:03 +00:00
kio
764529f36b add pkgconfig
Some checks failed
Build & Test / build-run (push) Failing after 16s
2025-02-03 22:15:07 +00:00
kio
b2cbd4cc5d add cargo
Some checks failed
Build & Test / build-run (push) Failing after 17s
2025-02-03 22:14:18 +00:00
kio
7aa1b483e1 add nodejs
Some checks failed
Build & Test / build-run (push) Failing after 8s
2025-02-03 22:13:43 +00:00
kio
72faa8901c fix command
Some checks failed
Build & Test / build-run (push) Failing after 8s
2025-02-03 22:13:04 +00:00
kio
da6d60f94c Use alpine instead?
Some checks failed
Build & Test / build-run (push) Failing after 8s
2025-02-03 22:12:21 +00:00
3671085acc Use circleci's image instead
Some checks failed
Build & Test / build-run (push) Failing after 27s
2025-02-03 22:36:46 +01:00
bc8d0a3f92 ok add node ig thanks actions/checkout
Some checks failed
Build & Test / build-run (push) Failing after 21s
2025-02-03 22:28:37 +01:00
93e1ac85cd Use the Rust docker image
Some checks failed
Build & Test / build-run (push) Failing after 3s
2025-02-03 22:26:07 +01:00
c3b5666bd5 Typo
Some checks failed
Build & Test / build-run (push) Failing after 17s
2025-02-03 22:18:57 +01:00
0ba211c054 Add setup rust toolchain to ci
Some checks failed
Build & Test / build-run (push) Failing after 3s
2025-02-03 22:16:29 +01:00
b3c049a8fa Rename because oops
Some checks failed
Build & Test / build-run (push) Failing after 7s
2025-02-03 22:12:40 +01:00
kio
5dde457d45 use literal instead of relative
Some checks failed
Test build & run / build-run (push) Failing after 8s
2025-02-03 21:11:06 +00:00
kio
532cd614ce Update CI
Some checks failed
Test build & run / build-run (push) Failing after 15s
2025-02-03 21:09:59 +00:00
be021c4b16 Add CI
Some checks are pending
Test build & run / build-run (push) Waiting to run
Uhhhh hoping it runs ig?
2025-02-03 21:56:13 +01:00
26a48f23a5 Sharkey has mastoapi 2025-02-03 20:14:51 +01:00
30b28b8aba This was bothering me 2025-02-03 20:07:19 +01:00
28bbdac90a Most people do not care about this 2025-02-03 20:03:32 +01:00
940fc92856 Refresh list on edit 2025-02-03 19:37:15 +01:00
93c9e5154f Only delete one 2025-02-03 19:24:34 +01:00
516473edeb Some more fixes 2025-02-03 19:03:18 +01:00
7e1416a721 Editing & saving changes & fixes 2025-02-03 19:00:15 +01:00
f451b1fbc3 Rewrite InstanceDetailsDialog
Also misc cleanup. Almost done with this
2025-02-03 17:23:45 +01:00
bfd61c2e50 Rewrite AddInstanceFlow
Now with spinner!
2025-02-03 03:52:11 +01:00
2be0658ed9 Rewrite AddInstanceDialog
Object oriented it is
2025-02-03 03:15:14 +01:00
5534bc3942 Make the CSP changes on config 2025-02-03 01:11:29 +01:00
3f9624fe91 Merge pull request 'no-more-proxy' (#3) from no-more-proxy into main
Reviewed-on: #3
2025-02-03 00:03:50 +00:00
cc15a4b29f CSP and other stuff 2025-02-03 01:01:53 +01:00
c7ea3326cb Rest in piss site_icons
You will not be missed
2025-02-02 23:54:36 +01:00
01d0b6a8bd Don't use the proxy in the JS 2025-01-31 15:58:10 +01:00
28a4ac2ca3 Oops 2025-01-30 17:44:44 +01:00
4793aa3a7a Add script to install site_icons 2025-01-30 17:33:15 +01:00
1449e2f5df Use external scraper for finding icons 2025-01-30 17:13:50 +01:00
c856ab9900 Redo safety checks for instance_info
Using the `ip` feature, and some clever use of `Url::set_host` and `Url::host_str`
2025-01-29 22:13:14 +01:00
6684943989 Switch toolchain to nightly 2025-01-29 22:08:30 +01:00
c1021077bc No more proxy 2025-01-29 22:08:07 +01:00
c0488d1767 Add defaults list for use later 2025-01-28 23:52:37 +01:00
8a02b645f3 Kinda rough but it works (mostly) 2025-01-28 23:52:37 +01:00
64291db3ea Disable "please select" 2025-01-28 23:45:59 +01:00
320e2abac2 Remove outdated comment 2025-01-28 22:00:02 +01:00
438f6e2384 Misc cleanup 2025-01-28 21:57:49 +01:00
8fe980c4be rename main css file 2025-01-20 20:09:41 +01:00
e86b79b26e Ship of theseus for commit 97fc0eb 2025-01-20 19:36:38 +01:00
Kio
97fc0eb60a Add redirects, and a dynamic "Add an instance please!" button. 2025-01-20 10:55:45 -05:00
CenTdemeern1
beab5a52a5
Merge pull request 'feat/redirect' (#2) from feat/redirect into main
Reviewed-on: https://git.gay/Nekomata/FeDirect/pulls/2
2025-01-20 05:36:47 +00:00
25 changed files with 1304 additions and 274 deletions

View file

@ -0,0 +1,24 @@
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 = 3
version = 4
[[package]]
name = "addr2line"
@ -185,6 +185,29 @@ 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"
@ -194,6 +217,17 @@ 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"
@ -238,6 +272,27 @@ 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"
@ -275,16 +330,32 @@ 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",
]
@ -332,6 +403,16 @@ 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"
@ -409,6 +490,15 @@ 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"
@ -422,6 +512,15 @@ 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"
@ -501,6 +600,20 @@ 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"
@ -804,6 +917,12 @@ 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"
@ -909,6 +1028,26 @@ 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"
@ -986,6 +1125,12 @@ 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"
@ -1129,6 +1274,77 @@ 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"
@ -1162,6 +1378,12 @@ 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"
@ -1530,6 +1752,21 @@ 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"
@ -1553,6 +1790,25 @@ 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"
@ -1612,6 +1868,15 @@ 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"
@ -1636,6 +1901,18 @@ 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"
@ -1691,6 +1968,32 @@ 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"
@ -1763,6 +2066,17 @@ 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"
@ -2039,6 +2353,12 @@ 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"
@ -2062,6 +2382,12 @@ 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,9 +5,11 @@ 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,11 +2,13 @@
Fedi links that open on your preferred instance!
## Building
## Building and Running
To compile TypeScript, the build script assumes Deno is installed.
When you have Deno and Rust installed, simply use Cargo to build the project
1. [Install Rust via rustup](https://rustup.rs/)
2. [Install Deno](https://deno.com/)
3. Use Cargo to build the project:
```sh
# For example, to build for release, you can do

View file

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

2
rust-toolchain.toml Normal file
View file

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

View file

@ -1,9 +1,14 @@
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,
@ -12,28 +17,6 @@ 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>,
@ -62,40 +45,59 @@ struct NodeInfoMetadata {
name: Option<String>,
}
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()),
])
/// 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())
}
#[get("/instance_info/<secure>/<host>")]
pub async fn instance_info(secure: bool, host: &str) -> Option<Json<InstanceInfo>> {
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" {
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()
{
return None;
}
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.............
let icon_url = find_icon(url.as_str()).await;
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()?;
@ -112,11 +114,12 @@ 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: name
.or(short_name)
.or(nodeinfo.metadata.and_then(|m| m.name))
.unwrap_or(url.host_str().unwrap().to_owned()),
name: nodeinfo
.metadata
.and_then(|m| m.name)
.unwrap_or(host.to_owned()),
software,
icon_url,
}))

View file

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

View file

@ -98,3 +98,17 @@ 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,3 +1,4 @@
#![feature(ip)]
#[macro_use]
extern crate rocket;
@ -24,6 +25,11 @@ 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) {
(
@ -46,7 +52,8 @@ fn rocket() -> _ {
routes![
known_software_json,
route_for_known_instance_software,
route_for_unknown_instance_software
route_for_unknown_instance_software,
configure_page
],
)
}

View file

@ -1,5 +1,6 @@
// 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 {
@ -13,43 +14,67 @@ export function parseHost(host: string): { host: string, secure: boolean } | nul
};
}
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 type AddInstanceDialogData = {
host: string,
secure: boolean,
autoQueryMetadata: boolean,
};
const form = findFormOrFail(dialog, ".addInstanceForm");
const instanceHost = findInputOrFail(form, "#instanceHost");
const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata");
const closeButton = findButtonOrFail(form, ".close");
export class AddInstanceDialog extends FormDialog {
protected instanceHost: HTMLInputElement;
protected autoQueryMetadata: HTMLInputElement;
protected closeButton: HTMLButtonElement;
instanceHost.addEventListener("input", e => {
if (parseHost(instanceHost.value) === null)
instanceHost.setCustomValidity("Invalid instance hostname or URL");
else
instanceHost.setCustomValidity("");
});
constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
super(dialog, findFormOrFail(dialog, ".addInstanceForm"));
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();
});
this.instanceHost = findInputOrFail(this.form, "#instanceHost");
this.autoQueryMetadata = findInputOrFail(this.form, "#autoQueryMetadata");
this.closeButton = findButtonOrFail(this.form, ".close");
closeButton.addEventListener("click", e => hideAddInstanceDialog());
if (initializeDOM) this.initializeDOM();
}
return {
showAddInstanceDialog,
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();
});
}
}

View file

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

115
static/config.html Normal file
View file

@ -0,0 +1,115 @@
<!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>

156
static/config.mts Normal file
View file

@ -0,0 +1,156 @@
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,88 +1,184 @@
// This file handles the "Confirm instance details" dialog
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findSelectOrFail } from "./dom.mjs";
import { resize } from "./image.mjs";
import { parseHost } from "./add_an_instance.mjs";
import { FormDialog, ONCE } from "./dialog.mjs";
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findOptionOrFail, findSelectOrFail } from "./dom.mjs";
import knownSoftware from "./known_software.mjs";
import { Instance } from "./storage_manager.mjs";
const blankImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
export function initializeInstanceDetailsDialog(
dialog: HTMLDialogElement,
callback: (
instanceName: string,
instanceHost: string,
instanceHostSecure: boolean,
instanceSoftware: string,
instanceIcon: string | null
) => void
): {
showInstanceDetailsDialog: () => void,
hideInstanceDetailsDialog: () => void,
populateInstanceDetailsDialog: (
instanceNameValue: string,
instanceHostValue: string,
instanceHostSecureValue: boolean,
instanceSoftwareValue: string,
instanceIconValue: string | null
) => void
} {
const showInstanceDetailsDialog = () => dialog.showModal();
const hideInstanceDetailsDialog = () => dialog.close();
export function mergeHost(host: string, secure: boolean): string {
return `http${secure ? "s" : ""}://${host}`;
}
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 type InstanceDetailsDialogData = {
name: string,
host: string,
hostSecure: boolean,
software: string,
iconURL: string | null,
preferredFor: string[],
};
export function dialogDetailsFromInstance(instance: Instance): InstanceDetailsDialogData {
const host = parseHost(instance.origin)!;
return {
showInstanceDetailsDialog,
hideInstanceDetailsDialog,
populateInstanceDetailsDialog
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)) {
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

@ -5,14 +5,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FeDirect</title>
<link rel="stylesheet" href="/static/crossroad.css">
<link rel="stylesheet" href="/static/main.css">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *;">
</head>
<body>
<script type="module">
Object.assign(globalThis, await import("/static/crossroad.mjs"));
getMainDialog().show(); // Don't show until the page is ready
</script>
<script type="module" src="/static/crossroad.mjs"></script>
<div class="flex-vcenter">
<dialog id="mainDialog" class="half-width half-height">
<header class="separator-bottom margin-large-bottom">
@ -22,13 +20,19 @@
</div>
<img src="/static/nekomata_small.png" alt="Nekomata Logo" class="logo" />
</header>
<div class="flex-row">
<center class="half-width">
<form id="instanceSelectForm" class="align-start wfit-content"></form>
<br>
<button onclick="showAddInstanceDialog()">Add an instance</button>
</center>
<div class="half-width">
<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>
@ -50,9 +54,8 @@
<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.
We do this on the backend to avoid CORS problems.
We do not track or save any requests or data.">
<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>
@ -85,7 +88,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="">(Please select)</option>
<option value="" disabled>(Please select)</option>
</select>
</div>
<div class="half-width flex-row-reverse">
@ -93,7 +96,6 @@ 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>
@ -105,6 +107,7 @@ 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,15 +1,37 @@
import { initializeAddInstanceFlow } from "./add_instance_flow.mjs";
import { findButtonOrFail, findDialogOrFail, findFormOrFail, findInputOrFail } from "./dom.mjs";
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";
const radioButtonName = "instanceSelect";
const RADIO_BUTTON_NAME = "instanceSelect";
const detailsDialog = findDialogOrFail(document.body, "#instanceDetails");
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");
// 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
@ -23,24 +45,12 @@ redirectAlwaysButton.addEventListener("click", e => {
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);
return initializeAddInstanceFlow(detailsDialog, addDialog)
})();
function updateNoInstanceHint() {
findParagraphOrFail(document.body, "#no-instance").style.display =
storageManager.storage.instances.length > 0
? "none"
: "";
}
function createInstanceSelectOptions() {
instanceSelectForm.replaceChildren(); // Erase all child nodes
@ -51,7 +61,7 @@ function createInstanceSelectOptions() {
radio.id = instance.origin;
radio.value = instance.origin;
radio.type = "radio";
radio.name = radioButtonName;
radio.name = RADIO_BUTTON_NAME;
const label = document.createElement("label");
label.htmlFor = instance.origin;
label.innerText = instance.name + " ";
@ -59,7 +69,7 @@ function createInstanceSelectOptions() {
const img = new Image();
img.src = instance.iconURL;
img.alt = `${instance.name} icon`;
img.className = "inlineIcon";
img.className = "inlineIcon medium-height";
label.append(img, " ");
}
const small = document.createElement("small");
@ -98,7 +108,7 @@ function getTargetPath(): string {
function getSelectedOption(): string | null {
try {
return findInputOrFail(instanceSelectForm, `input[name="${radioButtonName}"]:checked`).value;
return findInputOrFail(instanceSelectForm, `input[name="${RADIO_BUTTON_NAME}"]:checked`).value;
} catch {
return null;
}
@ -117,7 +127,6 @@ function autoRedirect(): boolean {
function setAutoRedirect(option: string) {
const instance = storageManager.storage.instances.find(e => e.origin === option);
if (!instance) throw new Error("Invalid argument");
instance.preferredFor ??= [];
instance.preferredFor.push(getTargetSoftwareOrGroup());
storageManager.save();
}

37
static/data_migration.mts Normal file
View file

@ -0,0 +1,37 @@
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;
}
}

46
static/dialog.mts Normal file
View file

@ -0,0 +1,46 @@
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,6 +1,34 @@
// 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))

1
static/down_arrow.svg Normal file
View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 579 B

View file

@ -1,3 +1,5 @@
@import url("/static/spinner.css");
:root {
--red: #cb0b0b;
--blue: #2081c3;
@ -11,6 +13,7 @@ html,
body {
background: linear-gradient(300deg, var(--red), var(--blue));
background-size: 100vw 100vh;
background-attachment: fixed;
margin: 0;
min-height: 100vh;
height: 100vh;
@ -58,6 +61,19 @@ abbr[title] {
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;
@ -113,10 +129,18 @@ 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);
}
@ -155,10 +179,23 @@ abbr[title] {
}
.inlineIcon {
height: var(--medium);
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);
}
}

78
static/spinner.css Normal file
View file

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

View file

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