Compare commits

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

111 commits

Author SHA1 Message Date
kio
21d7738c52 Update Dockerfile
All checks were successful
Build & Test / build-run (push) Successful in 1m15s
Build Docker Container / publish-docker (push) Successful in 2m27s
2025-02-13 20:29:35 +00:00
kio
40ea027ddf Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Successful in 40s
Build Docker Container / publish-docker (push) Failing after 6s
2025-02-13 19:15:25 +00:00
kio
0ff55afcad Update .forgejo/workflows/docker.yaml
Some checks failed
Build Docker Container / publish-docker (push) Failing after 6s
Build & Test / build-run (push) Successful in 40s
2025-02-13 19:13:14 +00:00
Kio
98a9cad7f4 Merge pull request 'Slim down the weight of packages we ship' (#7) from Dockerfile-redo into main
Some checks failed
Build & Test / build-run (push) Successful in 40s
Build Docker Container / publish-docker (push) Failing after 5s
Reviewed-on: #7
2025-02-13 19:09:10 +00:00
kio
7870658c82 Update Dockerfile
All checks were successful
Build & Test / build-run (push) Successful in 42s
2025-02-13 19:06:58 +00:00
kio
b1c2611b68 Update Dockerfile
All checks were successful
Build & Test / build-run (push) Successful in 42s
2025-02-13 17:12:56 +00:00
kio
e644a5634a Slim down the weight of packages we ship
All checks were successful
Build & Test / build-run (push) Successful in 41s
2025-02-13 16:52:35 +00:00
e527da497f Merge pull request 'Add about section' (#6) from about into main
All checks were successful
Build & Test / build-run (push) Successful in 1m15s
Build Docker Container / publish-docker (push) Successful in 4m58s
Reviewed-on: #6
2025-02-13 06:11:57 +00:00
d72ebcdbef Add about dialog to config as well
All checks were successful
Build & Test / build-run (push) Successful in 40s
2025-02-13 07:00:14 +01:00
dd53074458 Open/close about dialog 2025-02-13 07:00:14 +01:00
fd63c0b12a Almost finished about dialog 2025-02-13 07:00:14 +01:00
3f52ae2147 Populate nekomata pfps
Now that there's an init function I should probably fail early on invalid knownsoftware json
2025-02-13 07:00:14 +01:00
bf963c4ab8 Wip about page 2025-02-13 07:00:14 +01:00
kio
da67b54f6e Update .forgejo/workflows/docker.yaml
Some checks failed
Build Docker Container / publish-docker (push) Has been cancelled
Build & Test / build-run (push) Has been cancelled
2025-02-13 05:43:26 +00:00
kio
e82b85d0d0 Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Successful in 5m2s
2025-02-13 05:33:55 +00:00
kio
f92ab0e44e Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 2m5s
2025-02-13 05:15:22 +00:00
kio
66234b7a66 Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 4s
2025-02-13 05:13:21 +00:00
kio
24d2850fc6 Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 4s
2025-02-13 05:12:35 +00:00
kio
30706aba84 Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 5s
2025-02-13 05:10:59 +00:00
kio
6babd3187b Update .forgejo/workflows/docker.yaml
Some checks failed
Build Docker Container / publish-docker (push) Failing after 0s
Build & Test / build-run (push) Has been cancelled
2025-02-13 05:10:26 +00:00
kio
42c9cbbc56 Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 5s
2025-02-13 04:57:51 +00:00
kio
1810e4869e Update .forgejo/workflows/docker.yaml
Some checks failed
Build Docker Container / publish-docker (push) Waiting to run
Build & Test / build-run (push) Has been cancelled
2025-02-13 04:49:45 +00:00
kio
866823e04f Update .forgejo/workflows/docker.yaml
Some checks failed
Build Docker Container / publish-docker (push) Waiting to run
Build & Test / build-run (push) Has been cancelled
2025-02-13 04:49:34 +00:00
kio
9490488331 Update .forgejo/workflows/docker.yaml
Some checks are pending
Build Docker Container / publish-docker (push) Waiting to run
Build & Test / build-run (push) Successful in 40s
2025-02-13 04:21:37 +00:00
kio
2d38927bc4 I FORGOT THE DOLLAR SIGN?????
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 6s
2025-02-13 03:38:51 +00:00
kio
27ebde8afe Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 5s
2025-02-13 03:36:40 +00:00
kio
b475692302 Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 5s
2025-02-13 03:32:53 +00:00
kio
2eabacc03d Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 2s
2025-02-13 03:32:18 +00:00
kio
8b256dc95d Update .forgejo/workflows/docker.yaml
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 2s
2025-02-13 03:31:36 +00:00
Kio
589322fe6d Merge pull request 'build docker' (#5) from docker-ci into main
Some checks failed
Build & Test / build-run (push) Has been cancelled
Build Docker Container / publish-docker (push) Failing after 7s
Reviewed-on: #5
2025-02-13 03:30:06 +00:00
Kio
2865b78bdb Merge branch 'main' into docker-ci
Some checks failed
Build & Test / build-run (push) Has been cancelled
2025-02-13 03:29:26 +00:00
Kio
c0467ccfd0 Merge pull request 'Add Dockerfile' (#4) from add-dockerfile into main
All checks were successful
Build & Test / build-run (push) Successful in 40s
Reviewed-on: #4
2025-02-13 03:29:13 +00:00
kio
79d7fa9ac5 build docker
All checks were successful
Build & Test / build-run (push) Successful in 44s
2025-02-13 03:28:16 +00:00
kio
b8764d99dd Add Dockerfile
All checks were successful
Build & Test / build-run (push) Successful in 40s
2025-02-13 03:20:15 +00:00
1057bf80dc Hotfix forks text showing when it shouldn't
All checks were successful
Build & Test / build-run (push) Successful in 40s
2025-02-13 03:27:39 +01:00
969538b8b4 Add sections to the CSS
All checks were successful
Build & Test / build-run (push) Successful in 43s
2025-02-13 02:22:43 +01:00
027faf8371 Now sorting
All checks were successful
Build & Test / build-run (push) Successful in 43s
2025-02-13 01:41:20 +01:00
bc7e388f04 TO THE SHADOW REALM WITH YOU, ICESHRIMP.NET FORKOF FIELD
All checks were successful
Build & Test / build-run (push) Successful in 41s
2025-02-13 01:28:44 +01:00
e7748a71da Instance name drop
All checks were successful
Build & Test / build-run (push) Successful in 41s
2025-02-12 22:27:32 +01:00
b408f66f9d Use hidden properly everywhere
All checks were successful
Build & Test / build-run (push) Successful in 45s
2025-02-12 21:59:35 +01:00
c1f61cfe9b Hide image instead of setting it to blank 2025-02-12 21:55:17 +01:00
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
70c941cf0e Stylistic changes 2025-01-20 05:30:58 +00:00
358527f59e Add some more aliases 2025-01-20 05:30:58 +00:00
3a09f6c1f0 Clean up a bit 2025-01-20 05:30:58 +00:00
e66b399961 Implement autoredirect 2025-01-20 05:30:58 +00:00
6861a549ac Naive redirect implementation 2025-01-20 05:30:58 +00:00
af41b8e10b Remove old dummy item 2025-01-20 05:30:58 +00:00
1e877a5d92 Populate the list 2025-01-20 05:30:58 +00:00
CenTdemeern1
6a12b59f87
Merge pull request 'Finish Add Instance Flow' (#1) from feat/add-instance-2 into main
Reviewed-on: https://git.gay/Nekomata/FeDirect/pulls/1
2025-01-20 05:30:18 +00:00
32 changed files with 2005 additions and 395 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

View file

@ -0,0 +1,21 @@
name: Build Docker Container
on:
push:
branches: ["main"]
env:
REGISTRY: kitsunes.dev
PUBLISH_AS: nekomata/fedirect
jobs:
publish-docker:
runs-on: self-hosted
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Login to Docker
run: docker login -u kio -p ${{ secrets.LOGINKEY }} ${{ env.REGISTRY }}
- name: build docker repository
run: docker build --no-cache --pull . -t ${{ env.REGISTRY }}/${{ env.PUBLISH_AS }}:latest
- name: Push to Repo Server
run: docker push ${{ env.REGISTRY }}/${{ env.PUBLISH_AS }}:latest

331
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 = 3 version = 4
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@ -185,6 +185,29 @@ 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"
@ -194,6 +217,17 @@ 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"
@ -238,6 +272,27 @@ 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"
@ -275,16 +330,32 @@ 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-alpha"
dependencies = [ dependencies = [
"bytes", "bytes",
"favicon-scraper",
"reqwest", "reqwest",
"rocket", "rocket",
"semver", "semver",
"serde", "serde",
"serde_json", "serde_json",
"tokio",
"url", "url",
] ]
@ -332,6 +403,16 @@ 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"
@ -409,6 +490,15 @@ 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"
@ -422,6 +512,15 @@ 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"
@ -501,6 +600,20 @@ 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"
@ -804,6 +917,12 @@ 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"
@ -909,6 +1028,26 @@ 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"
@ -986,6 +1125,12 @@ 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"
@ -1129,6 +1274,77 @@ 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"
@ -1162,6 +1378,12 @@ 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"
@ -1305,6 +1527,7 @@ dependencies = [
"base64", "base64",
"bytes", "bytes",
"encoding_rs", "encoding_rs",
"futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2 0.4.7", "h2 0.4.7",
@ -1530,6 +1753,21 @@ 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"
@ -1553,6 +1791,25 @@ 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"
@ -1612,6 +1869,15 @@ 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"
@ -1636,6 +1902,18 @@ 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"
@ -1691,6 +1969,32 @@ 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"
@ -1763,6 +2067,17 @@ 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"
@ -2039,6 +2354,12 @@ 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"
@ -2062,6 +2383,12 @@ 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

@ -1,13 +1,15 @@
[package] [package]
name = "fedirect" name = "fedirect"
version = "0.1.0" version = "0.1.0-alpha"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
bytes = "1.9.0" bytes = "1.9.0"
reqwest = { version = "0.12.12", features = ["stream"] } favicon-scraper = "0.3.1"
reqwest = { version = "0.12.12", features = ["stream", "blocking"] }
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"

15
Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM rust:alpine AS build
RUN apk add deno pkgconfig libressl-dev musl-dev
WORKDIR /FeDirect
COPY --link . ./
RUN cargo build --release
FROM scratch
COPY --from=build /FeDirect/known-software.json /FeDirect/target/release/fedirect /
COPY --from=build /FeDirect/static/ /static
ENV ROCKET_ADDRESS=0.0.0.0
WORKDIR /
CMD ["/fedirect"]

View file

@ -2,11 +2,13 @@
Fedi links that open on your preferred instance! Fedi links that open on your preferred instance!
## Building ## Building and Running
To compile TypeScript, the build script assumes Deno is installed. 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 ```sh
# For example, to build for release, you can do # For example, to build for release, you can do

View file

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

2
rust-toolchain.toml Normal file
View file

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

54
src/api/about.rs Normal file
View file

@ -0,0 +1,54 @@
use reqwest::blocking::Client;
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::OnceLock;
/// Gets the FeDirect version
#[get("/about/version")]
pub async fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
#[derive(Debug, Serialize)]
pub struct Avatars {
charlotte: Option<String>,
kio: Option<String>,
}
#[derive(Deserialize)]
struct MisskeyUser {
#[serde(rename = "avatarUrl")]
avatar_url: String,
}
fn get_avatar(client: &Client, origin: &str, user_id: &str) -> Option<String> {
let body = json!({
"userId": user_id
})
.to_string();
let user: MisskeyUser = client
.post(format!("https://{origin}/api/users/show"))
.header("content-type", "application/json")
.body(body)
.send()
.ok()?
.json()
.ok()?;
Some(user.avatar_url)
}
pub static AVATARS: OnceLock<Avatars> = OnceLock::new();
pub fn init() {
let client = Client::new();
let charlotte = get_avatar(&client, "eepy.moe", "9xt2s326nxev039h");
let kio = get_avatar(&client, "kitsunes.club", "9810gvfne3");
AVATARS.set(Avatars { charlotte, kio }).unwrap();
}
/// Gets (relatively) up-to-date Nekomata avatars
#[get("/about/nekomata_avatars")]
pub async fn nekomata_avatars() -> Json<&'static Avatars> {
Json(AVATARS.get().unwrap())
}

View file

@ -1,9 +1,14 @@
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,
@ -12,28 +17,6 @@ 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>,
@ -62,40 +45,59 @@ struct NodeInfoMetadata {
name: Option<String>, name: Option<String>,
} }
async fn get_info_from_manifest(url: Url) -> Option<[Option<String>; 3]> { /// Scrapes icons and returns the smallest one where the smallest axis is bigger than or equal to [MINIMUM_ICON_SIZE]
// FIXME: Iceshrimp.NET doesn't have a manifest... async fn find_icon(host: &str) -> Option<String> {
let response = reqwest::get(url.clone()).await.ok()?.text().await.ok()?; let icons: Vec<Icon> = favicon_scraper::scrape(host)
let manifest: InstanceManifest = serde_json::from_str(&response).ok()?; .await
Some([ .ok()?
manifest.name, .into_iter()
manifest.short_name, .filter(|i| i.size.width.min(i.size.height) >= MINIMUM_ICON_SIZE)
manifest .collect();
.icons
.as_ref() let priority = |kind: &IconKind| match kind {
.and_then(|icons| icons.iter().min_by_key(|icon| icon.get_size())) IconKind::LinkedInHTML => 0,
.map(|icon| icon.src.to_owned()), 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>")] #[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(&format!( let mut url = Url::parse(if secure {
"http{}://{host}/manifest.json", "https://temp.host/"
if secure { "s" } else { "" } } else {
)) "http://temp.host/"
.ok()?; })
// I'm not sure if you can sneak in a path, but better safe than sorry .unwrap();
// I don't really care about username/password/port, those are fine url.set_host(Some(host)).ok()?; // Using this to catch malformed hosts
if url.path() != "/manifest.json" { 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; return None;
} }
let [name, short_name, icon_url] = get_info_from_manifest(url.clone())
.await let icon_url = find_icon(url.as_str()).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()?;
@ -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())) .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: name name: nodeinfo
.or(short_name) .metadata
.or(nodeinfo.metadata.and_then(|m| m.name)) .and_then(|m| m.name)
.unwrap_or(url.host_str().unwrap().to_owned()), .unwrap_or(host.to_owned()),
software, software,
icon_url, icon_url,
})) }))

View file

@ -1,8 +1,19 @@
use rocket::Route; use rocket::Route;
pub mod about;
pub mod instance_info; pub mod instance_info;
pub mod proxy; pub mod proxy;
pub fn get_routes() -> Vec<Route> { pub fn init() {
routes![instance_info::instance_info, proxy::proxy] about::init();
}
pub fn get_routes() -> Vec<Route> {
routes![
instance_info::instance_info,
about::version,
about::nekomata_avatars,
// 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] #[macro_use]
extern crate rocket; extern crate rocket;
@ -24,6 +25,11 @@ async fn route_for_known_instance_software(
NamedFile::open("static/crossroad.html").await.ok() 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)] #[get("/<instance>/<route..>", rank = 2)]
fn route_for_unknown_instance_software(instance: &str, route: PathBuf) -> (ContentType, String) { fn route_for_unknown_instance_software(instance: &str, route: PathBuf) -> (ContentType, String) {
( (
@ -38,6 +44,8 @@ fn route_for_unknown_instance_software(instance: &str, route: PathBuf) -> (Conte
#[launch] #[launch]
fn rocket() -> _ { fn rocket() -> _ {
api::init();
rocket::build() rocket::build()
.mount("/static", FileServer::from("static").rank(0)) .mount("/static", FileServer::from("static").rank(0))
.mount("/api", api::get_routes()) .mount("/api", api::get_routes())
@ -46,7 +54,8 @@ fn rocket() -> _ {
routes![ routes![
known_software_json, known_software_json,
route_for_known_instance_software, route_for_known_instance_software,
route_for_unknown_instance_software route_for_unknown_instance_software,
configure_page
], ],
) )
} }

27
static/about.mts Normal file
View file

@ -0,0 +1,27 @@
import { Dialog } from "./dialog.mjs";
import { findAnchorOrFail, findButtonOrFail, findDialogOrFail, findImageOrFail, findSpanOrFail } from "./dom.mjs";
const openButton = findAnchorOrFail(document.body, "#aboutLink");
const dialog = findDialogOrFail(document.body, "#about")
const versionParagraph = findSpanOrFail(dialog, "#version");
const charlotteImage = findImageOrFail(dialog, "#charlotteAvatar");
const kioImage = findImageOrFail(dialog, "#kioAvatar");
const closeButton = findButtonOrFail(dialog, ".close");
const aboutDialog = new Dialog(dialog);
openButton.addEventListener("click", e => aboutDialog.open());
closeButton.addEventListener("click", e => aboutDialog.close());
populateVersion();
populateUsers();
async function populateVersion() {
versionParagraph.innerText = await fetch("/api/about/version").then(r => r.text());
}
async function populateUsers() {
const { charlotte, kio } = await fetch("/api/about/nekomata_avatars").then(r => r.json());
if (charlotte) charlotteImage.src = charlotte;
if (kio) kioImage.src = kio;
}

View file

@ -1,5 +1,6 @@
// 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 {
@ -13,43 +14,67 @@ export function parseHost(host: string): { host: string, secure: boolean } | nul
}; };
} }
export function initializeAddInstanceDialog( export type AddInstanceDialogData = {
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();
const form = findFormOrFail(dialog, ".addInstanceForm"); export class AddInstanceDialog extends FormDialog {
const instanceHost = findInputOrFail(form, "#instanceHost"); protected instanceHost: HTMLInputElement;
const autoQueryMetadata = findInputOrFail(form, "#autoQueryMetadata"); protected autoQueryMetadata: HTMLInputElement;
const closeButton = findButtonOrFail(form, ".close"); protected closeButton: HTMLButtonElement;
instanceHost.addEventListener("input", e => { constructor(dialog: HTMLDialogElement, initializeDOM: boolean = true) {
if (parseHost(instanceHost.value) === null) super(dialog, findFormOrFail(dialog, ".addInstanceForm"));
instanceHost.setCustomValidity("Invalid instance hostname or URL");
else
instanceHost.setCustomValidity("");
});
form.addEventListener("submit", e => { this.instanceHost = findInputOrFail(this.form, "#instanceHost");
// A sane browser doesn't allow for submitting the form if the above validation fails this.autoQueryMetadata = findInputOrFail(this.form, "#autoQueryMetadata");
const { host, secure } = parseHost(instanceHost.value)!; this.closeButton = findButtonOrFail(this.form, ".close");
callback(host, secure, autoQueryMetadata.checked);
form.reset();
});
closeButton.addEventListener("click", e => hideAddInstanceDialog()); 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 { return {
showAddInstanceDialog, host: parsedHost.host,
hideAddInstanceDialog 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 { AddInstanceDialog } from "./add_an_instance.mjs";
import { initializeInstanceDetailsDialog } from "./confirm_instance_details.mjs"; import { dialogDetailsToInstance, InstanceDetailsDialog, InstanceDetailsDialogData } 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 function initializeAddInstanceFlow( export class AddInstanceFlow {
detailsDialog: HTMLDialogElement, addDialog: AddInstanceDialog;
addDialog: HTMLDialogElement spinnerDialog: Dialog;
): { detailsDialog: InstanceDetailsDialog;
showAddInstanceDialog: () => void,
hideAddInstanceDialog: () => void
} {
const instanceDetailsDialogCallback = (
name: string,
host: string,
hostSecure: boolean,
software: string,
icon: string | null
) => {
const instance: Instance = {
name,
origin: `http${hostSecure ? "s" : ""}://${host}`,
software,
iconURL: icon ?? undefined
};
storageManager.storage.instances.push(instance);
storageManager.save();
console.log("Successfully added new instance:", instance);
};
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 { const {
showInstanceDetailsDialog, autoQueryMetadata,
hideInstanceDetailsDialog, host,
populateInstanceDetailsDialog secure,
} = initializeInstanceDetailsDialog(detailsDialog, instanceDetailsDialogCallback); } = await this.addDialog.present();
const detailsDialogData: InstanceDetailsDialogData = {
name: host,
host,
hostSecure: secure,
software: "",
iconURL: null,
preferredFor: []
};
const addInstanceDialogCallback = async (
host: string,
secure: boolean,
autoQueryMetadata: boolean,
) => {
try { try {
if (!autoQueryMetadata) throw new Error("Don't"); if (!autoQueryMetadata) throw null; // Skip to catch block
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) || !(typeof iconURL === "string" || iconURL === null) // I guess TS is too stupid to understand this?
) )
throw new Error("Invalid API response"); throw new Error("Invalid API response");
populateInstanceDetailsDialog(name, host, secure, software, iconURL as string | null);
} catch {
populateInstanceDetailsDialog(host, host, secure, "", null);
} finally {
showInstanceDetailsDialog();
}
}
const { detailsDialogData.name = name;
showAddInstanceDialog, detailsDialogData.software = software;
hideAddInstanceDialog detailsDialogData.iconURL = iconURL as string | null;
} = initializeAddInstanceDialog(addDialog, addInstanceDialogCallback); } catch { }
this.spinnerDialog.close();
return { const finalData = await this.detailsDialog.present(detailsDialogData);
showAddInstanceDialog, const instance = dialogDetailsToInstance(finalData, {});
hideAddInstanceDialog
}; storageManager.storage.instances.push(instance);
if (autoSave) storageManager.save();
console.log("Successfully added new instance:", instance);
}
} }

147
static/config.html Normal file
View file

@ -0,0 +1,147 @@
<!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>
<script type="module" src="/static/about.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;<a id="aboutLink" href="#"
title="About FeDirect & Nekomata">By Nekomata</a></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="about" class="half-width-max half-height">
<center>
<div class="flex-row wfit-content">
<h1>About FeDirect</h1>
<p class="margin-auto-top">&ThickSpace;(v<span id="version"></span>)</p>
</div>
<p class="margin-none-top wrap-balance">
FeDirect links the Fediverse together by allowing you to create generic links that
link people to their native instance!
</p>
<a href="https://kitsunes.dev/Nekomata/FeDirect">Source code</a>
<h2>About Nekomata</h2>
<div class="circlingMembers">
<center class="absolute-centered member charlotte align-content-center flex-column">
<img id="charlotteAvatar" class="xl-size" alt="Charlotte's avatar" />
<p class="margin-none">Charlotte</p>
<a href="https://eepy.moe/@CenTdemeern1" class="margin-none">@CenTdemeern1@eepy.moe</a>
<p class="margin-none">Programming, design</p>
</center>
<center class="absolute-centered member kio align-content-center flex-column">
<img id="kioAvatar" class="xl-size" alt="Charlotte's avatar" />
<p class="margin-none">Kio</p>
<a href="https://kitsunes.club/@Kio" class="margin-none">@Kio@kitsunes.club</a>
<p class="margin-none">Funding, hosting, design</p>
</center>
<img src="/static/nekomata_small.png" alt="Nekomata Logo" class="absolute-centered xl-size-max" />
</div>
<button class="close">Close</button>
</center>
</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,185 @@
// This file handles the "Confirm instance details" dialog // This file handles the "Confirm instance details" dialog
import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findSelectOrFail } from "./dom.mjs"; import { parseHost } from "./add_an_instance.mjs";
import { resize } from "./image.mjs"; import { FormDialog, ONCE } from "./dialog.mjs";
import knownSoftware from "./known_software.mjs"; import { findButtonOrFail, findFormOrFail, findImageOrFail, findInputOrFail, findOptionOrFail, findSelectOrFail } from "./dom.mjs";
import knownSoftware, { getName } from "./known_software.mjs";
import { Instance } from "./storage_manager.mjs";
const blankImage = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; export function mergeHost(host: string, secure: boolean): string {
return `http${secure ? "s" : ""}://${host}`;
}
export function initializeInstanceDetailsDialog( export type InstanceDetailsDialogData = {
dialog: HTMLDialogElement, name: string,
callback: ( host: string,
instanceName: string, hostSecure: boolean,
instanceHost: string, software: string,
instanceHostSecure: boolean, iconURL: string | null,
instanceSoftware: string, preferredFor: string[],
instanceIcon: string | null };
) => void
): {
showInstanceDetailsDialog: () => void,
hideInstanceDetailsDialog: () => void,
populateInstanceDetailsDialog: (
instanceNameValue: string,
instanceHostValue: string,
instanceHostSecureValue: boolean,
instanceSoftwareValue: string,
instanceIconValue: string | null
) => void
} {
const showInstanceDetailsDialog = () => dialog.showModal();
const hideInstanceDetailsDialog = () => dialog.close();
const form = findFormOrFail(dialog, ".instanceDetailsForm"); export function dialogDetailsFromInstance(instance: Instance): InstanceDetailsDialogData {
const instanceName = findInputOrFail(form, "#instanceName"); const host = parseHost(instance.origin)!;
const instanceHost = findInputOrFail(form, "#instanceHost"); return {
const instanceHostSecure = findInputOrFail(form, "#instanceHostSecure"); name: instance.name,
const instanceSoftware = findSelectOrFail(form, "#instanceSoftware"); host: host.host,
const instanceIcon = findImageOrFail(form, "#instanceIcon"); hostSecure: host.secure,
const closeButton = findButtonOrFail(form, ".close"); 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);
instanceSoftware.appendChild(option); this.instanceSoftware.appendChild(option);
} }
instanceIcon.src = blankImage; this.instanceIcon.hidden = true;
const populateInstanceDetailsDialog = ( this.closeButton.addEventListener("click", e => this.close());
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 => { if (this.defaultsList) {
let image: string | null = null; this.defaultsList.list.addEventListener("change", e => this.#handleListSelectionChange());
if (instanceIcon.src !== blankImage) { this.defaultsList.removeButton.addEventListener("click", e => this.#removeSelectedListOptions());
try { }
image = resize(instanceIcon);
} catch { }
} }
callback(
instanceName.value,
instanceHost.value,
instanceHostSecure.checked,
instanceSoftware.value,
image
);
form.reset();
});
closeButton.addEventListener("click", e => { #getRemainingListOptions(): string[] {
instanceIcon.src = blankImage; if (!this.defaultsList) return [];
hideInstanceDetailsDialog();
});
return { const items: string[] = [];
showInstanceDetailsDialog,
hideInstanceDetailsDialog, for (const option of this.defaultsList.list.options) {
populateInstanceDetailsDialog 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 = getName(knownSoftware, item) ?? 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.hidden = false;
} else {
this.defaultsList.noDefaultsOption.hidden = true;
}
}
#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;
if (data.iconURL !== null) {
this.instanceIcon.src = data.iconURL;
this.instanceIcon.hidden = false;
} else this.instanceIcon.hidden = true;
this.#populateDefaultsList(data.preferredFor);
return new Promise((resolve, reject) => {
this.cancelOnceClosed(reject);
this.#handleSubmit(data, resolve);
this.open();
});
}
} }

View file

@ -1,122 +0,0 @@
:root {
--red: #cb0b0b;
--blue: #2081c3;
--transparent-black: #0008;
--large: 2em;
--medium: 1em;
}
html,
body {
background: linear-gradient(300deg, var(--red), var(--blue));
background-size: 100vw 100vh;
margin: 0;
min-height: 100vh;
height: 100vh;
font-family: sans-serif;
}
dialog {
background-color: white;
border: 0;
border-radius: var(--large);
opacity: 0;
transition: opacity 0.125s ease-out;
}
dialog::backdrop {
backdrop-filter: blur(5px);
background-color: var(--transparent-black);
transition: background-color 0.125s ease-out;
@starting-style {
/* This might not work on Firefox because Firefox being behind moment */
background-color: #0000;
}
}
dialog[open] {
opacity: 1;
@starting-style {
/* This might not work on Firefox because Firefox being behind moment */
opacity: 0;
}
}
header {
display: flex;
justify-content: space-between;
}
abbr[title] {
text-decoration-color: var(--blue);
}
.flex-vcenter {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
}
.flex-row {
display: flex;
flex-direction: row;
}
.flex-row-reverse {
display: flex;
flex-direction: row-reverse;
}
.flex-column-reverse {
display: flex;
flex-direction: column-reverse;
}
.half-width {
min-width: 50%;
}
.half-height {
min-height: 50%;
}
.full-height {
min-height: 100%;
}
.separator-bottom {
border-bottom: solid 1px var(--transparent-black);
}
.margin-auto-top {
margin-top: auto;
}
.margin-large-bottom {
margin-bottom: var(--large);
}
.square {
aspect-ratio: 1;
}
.iconContainer {
width: 64px;
height: 64px;
padding: 1px;
border: solid 1px var(--transparent-black);
}
.icon {
position: relative;
max-width: 64px;
max-height: 64px;
margin: auto;
top: 50%;
left: 50%;
translate: -50% -50%;
}

View file

@ -5,35 +5,57 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<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/crossroad.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"> <script type="module" src="/static/crossroad.mjs"></script>
Object.assign(globalThis, await import("/static/crossroad.mjs")); <script type="module" src="/static/about.mjs"></script>
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">
<div class="flex-row"> <div class="flex-row">
<h1>FeDirect</h1> <h1>FeDirect</h1>
<p class="margin-auto-top">&ThickSpace;By Nekomata</p> <p class="margin-auto-top">&ThickSpace;<a id="aboutLink" href="#"
title="About FeDirect & Nekomata">By Nekomata</a></p>
</div> </div>
<img src="/static/nekomata_small.png" alt="Nekomata Logo" style="height: 4em;" /> <img src="/static/nekomata_small.png" alt="Nekomata Logo" class="logo" />
</header> </header>
<div class="flex-row"> <div class="flex-row align-content-center">
<div class="half-width"> <div class="flex-vcenter full-height">
<form> <center class="half-width">
<input id="radio" type="radio" /> You're about to go to
<label for="radio"> <pre id="path" class="inline-block margin-none"></pre>
Instances and stuff go here! on <span id="aOrAn"></span>
</label> <span id="destination" class="inline-block margin-none"></span>
instance.<br>
<img src="/static/down_arrow.svg" alt="" class="medium-height" />
<p id="noInstance">You currently don't have any instances. You should add one!</p>
<form id="instanceSelectForm" class="align-start wfit-content">
<ol id="preferredList" class="margin-none" hidden></ol>
<div id="forks" hidden>
<p class="align-center margin-none-bottom">Other <span id="forkOf"></span> forks</p>
<ol id="forksList" class="margin-none"></ol>
</div>
<div id="others" hidden>
<p class="align-center margin-none-bottom">Other instances</p>
<ol id="othersList" class="margin-none"></ol>
</div>
</form> </form>
<br> <br>
<button onclick="showAddInstanceDialog()">Add an instance</button> <button id="startAddInstanceFlow">Add an instance</button>
</center>
</div>
<div class="half-width align-self-start">
<div class="flex-hcenter">
<div class="flex-column buttonPanel">
<button id="redirect">Redirect</button>
<button id="redirectAlways">Redirect always</button>
<a href="/">Manage instances</a>
</div>
</div>
</div> </div>
<div class="half-width"></div>
</div> </div>
</dialog> </dialog>
</div> </div>
@ -47,9 +69,8 @@
<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 title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon. <abbr
We do this on the backend to avoid CORS problems. title="This uses FeDirect's API to automatically try to detect instance metadata, such as the name, software, and an icon.">
We do not track or save any requests or data.">
Automatically query metadata Automatically query metadata
</abbr> </abbr>
</label> </label>
@ -82,7 +103,7 @@ Unchecking this is not recommended, and this option only exists for exceptional
<label for="instanceSoftware">Instance software</label> <label for="instanceSoftware">Instance software</label>
<br> <br>
<select id="instanceSoftware" type="text" name="instanceSoftware" required> <select id="instanceSoftware" type="text" name="instanceSoftware" required>
<option value="">(Please select)</option> <option value="" disabled>(Please select)</option>
</select> </select>
</div> </div>
<div class="half-width flex-row-reverse"> <div class="half-width flex-row-reverse">
@ -90,7 +111,6 @@ Unchecking this is not recommended, and this option only exists for exceptional
<div> <div>
<label for="iconContainer">Instance icon</label> <label for="iconContainer">Instance icon</label>
<div id="iconContainer" class="square iconContainer"> <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" /> <img id="instanceIcon" alt="Icon for the selected instance" class="icon" />
</div> </div>
</div> </div>
@ -102,6 +122,37 @@ 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="about" class="half-width-max half-height">
<center>
<div class="flex-row wfit-content">
<h1>About FeDirect</h1>
<p class="margin-auto-top">&ThickSpace;(v<span id="version"></span>)</p>
</div>
<p class="margin-none-top wrap-balance">
FeDirect links the Fediverse together by allowing you to create generic links that
link people to their native instance!
</p>
<a href="https://kitsunes.dev/Nekomata/FeDirect">Source code</a>
<h2>About Nekomata</h2>
<div class="circlingMembers">
<center class="absolute-centered member charlotte align-content-center flex-column">
<img id="charlotteAvatar" class="xl-size" alt="Charlotte's avatar" />
<p class="margin-none">Charlotte</p>
<a href="https://eepy.moe/@CenTdemeern1" class="margin-none">@CenTdemeern1@eepy.moe</a>
<p class="margin-none">Programming, design</p>
</center>
<center class="absolute-centered member kio align-content-center flex-column">
<img id="kioAvatar" class="xl-size" alt="Charlotte's avatar" />
<p class="margin-none">Kio</p>
<a href="https://kitsunes.club/@Kio" class="margin-none">@Kio@kitsunes.club</a>
<p class="margin-none">Funding, hosting, design</p>
</center>
<img src="/static/nekomata_small.png" alt="Nekomata Logo" class="absolute-centered xl-size-max" />
</div>
<button class="close">Close</button>
</center>
</dialog>
<dialog id="spinner"><span class="spinner"></span></dialog>
</body> </body>
</html> </html>

View file

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

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,55 @@
// 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 findAnchorOrFail(on: Element, selector: string): HTMLAnchorElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLAnchorElement))
throw new Error(`${selector} isn't an a`);
return element;
}
export function findDivOrFail(on: Element, selector: string): HTMLDivElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLDivElement))
throw new Error(`${selector} isn't a div`);
return element;
}
export function findSpanOrFail(on: Element, selector: string): HTMLSpanElement {
const element = on.querySelector(selector);
if (!(element instanceof HTMLSpanElement))
throw new Error(`${selector} isn't a span`);
return element;
}
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 { export function findDialogOrFail(on: Element, selector: string): HTMLDialogElement {
const element = on.querySelector(selector); const element = on.querySelector(selector);
if (!(element instanceof HTMLDialogElement)) 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

@ -2,7 +2,7 @@ export function resize(image: HTMLImageElement, width: number = 16, height: numb
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
canvas.style.display = "none"; canvas.hidden = true;
document.body.appendChild(canvas); document.body.appendChild(canvas);
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (ctx === null) throw Error("Resize failed"); if (ctx === null) throw Error("Resize failed");

View file

@ -16,4 +16,8 @@ type KnownSoftware = {
groups: Record<string, Group>, groups: Record<string, Group>,
} }
export function getName(knownSoftware: KnownSoftware, id: string): string | undefined {
return knownSoftware.software[id]?.name ?? knownSoftware.groups[id].name;
}
export default await fetch("/known-software.json").then(r => r.json()) as KnownSoftware; export default await fetch("/known-software.json").then(r => r.json()) as KnownSoftware;

287
static/main.css Normal file
View file

@ -0,0 +1,287 @@
@import url("/static/spinner.css");
/* Variables */
:root {
--red: #cb0b0b;
--blue: #2081c3;
--transparent-black: #0008;
--xl: 4em;
--large: 2em;
--medium: 1em;
}
/* Generic styling for elements */
html,
body {
background: linear-gradient(300deg, var(--red), var(--blue));
background-size: 100vw 100vh;
background-attachment: fixed;
margin: 0;
min-height: 100vh;
height: 100vh;
font-family: sans-serif;
}
dialog {
background-color: white;
border: 0;
border-radius: var(--large);
opacity: 0;
transition: opacity 0.125s ease-out;
}
dialog::backdrop {
backdrop-filter: blur(5px);
background-color: var(--transparent-black);
transition: background-color 0.125s ease-out;
@starting-style {
/* This might not work on Firefox because Firefox being behind moment */
background-color: #0000;
}
}
dialog[open] {
opacity: 1;
@starting-style {
/* This might not work on Firefox because Firefox being behind moment */
opacity: 0;
}
}
header {
display: flex;
justify-content: space-between;
}
abbr[title] {
text-decoration-color: var(--blue);
}
/* Generic styling properties */
.wrap-balance {
text-wrap: balance;
}
.align-start {
text-align: start;
}
.align-center {
text-align: center;
}
.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;
justify-content: center;
width: 100%;
height: 100%;
}
.flex-hcenter {
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
height: 100%;
}
.flex-row {
display: flex;
flex-direction: row;
}
.flex-row-reverse {
display: flex;
flex-direction: row-reverse;
}
.flex-column {
display: flex;
flex-direction: column;
}
.flex-column-reverse {
display: flex;
flex-direction: column-reverse;
}
.flex-vevenly {
display: flex;
flex-direction: column;
justify-content: space-evenly;
height: 100%;
}
.wfit-content {
width: fit-content;
}
.half-width {
min-width: 50%;
}
.half-height {
min-height: 50%;
}
.half-width-max {
max-width: 50%;
}
.full-width {
min-width: 100%;
}
.full-height {
min-height: 100%;
}
.medium-height {
height: var(--medium);
}
.xl-size {
width: var(--xl);
height: var(--xl);
}
.xl-size-max {
max-width: var(--xl);
max-height: var(--xl);
}
.separator-bottom {
border-bottom: solid 1px var(--transparent-black);
}
.margin-none {
margin: 0;
}
.margin-auto-top {
margin-top: auto;
}
.margin-none-top {
margin-top: 0;
}
.margin-large-bottom {
margin-bottom: var(--large);
}
.margin-none-bottom {
margin-bottom: 0;
}
.square {
aspect-ratio: 1;
}
.absolute-centered {
position: absolute;
top: 50%;
left: 50%;
translate: -50% -50%;
}
/* Specialized elements */
.iconContainer {
width: 64px;
height: 64px;
padding: 1px;
border: solid 1px var(--transparent-black);
}
.icon {
position: relative;
max-width: 64px;
max-height: 64px;
margin: auto;
top: 50%;
left: 50%;
translate: -50% -50%;
}
.logo {
height: 4em;
}
.inlineIcon {
vertical-align: text-top;
}
.buttonPanel>* {
margin-top: min(var(--xl), 6vh);
}
.circlingMembers {
position: relative;
width: 24em;
height: 24em;
animation-play-state: running;
transition: animation-play-state 1s;
}
.circlingMembers:hover {
animation-play-state: paused;
}
.member {
animation: 8s infinite linear orbit;
animation-play-state: inherit;
}
.member.charlotte {
--orbit-translate-x: 8em;
}
.member.kio {
--orbit-translate-x: -8em;
}
/* Animations */
.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);
}
}
@keyframes orbit {
0% {
transform: rotate(0deg) translateX(var(--orbit-translate-x)) rotate(0deg);
}
100% {
transform: rotate(360deg) translateX(var(--orbit-translate-x)) rotate(-360deg);
}
}

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

View file

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