Compare commits

..

1 commit

Author SHA1 Message Date
b7cc5e79b3
feat: use cargo-leptos for all-in-one ssr binary..
... that doesn't work?!? spent hours getting this to compile, it
munched 20GB like it was nothing, took its damn time just to then crash
while running because "cannot access imported statics on non-wasm
targets" ?!?!!?? no clue, also not super sold on this SSR thing because
it adds so much complexity, will probably leave this branch up here for
future reference in case i want to try this again, and go back to trunk
+ include! static assets and full CSR for leptos
2025-01-21 02:39:43 +01:00
67 changed files with 1088 additions and 2239 deletions

View file

@ -1,22 +0,0 @@
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > rustup-install.sh
chmod +x rustup-install.sh
./rustup-install.sh -y
- run: PATH="$PATH:$HOME/.cargo/bin" rustup target add wasm32-unknown-unknown
- run: |
curl --proto '=https' --tlsv1.2 -sSfL https://github.com/trunk-rs/trunk/releases/download/v0.21.7/trunk-x86_64-unknown-linux-gnu.tar.gz > $HOME/.cargo/bin/trunk.tar.gz
tar --directory $HOME/.cargo/bin/ -xf $HOME/.cargo/bin/trunk.tar.gz
- uses: actions/checkout@v4
- run: PATH="$PATH:$HOME/.cargo/bin" trunk build --release
working-directory: web/
- run: PATH="$PATH:$HOME/.cargo/bin" cargo build --release --features=web
# - uses: actions/upload-artifact@v4
- uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
path: target/release/upub

View file

@ -1,16 +0,0 @@
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions-rust-lang/setup-rust-toolchain@v1
- run: rustup target add wasm32-unknown-unknown
- run: cargo install trunk
- uses: actions/checkout@v4
- run: trunk build --release
working-directory: web/
- run: cargo build --release --features=web
- uses: actions/upload-artifact@v4
with:
path: target/release/upub

12
.tci
View file

@ -1,11 +1,7 @@
#!/bin/bash #!/bin/bash
echo "building frontend bundle"
cd web
UPUB_BASE_URL="https://dev.upub.social" CARGO_BUILD_JOBS=4 /opt/bin/trunk build --release --public-url 'https://dev.upub.social/web/assets/'
cd ..
echo "building release binary" echo "building release binary"
cargo build --release --features=web -j 4 cargo build --release --all-features -j 4
echo "stopping service" echo "stopping service"
systemctl --user stop upub systemctl --user stop upub
echo "installing new binary" echo "installing new binary"
@ -14,4 +10,10 @@ echo "migrating database"
/opt/bin/upub -c /etc/upub/config.toml migrate /opt/bin/upub -c /etc/upub/config.toml migrate
echo "restarting service" echo "restarting service"
systemctl --user start upub systemctl --user start upub
echo "rebuilding frontend"
cd web
CARGO_BUILD_JOBS=4 /opt/bin/trunk build --profile=wasm-release --public-url 'https://dev.upub.social/web'
echo "deploying frontend"
rm /srv/http/upub/dev/web/*
mv ./dist/* /srv/http/upub/dev/web/
echo "done" echo "done"

212
Cargo.lock generated
View file

@ -23,7 +23,7 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [ dependencies = [
"getrandom 0.2.15", "getrandom",
"once_cell", "once_cell",
"version_check", "version_check",
] ]
@ -270,44 +270,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum-core 0.4.5", "axum-core",
"bytes", "bytes",
"futures-util", "futures-util",
"http 1.2.0", "http 1.2.0",
"http-body", "http-body",
"http-body-util", "http-body-util",
"itoa",
"matchit 0.7.3",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"sync_wrapper",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8"
dependencies = [
"axum-core 0.5.0",
"bytes",
"form_urlencoded",
"futures-util",
"http 1.2.0",
"http-body",
"http-body-util",
"hyper", "hyper",
"hyper-util", "hyper-util",
"itoa", "itoa",
"matchit 0.8.4", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer", "multer",
@ -344,25 +316,6 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
]
[[package]]
name = "axum-core"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733"
dependencies = [
"bytes",
"futures-util",
"http 1.2.0",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing", "tracing",
] ]
@ -1281,22 +1234,10 @@ dependencies = [
"cfg-if", "cfg-if",
"js-sys", "js-sys",
"libc", "libc",
"wasi 0.11.0+wasi-snapshot-preview1", "wasi",
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "getrandom"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8"
dependencies = [
"cfg-if",
"libc",
"wasi 0.13.3+wasi-0.2.2",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "gimli" name = "gimli"
version = "0.31.1" version = "0.31.1"
@ -1720,9 +1661,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "httpsign" name = "httpsign"
version = "0.1.1" version = "0.1.0"
dependencies = [ dependencies = [
"axum 0.8.1", "axum",
"base64", "base64",
"openssl", "openssl",
"thiserror 2.0.11", "thiserror 2.0.11",
@ -2089,7 +2030,7 @@ dependencies = [
"cfg-if", "cfg-if",
"either_of", "either_of",
"futures", "futures",
"getrandom 0.2.15", "getrandom",
"hydration_context", "hydration_context",
"leptos_config", "leptos_config",
"leptos_dom", "leptos_dom",
@ -2099,7 +2040,7 @@ dependencies = [
"oco_ref", "oco_ref",
"or_poisoned", "or_poisoned",
"paste", "paste",
"rand 0.8.5", "rand",
"reactive_graph", "reactive_graph",
"rustc-hash", "rustc-hash",
"send_wrapper", "send_wrapper",
@ -2149,7 +2090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b613d5784037baee42a11d21bc263adfc1a55e416556a3d5bfe39c7b87fadf" checksum = "43b613d5784037baee42a11d21bc263adfc1a55e416556a3d5bfe39c7b87fadf"
dependencies = [ dependencies = [
"any_spawner", "any_spawner",
"axum 0.7.9", "axum",
"dashmap", "dashmap",
"futures", "futures",
"hydration_context", "hydration_context",
@ -2464,12 +2405,6 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]] [[package]]
name = "md-5" name = "md-5"
version = "0.10.6" version = "0.10.6"
@ -2533,7 +2468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [ dependencies = [
"libc", "libc",
"wasi 0.11.0+wasi-snapshot-preview1", "wasi",
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
@ -2635,7 +2570,7 @@ dependencies = [
"num-integer", "num-integer",
"num-iter", "num-iter",
"num-traits", "num-traits",
"rand 0.8.5", "rand",
"smallvec", "smallvec",
"zeroize", "zeroize",
] ]
@ -2904,7 +2839,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
dependencies = [ dependencies = [
"phf_shared 0.10.0", "phf_shared 0.10.0",
"rand 0.8.5", "rand",
] ]
[[package]] [[package]]
@ -2914,7 +2849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [ dependencies = [
"phf_shared 0.11.3", "phf_shared 0.11.3",
"rand 0.8.5", "rand",
] ]
[[package]] [[package]]
@ -3019,7 +2954,7 @@ version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
dependencies = [ dependencies = [
"zerocopy 0.7.35", "zerocopy",
] ]
[[package]] [[package]]
@ -3198,19 +3133,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha 0.3.1", "rand_chacha",
"rand_core 0.6.4", "rand_core",
]
[[package]]
name = "rand"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.0",
"zerocopy 0.8.14",
] ]
[[package]] [[package]]
@ -3220,17 +3144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core 0.6.4", "rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.0",
] ]
[[package]] [[package]]
@ -3239,17 +3153,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [ dependencies = [
"getrandom 0.2.15", "getrandom",
]
[[package]]
name = "rand_core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b08f3c9802962f7e1b25113931d94f43ed9725bebc59db9d0c3e9a23b67e15ff"
dependencies = [
"getrandom 0.3.1",
"zerocopy 0.8.14",
] ]
[[package]] [[package]]
@ -3416,7 +3320,7 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [ dependencies = [
"cc", "cc",
"cfg-if", "cfg-if",
"getrandom 0.2.15", "getrandom",
"libc", "libc",
"spin", "spin",
"untrusted", "untrusted",
@ -3465,7 +3369,7 @@ dependencies = [
"num-traits", "num-traits",
"pkcs1", "pkcs1",
"pkcs8", "pkcs8",
"rand_core 0.6.4", "rand_core",
"signature", "signature",
"spki", "spki",
"subtle", "subtle",
@ -3497,7 +3401,7 @@ dependencies = [
"borsh", "borsh",
"bytes", "bytes",
"num-traits", "num-traits",
"rand 0.8.5", "rand",
"rkyv", "rkyv",
"serde", "serde",
"serde_json", "serde_json",
@ -3924,7 +3828,7 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5dd7fcccd3ef2081da086c1f8595b506627abbbbc9f64be0141d2251219570e" checksum = "f5dd7fcccd3ef2081da086c1f8595b506627abbbbc9f64be0141d2251219570e"
dependencies = [ dependencies = [
"axum 0.7.9", "axum",
"bytes", "bytes",
"const_format", "const_format",
"dashmap", "dashmap",
@ -4072,7 +3976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest",
"rand_core 0.6.4", "rand_core",
] ]
[[package]] [[package]]
@ -4283,7 +4187,7 @@ dependencies = [
"memchr", "memchr",
"once_cell", "once_cell",
"percent-encoding", "percent-encoding",
"rand 0.8.5", "rand",
"rsa", "rsa",
"rust_decimal", "rust_decimal",
"serde", "serde",
@ -4327,7 +4231,7 @@ dependencies = [
"memchr", "memchr",
"num-bigint", "num-bigint",
"once_cell", "once_cell",
"rand 0.8.5", "rand",
"rust_decimal", "rust_decimal",
"serde", "serde",
"serde_json", "serde_json",
@ -4658,7 +4562,7 @@ checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"fastrand", "fastrand",
"getrandom 0.2.15", "getrandom",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.59.0", "windows-sys 0.59.0",
@ -5162,16 +5066,18 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "upub" name = "upub"
version = "0.5.1-dev" version = "0.4.3"
dependencies = [ dependencies = [
"apb", "apb",
"async-recursion", "async-recursion",
"async-trait", "async-trait",
"axum",
"base64", "base64",
"chrono", "chrono",
"hmac", "hmac",
"httpsign", "httpsign",
"jrd", "jrd",
"leptos_config",
"mdhtml", "mdhtml",
"nodeinfo", "nodeinfo",
"openssl", "openssl",
@ -5192,7 +5098,7 @@ dependencies = [
[[package]] [[package]]
name = "upub-bin" name = "upub-bin"
version = "0.5.1-dev" version = "0.4.3"
dependencies = [ dependencies = [
"clap", "clap",
"futures", "futures",
@ -5212,7 +5118,7 @@ dependencies = [
[[package]] [[package]]
name = "upub-cli" name = "upub-cli"
version = "0.3.1" version = "0.3.0"
dependencies = [ dependencies = [
"apb", "apb",
"chrono", "chrono",
@ -5237,20 +5143,18 @@ dependencies = [
[[package]] [[package]]
name = "upub-routes" name = "upub-routes"
version = "0.4.1" version = "0.3.0"
dependencies = [ dependencies = [
"apb", "apb",
"axum 0.8.1", "axum",
"chrono", "chrono",
"httpsign", "httpsign",
"jrd", "jrd",
"leptos",
"leptos_axum", "leptos_axum",
"leptos_meta", "leptos_config",
"leptos_router",
"mastodon-async-entities", "mastodon-async-entities",
"nodeinfo", "nodeinfo",
"rand 0.9.0", "rand",
"reqwest", "reqwest",
"sea-orm", "sea-orm",
"serde", "serde",
@ -5259,15 +5163,15 @@ dependencies = [
"thiserror 2.0.11", "thiserror 2.0.11",
"time", "time",
"tokio", "tokio",
"tower",
"tower-http", "tower-http",
"tracing", "tracing",
"upub", "upub",
"upub-web",
] ]
[[package]] [[package]]
name = "upub-web" name = "upub-web"
version = "0.5.1-dev" version = "0.4.3"
dependencies = [ dependencies = [
"apb", "apb",
"base64", "base64",
@ -5368,7 +5272,7 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "744018581f9a3454a9e15beb8a33b017183f1e7c0cd170232a2d1453b23a51c4" checksum = "744018581f9a3454a9e15beb8a33b017183f1e7c0cd170232a2d1453b23a51c4"
dependencies = [ dependencies = [
"getrandom 0.2.15", "getrandom",
"serde", "serde",
] ]
@ -5451,15 +5355,6 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasi"
version = "0.13.3+wasi-0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2"
dependencies = [
"wit-bindgen-rt",
]
[[package]] [[package]]
name = "wasite" name = "wasite"
version = "0.1.0" version = "0.1.0"
@ -5806,15 +5701,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "wit-bindgen-rt"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
dependencies = [
"bitflags 2.8.0",
]
[[package]] [[package]]
name = "write16" name = "write16"
version = "1.0.0" version = "1.0.0"
@ -5894,16 +5780,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [ dependencies = [
"byteorder", "byteorder",
"zerocopy-derive 0.7.35", "zerocopy-derive",
]
[[package]]
name = "zerocopy"
version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a367f292d93d4eab890745e75a778da40909cab4d6ff8173693812f79c4a2468"
dependencies = [
"zerocopy-derive 0.8.14",
] ]
[[package]] [[package]]
@ -5917,17 +5794,6 @@ dependencies = [
"syn 2.0.96", "syn 2.0.96",
] ]
[[package]]
name = "zerocopy-derive"
version = "0.8.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3931cb58c62c13adec22e38686b559c86a30565e16ad6e8510a337cedc611e1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.96",
]
[[package]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.5" version = "0.1.5"

View file

@ -14,7 +14,7 @@ members = [
[package] [package]
name = "upub-bin" name = "upub-bin"
version = "0.5.1-dev" version = "0.4.3"
edition = "2021" edition = "2021"
authors = [ "alemi <me@alemi.dev>" ] authors = [ "alemi <me@alemi.dev>" ]
description = "Traits and types to handle ActivityPub objects" description = "Traits and types to handle ActivityPub objects"
@ -24,7 +24,7 @@ repository = "https://git.alemi.dev/upub.git"
readme = "README.md" readme = "README.md"
[[bin]] [[bin]]
name = "upub" name = "upub-bin"
path = "main.rs" path = "main.rs"
[dependencies] [dependencies]
@ -45,28 +45,21 @@ upub-routes = { path = "routes", optional = true }
upub-worker = { path = "worker", optional = true } upub-worker = { path = "worker", optional = true }
[features] [features]
default = ["serve", "migrate", "cli", "worker"] default = ["serve", "migrate", "cli", "worker", "web"]
serve = ["dep:upub-routes"] serve = ["dep:upub-routes"]
migrate = ["dep:upub-migrations"] migrate = ["dep:upub-migrations"]
cli = ["dep:upub-cli"] cli = ["dep:upub-cli"]
worker = ["dep:upub-worker"] worker = ["dep:upub-worker"]
web = ["upub-routes?/web"] web = ["upub/web", "upub-routes?/web"]
web-build-fe = []
# upub: ~38M [[workspace.metadata.leptos]]
# upub-web: ~9M name = "upub"
# [profile.release] # without any tweak bin-package = "upub-bin"
bin-features = ["serve", "migrate", "cli", "worker", "web"]
lib-package = "upub-web"
lib-features = ["leptos-hydrate"]
# upub: ~22M [profile.wasm-release]
# upub-web.wasm: ~5.8M
[profile.release]
opt-level = 'z'
lto = true
codegen-units = 1
# upub: ~18M
# upub-web.wasm: ~4.1M
[profile.release-tiny]
inherits = "release" inherits = "release"
opt-level = 'z' opt-level = 'z'
lto = true lto = true

222
README.md
View file

@ -1,172 +1,42 @@
<p align="center">
<img src="https://dev.upub.social/web/assets/icon.png" alt="upub logo: greep mu letter with blue and pink-reddish gradient" height="150" />
</p>
# μpub # μpub
> [micro social network, federated](https://join.upub.social)
> ## [micro social network, federated](https://join.upub.social) ![screenshot of upub simple frontend](https://cdn.alemi.dev/proj/upub/fe/20240704.png)
>
> - [about](#about)
> - [features](#features)
> - [security](#security)
> - [caching](#caching)
> - [deploy](#deploy)
> - [install](#install)
> - [run](#run)
> - [configure](#configure)
> - [development](#development)
> - [contacts](#contacts)
> - [contributing](#contributing)
# about
μpub aims to be a private, lightweight, modular and **secure** [ActivityPub](https://www.w3.org/TR/activitypub/) server μpub aims to be a private, lightweight, modular and **secure** [ActivityPub](https://www.w3.org/TR/activitypub/) server
μpub is modeled around timelines but tries to be unopinionated in its implementation, allowing representing multiple different fediverse "modalities" together * follow development [in the dedicated matrix room](https://matrix.to/#/#upub:moonlit.technology)
all client interactions happen with ActivityPub's client-server methods (basically POST your activities to your outbox), with [appropriate extensions](https://ns.alemi.dev/as): **μpub doesn't want to invent another API**! μpub is usable as a very simple ActivityPub project: it has a home and server timeline, it allows to browse threads, star notes and leave replies, it renders remote media of any kind and can be used to browse and follow remote users
> [!NOTE] all interactions happen with ActivityPub's client-server methods (basically POST your activities to your outbox), with [appropriate extensions](https://ns.alemi.dev/as): **μpub doesn't want to invent another API**!
> a test instance is available at [dev.upub.social](https://dev.upub.social)
## features development is still active, so expect more stuff to come! since most fediverse software uses Mastodon's API, μpub plans to implement it as an optional feature, becoming eventually compatible with most existing frontends and mobile applications, but focus right now is on producing something specific to μpub needs
μpub boasts both known features and new experimental ideas:
* quote posts, groups, tree view
* media proxy: minimal local storage impact
* AP explorer: navigate underlying AP documents
* on-demand thread fetching: get missing remote replies on-demand
* granular activity privacy: control who gets to see each of your likes and shares
* actor liked feeds: browse all publicly likes content from users, as "curated timelines"
## security a test instance is available at [dev.upub.social](https://dev.upub.social)
## about security
most activitypub implementations don't really validate fetches: knowing an activity/object id will allow anyone to resolve it on most fedi software. this is of course unacceptable: "security through obscurity" just doesn't work most activitypub implementations don't really validate fetches: knowing an activity/object id will allow anyone to resolve it on most fedi software. this is of course unacceptable: "security through obscurity" just doesn't work
μpub correctly and rigorously implements and enforces access control on each object based on its addressing μpub correctly and rigorously implements and enforces access control on each object based on its addressing
> [!IMPORTANT] most instances will have "authorized fetch" which kind of makes the issue less bad, but anyone can host an actor, have any server download their pubkey and then start fetching
> most instances will have "authorized fetch" which kind of makes the issue less bad, but anyone can host an actor, have any server download their pubkey and then start fetching
μpub may be considered to have "authorized fetch" permanently on, except it depends on each post: μpub may be considered to have "authorized fetch" permanently on, except it depends on each post:
* all incoming activities must be signed or will be rejected * all posts marked public (meaning, addressed to "https://www.w3.org/ns/activitystreams#Public"), will be fetchable without any authorization
* all posts marked public (meaning, addressed to `https://www.w3.org/ns/activitystreams#Public`), will be fetchable without any authorization
* all posts not public will require explicit addressing and authentication: for example if post A is addressed to example.net/actor * all posts not public will require explicit addressing and authentication: for example if post A is addressed to example.net/actor
* anonymous fetchers will receive 404 on GET /posts/A * anonymous fetchers will receive 404 on GET /posts/A
* local users must authenticate and will be given said post only if it's addressed to them * local users must authenticate and will be given said post only if it's addressed to them
* remote servers will be given access to all posts from any of their users once they have authenticated themselves (with http signing) * remote servers will be given access to all posts from any of their users once they have authenticated themselves (with http signing)
> [!TIP] note that followers get expanded: addressing to example.net/actor/followers will address to anyone following actor that the server knows of, at that time
> note that followers get expanded at insertion time: addressing to `example.net/actor/followers` will address to anyone following actor that the server knows of, **at that time**
## caching ## media caching
μpub **doesn't download remote media** to both minimize local resources requirement and avoid storing media that remotes want gone. to prevent leaking local user ip addresses, all media links are cloaked and proxied. μpub doesn't download remote media to both minimize local resources requirement and avoid storing media that remotes want gone. to prevent leaking local user ip addresses, all media links are cloaked and proxied.
while this just works for small instances, larger servers should set up aggressive caching on `/proxy/...` path: more info [in following sections](#media-proxy-cache) while this just works for small instances, larger servers should set up aggressive caching on `/proxy/...` path
# deploy for example, on `nginx`:
μpub is built with the needs of small deployments in mind: getting a dev instance up is as easy as running one command, and setting up for production just requires some config tweaking
## install
latest μpub build can be downloaded from [moonlit.technology releases page](https://moonlit.technology/alemi/upub/releases)
```sh
curl -s https://moonlit.technology/alemi/upub/releases/download/v0.5.0/upub > ~/.local/bin/upub; chmod +x ~/.local/bin/upub
```
> [!IMPORTANT]
> automated cross-platform builds by GitHub are planned and will be made available soon
### from source
building μpub from source is also possible without too much effort. it will also allow to customize the resulting binary to your specific use case
if you just want to build the backend (or some of its components), a simple `$ cargo build` will do
---
to also build upub-web, some extra tooling must be installed:
* rust `wasm32-unknown-unknown` target (`$ rustup target add wasm32-unknown-unknown`)
* wasm-bindgen (`$ cargo install wasm-bindgen-cli`)
* trunk (`$ cargo install trunk`)
from inside `web` project directory, run `trunk build --release`. once it finishes, a `dist` directory should appear inside `web` project. it is now possible to build μpub with the `web` feature flag enabled, which will include upub-web frontend
```sh
cd web
trunk build --release
cd ..
cargo build --release --features=web
```
## run
μpub includes its maintenance tooling and different operation modes, all documented in its extensive command line interface.
> [!TIP]
> make sure to use `--help` if you're lost! each subcommands has its own help screen
all modes share `-c`, `--db` and `--domain` options, which will set respectively config path, database connection string and instance domain url.
none of these is necessary: by default a sqlite database `upub.db` will be created in current directory, default config will be used and domain will be a localhost http url
bring up a complete instance with `monolith` mode: `$ upub monolith` will
* run migrations
* setup frontend and routes
* spawn a background worker
most maintenance tasks can be done with `$ upub cli`: register a test user with `$ upub cli register user password`
done! try connecting to http://127.0.0.1:3000/web
## configure
all configuration lives under a `.toml` file. there is no default config path: point to it explicitly with `-c` flag while starting `upub`
> [!TIP]
> to view μpub full default config, use `$ upub config`
a super minimal config may look like this
```toml
[instance]
name = "my-upub-instance"
domain = "https://my.domain.social"
[datasource]
connection_string = "postgres://localhost/upub"
[security]
allow_registration = true
```
### moderation
> [!CAUTION]
> currently there aren't many moderation tools and tasks will require querying db directly
interactions with remote instances can be finetuned using the `[reject]` table:
```
# discard incoming activities from these instances
incoming = []
# prevent fetching content from these instances
fetch = []
# prevent content from these instances from being displayed publicly
# this effectively removes the public (aka NULL) addressing = only other addressees (followers,
# mentions) will be able to see content from these instances on timelines and directly
public = []
# prevent proxying media coming from these instances
media = []
# skip delivering to these instances
delivery = []
# prevent fetching private content from these instances
access = []
# reject any request from these instances (ineffective as they can still fetch anonymously)
requests = []
```
### media proxy cache
caching proxied media is quite important for performance, as it keeps proxying load away from μpub itself
for example, caching `nginx` could be achieved this way:
```nginx ```nginx
proxy_cache_path /tmp/upub/cache levels=1:2 keys_zone=upub_cache:100m max_size=50g inactive=168h use_temp_path=off; proxy_cache_path /tmp/upub/cache levels=1:2 keys_zone=upub_cache:100m max_size=50g inactive=168h use_temp_path=off;
@ -188,34 +58,44 @@ server {
``` ```
### polylith
it's also possible to deploy μpub as multiple smaller services, but this process will require some expertise as the setup is experimental and poorly documented
multiple specific binaries can be compiled with various feature flags:
- `cli` and `migrations` are only required at maintenance time
- `worker` processes jobs, only interacts with database
- `serve` answers http requests, can also queue jobs POSTed on inboxes, can be further split into
- `activitypub` with core AP routes
- `web` serving static frontend
remember to prepare config file and run migrations!
# development
development is still active, so expect more stuff to come! since most fediverse software uses Mastodon's API, μpub plans to implement it as an optional feature, becoming eventually compatible with most existing frontends and mobile applications, but focus right now is on producing something specific to μpub needs
## contacts
* new features or releases are announced [directly on the fediverse](https://dev.upub.social/actors/upub)
* direct questions about deployment or development, or general chatter around this project, [happens on matrix](https://matrix.to/#/#upub:moonlit.technology)
* development mainly happens on [moonlit.technology](https://moonlit.technology/alemi/upub), but a [github mirror](https://github.com/alemidev/upub) is also available. if you prefer a forge-less development you can browse the repo on [my cgit](https://git.alemi.dev/upub.git), and send me patches on any contact listed on [my site](https://alemi.dev/about/contacts)
## contributing ## contributing
μpub can always use more dev time!
if you want to contribute you will need to be somewhat familiar with [rust](https://www.rust-lang.org/): even the frontend is built with it! all help is extremely welcome! development mostly happens on [moonlit.technology](https://moonlit.technology/alemi/upub.git), but there's a [github mirror](https://github.com/alemidev/upub) available too
reading a bit of the [ActivityPub](https://www.w3.org/TR/activitypub/) specification can be useful but not really required if you prefer a forge-less development you can browse the repo on [my cgit](https://git.alemi.dev/upub.git), and send me patches on any contact listed on [my site](https://alemi.dev/about/contacts)
hanging out in the relevant matrix room will probably be useful, as you can ask questions while familiarizing with the codebase don't hesitate to get in touch, i'd be thrilled to showcase the project to you!
once you feel ready to tackle some development, head over to [the issues tab](https://moonlit.technology/alemi/upub/issues) and look around for something that needs to be done! ## progress
- [x] barebone actors
- [x] barebone activities and objects
- [x] activitystreams/activitypub compliance (well mostly)
- [x] process barebones feeds
- [x] process barebones inbox
- [x] process barebones outbox
- [x] http signatures
- [x] privacy, targets, scopes
- [x] simple web client
- [x] announce (boosts)
- [x] threads
- [x] remote media
- [x] editing via api
- [x] advanced composer
- [x] api for fetching
- [x] like, share, reply via frontend
- [x] backend config
- [x] frontend config
- [x] optimize `addressing` database schema
- [x] mentions, notifications
- [x] hashtags
- [x] remote media proxy
- [x] user fields
- [ ] better editing via web frontend
- [ ] upload media
- [ ] public vs unlisted for discovery
- [ ] mastodon-like search bar
- [ ] polls
- [ ] lists
- [ ] full mastodon api
- [ ] get rid of internal ids from code

View file

@ -1,21 +1,5 @@
use crate::Object; use crate::Object;
/// recommended content-type header value for AP fetches and responses
pub const CONTENT_TYPE_LD_JSON_ACTIVITYPUB: &str = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
/// alternative content-type header value for AP fetches and responses
pub const CONTENT_TYPE_ACTIVITY_JSON: &str = "application/activity+json";
/// uncommon and not officially supported content-type header value for AP fetches and responses
#[deprecated = "use CONTENT_TYPE_LD_JSON_ACTIVITYPUB: 'application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"'"]
pub const CONTENT_TYPE_LD_JSON: &str = "application/ld+json";
#[allow(deprecated)]
pub fn is_activity_pub_content_type<T: AsRef<str>>(txt: T) -> bool {
let r = txt.as_ref();
r == CONTENT_TYPE_LD_JSON_ACTIVITYPUB
|| r == CONTENT_TYPE_ACTIVITY_JSON
|| r == CONTENT_TYPE_LD_JSON
}
pub trait LD { pub trait LD {
fn ld_context(self) -> Self; fn ld_context(self) -> Self;
} }

View file

@ -110,7 +110,7 @@ pub mod shortcuts;
pub use shortcuts::Shortcuts; pub use shortcuts::Shortcuts;
#[cfg(feature = "jsonld")] #[cfg(feature = "jsonld")]
pub mod jsonld; mod jsonld;
#[cfg(feature = "jsonld")] #[cfg(feature = "jsonld")]
pub use jsonld::LD; pub use jsonld::LD;

View file

@ -1,6 +1,6 @@
[package] [package]
name = "upub-cli" name = "upub-cli"
version = "0.3.1" version = "0.3.0"
edition = "2021" edition = "2021"
authors = [ "alemi <me@alemi.dev>" ] authors = [ "alemi <me@alemi.dev>" ]
description = "cli maintenance tasks for upub" description = "cli maintenance tasks for upub"

View file

@ -1,55 +1 @@
# upub cli # upub cli
command line interface tools for `upub`
everything is pretty well documented: just add `--help` to get detailed info
```sh
$ upub --help
micro social network, federated
Usage: upub [OPTIONS] <COMMAND>
Commands:
config print current or default configuration
migrate apply database migrations
cli run maintenance CLI tasks
monolith start both api routes and background workers
serve start api routes server
work start background job worker
help Print this message or the help of the given subcommand(s)
Options:
-c, --config <CONFIG> path to config file, leave empty to not use any
--db <DATABASE> database connection uri, overrides config value
--domain <DOMAIN> instance base domain, for AP ids, overrides config value
--debug run with debug level tracing
--threads <THREADS> force set number of worker threads for async runtime, defaults to number of cores
-h, --help Print help
```
---
```sh
$ upub cli --help
run maintenance CLI tasks
Usage: upub cli <COMMAND>
Commands:
faker generate fake user, note and activity
fetch fetch a single AP object
relay act on remote relay actors at instance level
count recount object statistics
update update remote actors
register register a new local user
nuke break all user relations so that instance can be shut down
thread attempt to fix broken threads and completely gather their context
cloak replaces all attachment urls with proxied local versions (only useful for old instances)
fix-activities restore activities links, only needed for very old installs
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
```

View file

@ -1,35 +0,0 @@
use futures::TryStreamExt;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, TransactionTrait};
pub async fn fix_attachments_types(ctx: upub::Context) -> Result<(), Box<dyn std::error::Error>> {
tracing::info!("fixing attachments documentType...");
let tx = ctx.db().begin().await?;
let mut stream = upub::model::attachment::Entity::find()
.filter(upub::model::attachment::Column::DocumentType.eq(apb::DocumentType::Document))
.stream(ctx.db())
.await?;
while let Some(attachment) = stream.try_next().await? {
let Some((mime_kind, _mime_type)) = attachment.media_type.split_once('/') else { continue };
let document_type = match mime_kind {
"image" => apb::DocumentType::Image,
"video" => apb::DocumentType::Video,
"audio" => apb::DocumentType::Audio,
"text" => apb::DocumentType::Page,
_ => continue,
};
tracing::info!("updating {} to {document_type}", attachment.url);
let mut active = attachment.into_active_model();
active.document_type = Set(document_type);
active.update(&tx).await?;
}
tracing::info!("committing transaction...");
tx.commit().await?;
tracing::info!("done");
Ok(())
}

View file

@ -2,36 +2,27 @@ use futures::TryStreamExt;
use sea_orm::{ActiveModelTrait, ActiveValue::{NotSet, Set, Unchanged}, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter, QuerySelect, SelectColumns}; use sea_orm::{ActiveModelTrait, ActiveValue::{NotSet, Set, Unchanged}, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter, QuerySelect, SelectColumns};
use upub::traits::{fetch::RequestError, Cloaker}; use upub::traits::{fetch::RequestError, Cloaker};
pub async fn cloak(ctx: upub::Context, post_contents: bool, objects: bool, actors: bool, re_cloak: bool) -> Result<(), RequestError> { pub async fn cloak(ctx: upub::Context, post_contents: bool, objects: bool, actors: bool) -> Result<(), RequestError> {
let local_base = format!("{}%", ctx.base()); let local_base = format!("{}%", ctx.base());
{ {
let mut select = upub::model::attachment::Entity::find(); let mut stream = upub::model::attachment::Entity::find()
if !re_cloak { .filter(upub::model::attachment::Column::Url.not_like(&local_base))
select = select.filter(upub::model::attachment::Column::Url.not_like(&local_base));
}
let mut stream = select
.stream(ctx.db()) .stream(ctx.db())
.await?; .await?;
while let Some(attachment) = stream.try_next().await? { while let Some(attachment) = stream.try_next().await? {
tracing::info!("cloaking {}", attachment.url); tracing::info!("cloaking {}", attachment.url);
let url = ctx.cloaked(&attachment.url); let (sig, url) = ctx.cloak(&attachment.url);
let mut model = attachment.into_active_model(); let mut model = attachment.into_active_model();
model.url = Set(url); model.url = Set(upub::url!(ctx, "/proxy/{sig}/{url}"));
model.update(ctx.db()).await?; model.update(ctx.db()).await?;
} }
} }
if objects { if objects {
let mut select = upub::model::object::Entity::find() let mut stream = upub::model::object::Entity::find()
.filter(upub::model::object::Column::Image.is_not_null()); .filter(upub::model::object::Column::Image.is_not_null())
.filter(upub::model::object::Column::Image.not_like(&local_base))
if !re_cloak {
select = select.filter(upub::model::object::Column::Image.not_like(&local_base));
}
let mut stream = select
.select_only() .select_only()
.select_column(upub::model::object::Column::Internal) .select_column(upub::model::object::Column::Internal)
.select_column(upub::model::object::Column::Image) .select_column(upub::model::object::Column::Image)
@ -51,18 +42,12 @@ pub async fn cloak(ctx: upub::Context, post_contents: bool, objects: bool, actor
} }
if actors { if actors {
let mut select = upub::model::actor::Entity::find(); let mut stream = upub::model::actor::Entity::find()
.filter(
if !re_cloak { Condition::any()
select = select .add(upub::model::actor::Column::Image.not_like(&local_base))
.filter( .add(upub::model::actor::Column::Icon.not_like(&local_base))
Condition::any() )
.add(upub::model::actor::Column::Image.not_like(&local_base))
.add(upub::model::actor::Column::Icon.not_like(&local_base))
);
}
let mut stream = select
.select_only() .select_only()
.select_column(upub::model::actor::Column::Internal) .select_column(upub::model::actor::Column::Internal)
.select_column(upub::model::actor::Column::Image) .select_column(upub::model::actor::Column::Image)

View file

@ -1,119 +0,0 @@
use apb::{Activity, ActivityMut, Base, BaseMut, CollectionMut, Document, DocumentMut, Object, ObjectMut};
use sea_orm::TransactionTrait;
pub async fn import(
ctx: upub::Context,
file: std::path::PathBuf,
from: String,
to: String,
attachment_base: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
// TODO worth including tokio/fs to do this async? it's a CLI task anyway
let raw_content = std::fs::read_to_string(file)?;
let objects : Vec<serde_json::Value> = serde_json::from_str(&raw_content)?;
let tx = ctx.db().begin().await?;
for mut obj in objects {
if let Some(data) = obj.get_mut("data") {
obj = data.take();
}
let Ok(oid) = obj.id() else {
tracing::warn!("skipping object without id : {obj}");
continue;
};
let attributed_to = match obj.attributed_to().id() {
Ok(id) => id,
Err(_) => match obj.actor().id() {
Ok(id) => id,
Err(_) => {
tracing::warn!("skipping object without author: {obj}");
continue;
},
},
};
if attributed_to != from {
tracing::warn!("skipping object not belonging to requested user: {obj}");
continue;
}
let normalized_attachments = match attachment_base {
Some(ref attachment_base) => {
let mut out = Vec::new();
for attachment in obj.attachment().flat() {
let Ok(doc) = attachment.inner() else {
tracing::warn!("skipping non embedded attachment: {attachment:?}");
continue;
};
out.push(
apb::new()
.set_document_type(doc.document_type().ok())
.set_name(doc.name().ok())
.set_media_type(doc.media_type().ok())
.set_url(apb::Node::link(
format!("{attachment_base}/{}", doc.url().id().unwrap_or_default().split('/').last().unwrap_or_default())
))
);
}
apb::Node::array(out)
},
None => obj.attachment()
};
let normalized_summary = obj.summary()
.ok()
.filter(|x| !x.is_empty());
let announces_count = match obj.get("announcement_count") {
Some(v) => v.as_u64().unwrap_or_default(),
None => obj.shares().flat().len() as u64,
};
let replies_count = match obj.get("repliesCount") {
Some(v) => v.as_u64().unwrap_or_default(),
None => obj.replies().flat().len() as u64,
};
let likes_count = match obj.get("like_count") {
Some(v) => v.as_u64().unwrap_or_default(),
None => obj.likes().flat().len() as u64,
};
let normalized_object = obj
.set_id(Some(ctx.oid(&upub::Context::new_id())))
.set_attributed_to(apb::Node::link(to.clone()))
.set_summary(normalized_summary)
.set_shares(apb::Node::object(
apb::new()
.set_total_items(Some(announces_count))
))
.set_likes(apb::Node::object(
apb::new()
.set_total_items(Some(likes_count))
))
.set_replies(apb::Node::object(
apb::new()
.set_total_items(Some(replies_count))
))
.set_attachment(normalized_attachments);
let activity = apb::new()
.set_id(Some(ctx.aid(&upub::Context::new_id())))
.set_activity_type(Some(apb::ActivityType::Create))
.set_actor(apb::Node::link(to.clone()))
.set_published(normalized_object.published().ok())
.set_object(apb::Node::object(normalized_object));
if let Err(e) = upub::traits::process::process_create(&ctx, activity, &tx).await {
tracing::error!("could not insert object {oid}: {e} ({e:?})");
}
}
tx.commit().await?;
Ok(())
}

View file

@ -28,14 +28,6 @@ pub use thread::*;
mod cloak; mod cloak;
pub use cloak::*; pub use cloak::*;
mod import;
pub use import::*;
mod attachments;
pub use attachments::*;
// TODO naming is going kind of all over the place, should probably rename lot of these...
#[derive(Debug, Clone, clap::Subcommand)] #[derive(Debug, Clone, clap::Subcommand)]
pub enum CliCommand { pub enum CliCommand {
/// generate fake user, note and activity /// generate fake user, note and activity
@ -146,10 +138,6 @@ pub enum CliCommand {
/// also replace urls inside post contents /// also replace urls inside post contents
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
contents: bool, contents: bool,
/// also re-cloak already cloaked urls, useful if changing cloak secret
#[arg(long, default_value_t = false)]
re_cloak: bool,
}, },
/// restore activities links, only needed for very old installs /// restore activities links, only needed for very old installs
@ -162,29 +150,6 @@ pub enum CliCommand {
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
announces: bool, announces: bool,
}, },
/// import posts coming from another instance: replay them as local
Import {
/// json backup file: must be an array of objects
file: std::path::PathBuf,
/// previous actor id, used in these posts
#[arg(long)]
from: String,
/// current actor id, will be replaced in all posts
#[arg(long)]
to: String,
/// base url where attachments are hosted now, if not given attachments will be kept unchanged
#[arg(short, long)]
attachment_base: Option<String>
},
/// fix attachments types based on mediaType
Attachments {
}
} }
pub async fn run(ctx: upub::Context, command: CliCommand) -> Result<(), Box<dyn std::error::Error>> { pub async fn run(ctx: upub::Context, command: CliCommand) -> Result<(), Box<dyn std::error::Error>> {
@ -206,13 +171,9 @@ pub async fn run(ctx: upub::Context, command: CliCommand) -> Result<(), Box<dyn
Ok(nuke(ctx, for_real, delete_objects).await?), Ok(nuke(ctx, for_real, delete_objects).await?),
CliCommand::Thread { } => CliCommand::Thread { } =>
Ok(thread(ctx).await?), Ok(thread(ctx).await?),
CliCommand::Cloak { objects, actors, contents, re_cloak } => CliCommand::Cloak { objects, actors, contents } =>
Ok(cloak(ctx, contents, objects, actors, re_cloak).await?), Ok(cloak(ctx, contents, objects, actors).await?),
CliCommand::FixActivities { likes, announces } => CliCommand::FixActivities { likes, announces } =>
Ok(fix_activities(ctx, likes, announces).await?), Ok(fix_activities(ctx, likes, announces).await?),
CliCommand::Import { file, from, to, attachment_base } =>
Ok(import(ctx, file, from, to, attachment_base).await?),
CliCommand::Attachments { } =>
Ok(fix_attachments_types(ctx).await?),
} }
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "upub" name = "upub"
version = "0.5.1-dev" version = "0.4.3"
edition = "2021" edition = "2021"
authors = [ "alemi <me@alemi.dev>" ] authors = [ "alemi <me@alemi.dev>" ]
description = "core inner workings of upub" description = "core inner workings of upub"
@ -36,3 +36,9 @@ reqwest = { version = "0.12", features = ["json"] }
apb = { path = "../apb", features = ["unstructured", "orm", "did-core", "activitypub-miscellaneous-terms", "activitypub-fe", "activitypub-counters", "litepub", "ostatus", "toot"] } apb = { path = "../apb", features = ["unstructured", "orm", "did-core", "activitypub-miscellaneous-terms", "activitypub-fe", "activitypub-counters", "litepub", "ostatus", "toot"] }
# nodeinfo = "0.0.2" # the version on crates.io doesn't re-export necessary types to build the struct!!! # nodeinfo = "0.0.2" # the version on crates.io doesn't re-export necessary types to build the struct!!!
nodeinfo = { git = "https://codeberg.org/thefederationinfo/nodeinfo-rs", rev = "e865094804" } nodeinfo = { git = "https://codeberg.org/thefederationinfo/nodeinfo-rs", rev = "e865094804" }
leptos_config = { version = "0.7", optional = true }
axum = { version = "0.7", optional = true }
[features]
default = []
web = ["dep:leptos_config", "dep:axum"]

View file

@ -1,5 +1 @@
# upub core # upub core
core traits, models and extensions for `upub`
this crate is not very useful on its own

View file

@ -1,3 +1,5 @@
#[serde_inline_default::serde_inline_default] #[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)] #[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct Config { pub struct Config {
@ -33,23 +35,23 @@ pub struct InstanceConfig {
/// description, shown in nodeinfo and instance actor /// description, shown in nodeinfo and instance actor
pub description: String, pub description: String,
#[serde_inline_default("http://127.0.0.1:3000".into())] #[serde_inline_default("upub.social".into())]
/// domain of current instance, must change this for prod /// domain of current instance
pub domain: String, pub domain: String,
#[serde(default)] #[serde(default)]
/// contact information for an administrator, currently unused /// contact information for an administrator, currently unused
pub contact: String, pub contact: Option<String>,
#[serde(default)] #[serde(default)]
/// base url for frontend, will be used to compose pretty urls /// base url for frontend, will be used to compose pretty urls
pub frontend: String, pub frontend: Option<String>,
} }
#[serde_inline_default::serde_inline_default] #[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)] #[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct DatasourceConfig { pub struct DatasourceConfig {
#[serde_inline_default("sqlite://./upub.db?mode=rwc".into())] #[serde_inline_default("sqlite://./upub.db".into())]
pub connection_string: String, pub connection_string: String,
#[serde_inline_default(32)] #[serde_inline_default(32)]
@ -92,11 +94,7 @@ pub struct SecurityConfig {
/// allow anonymous users to perform full-text searches /// allow anonymous users to perform full-text searches
pub allow_public_search: bool, pub allow_public_search: bool,
#[serde_inline_default(30)] #[serde_inline_default("changeme".to_string())]
/// max time, in seconds, before requests fail with timeout
pub request_timeout: u64,
#[serde_inline_default("definitely-change-this-in-prod".to_string())]
/// secret for media proxy, set this to something random /// secret for media proxy, set this to something random
pub proxy_secret: String, pub proxy_secret: String,
@ -128,25 +126,17 @@ pub struct SecurityConfig {
#[serde_inline_default::serde_inline_default] #[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)] #[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct CompatibilityConfig { pub struct CompatibilityConfig {
#[serde_inline_default(true)] #[serde(default)]
/// compatibility with almost everything: set document type as image/video/audio according to /// compatibility with almost everything: set image attachments as images
/// mediaType, because almost all software sends us `Document` attachments pub fix_attachment_images_media_type: bool,
pub fix_attachment_media_type: bool,
#[serde_inline_default(true)] #[serde(default)]
/// compatibility with mastodon and misskey (and somewhat lemmy?): notify like receiver /// compatibility with lemmy and mastodon: notify like receiver
pub add_explicit_target_to_likes_if_local: bool, pub add_explicit_target_to_likes_if_local: bool,
#[serde_inline_default(true)] #[serde(default)]
/// compatibility with lemmy: avoid showing images twice /// compatibility with lemmy: avoid showing images twice
pub skip_single_attachment_if_image_is_set: bool, pub skip_single_attachment_if_image_is_set: bool,
#[serde_inline_default(false)]
/// compatibility with most relays: since they send us other server's activities, we must fetch
/// them to verify that they aren't falsified by the relay itself. this is quite expensive, as
/// relays send a lot of activities and we effectively end up fetching again all these, so this
/// defaults to false
pub verify_relayed_activities_by_fetching: bool,
} }
#[serde_inline_default::serde_inline_default] #[serde_inline_default::serde_inline_default]
@ -204,13 +194,7 @@ impl Config {
Config::default() Config::default()
} }
// TODO this is very magic... can we do better? maybe formalize frontend url as an attribute of
// our application?
pub fn frontend_url(&self, url: &str) -> Option<String> { pub fn frontend_url(&self, url: &str) -> Option<String> {
if !self.instance.frontend.is_empty() { Some(format!("{}{}", self.instance.frontend.as_deref()?, url))
Some(format!("{}{url}", self.instance.frontend))
} else {
None
}
} }
} }

View file

@ -193,3 +193,30 @@ pub enum Internal {
Activity(i64), Activity(i64),
Actor(i64), Actor(i64),
} }
#[cfg(feature = "web")]
mod leptos_state {
impl axum::extract::FromRef<super::Context> for leptos_config::LeptosOptions {
fn from_ref(_ctx: &super::Context) -> leptos_config::LeptosOptions {
static CONF: std::sync::OnceLock<leptos_config::LeptosOptions> = std::sync::OnceLock::new();
CONF.get_or_init(||
leptos_config::LeptosOptions {
env: {
#[cfg(debug_assertions)]{ leptos_config::Env::DEV }
#[cfg(not(debug_assertions))] { leptos_config::Env::PROD }
},
output_name: "upub_web".into(),
site_root: "web/dist".into(),
site_pkg_dir: "pkg".into(),
site_addr: "127.0.0.1:3000/web".parse().expect("could not create socket addr"), // TODO we don't want to serve? what is this for??
reload_port: 3001,
reload_external_port: None,
reload_ws_protocol: leptos_config::ReloadWSProtocol::WS,
not_found_path: "web/404.html".into(),
hash_file: "hash.txt".into(),
hash_files: true,
}
).clone()
}
}
}

View file

@ -66,38 +66,9 @@ impl Entity {
pub async fn nodeinfo(domain: &str) -> reqwest::Result<NodeInfoOwned> { pub async fn nodeinfo(domain: &str) -> reqwest::Result<NodeInfoOwned> {
match reqwest::get(format!("https://{domain}/nodeinfo/2.0.json")).await { match reqwest::get(format!("https://{domain}/nodeinfo/2.0.json")).await {
Ok(res) => { Ok(res) => res.json().await,
// oh my god gotosocial, the face of telling me that
// * ah-hoc interop is not needed because "AP is a protocol and the whole point is not working around edge cases"
// * you don't consider other AP instances fetching your nodeinfo "crawling"
// and then just treat us all like crawlers
// this isn't about user privacy, nodeinfo has that builtin with NULL. it JUST WORKS without workarounds
// this is about trolling crawlers
// you're trolling me too
// check below another example of "not working around edge cases"
// at least you gave me a way to not throw all gotosocial instances in the bin, so at least there's that
let noindex_nofollow: Vec<&str> = res.headers()
.get_all("X-Robots-Tag")
.iter()
.filter_map(|h| h.to_str().ok())
.filter(|h| *h == "noindex" || *h == "nofollow")
.collect();
let gotosocial_is_fucking_with_us = noindex_nofollow.contains(&"noindex") && noindex_nofollow.contains(&"nofollow");
let mut nodeinfo : NodeInfoOwned = res.json().await?;
if gotosocial_is_fucking_with_us {
nodeinfo.usage = nodeinfo::types::Usage {
users: None,
local_posts: None,
local_comments: None,
};
}
Ok(nodeinfo)
},
// ughhh pleroma wants with json, key without // ughhh pleroma wants with json, key without
Err(_) => reqwest::get(format!("https://{domain}/nodeinfo/2.0")) Err(_) => reqwest::get(format!("https://{domain}/nodeinfo/2.0.json"))
.await? .await?
.json() .json()
.await, .await,

View file

@ -24,12 +24,11 @@ pub struct RichHashtag {
} }
impl IntoActivityPub for RichHashtag { impl IntoActivityPub for RichHashtag {
fn into_activity_pub_json(self, ctx: &crate::Context) -> serde_json::Value { fn into_activity_pub_json(self, _ctx: &crate::Context) -> serde_json::Value {
use apb::LinkMut; use apb::LinkMut;
apb::new() apb::new()
.set_name(Some(format!("#{}", self.hash.name))) .set_name(Some(format!("#{}", self.hash.name)))
.set_link_type(Some(apb::LinkType::Hashtag)) .set_link_type(Some(apb::LinkType::Hashtag))
.set_href(Some(crate::url!(ctx, "/tags/{}", self.hash.name)))
} }
} }

View file

@ -38,25 +38,6 @@ impl Cloaker for crate::Context {
} }
fn cloaked(&self, url: &str) -> String { fn cloaked(&self, url: &str) -> String {
// pre-cloaked: uncloak without validating and re-cloak
let cloak_base = crate::url!(self, "/proxy/");
if url.starts_with(&cloak_base) {
if let Some((_sig, url_b64)) = url.replace(&cloak_base, "").split_once("/") {
if let Some(actual_url) = BASE64_URL_SAFE.decode(url_b64).ok()
.and_then(|x| std::str::from_utf8(&x).ok().map(|x| x.to_string()))
{
let (sig, url) = self.cloak(&actual_url);
return crate::url!(self, "/proxy/{sig}/{url}");
}
}
}
// local: don't cloak
if self.is_local(url) {
return url.to_string();
}
// everything else
let (sig, url) = self.cloak(url); let (sig, url) = self.cloak(url);
crate::url!(self, "/proxy/{sig}/{url}") crate::url!(self, "/proxy/{sig}/{url}")
} }
@ -66,6 +47,15 @@ impl Cloaker for crate::Context {
impl crate::Context { impl crate::Context {
pub fn sanitize(&self, text: &str) -> String { pub fn sanitize(&self, text: &str) -> String {
let _ctx = self.clone(); let _ctx = self.clone();
mdhtml::Sanitizer::new(Box::new(move |txt| _ctx.cloaked(txt))).html(text) mdhtml::Sanitizer::new(
Box::new(move |txt| {
if _ctx.is_local(txt) {
txt.to_string()
} else {
_ctx.cloaked(txt)
}
})
)
.html(text)
} }
} }

View file

@ -146,8 +146,8 @@ pub trait Fetcher {
let response = Self::client(domain) let response = Self::client(domain)
.request(method, url) .request(method, url)
.header(ACCEPT, apb::jsonld::CONTENT_TYPE_LD_JSON_ACTIVITYPUB) .header(ACCEPT, "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")
.header(CONTENT_TYPE, apb::jsonld::CONTENT_TYPE_LD_JSON_ACTIVITYPUB) .header(CONTENT_TYPE, "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")
.header("Host", host.clone()) .header("Host", host.clone())
.header("Date", date.clone()) .header("Date", date.clone())
.header("Digest", digest) .header("Digest", digest)

View file

@ -1,4 +1,4 @@
use apb::{Endpoints, Node, Object, PublicKey, Shortcuts}; use apb::{Document, Endpoints, Node, Object, PublicKey, Shortcuts};
use sea_orm::{sea_query::Expr, ActiveModelTrait, ActiveValue::{Unchanged, NotSet, Set}, ColumnTrait, ConnectionTrait, DbErr, EntityTrait, IntoActiveModel, QueryFilter}; use sea_orm::{sea_query::Expr, ActiveModelTrait, ActiveValue::{Unchanged, NotSet, Set}, ColumnTrait, ConnectionTrait, DbErr, EntityTrait, IntoActiveModel, QueryFilter};
use super::{Cloaker, Fetcher}; use super::{Cloaker, Fetcher};
@ -88,43 +88,20 @@ impl Normalizer for crate::Context {
if u == obj_image { continue }; if u == obj_image { continue };
model.url = Set(self.cloaked(&u)); model.url = Set(self.cloaked(&u));
} }
// TODO this is the third time we do this check... can we somehow centralize it?
if self.cfg().compat.fix_attachment_media_type && model.document_type == Set(apb::DocumentType::Document) {
let media_type = model.media_type.clone().take().unwrap_or_default();
let (mime_kind, _mime_type) = media_type.split_once('/').unwrap_or_default();
model.document_type = Set(match mime_kind {
"image" => apb::DocumentType::Image,
"video" => apb::DocumentType::Video,
"audio" => apb::DocumentType::Audio,
"text" => apb::DocumentType::Page,
_ => apb::DocumentType::Document,
});
}
model model
}, },
Node::Link(l) => { Node::Link(l) => {
let url = l.href().unwrap_or_default(); let url = l.href().unwrap_or_default();
if url == obj_image { continue }; if url == obj_image { continue };
let mut media_type = l.media_type().unwrap_or("link".to_string());
let mut media_type = l.media_type().unwrap_or("text/html".to_string()); let mut document_type = apb::DocumentType::Page;
let (mime_kind, _mime_type) = media_type.split_once('/').unwrap_or_default();
let mut document_type = match mime_kind {
"image" => apb::DocumentType::Image,
"video" => apb::DocumentType::Video,
"audio" => apb::DocumentType::Audio,
"text" => apb::DocumentType::Page,
_ => apb::DocumentType::Document,
};
// in case we get both broken media_type and document_type, try to fix images with url
// TODO is this still needed? above case with mediaType should solve most issues
let mut is_image = false; let mut is_image = false;
if [".jpg", ".jpeg", ".png", ".webp", ".bmp"] // TODO more image types??? if [".jpg", ".jpeg", ".png", ".webp", ".bmp"] // TODO more image types???
.iter() .iter()
.any(|x| url.ends_with(x)) .any(|x| url.ends_with(x))
{ {
is_image = true; is_image = true;
if self.cfg().compat.fix_attachment_media_type { if self.cfg().compat.fix_attachment_images_media_type {
document_type = apb::DocumentType::Image; document_type = apb::DocumentType::Image;
media_type = format!("image/{}", url.split('.').last().unwrap_or_default()); media_type = format!("image/{}", url.split('.').last().unwrap_or_default());
} }
@ -291,27 +268,17 @@ impl AP {
pub fn attachment(document: &impl apb::Document, parent: i64) -> Result<crate::model::attachment::Model, NormalizerError> { pub fn attachment(document: &impl apb::Document, parent: i64) -> Result<crate::model::attachment::Model, NormalizerError> {
let base_type = document.base_type()?; let t = document.base_type()?;
if !matches!(base_type, apb::BaseType::Object(apb::ObjectType::Document(_))) { if !matches!(t, apb::BaseType::Object(apb::ObjectType::Document(_))) {
return Err(NormalizerError::WrongType(apb::BaseType::Object(apb::ObjectType::Document(apb::DocumentType::Document)), base_type)); return Err(NormalizerError::WrongType(apb::BaseType::Object(apb::ObjectType::Document(apb::DocumentType::Document)), t));
} }
let media_type = document.media_type().unwrap_or("text/html".to_string());
let (mime_kind, _mime_type) = media_type.split_once('/').unwrap_or_default();
let document_type = document.document_type().unwrap_or(match mime_kind {
"image" => apb::DocumentType::Image,
"video" => apb::DocumentType::Video,
"audio" => apb::DocumentType::Audio,
"text" => apb::DocumentType::Page,
_ => apb::DocumentType::Document,
});
Ok(crate::model::attachment::Model { Ok(crate::model::attachment::Model {
internal: 0, internal: 0,
url: document.url().id().unwrap_or_default(), url: document.url().id().unwrap_or_default(),
object: parent, object: parent,
document_type: document.as_document().map_or(apb::DocumentType::Document, |x| x.document_type().unwrap_or(apb::DocumentType::Page)),
name: document.name().ok(), name: document.name().ok(),
media_type, document_type, media_type: document.media_type().unwrap_or("link".to_string()),
}) })
} }

View file

@ -1,4 +1,4 @@
use apb::{target::Addressed, Actor, Base, Object}; use apb::{target::Addressed, Activity, Actor, Base, Object};
use sea_orm::{sea_query::Expr, ActiveModelTrait, ActiveValue::{NotSet, Set}, ColumnTrait, Condition, DatabaseTransaction, EntityTrait, QueryFilter, QuerySelect, SelectColumns}; use sea_orm::{sea_query::Expr, ActiveModelTrait, ActiveValue::{NotSet, Set}, ColumnTrait, Condition, DatabaseTransaction, EntityTrait, QueryFilter, QuerySelect, SelectColumns};
use crate::{ext::{AnyQuery, LoggableError}, model, traits::{fetch::Pull, Addresser, Cloaker, Fetcher, Normalizer}}; use crate::{ext::{AnyQuery, LoggableError}, model, traits::{fetch::Pull, Addresser, Cloaker, Fetcher, Normalizer}};
@ -122,7 +122,7 @@ pub async fn process_like(ctx: &crate::Context, activity: impl apb::Activity, tx
// force set the like-receiver as a `to` target, so that they can see this like regardless // force set the like-receiver as a `to` target, so that they can see this like regardless
// of the empty addressing. as side effect, we know when we get "upvoted" from lemmy, // of the empty addressing. as side effect, we know when we get "upvoted" from lemmy,
// which instead would expect likes to be anonymous. oh well... // which instead would expect likes to be anonymous. oh well...
if ctx.cfg().compat.add_explicit_target_to_likes_if_local && likes_local_object { if likes_local_object {
activity_model.to.0.push(obj.attributed_to.clone().unwrap_or_default()); activity_model.to.0.push(obj.attributed_to.clone().unwrap_or_default());
} }
ctx.address(Some(&activity_model), None, tx).await?; ctx.address(Some(&activity_model), None, tx).await?;
@ -434,26 +434,21 @@ pub async fn process_update(ctx: &crate::Context, activity: impl apb::Activity,
pub async fn process_undo(ctx: &crate::Context, activity: impl apb::Activity, tx: &DatabaseTransaction) -> Result<(), ProcessorError> { pub async fn process_undo(ctx: &crate::Context, activity: impl apb::Activity, tx: &DatabaseTransaction) -> Result<(), ProcessorError> {
// TODO in theory we could work with just object_id but right now only accept embedded // TODO in theory we could work with just object_id but right now only accept embedded
let undone_activity_id = activity.object().id()?; let undone_activity = activity.object().into_inner()?;
let uid = activity.actor().id()?.to_string(); let uid = activity.actor().id()?.to_string();
let internal_uid = crate::model::actor::Entity::ap_to_internal(&uid, tx) let internal_uid = crate::model::actor::Entity::ap_to_internal(&uid, tx)
.await? .await?
.ok_or(ProcessorError::Incomplete)?; .ok_or(ProcessorError::Incomplete)?;
let undone_activity = crate::model::activity::Entity::find_by_ap_id(&undone_activity_id) if uid != undone_activity.as_activity()?.actor().id()? {
.one(tx)
.await?
.ok_or(ProcessorError::Incomplete)?;
if uid != undone_activity.actor {
return Err(ProcessorError::Unauthorized); return Err(ProcessorError::Unauthorized);
} }
match undone_activity.activity_type { match undone_activity.as_activity()?.activity_type()? {
apb::ActivityType::Like => { apb::ActivityType::Like => {
let internal_oid = crate::model::object::Entity::ap_to_internal( let internal_oid = crate::model::object::Entity::ap_to_internal(
&undone_activity.object.ok_or(apb::FieldErr("object"))?, &undone_activity.as_activity()?.object().id()?,
tx tx
) )
.await? .await?
@ -466,45 +461,15 @@ pub async fn process_undo(ctx: &crate::Context, activity: impl apb::Activity, tx
) )
.exec(tx) .exec(tx)
.await?; .await?;
crate::model::activity::Entity::delete_many()
.filter(crate::model::activity::Column::Id.eq(undone_activity_id))
.exec(tx)
.await?;
crate::model::object::Entity::update_many() crate::model::object::Entity::update_many()
.filter(crate::model::object::Column::Internal.eq(internal_oid)) .filter(crate::model::object::Column::Internal.eq(internal_oid))
.col_expr(crate::model::object::Column::Likes, Expr::col(crate::model::object::Column::Likes).sub(1)) .col_expr(crate::model::object::Column::Likes, Expr::col(crate::model::object::Column::Likes).sub(1))
.exec(tx) .exec(tx)
.await?; .await?;
}, },
apb::ActivityType::Announce => {
let internal_oid = crate::model::object::Entity::ap_to_internal(
&undone_activity.object.ok_or(apb::FieldErr("object"))?,
tx
)
.await?
.ok_or(ProcessorError::Incomplete)?;
crate::model::announce::Entity::delete_many()
.filter(
Condition::all()
.add(crate::model::announce::Column::Actor.eq(internal_uid))
.add(crate::model::announce::Column::Object.eq(internal_oid))
.add(crate::model::announce::Column::Activity.eq(undone_activity.internal))
)
.exec(tx)
.await?;
crate::model::activity::Entity::delete_many()
.filter(crate::model::activity::Column::Id.eq(undone_activity_id))
.exec(tx)
.await?;
crate::model::object::Entity::update_many()
.filter(crate::model::object::Column::Internal.eq(internal_oid))
.col_expr(crate::model::object::Column::Announces, Expr::col(crate::model::object::Column::Announces).sub(1))
.exec(tx)
.await?;
},
apb::ActivityType::Follow => { apb::ActivityType::Follow => {
let internal_uid_following = crate::model::actor::Entity::ap_to_internal( let internal_uid_following = crate::model::actor::Entity::ap_to_internal(
&undone_activity.object.ok_or(apb::FieldErr("object"))?, &undone_activity.as_activity()?.object().id()?,
tx, tx,
) )
.await? .await?
@ -548,10 +513,12 @@ pub async fn process_undo(ctx: &crate::Context, activity: impl apb::Activity, tx
ctx.address(Some(&activity_model), None, tx).await?; ctx.address(Some(&activity_model), None, tx).await?;
} }
crate::model::notification::Entity::delete_many() if let Some(internal) = crate::model::activity::Entity::ap_to_internal(&undone_activity.id()?, tx).await? {
.filter(crate::model::notification::Column::Activity.eq(undone_activity.internal)) crate::model::notification::Entity::delete_many()
.exec(tx) .filter(crate::model::notification::Column::Activity.eq(internal))
.await?; .exec(tx)
.await?;
}
Ok(()) Ok(())
} }

12
main.rs
View file

@ -20,7 +20,7 @@ use upub_worker as worker;
#[derive(Parser)] #[derive(Parser)]
/// micro social network, federated /// all names were taken
struct Args { struct Args {
#[clap(subcommand)] #[clap(subcommand)]
/// command to run /// command to run
@ -157,19 +157,14 @@ async fn init(args: Args, config: upub::Config) {
.await.expect("error connecting to db"); .await.expect("error connecting to db");
#[cfg(feature = "migrate")] #[cfg(feature = "migrate")]
if matches!(args.command, Mode::Migrate | Mode::Monolith { bind: _, tasks: _, poll: _ }) { if matches!(args.command, Mode::Migrate) {
// note that, if running in monolith mode, we want to apply migrations before starting, as a
// convenience for quickly spinning up new test instances and to prevent new server admins from
// breaking stuff by forgetting to migrate
use migrations::MigratorTrait; use migrations::MigratorTrait;
migrations::Migrator::up(&db, None) migrations::Migrator::up(&db, None)
.await .await
.expect("error applying migrations"); .expect("error applying migrations");
if matches!(args.command, Mode::Migrate) { return;
return; // if mode == 'migrate', we're done! otherwise keep going
}
} }
let (tx_wake, rx_wake) = tokio::sync::mpsc::unbounded_channel(); let (tx_wake, rx_wake) = tokio::sync::mpsc::unbounded_channel();
@ -186,6 +181,7 @@ async fn init(args: Args, config: upub::Config) {
return; return;
} }
// register signal handler only for long-lasting modes, such as server or worker // register signal handler only for long-lasting modes, such as server or worker
let (tx, rx) = tokio::sync::watch::channel(false); let (tx, rx) = tokio::sync::watch::channel(false);
let signals = Signals::new([SIGTERM, SIGINT]).expect("failed registering signal handler"); let signals = Signals::new([SIGTERM, SIGINT]).expect("failed registering signal handler");

View file

@ -1,3 +1 @@
# upub migrations # upub migrations
database migrations for `upub`

View file

@ -1,6 +1,6 @@
[package] [package]
name = "upub-routes" name = "upub-routes"
version = "0.4.1" version = "0.3.0"
edition = "2021" edition = "2021"
authors = [ "alemi <me@alemi.dev>" ] authors = [ "alemi <me@alemi.dev>" ]
description = "api route definitions for upub" description = "api route definitions for upub"
@ -12,7 +12,7 @@ readme = "README.md"
[dependencies] [dependencies]
thiserror = "2.0" thiserror = "2.0"
rand = "0.9" rand = "0.8"
sha256 = "1.5" # TODO ughhh sha256 = "1.5" # TODO ughhh
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@ -22,9 +22,8 @@ jrd = "0.1"
tracing = "0.1" tracing = "0.1"
tokio = "1.43" tokio = "1.43"
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
axum = { version = "0.8", features = ["multipart"] } axum = { version = "0.7", features = ["multipart"] }
tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace"] }
tower-http = { version = "0.6", features = ["cors", "trace", "timeout"] }
httpsign = { path = "../utils/httpsign/", features = ["axum"] } httpsign = { path = "../utils/httpsign/", features = ["axum"] }
apb = { path = "../apb", features = ["unstructured", "orm", "activitypub-fe", "activitypub-counters", "litepub", "ostatus", "toot", "jsonld"] } apb = { path = "../apb", features = ["unstructured", "orm", "activitypub-fe", "activitypub-counters", "litepub", "ostatus", "toot", "jsonld"] }
sea-orm = "1.1" sea-orm = "1.1"
@ -34,20 +33,12 @@ nodeinfo = { git = "https://codeberg.org/thefederationinfo/nodeinfo-rs", rev = "
mastodon-async-entities = { version = "1.1.0", optional = true } mastodon-async-entities = { version = "1.1.0", optional = true }
time = { version = "0.3", features = ["serde"], optional = true } time = { version = "0.3", features = ["serde"], optional = true }
# frontend # frontend
leptos = { version = "0.7", optional = true }
leptos_router = { version = "0.7", optional = true }
leptos_axum = { version = "0.7", optional = true } leptos_axum = { version = "0.7", optional = true }
leptos_meta = { version = "0.7", optional = true } leptos_config = { version = "0.7", optional = true }
upub-web = { path = "../web", default-features = false, optional = true }
[features] [features]
default = ["activitypub"] default = ["activitypub"]
activitypub = [] activitypub = []
mastodon = ["dep:mastodon-async-entities"] mastodon = ["dep:mastodon-async-entities"]
web = [ web = ["dep:leptos_axum", "dep:leptos_config", "dep:upub-web", "upub-web?/leptos-ssr", "upub/web"]
"dep:leptos",
"dep:leptos_router",
"dep:leptos_axum",
"dep:leptos_meta"
]
web-redirect = []
activitypub-redirect = []

View file

@ -1,3 +1 @@
# upub routes # upub routes
all web routes for `upub`: API, web and activitypub

View file

@ -1,89 +0,0 @@
fn main() {
println!("cargo::rerun-if-changed=../web/dist");
#[cfg(feature = "web")]
{
println!("cargo::warning=searching frontend files in $WORKSPACE_ROOT/web/dist");
let Ok(dist) = std::fs::read_dir(std::path::Path::new("../web/dist")) else {
println!("cargo::error=could not find 'web/dist' dir: did you 'trunk build' the frontend crate?");
return;
};
let mut found_wasm = false;
let mut found_js = false;
let mut found_index = false;
let mut found_style = false;
let mut found_favicon = false;
let mut found_icon = false;
let mut found_manifest = false;
for f in dist.flatten() {
if let Ok(ftype) = f.file_type() {
if ftype.is_file() {
let fname = f.file_name().to_string_lossy().to_string();
if !found_wasm {
found_wasm = if_matches_set_env_path("CARGO_UPUB_FRONTEND_WASM", &f, &fname, "upub-web", ".wasm");
}
if !found_js {
found_js = if_matches_set_env_path("CARGO_UPUB_FRONTEND_JS", &f, &fname, "upub-web", ".js");
}
if !found_style {
found_style = if_matches_set_env_path("CARGO_UPUB_FRONTEND_STYLE", &f, &fname, "style", ".css");
}
if !found_index {
found_index = if_matches_set_env_path("CARGO_UPUB_FRONTEND_INDEX", &f, &fname, "index", ".html");
}
if !found_favicon {
found_favicon = if_matches_set_env_path("CARGO_UPUB_FRONTEND_FAVICON", &f, &fname, "favicon", ".ico")
}
if !found_icon {
found_icon = if_matches_set_env_path("CARGO_UPUB_FRONTEND_PWA_ICON", &f, &fname, "icon", ".png")
}
if !found_manifest {
found_manifest = if_matches_set_env_path("CARGO_UPUB_FRONTEND_PWA_MANIFEST", &f, &fname, "manifest", ".json")
}
}
}
}
if !found_wasm {
println!("cargo::error=could not find wasm payload");
}
if !found_js {
println!("cargo::error=could not find js bindings");
}
if !found_style {
println!("cargo::error=could not find style sheet");
}
if !found_favicon {
println!("cargo::error=could not find favicon image");
}
if !found_icon {
println!("cargo::error=could not find pwa icon image");
}
if !found_manifest {
println!("cargo::error=could not find pwa manifest");
}
if !found_index {
println!("cargo::error=could not find html index");
}
}
}
#[cfg(feature = "web")]
fn if_matches_set_env_path(var: &str, f: &std::fs::DirEntry, fname: &str, first: &str, last: &str) -> bool {
if fname.starts_with(first) && fname.ends_with(last) {
match f.path().canonicalize() {
Ok(path) => println!("cargo::rustc-env={var}={}", path.to_string_lossy()),
Err(e) => println!("cargo::warning=could not canonicalize '{}': {e}", f.path().to_string_lossy()),
}
true
} else {
false
}
}

View file

@ -75,8 +75,8 @@ pub async fn view(
} }
// TODO this is known "magically" !! very tight coupling ouchhh // TODO this is known "magically" !! very tight coupling ouchhh
if !ctx.cfg().instance.frontend.is_empty() { if let Some(ref fe) = ctx.cfg().instance.frontend {
user = user.set_url(Node::link(format!("{}/actors/{id}", ctx.cfg().instance.frontend))); user = user.set_url(Node::link(format!("{fe}/actors/{id}")));
} }
Ok(JsonLD(user.ld_context())) Ok(JsonLD(user.ld_context()))

View file

@ -1,5 +1,5 @@
use apb::{LD, ActorMut, BaseMut, ObjectMut, PublicKeyMut}; use apb::{LD, ActorMut, BaseMut, ObjectMut, PublicKeyMut};
use axum::{extract::{Path, Query, State}, response::{IntoResponse, Response}}; use axum::{extract::{Path, Query, State}, http::HeaderMap, response::{IntoResponse, Redirect, Response}};
use reqwest::Method; use reqwest::Method;
use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; use sea_orm::{ColumnTrait, Condition, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use upub::{selector::{RichFillable, RichObject}, traits::{Cloaker, Fetcher}, Context}; use upub::{selector::{RichFillable, RichObject}, traits::{Cloaker, Fetcher}, Context};
@ -9,7 +9,17 @@ use crate::{builders::JsonLD, ApiError, AuthIdentity};
use super::{PaginatedSearch, Pagination}; use super::{PaginatedSearch, Pagination};
pub async fn view(State(ctx): State<Context>) -> crate::ApiResult<Response> { pub async fn view(
headers: HeaderMap,
State(ctx): State<Context>,
) -> crate::ApiResult<Response> {
if let Some(accept) = headers.get("Accept") {
if let Ok(accept) = accept.to_str() {
if accept.contains("text/html") && !accept.contains("application/ld+json") {
return Ok(Redirect::to("/web").into_response());
}
}
}
Ok(JsonLD( Ok(JsonLD(
apb::new() apb::new()
.set_id(Some(upub::url!(ctx, ""))) .set_id(Some(upub::url!(ctx, "")))
@ -48,13 +58,13 @@ pub async fn search(
// TODO lmao rethink this all // TODO lmao rethink this all
// still haven't redone this gg me // still haven't redone this gg me
// have redone it but didnt rethink it properly so we're stuck with this bahahaha // have redone it but didnt rethink it properly so we're stuck with this bahahaha
let p = Pagination { let page = Pagination {
offset: page.offset, offset: page.offset,
batch: page.batch, batch: page.batch,
replies: Some(true), replies: Some(true),
}; };
let (limit, offset) = p.pagination(); let (limit, offset) = page.pagination();
let items = upub::Query::feed(auth.my_id(), true) let items = upub::Query::feed(auth.my_id(), true)
.filter(filter) .filter(filter)
.limit(limit) .limit(limit)
@ -70,7 +80,7 @@ pub async fn search(
.map(|item| ctx.ap(item)) .map(|item| ctx.ap(item))
.collect(); .collect();
crate::builders::collection_page(&upub::url!(ctx, "/search?q={}", page.q), p, apb::Node::array(items)) crate::builders::collection_page(&upub::url!(ctx, "/search"), page, apb::Node::array(items))
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]

View file

@ -19,8 +19,8 @@ pub struct AuthSuccess {
fn token() -> String { fn token() -> String {
// TODO should probably use crypto-safe rng // TODO should probably use crypto-safe rng
rand::rng() rand::thread_rng()
.sample_iter(&rand::distr::Alphanumeric) .sample_iter(&rand::distributions::Alphanumeric)
.take(128) .take(128)
.map(char::from) .map(char::from)
.collect() .collect()

View file

@ -1,7 +1,7 @@
use apb::{Activity, ActivityType, Base}; use apb::{Activity, ActivityType, Base};
use axum::{extract::{Query, State}, http::StatusCode, Json}; use axum::{extract::{Query, State}, http::StatusCode, Json};
use sea_orm::{sea_query::IntoCondition, ActiveValue::{NotSet, Set}, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; use sea_orm::{sea_query::IntoCondition, ActiveValue::{NotSet, Set}, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use upub::{model::job::JobType, selector::{RichActivity, RichFillable}, traits::Fetcher, Context}; use upub::{model::job::JobType, selector::{RichActivity, RichFillable}, Context};
use crate::{AuthIdentity, Identity, builders::JsonLD}; use crate::{AuthIdentity, Identity, builders::JsonLD};
@ -41,7 +41,7 @@ pub async fn page(
pub async fn post( pub async fn post(
State(ctx): State<Context>, State(ctx): State<Context>,
AuthIdentity(auth): AuthIdentity, AuthIdentity(auth): AuthIdentity,
Json(mut activity): Json<serde_json::Value> Json(activity): Json<serde_json::Value>
) -> crate::ApiResult<StatusCode> { ) -> crate::ApiResult<StatusCode> {
let Identity::Remote { domain, user: uid, .. } = auth else { let Identity::Remote { domain, user: uid, .. } = auth else {
if matches!(activity.activity_type(), Ok(ActivityType::Delete)) { if matches!(activity.activity_type(), Ok(ActivityType::Delete)) {
@ -72,11 +72,7 @@ pub async fn post(
let server = upub::Context::server(&aid); let server = upub::Context::server(&aid);
if activity.actor().id()? != uid { if activity.actor().id()? != uid {
if ctx.cfg().compat.verify_relayed_activities_by_fetching { return Err(crate::ApiError::forbidden());
activity = ctx.pull(&activity.id()?).await?.activity()?;
} else {
return Err(crate::ApiError::forbidden());
}
} }
if let Some(_internal) = upub::model::activity::Entity::ap_to_internal(&aid, ctx.db()).await? { if let Some(_internal) = upub::model::activity::Entity::ap_to_internal(&aid, ctx.db()).await? {

View file

@ -12,100 +12,77 @@ pub mod well_known;
use axum::{http::StatusCode, response::IntoResponse, routing::{get, patch, post, put}, Router}; use axum::{http::StatusCode, response::IntoResponse, routing::{get, patch, post, put}, Router};
pub fn ap_routes(ctx: upub::Context) -> Router { impl super::ActivityPubRouter for Router<upub::Context> {
use crate::activitypub as ap; // TODO use self ? fn ap_routes(self) -> Self {
use crate::activitypub as ap; // TODO use self ?
Router::new()
.route("/", get(ap::application::view)) self
.route("/search", get(ap::application::search)) // core server inbox/outbox, maybe for feeds? TODO do we need these?
.route("/fetch", get(ap::application::ap_fetch)) .route("/", get(ap::application::view))
.route("/proxy/{hmac}/{uri}", get(ap::application::cloak_proxy)) // fetch route, to debug and retreive remote objects
.route("/inbox", post(ap::inbox::post)) .route("/search", get(ap::application::search))
.route("/inbox", get(ap::inbox::get)) .route("/fetch", get(ap::application::ap_fetch))
.route("/inbox/page", get(ap::inbox::page)) .route("/proxy/:hmac/:uri", get(ap::application::cloak_proxy))
.route("/outbox", post(ap::outbox::post)) .route("/inbox", post(ap::inbox::post))
.route("/outbox", get(ap::outbox::get)) .route("/inbox", get(ap::inbox::get))
.route("/outbox/page", get(ap::outbox::page)) .route("/inbox/page", get(ap::inbox::page))
.route("/auth", put(ap::auth::register)) .route("/outbox", post(ap::outbox::post))
.route("/auth", post(ap::auth::login)) .route("/outbox", get(ap::outbox::get))
.route("/auth", patch(ap::auth::refresh)) .route("/outbox/page", get(ap::outbox::page))
.nest("/.well-known", Router::new() // AUTH routes
.route("/webfinger", get(ap::well_known::webfinger)) .route("/auth", put(ap::auth::register))
.route("/host-meta", get(ap::well_known::host_meta)) .route("/auth", post(ap::auth::login))
.route("/nodeinfo", get(ap::well_known::nodeinfo_discovery)) .route("/auth", patch(ap::auth::refresh))
.route("/oauth-authorization-server", get(ap::well_known::oauth_authorization_server)) // .well-known and discovery
) .route("/manifest.json", get(ap::well_known::manifest))
.route("/manifest.json", get(ap::well_known::manifest)) .route("/.well-known/webfinger", get(ap::well_known::webfinger))
.route("/nodeinfo/{version}", get(ap::well_known::nodeinfo)) .route("/.well-known/host-meta", get(ap::well_known::host_meta))
.route("/groups", get(ap::groups::get)) .route("/.well-known/nodeinfo", get(ap::well_known::nodeinfo_discovery))
.route("/groups/page", get(ap::groups::page)) .route("/.well-known/oauth-authorization-server", get(ap::well_known::oauth_authorization_server))
.nest("/actors/{id}", Router::new() .route("/nodeinfo/:version", get(ap::well_known::nodeinfo))
.route("/", get(ap::actor::view)) // actor routes
.route("/inbox", post(ap::actor::inbox::post)) .route("/actors/:id", get(ap::actor::view))
.route("/inbox", get(ap::actor::inbox::get)) .route("/actors/:id/inbox", post(ap::actor::inbox::post))
.route("/inbox/page", get(ap::actor::inbox::page)) .route("/actors/:id/inbox", get(ap::actor::inbox::get))
.route("/outbox", post(ap::actor::outbox::post)) .route("/actors/:id/inbox/page", get(ap::actor::inbox::page))
.route("/outbox", get(ap::actor::outbox::get)) .route("/actors/:id/outbox", post(ap::actor::outbox::post))
.route("/outbox/page", get(ap::actor::outbox::page)) .route("/actors/:id/outbox", get(ap::actor::outbox::get))
.route("/notifications", get(ap::actor::notifications::get)) .route("/actors/:id/outbox/page", get(ap::actor::outbox::page))
.route("/notifications/page", get(ap::actor::notifications::page)) .route("/actors/:id/notifications", get(ap::actor::notifications::get))
.route("/followers", get(ap::actor::following::get::<false>)) .route("/actors/:id/notifications/page", get(ap::actor::notifications::page))
.route("/followers/page", get(ap::actor::following::page::<false>)) .route("/actors/:id/followers", get(ap::actor::following::get::<false>))
.route("/following", get(ap::actor::following::get::<true>)) .route("/actors/:id/followers/page", get(ap::actor::following::page::<false>))
.route("/following/page", get(ap::actor::following::page::<true>)) .route("/actors/:id/following", get(ap::actor::following::get::<true>))
// .route("/audience", get(ap::actor::audience::get)) .route("/actors/:id/following/page", get(ap::actor::following::page::<true>))
// .route("/audience/page", get(ap::actor::audience::page)) .route("/actors/:id/likes", get(ap::actor::likes::get))
.route("/likes", get(ap::actor::likes::get)) .route("/actors/:id/likes/page", get(ap::actor::likes::page))
.route("/likes/page", get(ap::actor::likes::page)) .route("/groups", get(ap::groups::get))
) .route("/groups/page", get(ap::groups::page))
.route("/activities/{id}", get(ap::activity::view)) // .route("/actors/:id/audience", get(ap::actor::audience::get))
.nest("/objects/{id}", Router::new() // .route("/actors/:id/audience/page", get(ap::actor::audience::page))
.route("/", get(ap::object::view)) // activities
.route("/replies", get(ap::object::replies::get)) .route("/activities/:id", get(ap::activity::view))
.route("/replies/page", get(ap::object::replies::page)) // hashtags
.route("/context", get(ap::object::context::get)) .route("/tags/:id", get(ap::tags::get))
.route("/context/page", get(ap::object::context::page)) .route("/tags/:id/page", get(ap::tags::page))
.route("/likes", get(ap::object::likes::get)) // specific object routes
.route("/likes/page", get(ap::object::likes::page)) .route("/objects/:id", get(ap::object::view))
.route("/shares", get(ap::object::shares::get)) .route("/objects/:id/replies", get(ap::object::replies::get))
.route("/shares/page", get(ap::object::shares::page)) .route("/objects/:id/replies/page", get(ap::object::replies::page))
) .route("/objects/:id/context", get(ap::object::context::get))
.route("/tags/{id}", get(ap::tags::get)) .route("/objects/:id/context/page", get(ap::object::context::page))
.route("/tags/{id}/page", get(ap::tags::page)) .route("/objects/:id/likes", get(ap::object::likes::get))
.route("/file", post(ap::file::upload)) .route("/objects/:id/likes/page", get(ap::object::likes::page))
.route("/file/{id}", get(ap::file::download)) .route("/objects/:id/shares", get(ap::object::shares::get))
.route_layer(axum::middleware::from_fn(redirect_to_web)) .route("/objects/:id/shares/page", get(ap::object::shares::page))
.with_state(ctx) // file routes
} .route("/file", post(ap::file::upload))
.route("/file/:id", get(ap::file::download))
async fn redirect_to_web( //.route("/objects/:id/likes", get(ap::object::likes::get))
request: axum::extract::Request, //.route("/objects/:id/likes/page", get(ap::object::likes::page))
next: axum::middleware::Next, //.route("/objects/:id/shares", get(ap::object::announces::get))
) -> axum::response::Response { //.route("/objects/:id/shares/page", get(ap::object::announces::page))
#[cfg(any(feature = "web", feature = "web-redirect"))]
{
let (accepts_activity_pub, accepts_html) = crate::builders::accepts_activitypub_html(request.headers());
if !accepts_activity_pub && accepts_html {
let uri = request.uri().clone();
let path_and_query = uri.path_and_query().map(|x| x.as_str()).unwrap_or_default();
if path_and_query == "/"
|| path_and_query.starts_with("/objects")
|| path_and_query.starts_with("/tags")
|| path_and_query.starts_with("/actors")
{
let new_uri = format!(
"{}{}/web{}",
uri.scheme().map(|x| format!("{}://", x.as_str())).unwrap_or_default(),
uri.authority().map(|x| x.as_str()).unwrap_or_default(),
path_and_query,
);
return axum::response::Redirect::temporary(&new_uri).into_response();
}
}
} }
next.run(request).await
} }
#[derive(Debug, serde::Deserialize)] #[derive(Debug, serde::Deserialize)]

View file

@ -1,10 +1,10 @@
use std::sync::atomic::AtomicI64;
use axum::{extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, Json}; use axum::{extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, Json};
use jrd::{JsonResourceDescriptor, JsonResourceDescriptorLink}; use jrd::{JsonResourceDescriptor, JsonResourceDescriptorLink};
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, SelectColumns}; use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};
use upub::{model, Context}; use upub::{model, Context};
use crate::ApiError;
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct NodeInfoDiscovery { pub struct NodeInfoDiscovery {
pub links: Vec<NodeInfoDiscoveryRel>, pub links: Vec<NodeInfoDiscoveryRel>,
@ -33,81 +33,12 @@ pub async fn nodeinfo_discovery(State(ctx): State<Context>) -> Json<NodeInfoDisc
// TODO either vendor or fork nodeinfo-rs because it still represents "repository" and "homepage" // TODO either vendor or fork nodeinfo-rs because it still represents "repository" and "homepage"
// even if None! technically leads to invalid nodeinfo 2.0 // even if None! technically leads to invalid nodeinfo 2.0
pub async fn nodeinfo(State(ctx): State<Context>, Path(version): Path<String>) -> crate::ApiResult<Json<nodeinfo::NodeInfoOwned>> { pub async fn nodeinfo(State(ctx): State<Context>, Path(version): Path<String>) -> Result<Json<nodeinfo::NodeInfoOwned>, StatusCode> {
// keep these as statics so they get calculated once and then stay cached // TODO it's unsustainable to count these every time, especially comments since it's a complex
// TODO this will cache them just once per runtime, maybe re-calculate them after some time? // filter! keep these numbers caches somewhere, maybe db, so that we can just look them up
static TOTAL_USERS: AtomicI64 = AtomicI64::new(i64::MIN); let total_users = model::actor::Entity::find().count(ctx.db()).await.ok();
static TOTAL_POSTS: AtomicI64 = AtomicI64::new(i64::MIN); let total_posts = None;
static TOTAL_COMMENTS: AtomicI64 = AtomicI64::new(i64::MIN); let total_comments = None;
static TOTAL_ACTIVE_USERS_MONTH: AtomicI64 = AtomicI64::new(i64::MIN);
static TOTAL_ACTIVE_USERS_HALFYEAR: AtomicI64 = AtomicI64::new(i64::MIN);
// TODO because we need to get the actual numbers with async operations we can't use OnceLocks...
// can we make the following lines way more compact?? this is hell to maintain
let mut total_users = TOTAL_USERS.load(std::sync::atomic::Ordering::Relaxed);
if total_users == i64::MIN {
let actual_total_users = model::actor::Entity::find()
.filter(model::actor::Column::Domain.eq(ctx.domain()))
.count(ctx.db())
.await? as i64; // TODO safe cast
TOTAL_USERS.store(actual_total_users, std::sync::atomic::Ordering::Relaxed);
total_users = actual_total_users;
}
let mut total_posts = TOTAL_POSTS.load(std::sync::atomic::Ordering::Relaxed);
if total_posts == i64::MIN {
let actual_total_posts = model::object::Entity::find()
.inner_join(model::actor::Entity)
.filter(model::actor::Column::Domain.eq(ctx.domain()))
.filter(model::object::Column::InReplyTo.is_null())
.count(ctx.db())
.await? as i64; // TODO safe cast
TOTAL_POSTS.store(actual_total_posts, std::sync::atomic::Ordering::Relaxed);
total_posts = actual_total_posts;
}
let mut total_comments = TOTAL_COMMENTS.load(std::sync::atomic::Ordering::Relaxed);
if total_comments == i64::MIN {
let actual_total_comments = model::object::Entity::find()
.inner_join(model::actor::Entity)
.filter(model::actor::Column::Domain.eq(ctx.domain()))
.filter(model::object::Column::InReplyTo.is_not_null())
.count(ctx.db())
.await? as i64; // TODO safe cast
TOTAL_COMMENTS.store(actual_total_comments, std::sync::atomic::Ordering::Relaxed);
total_comments = actual_total_comments;
}
let mut total_active_users_month = TOTAL_ACTIVE_USERS_MONTH.load(std::sync::atomic::Ordering::Relaxed);
if total_active_users_month == i64::MIN {
let actual_total_active_users_month = model::actor::Entity::find()
.distinct()
.inner_join(model::object::Entity)
.select_only()
.select_column(model::actor::Column::Id)
.filter(model::actor::Column::Domain.eq(ctx.domain()))
.filter(model::object::Column::Published.gte(chrono::Utc::now() - std::time::Duration::from_secs(60 * 60 * 24 * 30)))
.count(ctx.db())
.await? as i64; // TODO safe cast
TOTAL_ACTIVE_USERS_MONTH.store(actual_total_active_users_month, std::sync::atomic::Ordering::Relaxed);
total_active_users_month = actual_total_active_users_month;
}
let mut total_active_users_halfyear = TOTAL_ACTIVE_USERS_HALFYEAR.load(std::sync::atomic::Ordering::Relaxed);
if total_active_users_halfyear == i64::MIN {
let actual_total_active_users_halfyear = model::actor::Entity::find()
.distinct()
.inner_join(model::object::Entity)
.select_only()
.select_column(model::actor::Column::Id)
.filter(model::actor::Column::Domain.eq(ctx.domain()))
.filter(model::object::Column::Published.gte(chrono::Utc::now() - std::time::Duration::from_secs(60 * 60 * 24 * 30 * 6)))
.count(ctx.db())
.await? as i64; // TODO safe cast
TOTAL_ACTIVE_USERS_HALFYEAR.store(actual_total_active_users_halfyear, std::sync::atomic::Ordering::Relaxed);
total_active_users_halfyear = actual_total_active_users_halfyear;
}
let (software, version) = match version.as_str() { let (software, version) = match version.as_str() {
"2.0.json" | "2.0" => ( "2.0.json" | "2.0" => (
nodeinfo::types::Software { nodeinfo::types::Software {
@ -122,30 +53,30 @@ pub async fn nodeinfo(State(ctx): State<Context>, Path(version): Path<String>) -
nodeinfo::types::Software { nodeinfo::types::Software {
name: "μpub".to_string(), name: "μpub".to_string(),
version: Some(upub::VERSION.into()), version: Some(upub::VERSION.into()),
repository: Some("https://moonlit.technology/alemi/upub".into()), repository: Some("https://git.alemi.dev/upub.git/".into()),
homepage: Some("https://join.upub.social".into()), homepage: None,
}, },
"2.1".to_string() "2.1".to_string()
), ),
_ => return Err(crate::ApiError::Status(StatusCode::NOT_IMPLEMENTED)), _ => return Err(StatusCode::NOT_IMPLEMENTED),
}; };
Ok(Json( Ok(Json(
nodeinfo::NodeInfoOwned { nodeinfo::NodeInfoOwned {
version, version,
software, software,
open_registrations: ctx.cfg().security.allow_registration, open_registrations: false,
protocols: vec!["activitypub".into()], protocols: vec!["activitypub".into()],
services: nodeinfo::types::Services { services: nodeinfo::types::Services {
inbound: vec![], inbound: vec![],
outbound: vec![], outbound: vec![],
}, },
usage: nodeinfo::types::Usage { usage: nodeinfo::types::Usage {
local_posts: Some(total_posts), local_posts: total_posts,
local_comments: Some(total_comments), local_comments: total_comments,
users: Some(nodeinfo::types::Users { users: Some(nodeinfo::types::Users {
active_month: Some(total_active_users_month), active_month: None,
active_halfyear: Some(total_active_users_halfyear), active_halfyear: None,
total: Some(total_users), total: total_users.map(|x| x as i64),
}), }),
}, },
metadata: serde_json::Map::default(), metadata: serde_json::Map::default(),
@ -193,7 +124,7 @@ pub async fn webfinger(
.await? .await?
{ {
Some(usr) => usr, Some(usr) => usr,
None => return Err(crate::ApiError::not_found()), None => return Err(ApiError::not_found()),
} }
} else { } else {
return Err(StatusCode::UNPROCESSABLE_ENTITY.into()); return Err(StatusCode::UNPROCESSABLE_ENTITY.into());
@ -214,7 +145,7 @@ pub async fn webfinger(
links: vec![ links: vec![
JsonResourceDescriptorLink { JsonResourceDescriptorLink {
rel: "self".to_string(), rel: "self".to_string(),
link_type: Some(apb::jsonld::CONTENT_TYPE_LD_JSON_ACTIVITYPUB.to_string()), link_type: Some("application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"".to_string()),
href: Some(user.id), href: Some(user.id),
properties: jrd::Map::default(), properties: jrd::Map::default(),
titles: jrd::Map::default(), titles: jrd::Map::default(),

View file

@ -82,6 +82,7 @@ impl Identity {
pub struct AuthIdentity(pub Identity); pub struct AuthIdentity(pub Identity);
#[axum::async_trait]
impl<S> FromRequestParts<S> for AuthIdentity impl<S> FromRequestParts<S> for AuthIdentity
where where
upub::Context: FromRef<S>, upub::Context: FromRef<S>,

View file

@ -7,8 +7,6 @@ pub fn collection_page(id: &str, page: Pagination, items: apb::Node<serde_json::
let (limit, offset) = page.pagination(); let (limit, offset) = page.pagination();
let next = if items.len() < limit as usize { let next = if items.len() < limit as usize {
apb::Node::Empty apb::Node::Empty
} else if id.contains('?') {
apb::Node::link(format!("{id}&offset={}", offset+limit))
} else { } else {
apb::Node::link(format!("{id}?offset={}", offset+limit)) apb::Node::link(format!("{id}?offset={}", offset+limit))
}; };
@ -41,27 +39,8 @@ pub struct JsonLD<T>(pub T);
impl<T: serde::Serialize> IntoResponse for JsonLD<T> { impl<T: serde::Serialize> IntoResponse for JsonLD<T> {
fn into_response(self) -> Response { fn into_response(self) -> Response {
( (
[("Content-Type", apb::jsonld::CONTENT_TYPE_LD_JSON_ACTIVITYPUB)], [("Content-Type", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")],
axum::Json(self.0) axum::Json(self.0)
).into_response() ).into_response()
} }
} }
pub fn accepts_activitypub_html(headers: &axum::http::HeaderMap) -> (bool, bool) {
let mut accepts_activity_pub = false;
let mut accepts_html = false;
for h in headers
.get_all(axum::http::header::ACCEPT)
.iter()
{
if h.to_str().is_ok_and(apb::jsonld::is_activity_pub_content_type) {
accepts_activity_pub = true;
}
if h.to_str().is_ok_and(|x| x.starts_with("text/html")) {
accepts_html = true;
}
}
(accepts_activity_pub, accepts_html)
}

View file

@ -1,3 +1,5 @@
use tower_http::classify::{SharedClassifier, StatusInRangeAsFailures};
pub mod auth; pub mod auth;
pub use auth::{AuthIdentity, Identity}; pub use auth::{AuthIdentity, Identity};
@ -7,52 +9,62 @@ pub use error::{ApiError, ApiResult};
pub mod builders; pub mod builders;
pub trait ActivityPubRouter {
fn ap_routes(self) -> Self where Self: Sized { self }
}
#[cfg(feature = "activitypub")] #[cfg(feature = "activitypub")]
pub mod activitypub; pub mod activitypub;
#[cfg(not(feature = "activitypub"))]
pub mod activitypub { impl super::ActivityPubRouter for axum::Router<upub::Context> {} }
pub trait MastodonRouter {
fn mastodon_routes(self) -> Self where Self: Sized { self }
}
#[cfg(feature = "mastodon")] #[cfg(feature = "mastodon")]
pub mod mastodon; pub mod mastodon;
#[cfg(not(feature = "mastodon"))]
pub mod mastodon { impl super::MastodonRouter for axum::Router<upub::Context> {} }
pub trait WebRouter {
fn web_routes(self, _ctx: &upub::Context) -> Self where Self: Sized { self }
}
#[cfg(feature = "web")] #[cfg(feature = "web")]
pub mod web; pub mod web;
#[cfg(not(feature = "web"))]
pub mod web {
impl super::WebRouter for axum::Router<upub::Context> {}
}
pub async fn serve(ctx: upub::Context, bind: String, shutdown: impl ShutdownToken) -> Result<(), std::io::Error> { pub async fn serve(ctx: upub::Context, bind: String, shutdown: impl ShutdownToken) -> Result<(), std::io::Error> {
use tower_http::{ use tower_http::{cors::CorsLayer, trace::TraceLayer};
cors::CorsLayer, trace::TraceLayer, timeout::TimeoutLayer,
classify::{SharedClassifier, StatusInRangeAsFailures}
};
let mut router = axum::Router::new(); let router = axum::Router::new()
#[cfg(all(not(feature = "activitypub"), not(feature = "mastodon"), not(feature = "web")))] {
compile_error!("at least one feature from ['activitypub', 'mastodon', 'web'] must be enabled");
}
#[cfg(feature = "activitypub")] { router = router.merge(activitypub::ap_routes(ctx.clone())); }
#[cfg(feature = "mastodon")] { router = router.merge(mastodon::masto_routes(ctx.clone())); }
#[cfg(feature = "web")] { router = router.merge(web::web_routes(ctx.clone())); }
router = router
.layer( .layer(
tower::ServiceBuilder::new() // TODO 4xx errors aren't really failures but since upub is in development it's useful to log
// TODO 4xx errors aren't really failures but since upub is in development it's useful to log // these too, in case something's broken
// these too, in case something's broken TraceLayer::new(SharedClassifier::new(StatusInRangeAsFailures::new(300..=999)))
.layer( .make_span_with(|req: &axum::http::Request<_>| {
TraceLayer::new(SharedClassifier::new(StatusInRangeAsFailures::new(400..=999))) tracing::span!(
.make_span_with(|req: &axum::http::Request<_>| { tracing::Level::INFO,
tracing::span!( "request",
tracing::Level::INFO, uri = %req.uri(),
"request", status_code = tracing::field::Empty,
agent = req.headers().get(axum::http::header::USER_AGENT).and_then(|x| x.to_str().ok()).unwrap_or_default(), )
uri = %req.uri(), })
status_code = tracing::field::Empty, )
) .ap_routes()
}) .mastodon_routes()
) .web_routes(&ctx)
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
.layer(TimeoutLayer::new(std::time::Duration::from_secs(ctx.cfg().security.request_timeout))) .with_state(ctx);
);
tracing::info!("serving api routes on {bind}"); tracing::info!("serving api routes on {bind}");

View file

@ -6,68 +6,73 @@ use crate::server::Context;
async fn todo() -> StatusCode { StatusCode::NOT_IMPLEMENTED } async fn todo() -> StatusCode { StatusCode::NOT_IMPLEMENTED }
pub fn masto_routes(ctx: upub::Context) -> Router { pub trait MastodonRouter {
use crate::routes::mastodon as mas; fn mastodon_routes(self) -> Self;
Router::new().nest( }
// TODO Oauth is just under /oauth
"/api/v1", Router::new() impl MastodonRouter for Router<Context> {
.route("/apps", post(todo)) // create an application fn mastodon_routes(self) -> Self {
.route("/apps/verify_credentials", post(todo)) // confirm that the app's oauth2 credentials work use crate::routes::mastodon as mas;
.route("/emails/confirmations", post(todo)) self.nest(
.route("/accounts", post(todo)) // TODO Oauth is just under /oauth
.route("/accounts/verify_credentials", get(todo)) "/api/v1", Router::new()
.route("/accounts/update_credentials", patch(todo)) .route("/apps", post(todo)) // create an application
.route("/accounts/:id", get(mas::accounts::view)) .route("/apps/verify_credentials", post(todo)) // confirm that the app's oauth2 credentials work
.route("/accounts/:id/statuses", get(todo)) .route("/emails/confirmations", post(todo))
.route("/accounts/:id/followers", get(todo)) .route("/accounts", post(todo))
.route("/accounts/:id/following", get(todo)) .route("/accounts/verify_credentials", get(todo))
.route("/accounts/:id/featured_tags", get(todo)) .route("/accounts/update_credentials", patch(todo))
.route("/accounts/:id/lists", get(todo)) .route("/accounts/:id", get(mas::accounts::view))
.route("/accounts/:id/follow", post(todo)) .route("/accounts/:id/statuses", get(todo))
.route("/accounts/:id/unfollow", post(todo)) .route("/accounts/:id/followers", get(todo))
.route("/accounts/:id/remove_from_followers", post(todo)) .route("/accounts/:id/following", get(todo))
.route("/accounts/:id/block", post(todo)) .route("/accounts/:id/featured_tags", get(todo))
.route("/accounts/:id/unblock", post(todo)) .route("/accounts/:id/lists", get(todo))
.route("/accounts/:id/mute", post(todo)) .route("/accounts/:id/follow", post(todo))
.route("/accounts/:id/unmute", post(todo)) .route("/accounts/:id/unfollow", post(todo))
.route("/accounts/:id/pin", post(todo)) .route("/accounts/:id/remove_from_followers", post(todo))
.route("/accounts/:id/unpin", post(todo)) .route("/accounts/:id/block", post(todo))
.route("/accounts/:id/note", post(todo)) .route("/accounts/:id/unblock", post(todo))
.route("/accounts/relationships", get(todo)) .route("/accounts/:id/mute", post(todo))
.route("/accounts/familiar_followers", get(todo)) .route("/accounts/:id/unmute", post(todo))
.route("/accounts/search", get(todo)) .route("/accounts/:id/pin", post(todo))
.route("/accounts/lookup", get(todo)) .route("/accounts/:id/unpin", post(todo))
.route("/accounts/:id/identity_proofs", get(todo)) .route("/accounts/:id/note", post(todo))
.route("/bookmarks", get(todo)) .route("/accounts/relationships", get(todo))
.route("/favourites", get(todo)) .route("/accounts/familiar_followers", get(todo))
.route("/mutes", get(todo)) .route("/accounts/search", get(todo))
.route("/blocks", get(todo)) .route("/accounts/lookup", get(todo))
.route("/domain_blocks", get(todo)) .route("/accounts/:id/identity_proofs", get(todo))
.route("/domain_blocks", post(todo)) .route("/bookmarks", get(todo))
.route("/domain_blocks", delete(todo)) .route("/favourites", get(todo))
// TODO filters! api v2 .route("/mutes", get(todo))
.route("/reports", post(todo)) .route("/blocks", get(todo))
.route("/follow_requests", get(todo)) .route("/domain_blocks", get(todo))
.route("/follow_requests/:account_id/authorize", get(todo)) .route("/domain_blocks", post(todo))
.route("/follow_requests/:account_id/reject", get(todo)) .route("/domain_blocks", delete(todo))
.route("/endorsements", get(todo)) // TODO filters! api v2
.route("/featured_tags", get(todo)) .route("/reports", post(todo))
.route("/featured_tags", post(todo)) .route("/follow_requests", get(todo))
.route("/featured_tags/:id", delete(todo)) .route("/follow_requests/:account_id/authorize", get(todo))
.route("/featured_tags/suggestions", get(todo)) .route("/follow_requests/:account_id/reject", get(todo))
.route("/preferences", get(todo)) .route("/endorsements", get(todo))
.route("/followed_tags", get(todo)) .route("/featured_tags", get(todo))
// TODO suggestions! api v2 .route("/featured_tags", post(todo))
.route("/suggestions", get(todo)) .route("/featured_tags/:id", delete(todo))
.route("/suggestions/:account_id", delete(todo)) .route("/featured_tags/suggestions", get(todo))
.route("/tags/:id", get(todo)) .route("/preferences", get(todo))
.route("/tags/:id/follow", post(todo)) .route("/followed_tags", get(todo))
.route("/tags/:id/unfollow", post(todo)) // TODO suggestions! api v2
.route("/profile/avatar", delete(todo)) .route("/suggestions", get(todo))
.route("/profile/header", delete(todo)) .route("/suggestions/:account_id", delete(todo))
.route("/statuses", post(todo)) .route("/tags/:id", get(todo))
// ... .route("/tags/:id/follow", post(todo))
.route("/instance", get(mas::instance::get)) .route("/tags/:id/unfollow", post(todo))
) .route("/profile/avatar", delete(todo))
.with_state(ctx) .route("/profile/header", delete(todo))
.route("/statuses", post(todo))
// ...
.route("/instance", get(mas::instance::get))
)
}
} }

View file

@ -1,93 +1,11 @@
use axum::{response::IntoResponse, routing, Router, http}; use leptos_axum::LeptosRoutes;
pub fn web_routes(ctx: upub::Context) -> Router { impl super::WebRouter for axum::Router<upub::Context> {
Router::new() fn web_routes(self, ctx: &upub::Context) -> Self where Self: Sized {
.route("/web/", routing::get(|| async { axum::response::Redirect::permanent("/web") })) self.leptos_routes(
.nest("/web", Router::new() ctx,
.nest("/assets", Router::new() leptos_axum::generate_route_list(upub_web::App),
.route("/upub-web.js", routing::get(upub_web_js)) move || ""
.route("/upub-web_bg.wasm", routing::get(upub_web_wasm))
.route("/style.css", routing::get(upub_style_css))
.route("/favicon.ico", routing::get(upub_favicon))
.route("/icon.png", routing::get(upub_pwa_icon))
.route("/manifest.json", routing::get(upub_pwa_manifest))
)
.route("/", routing::get(upub_web_index))
.route("/{*any}", routing::get(upub_web_index))
) )
.route_layer(axum::middleware::from_fn(redirect_to_ap))
.with_state(ctx)
}
async fn redirect_to_ap(
request: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
#[cfg(any(feature = "activitypub", feature = "activitypub-redirect"))]
{
let (accepts_activity_pub, accepts_html) = crate::builders::accepts_activitypub_html(request.headers());
if !accepts_html && accepts_activity_pub {
let uri = request.uri().clone();
let path_and_query = uri.path_and_query().map(|x| x.as_str()).unwrap_or_default();
if path_and_query == "/web"
|| path_and_query.starts_with("/web/objects")
|| path_and_query.starts_with("/web/tags")
|| path_and_query.starts_with("/web/actors")
{
let new_uri = uri.to_string().replacen("/web", "", 1);
return axum::response::Redirect::temporary(&new_uri).into_response();
}
}
} }
next.run(request).await
}
async fn upub_web_wasm() -> impl IntoResponse {
(
[(http::header::CONTENT_TYPE, "application/wasm")],
include_bytes!(std::env!("CARGO_UPUB_FRONTEND_WASM"))
)
}
async fn upub_web_js() -> impl IntoResponse {
(
[(http::header::CONTENT_TYPE, "text/javascript")],
include_str!(std::env!("CARGO_UPUB_FRONTEND_JS"))
)
}
async fn upub_style_css() -> impl IntoResponse {
(
[(http::header::CONTENT_TYPE, "text/css")],
include_str!(std::env!("CARGO_UPUB_FRONTEND_STYLE"))
)
}
async fn upub_web_index() -> impl IntoResponse {
axum::response::Html(
include_str!(std::env!("CARGO_UPUB_FRONTEND_INDEX"))
)
}
async fn upub_favicon() -> impl IntoResponse {
(
[(http::header::CONTENT_TYPE, "image/x-icon")],
include_bytes!(std::env!("CARGO_UPUB_FRONTEND_FAVICON"))
)
}
async fn upub_pwa_icon() -> impl IntoResponse {
(
[(http::header::CONTENT_TYPE, "image/png")],
include_bytes!(std::env!("CARGO_UPUB_FRONTEND_PWA_ICON"))
)
}
async fn upub_pwa_manifest() -> impl IntoResponse {
(
[(http::header::CONTENT_TYPE, "application/json")],
include_bytes!(std::env!("CARGO_UPUB_FRONTEND_PWA_MANIFEST"))
)
} }

View file

@ -1,6 +1,6 @@
[package] [package]
name = "httpsign" name = "httpsign"
version = "0.1.1" version = "0.1.0"
edition = "2021" edition = "2021"
authors = [ "alemi <me@alemi.dev>" ] authors = [ "alemi <me@alemi.dev>" ]
description = "fediverse-friendly implementation of http signaures in rust" description = "fediverse-friendly implementation of http signaures in rust"
@ -19,7 +19,7 @@ thiserror = "2.0"
tracing = "0.1" tracing = "0.1"
base64 = "0.22" base64 = "0.22"
openssl = "0.10" # TODO handle pubkeys with a smaller crate openssl = "0.10" # TODO handle pubkeys with a smaller crate
axum = { version = "0.8", optional = true } axum = { version = "0.7", optional = true }
[features] [features]
default = [] default = []

View file

@ -85,26 +85,16 @@ impl HttpSignature {
#[cfg(feature = "axum")] #[cfg(feature = "axum")]
pub fn build_from_parts(&mut self, parts: &axum::http::request::Parts) -> &mut Self { pub fn build_from_parts(&mut self, parts: &axum::http::request::Parts) -> &mut Self {
let mut out = Vec::new(); let mut out = Vec::new();
let method = parts.method.to_string().to_lowercase();
// since we're using nested routes, the request uri gets trimmed at each nesting
// this breaks http signatures! we need to maintain the original uri, so we try extracting it
let uri = match parts.extensions.get::<axum::extract::OriginalUri>() {
Some(original) => original.path_and_query(),
None => parts.uri.path_and_query(),
}
.map(|x| x.as_str())
.unwrap_or("/");
for header in self.headers.iter() { for header in self.headers.iter() {
match header.as_str() { match header.as_str() {
// pseudo-headers "(request-target)" => out.push(
"(request-target)" => out.push(format!("(request-target): {method} {uri}")), format!(
"(request-target): {} {}",
// TODO handle other pseudo-headers, parts.method.to_string().to_lowercase(),
parts.uri.path_and_query().map(|x| x.as_str()).unwrap_or("/")
// normal headers )
),
// TODO other pseudo-headers,
_ => out.push(format!("{}: {}", _ => out.push(format!("{}: {}",
header.to_lowercase(), header.to_lowercase(),
parts.headers.get(header).map(|x| x.to_str().unwrap_or("")).unwrap_or("") parts.headers.get(header).map(|x| x.to_str().unwrap_or("")).unwrap_or("")

View file

@ -1,7 +0,0 @@
# mdhtml
> batteries-included opinionated html escaping and markdown parsing
basically anything you may need to sanitize html before displaying it, and converting user generated markdown into safe html
this is probably only useful for [upub](https://join.upub.social), and it was made a standalone crate to be shared across components

View file

@ -1,7 +0,0 @@
# uriproxy
> a way to encode urls in urls
this is basically probably only useful for [upub](https://join.upub.social), as it needs to encode remote object urls in its urls
this is made as a tiny standalone crate so it can be shared across upub components

View file

@ -1,6 +1,6 @@
[package] [package]
name = "upub-web" name = "upub-web"
version = "0.5.1-dev" version = "0.4.3"
edition = "2021" edition = "2021"
authors = [ "alemi <me@alemi.dev>" ] authors = [ "alemi <me@alemi.dev>" ]
description = "web frontend for upub" description = "web frontend for upub"
@ -9,6 +9,9 @@ keywords = ["activitypub", "upub", "json", "web", "wasm"]
repository = "https://git.alemi.dev/upub.git" repository = "https://git.alemi.dev/upub.git"
#readme = "README.md" #readme = "README.md"
[lib]
crate-type = ["rlib", "cdylib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
@ -24,7 +27,7 @@ serde_json = "1.0"
serde_default = "0.2" serde_default = "0.2"
serde-inline-default = "0.2" serde-inline-default = "0.2"
dashmap = "6.1" dashmap = "6.1"
leptos = { version = "0.7", features = ["csr", "tracing"] } leptos = { version = "0.7", features = ["tracing"] }
leptos_router = { version = "0.7", features = ["tracing"] } leptos_router = { version = "0.7", features = ["tracing"] }
leptos-use = "0.15" leptos-use = "0.15"
codee = { version = "0.2", features = ["json_serde"] } # WHYYY LEPTOS-USE AKSJFOASHGOAEG codee = { version = "0.2", features = ["json_serde"] } # WHYYY LEPTOS-USE AKSJFOASHGOAEG
@ -39,7 +42,8 @@ tld = "2.36"
web-sys = { version = "0.3", features = ["Screen"] } web-sys = { version = "0.3", features = ["Screen"] }
regex = "1.11" regex = "1.11"
[package.metadata.trunk.build] [features]
public_url = "/web/assets/" default = ["leptos-csr"]
filehash = false leptos-ssr = ["leptos/ssr"]
#offline = true # if you're looking in here, you may want to uncomment this and download wasm-bindgen-cli yourself leptos-csr = ["leptos/csr"]
leptos-hydrate = ["leptos/hydrate"]

View file

@ -1,31 +0,0 @@
# upub-web
![screenshot of upub frontend](https://cdn.alemi.dev/proj/upub/fe/20240704.png)
this is μpub main frontend: it's a single wasm bundle doing client-side routing for faster navigation between objects
it has the drawback of not being search-engine friendly, but machines should process data for machines themselves (aka: the AP documents), so it's probably fine to have a "js"-heavy frontend
## development
it's probably possible to get `upub-web` to build with just `wasm-bindgen`, but i recommend just using `trunk` to keep your sanity. trunk will download by itself `wasm-bindgen-cli`.
```
UPUB_BASE_URL=https://dev.upub.social trunk serve --public-url http://127.0.0.1:8080/web
```
will give you a local development server with auto-reload pointing to `dev.upub.social`, so you don't even need to spin up a local instance (omit `UPUB_BASE_URL` env variable to make frontend point to localhost instead)
setting `public-url` is necessary: while deploying, axum will handle getting you to the correct pages, but in trunk dev env this has to be done by trunk itself. note also that resource urls will differ: on prod they will be `/web/assets/style.css`, while on dev `/assets` segment won't be present!
## building
just run
```
$ trunk build --release
```
to generate the `./web/dist` folder containing all necessary assets
either serve these yourself, or compile main `upub` with `web` feature enable to have it bundle these freshly built frontend files

0
web/assets/style.css Normal file
View file

Binary file not shown.

Before

Width: 64px  |  Height: 64px  |  Size: 17 KiB

Binary file not shown.

Before

(image error) Size: 43 KiB

View file

@ -12,20 +12,455 @@
<meta property="og:url" content="https://upub.alemi.dev/web" /> <meta property="og:url" content="https://upub.alemi.dev/web" />
<meta property="og:site_name" content="upub" /> <meta property="og:site_name" content="upub" />
<link rel="icon" data-trunk href="favicon.ico" type="image/x-icon" /> <link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="preload" href="https://cdn.alemi.dev/web/font/FiraCode-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="manifest" href="/web/assets/manifest.json" /> <link rel="preload" href="https://cdn.alemi.dev/web/font/FiraCode-Bold.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="copy-file" data-trunk href="manifest.json" />
<link rel="copy-file" data-trunk href="icon.png" />
<link rel="preload" href="https://cdn.alemi.dev/web/font/FiraCode-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href="https://cdn.alemi.dev/web/font/FiraCode-Bold.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link crossorigin rel="stylesheet" href="https://cdn.alemi.dev/web/alemi.css"> <link crossorigin rel="stylesheet" href="https://cdn.alemi.dev/web/alemi.css">
<link rel="css" data-trunk href="style.css" /> <style>
:root {
--main-col-percentage: 75%;
--transition-time: .05s;
--transition-time-long: .1s;
}
@font-face {
font-family: 'Fira Code';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("https://cdn.alemi.dev/web/font/FiraCode-Regular.woff2") format("woff2"), url("https://cdn.alemi.dev/web/font/FiraCode-Regular.woff") format("woff");
}
@font-face {
font-family: 'Fira Code';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("https://cdn.alemi.dev/web/font/FiraCode-Bold.woff2") format("woff2"), url("https://cdn.alemi.dev/web/font/FiraCode-Bold.woff") format("woff");
}
* {
font-family: 'Fira Code', Menlo, DejaVu Sans Mono, Monaco, Consolas, Ubuntu Mono, monospace;
}
html {
overflow-y: scroll;
height: 100vh;
}
body {
margin: 0;
padding-bottom: 1.2em;
font-size: 11pt;
}
textarea {
font-size: 10pt;
}
nav {
z-index: 90;
top: 0;
position: sticky;
padding-top: .05em;
background-color: var(--background);
}
footer {
width: 100%;
position: fixed;
bottom: 0;
background-color: var(--background);
text-align: center;
padding-bottom: 0;
line-height: 1rem;
}
main {
margin: 0em 1em;
}
blockquote {
margin-top: .5em;
margin-bottom: .5em;
margin-left: 1.25em;
padding-left: .3em;
overflow-wrap: break-word;
hyphens: auto;
border-left: solid 3px var(--background-secondary);
}
article {
word-break: break-word;
}
article.tl {
color: var(--text);
border-left: solid 3px var(--accent);
margin-left: 1.25em;
margin-right: 1em;
margin-top: 0;
margin-bottom: 0;
}
article.tl h1,
article.tl h2,
article.tl h3 {
margin-top: .1em;
margin-bottom: .1em;
}
article p {
margin: 0 0 0 .5em;
}
article.float-container {
overflow-y: auto;
}
b.displayname {
overflow-wrap: break-word;
}
table.align {
max-width: 100%;
}
table.fields,
table.fields tr,
table.fields td
{
border: 1px solid var(--background-dim);
word-wrap: break-word;
}
span.footer {
padding: .1em;
font-size: .6em;
color: var(--secondary);
}
span.nowrap {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: block;
}
hr.sep {
border: 1px solid rgba(var(--accent-rgb), 0.45);
}
div.sep-top {
border-top: 2px solid rgba(var(--accent-rgb), 0.45);
}
div.quote {
border: 3px solid var(--background-dim);
margin-top: 1em;
margin-left: 1em;
margin-bottom: 1em;
padding: 1em;
}
hr.sticky {
position: sticky;
z-index: 100;
margin-top: 0;
padding-top: 0;
margin-bottom: 0;
padding-bottom: 0;
top: 1.65rem;
}
div.sticky {
z-index: 100;
top: 2rem;
position: sticky;
background-color: var(--background);
}
span.border-button {
border: 1px solid var(--background-dim);
}
span.border-button:hover {
background-color: var(--background-dim);
}
div.border,
span.border {
border: 1px dashed var(--accent);
}
div.inline {
display: inline;
}
div.notification {
background-color: var(--background-dim);
}
@media screen and (max-width: 786px) {
div.sticky {
top: 1.75rem;
padding-top: .25rem;
}
}
a.upub-title {
color: var(--accent);
text-decoration: none;
}
a.upub-title:hover {
text-decoration: underline;
}
a.hover {
text-decoration: none;
}
a.hover:hover {
text-decoration: underline;
}
a.breadcrumb {
text-decoration: none;
color: var(--secondary);
}
a.breadcrumb:hover {
font-weight: bold;
color: var(--accent);
}
b.big {
font-size: 18pt;
}
div.banner {
margin-top: .3em;
outline: .3em solid rgba(var(--accent-rgb), 0.33);
}
div.overlap {
position: relative;
bottom: 2em;
margin-bottom: -2em;
}
img {
max-width: 100%;
}
img.avatar {
display: inline;
border-radius: 50%;
}
img.avatar-border {
background-color: var(--background);
border: .3em solid var(--accent);
}
img.inline {
height: .75em;
}
img.avatar-actor {
min-height: 2em;
max-height: 2em;
min-width: 2em;
max-width: 2em;
}
img.flex-pic {
float: left;
width: 10em;
height: 10em;
object-fit: cover;
margin-right: 1em;
margin-top: .5em;
margin-bottom: .5em;
margin-left: .5em;
border: 3px solid var(--accent);
box-sizing: border-box;
}
img.flex-pic-expand {
width: unset;
height: unset;
max-width: calc(100% - 1.5em);
max-height: 90vh;
}
.box {
border: 3px solid var(--accent);
box-sizing: border-box;
}
.cursor {
cursor: pointer;
}
video.attachment {
height: 10em;
}
img.attachment {
cursor: pointer;
height: 10em;
border: 3px solid var(--accent);
padding: 5px;
object-fit: cover;
box-sizing: border-box;
}
img.expand,
video.expand {
height: unset;
max-height: 90vh;
max-width: 100%;
object-fit: contain;
}
div.tl-header {
background-color: rgba(var(--accent-rgb), 0.33);
color: var(--accent);
}
p.bio {
line-height: 1.2rem;
font-size: .8rem;
}
p.tiny-text {
line-height: .75em;
}
p.line {
margin: 0;
}
p.shadow {
text-shadow: 0px 0px 3px var(--background);
}
table.post-table {
border-collapse: collapse;
}
table p {
margin: .25em 1em;
}
tr.post-table,
td.post-table {
border: 1px dashed var(--accent);
padding: .5em;
}
td.top {
vertical-align: top;
}
td.bottom {
vertical-align: bottom;
}
details>summary::marker {
display: none;
}
details>summary {
list-style: none;
cursor: pointer;
}
details.cw>summary:hover {
font-weight: bold;
}
details.thread>summary {
background-color: var(--background-dim);
}
details.thread[open]>summary {
background-color: var(--background);
}
details.thread>summary:hover {
background-color: var(--background-dim);
}
code.cw {
display: block;
}
input[type=button]:hover,
input[type=submit].active {
background-color: var(--accent);
border-color: var(--accent);
color: var(--background);
cursor: pointer;
}
input[type="range"] {
accent-color: var(--accent);
}
input[type="range"]:hover {
outline: none;
}
input[type="range"]:focus {
outline: none;
accent-color: var(--accent-dim);
}
.ml-1-r {
margin-left: 1em;
}
.mr-1-r {
margin-right: 1em;
}
.ml-3-r {
margin-left: 3em;
}
.mr-3-r {
margin-right: 3em;
}
.depth-r {
margin-left: .5em;
}
.only-on-mobile {
display: none;
}
@media screen and (max-width: 786px) {
.depth-r {
margin-left: .125em;
}
.ml-1-l {
margin-left: 0;
}
.mr-1-r {
margin-right: 0;
}
.ml-3-r {
margin-left: 0;
}
.mr-3-r {
margin-right: 0;
}
.only-on-mobile {
display: inherit;
}
.hidden-on-mobile {
display: none;
}
div.col-side {
padding-right: .25em;
}
main {
margin: 0;
}
}
@media screen and (max-width: 400px) {
.hidden-on-tiny {
display: none;
}
}
span.emoji {
color: transparent;
text-shadow: 0 0 0 var(--secondary);
}
span.emoji-btn:hover {
color: unset;
text-shadow: unset;
}
span.big-emoji {
font-size: 1.5em;
}
details.context {
border-left: 1px solid var(--background-dim);
padding-left: 1px;
}
span.json-key {
color: var(--accent);
}
span.json-text {
color: var(--text);
}
span.tab-active {
color: var(--accent);
font-weight: bold;
}
pre.striped {
background: repeating-linear-gradient(
135deg,
var(--background-dim),
var(--background-dim) .9em,
var(--background) .9em,
var(--background) 1em
);
}
.spinner {
animation: spin 1s linear infinite;
}
span.dots {
&:after {
animation: dots 1.5s linear infinite;
display: inline-block;
content: "\00a0\00a0\00a0";
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* \00a0 is unicode for "space", because otherwise it gets removed */
@keyframes dots {
0% { content: "\00a0\00a0\00a0"; }
25% { content: ".\00a0\00a0"; }
50% { content: "..\00a0"; }
75% { content: "..."; }
100% { content: "\00a0\00a0\00a0"; }
}
</style>
</head> </head>
</head>
<body> <body>
</body> </body>
</html> </html>

View file

@ -1,15 +0,0 @@
{
"short_name": "upub",
"name": "μpub",
"icons": [
{
"src": "/web/assets/icon.png",
"sizes": "500x500",
"type": "image/png"
}
],
"start_url": "/web",
"display": "standalone",
"theme_color": "#BF616A",
"background_color": "#201F29"
}

View file

@ -61,7 +61,7 @@ pub fn Item(
} }
match item.object_type().unwrap_or(apb::ObjectType::Object) { match item.object_type().unwrap_or(apb::ObjectType::Object) {
// special case for placeholder activities // special case for placeholder activities
apb::ObjectType::Note | apb::ObjectType::Document(_) | apb::ObjectType::Article => apb::ObjectType::Note | apb::ObjectType::Document(_) =>
Some(view! { <Object object=item.clone() />{sep.clone()} }.into_any()), Some(view! { <Object object=item.clone() />{sep.clone()} }.into_any()),
// everything else // everything else
apb::ObjectType::Activity(t) => { apb::ObjectType::Activity(t) => {

View file

@ -57,7 +57,6 @@ pub fn ActorHeader() -> impl IntoView {
// TODO what the fuck... // TODO what the fuck...
let _uid = uid.clone(); let _uid = uid.clone();
let __uid = uid.clone(); let __uid = uid.clone();
let ___uid = uid.clone();
view! { view! {
<div class="ml-3 mr-3"> <div class="ml-3 mr-3">
<div <div
@ -86,8 +85,8 @@ pub fn ActorHeader() -> impl IntoView {
<div class="mr-1 ml-1" class:hidden=move || !auth.present() || auth.user_id() == uid> <div class="mr-1 ml-1" class:hidden=move || !auth.present() || auth.user_id() == uid>
{if following_me { {if following_me {
Some(view! { Some(view! {
<a class="clean dim" href="#remove" on:click=move |_| remove_follower(___uid.clone(), auth)> <a class="clean dim" href="#remove" on:click=move |_| tracing::error!("not yet implemented")>
<span class="border-button ml-s" title="remove follower"> <span class="border-button ml-s" title="remove follower (not yet implemented)">
<code class="color mr-s">"!"</code> <code class="color mr-s">"!"</code>
<small class="mr-s">follows you</small> <small class="mr-s">follows you</small>
</span> </span>
@ -97,23 +96,23 @@ pub fn ActorHeader() -> impl IntoView {
None None
}} }}
{if followed_by_me { {if followed_by_me {
Either::Left(view! { view! {
<a class="clean dim" href="#unfollow" on:click=move |_| unfollow(_uid.clone(), auth)> <a class="clean dim" href="#unfollow" on:click=move |_| unfollow(_uid.clone())>
<span class="border-button ml-s" title="undo follow"> <span class="border-button ml-s" title="undo follow">
<code class="color mr-s">x</code> <code class="color mr-s">x</code>
<small class="mr-s">following</small> <small class="mr-s">following</small>
</span> </span>
</a> </a>
}) }.into_any()
} else { } else {
Either::Right(view! { view! {
<a class="clean dim" href="#follow" on:click=move |_| send_follow_request(_uid.clone(), auth)> <a class="clean dim" href="#follow" on:click=move |_| send_follow_request(_uid.clone())>
<span class="border-button ml-s" title="send follow request"> <span class="border-button ml-s" title="send follow request">
<code class="color mr-s">+</code> <code class="color mr-s">+</code>
<small class="mr-s">follow</small> <small class="mr-s">follow</small>
</span> </span>
</a> </a>
}) }.into_any()
}} }}
</div> </div>
</div> </div>
@ -178,7 +177,8 @@ async fn send_follow_response(kind: apb::ActivityType, target: String, to: Strin
} }
} }
fn send_follow_request(target: String, auth: Auth) { fn send_follow_request(target: String) {
let auth = use_context::<Auth>().expect("missing auth context");
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
let payload = apb::new() let payload = apb::new()
.set_activity_type(Some(apb::ActivityType::Follow)) .set_activity_type(Some(apb::ActivityType::Follow))
@ -190,7 +190,8 @@ fn send_follow_request(target: String, auth: Auth) {
}) })
} }
fn unfollow(target: String, auth: Auth) { fn unfollow(target: String) {
let auth = use_context::<Auth>().expect("missing auth context");
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
let payload = apb::new() let payload = apb::new()
.set_activity_type(Some(apb::ActivityType::Undo)) .set_activity_type(Some(apb::ActivityType::Undo))
@ -201,24 +202,7 @@ fn unfollow(target: String, auth: Auth) {
.set_object(apb::Node::link(target)) .set_object(apb::Node::link(target))
)); ));
if let Err(e) = Http::post(&auth.outbox(), &payload, auth).await { if let Err(e) = Http::post(&auth.outbox(), &payload, auth).await {
tracing::error!("failed sending unfollow: {e}"); tracing::error!("failed sending follow request: {e}");
}
})
}
fn remove_follower(target: String, auth: Auth) {
leptos::task::spawn_local(async move {
let payload = apb::new()
.set_activity_type(Some(apb::ActivityType::Undo))
.set_to(apb::Node::links(vec![target.clone()]))
.set_object(apb::Node::object(
apb::new()
.set_activity_type(Some(apb::ActivityType::Follow))
.set_actor(apb::Node::link(target))
.set_object(apb::Node::link(auth.user_id()))
));
if let Err(e) = Http::post(&auth.outbox(), &payload, auth).await {
tracing::error!("failed sending follow removal request: {e}");
} }
}) })
} }

View file

@ -62,22 +62,21 @@ pub fn App() -> impl IntoView {
let title_target = move || if auth.present() { "/web/home" } else { "/web/global" }; let title_target = move || if auth.present() { "/web/home" } else { "/web/global" };
// refresh token immediately and every hour // refresh token immediately and every hour
let refresh_token = move || leptos::task::spawn_local(async move { Auth::refresh(auth, set_token, set_userid).await; }); let refresh_token = move || leptos::task::spawn_local(async move { Auth::refresh(auth.token, set_token, set_userid).await; });
refresh_token(); refresh_token();
set_interval(refresh_token, std::time::Duration::from_secs(3600)); set_interval(refresh_token, std::time::Duration::from_secs(3600));
// refresh notifications // refresh notifications
let (notifications, set_notifications) = signal(0); let (notifications, set_notifications) = signal(0);
let fetch_notifications = move || leptos::task::spawn_local(async move { let fetch_notifications = move || leptos::task::spawn_local(async move {
if let Some(actor_id) = userid.get_untracked() { let actor_id = userid.get_untracked().unwrap_or_default();
let notif_url = format!("{actor_id}/notifications"); let notif_url = format!("{actor_id}/notifications");
match Http::fetch::<serde_json::Value>(&notif_url, auth).await { match Http::fetch::<serde_json::Value>(&notif_url, auth).await {
Err(e) => tracing::error!("failed fetching notifications: {e}"), Err(e) => tracing::error!("failed fetching notifications: {e}"),
Ok(doc) => if let Ok(count) = doc.total_items() { Ok(doc) => if let Ok(count) = doc.total_items() {
set_notifications.set(count); set_notifications.set(count);
}, },
} }
}
}); });
fetch_notifications(); fetch_notifications();
set_interval(fetch_notifications, std::time::Duration::from_secs(60)); set_interval(fetch_notifications, std::time::Duration::from_secs(60));
@ -130,7 +129,7 @@ pub fn App() -> impl IntoView {
<Route path=path!("home") view=move || if auth.present() { <Route path=path!("home") view=move || if auth.present() {
Either::Left(view! { Either::Left(view! {
<Loadable <Loadable
base=format!("{}/inbox/page", auth.user_id()) base=format!("{URL_BASE}/actors/{}/inbox/page", auth.username())
element=move |obj| view! { <Item item=obj sep=true /> } element=move |obj| view! { <Item item=obj sep=true /> }
/> />
}) })
@ -155,7 +154,7 @@ pub fn App() -> impl IntoView {
<Route path=path!("notifications") view=move || if auth.present() { <Route path=path!("notifications") view=move || if auth.present() {
Either::Left(view! { Either::Left(view! {
<Loadable <Loadable
base=format!("{}/notifications/page", auth.user_id()) base=format!("{URL_BASE}/actors/{}/notifications/page", auth.username())
element=move |obj| view! { <Item item=obj sep=true always=true /> } element=move |obj| view! { <Item item=obj sep=true always=true /> }
/> />
}) })
@ -168,7 +167,7 @@ pub fn App() -> impl IntoView {
let tag = params.get().ok().and_then(|x| x.id).unwrap_or_default(); let tag = params.get().ok().and_then(|x| x.id).unwrap_or_default();
view! { view! {
<Loadable <Loadable
base=format!("{URL_BASE}/tags/{tag}/page", ) base=format!("{URL_BASE}/tag/{tag}/page", )
element=move |obj| view! { <Item item=obj sep=true always=true /> } element=move |obj| view! { <Item item=obj sep=true always=true /> }
/> />
} }

View file

@ -32,21 +32,19 @@ impl Auth {
} }
pub fn outbox(&self) -> String { pub fn outbox(&self) -> String {
format!("{}/outbox", self.user_id()) format!("{URL_BASE}/actors/{}/outbox", self.username())
} }
pub async fn refresh( pub async fn refresh(
auth: Auth, token: Signal<Option<String>>,
set_token: WriteSignal<Option<String>>, set_token: WriteSignal<Option<String>>,
set_userid: WriteSignal<Option<String>>, set_userid: WriteSignal<Option<String>>
) -> bool { ) -> bool {
if let Some(tok) = auth.token.get_untracked() { if let Some(tok) = token.get_untracked() {
match crate::Http::request::<>( match reqwest::Client::new()
Method::PATCH, .request(Method::PATCH, format!("{URL_BASE}/auth"))
&format!("{URL_BASE}/auth"), .json(&serde_json::json!({"token": tok}))
Some(&serde_json::json!({"token": tok})), .send()
auth,
)
.await .await
{ {
Err(e) => tracing::error!("could not refresh token: {e}"), Err(e) => tracing::error!("could not refresh token: {e}"),

View file

@ -27,31 +27,16 @@ pub fn LoginBox(
let email = username_ref.get().map(|x| x.value()).unwrap_or("".into()); let email = username_ref.get().map(|x| x.value()).unwrap_or("".into());
let password = password_ref.get().map(|x| x.value()).unwrap_or("".into()); let password = password_ref.get().map(|x| x.value()).unwrap_or("".into());
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
let res = match crate::Http::request::<LoginForm>( let Ok(res) = reqwest::Client::new()
reqwest::Method::POST, .post(format!("{URL_BASE}/auth"))
&format!("{URL_BASE}/auth"), .json(&LoginForm { email, password })
Some(&LoginForm { email, password }), .send()
auth, .await
).await { else { if let Some(rf) = password_ref.get() { rf.set_value("") }; return };
Ok(res) => res, let Ok(auth_response) = res
Err(e) => { .json::<AuthResponse>()
tracing::warn!("could not login: {e}"); .await
if let Some(rf) = password_ref.get() { else { if let Some(rf) = password_ref.get() { rf.set_value("") }; return };
rf.set_value("")
};
return
}
};
let auth_response = match res.json::<AuthResponse>().await {
Ok(r) => r,
Err(e) => {
tracing::warn!("could not deserialize token response: {e}");
if let Some(rf) = password_ref.get() {
rf.set_value("")
};
return
},
};
tracing::info!("logged in until {}", auth_response.expires); tracing::info!("logged in until {}", auth_response.expires);
// update our username and token cookies // update our username and token cookies
userid_tx.set(Some(auth_response.user)); userid_tx.set(Some(auth_response.user));

View file

@ -1,4 +1,4 @@
use apb::{ActivityMut, Base, BaseMut, DocumentMut, Object, ObjectMut}; use apb::{ActivityMut, Base, BaseMut, Object, ObjectMut};
use leptos::prelude::*; use leptos::prelude::*;
use crate::prelude::*; use crate::prelude::*;
@ -89,20 +89,19 @@ impl Privacy {
} }
} }
// TODO this is weird... should probably come from core or apb pub fn address(&self, user: &str) -> (Vec<String>, Vec<String>) {
pub fn address(&self, user_id: &str) -> (Vec<String>, Vec<String>) {
match self { match self {
Self::Broadcast => ( Self::Broadcast => (
vec![apb::target::PUBLIC.to_string()], vec![apb::target::PUBLIC.to_string()],
vec![format!("{user_id}/followers")], vec![format!("{URL_BASE}/actors/{user}/followers")],
), ),
Self::Public => ( Self::Public => (
vec![], vec![],
vec![apb::target::PUBLIC.to_string(), format!("{user_id}/followers")], vec![apb::target::PUBLIC.to_string(), format!("{URL_BASE}/actors/{user}/followers")],
), ),
Self::Private => ( Self::Private => (
vec![], vec![],
vec![format!("{user_id}/followers")], vec![format!("{URL_BASE}/actors/{user}/followers")],
), ),
Self::Direct => ( Self::Direct => (
vec![], vec![],
@ -134,7 +133,7 @@ pub fn PrivacySelector(setter: WriteSignal<Privacy>) -> impl IntoView {
<td> <td>
{move || { {move || {
let p = privacy.get(); let p = privacy.get();
let (to, cc) = p.address(&auth.user_id()); let (to, cc) = p.address(&auth.username());
view! { view! {
<PrivacyMarker privacy=p to=to cc=cc big=true /> <PrivacyMarker privacy=p to=to cc=cc big=true />
} }
@ -145,19 +144,6 @@ pub fn PrivacySelector(setter: WriteSignal<Privacy>) -> impl IntoView {
} }
} }
fn attachment_id() -> u64 {
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
#[derive(Default, Clone)]
struct AttachmentInput {
id: u64,
url_ref: NodeRef<leptos::html::Input>,
summary_ref: NodeRef<leptos::html::Input>,
media_type_ref: NodeRef<leptos::html::Input>,
}
#[component] #[component]
pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView { pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context"); let auth = use_context::<Auth>().expect("missing auth context");
@ -167,7 +153,6 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
let (error, set_error) = signal(None); let (error, set_error) = signal(None);
let (content, set_content) = signal("".to_string()); let (content, set_content) = signal("".to_string());
let summary_ref: NodeRef<leptos::html::Input> = NodeRef::new(); let summary_ref: NodeRef<leptos::html::Input> = NodeRef::new();
let (attachments, set_attachments) = signal(vec![]);
// TODO is this too abusive with resources? im even checking if TLD exists... // TODO is this too abusive with resources? im even checking if TLD exists...
// TODO debounce this! // TODO debounce this!
@ -181,7 +166,7 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
if let Some((name, domain)) = stripped.split_once('@') { if let Some((name, domain)) = stripped.split_once('@') {
if let Some(tld) = domain.split('.').last() { if let Some(tld) = domain.split('.').last() {
if tld::exist(tld) { if tld::exist(tld) {
if let Some(uid) = cache::WEBFINGER.blocking_resolve(name, domain, auth).await { if let Some(uid) = cache::WEBFINGER.blocking_resolve(name, domain).await {
out.push(TextMatch::Mention { name: name.to_string(), domain: domain.to_string(), href: uid }); out.push(TextMatch::Mention { name: name.to_string(), domain: domain.to_string(), href: uid });
} }
} }
@ -232,16 +217,6 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
} }
<table class="align w-100"> <table class="align w-100">
<tr> <tr>
<td>
<input type="button" value="+" on:click=move |_| {
let mut a = attachments.get();
a.push(AttachmentInput {
id: attachment_id(),
..Default::default()
});
set_attachments.set(a);
} />
</td>
<td><input type="checkbox" on:input=move |ev| advanced.set(event_target_checked(&ev)) title="toggle advanced controls" /></td> <td><input type="checkbox" on:input=move |ev| advanced.set(event_target_checked(&ev)) title="toggle advanced controls" /></td>
<td class="w-100"><input class="w-100" type="text" node_ref=summary_ref title="summary" /></td> <td class="w-100"><input class="w-100" type="text" node_ref=summary_ref title="summary" /></td>
</tr> </tr>
@ -252,35 +227,17 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
on:input=move |ev| set_content.set(event_target_value(&ev)) on:input=move |ev| set_content.set(event_target_value(&ev))
></textarea> ></textarea>
<For
each=move || attachments.get()
key=|x: &AttachmentInput| x.id
children=move |x: AttachmentInput| view! {
<table class="align w-100 mb-1">
<tr>
<td colspan="3"><input type="text" class="w-100" node_ref=x.url_ref title="url" placeholder="attachment url" /></td>
</tr>
<tr>
<td><input type="button" title="remove attachment" on:click=move |_| set_attachments.set(attachments.get().into_iter().filter(|a| a.id != x.id).collect()) value="x" /></td>
<td><input type="text" class="w-100" node_ref=x.media_type_ref title="media type" placeholder="media type" /></td>
<td><input type="text" class="w-100" node_ref=x.summary_ref title="name (media description)" placeholder="name" /></td>
</tr>
</table>
}
/>
<button class="w-100" prop:disabled=posting type="button" style="height: 3em" on:click=move |_| { <button class="w-100" prop:disabled=posting type="button" style="height: 3em" on:click=move |_| {
let content = content.get_untracked(); let content = content.get();
let attachments_vec = attachments.get_untracked(); if content.is_empty() {
if content.is_empty() && attachments_vec.is_empty() { set_error.set(Some("missing post body".to_string()));
set_error.set(Some("missing post body or attachments".to_string()));
return; return;
} }
set_posting.set(true); set_posting.set(true);
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
let summary = get_if_some(summary_ref); let summary = get_if_some(summary_ref);
let (mut to_vec, cc_vec) = privacy.get_untracked().address(&auth.user_id()); let (mut to_vec, cc_vec) = privacy.get().address(&auth.username());
let mut mention_tags : Vec<serde_json::Value> = mentions.get_untracked() let mut mention_tags : Vec<serde_json::Value> = mentions.get()
.map(|x| x.take()) .map(|x| x.take())
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
@ -301,7 +258,7 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
}) })
.collect(); .collect();
if let Some(r) = reply.reply_to.get_untracked() { if let Some(r) = reply.reply_to.get() {
if let Some(au) = post_author(&r) { if let Some(au) = post_author(&r) {
if let Ok(uid) = au.id() { if let Ok(uid) = au.id() {
to_vec.push(uid.to_string()); to_vec.push(uid.to_string());
@ -317,43 +274,13 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
} }
} }
} }
for mention in mentions.get_untracked().map(|x| x.take()).as_deref().unwrap_or(&[]) { for mention in mentions.get().map(|x| x.take()).as_deref().unwrap_or(&[]) {
if let TextMatch::Mention { href, .. } = mention { if let TextMatch::Mention { href, .. } = mention {
to_vec.push(href.clone()); to_vec.push(href.clone());
} }
} }
let attachments_node = if attachments_vec.is_empty() {
apb::Node::Empty
} else {
apb::Node::array(
attachments_vec
.into_iter()
.map(|x| (get_if_some(x.url_ref), get_if_some(x.media_type_ref), get_if_some(x.summary_ref)))
.filter_map(|(url, ty, sum)| Some((url?, ty?, sum)))
.map(|(url, ty, summary)| {
let document_type = if let Some((t, _mime)) = ty.split_once('/') {
match t {
"audio" => apb::DocumentType::Audio,
"image" => apb::DocumentType::Image,
"video" => apb::DocumentType::Video,
_ => apb::DocumentType::Document,
}
} else {
apb::DocumentType::Page
};
apb::new()
.set_url(apb::Node::link(url))
.set_media_type(Some(ty))
.set_name(summary)
.set_document_type(Some(document_type))
})
.collect()
)
};
let payload = apb::new() let payload = apb::new()
.set_object_type(Some(apb::ObjectType::Note)) .set_object_type(Some(apb::ObjectType::Note))
.set_attachment(attachments_node)
.set_summary(summary) .set_summary(summary)
.set_content(Some(content)) .set_content(Some(content))
.set_context(apb::Node::maybe_link(reply.context.get())) .set_context(apb::Node::maybe_link(reply.context.get()))
@ -367,7 +294,6 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
set_error.set(None); set_error.set(None);
if let Some(x) = summary_ref.get() { x.set_value("") } if let Some(x) = summary_ref.get() { x.set_value("") }
set_content.set("".to_string()); set_content.set("".to_string());
set_attachments.set(vec![]);
}, },
} }
set_posting.set(false); set_posting.set(false);
@ -402,11 +328,6 @@ pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
<table class="align w-100"> <table class="align w-100">
<tr> <tr>
<td>
<input type="checkbox" title="embedded object" on:input=move |ev| {
set_embedded.set(event_target_checked(&ev))
}/>
</td>
<td> <td>
<input type="checkbox" title="advanced" checked on:input=move |ev| { <input type="checkbox" title="advanced" checked on:input=move |ev| {
advanced.set(event_target_checked(&ev)) advanced.set(event_target_checked(&ev))
@ -425,6 +346,11 @@ pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
<SelectOption value is="Update" /> <SelectOption value is="Update" />
</select> </select>
</td> </td>
<td>
<input type="checkbox" title="embedded object" on:input=move |ev| {
set_embedded.set(event_target_checked(&ev))
}/>
</td>
</tr> </tr>
</table> </table>
@ -454,7 +380,7 @@ pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
<td class="w-66"><input class="w-100" type="text" node_ref=bto_ref title="bto" placeholder="bto" /></td> <td class="w-66"><input class="w-100" type="text" node_ref=bto_ref title="bto" placeholder="bto" /></td>
</tr> </tr>
<tr> <tr>
<td class="w-33"><input class="w-100" type="text" node_ref=cc_ref title="cc" placeholder="cc" value=format!("{}/followers", auth.user_id()) /></td> <td class="w-33"><input class="w-100" type="text" node_ref=cc_ref title="cc" placeholder="cc" value=format!("{URL_BASE}/actors/{}/followers", auth.username()) /></td>
<td class="w-33"><input class="w-100" type="text" node_ref=bcc_ref title="bcc" placeholder="bcc" /></td> <td class="w-33"><input class="w-100" type="text" node_ref=bcc_ref title="bcc" placeholder="bcc" /></td>
</tr> </tr>
</table> </table>
@ -472,10 +398,6 @@ pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
let bto = get_vec_if_some(bto_ref); let bto = get_vec_if_some(bto_ref);
let cc = get_vec_if_some(cc_ref); let cc = get_vec_if_some(cc_ref);
let bcc = get_vec_if_some(bcc_ref); let bcc = get_vec_if_some(bcc_ref);
let audience = match reply {
Some(ref reply) => crate::cache::OBJECTS.get(reply).and_then(|x| x.audience().id().ok()),
None => None,
};
let payload = serde_json::Value::Object(serde_json::Map::default()) let payload = serde_json::Value::Object(serde_json::Map::default())
.set_activity_type(Some(value.get().as_str().try_into().unwrap_or(apb::ActivityType::Create))) .set_activity_type(Some(value.get().as_str().try_into().unwrap_or(apb::ActivityType::Create)))
.set_to(apb::Node::links(to.clone())) .set_to(apb::Node::links(to.clone()))
@ -492,7 +414,6 @@ pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
.set_summary(summary) .set_summary(summary)
.set_content(content) .set_content(content)
.set_in_reply_to(apb::Node::maybe_link(reply)) .set_in_reply_to(apb::Node::maybe_link(reply))
.set_audience(apb::Node::maybe_link(audience))
.set_context(apb::Node::maybe_link(context)) .set_context(apb::Node::maybe_link(context))
.set_to(apb::Node::links(to)) .set_to(apb::Node::links(to))
.set_bto(apb::Node::links(bto)) .set_bto(apb::Node::links(bto))
@ -503,7 +424,7 @@ pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
apb::Node::maybe_link(object_id) apb::Node::maybe_link(object_id)
} }
); );
let target_url = auth.outbox(); let target_url = format!("{URL_BASE}/actors/{}/outbox", auth.username());
match Http::post(&target_url, &payload, auth).await { match Http::post(&target_url, &payload, auth).await {
Err(e) => set_error.set(Some(e.to_string())), Err(e) => set_error.set(Some(e.to_string())),
Ok(()) => set_error.set(None), Ok(()) => set_error.set(None),

View file

@ -31,7 +31,7 @@ pub fn ActorBanner(object: crate::Doc) -> impl IntoView {
let uri = Uri::web(U::Actor, &uid); let uri = Uri::web(U::Actor, &uid);
let avatar_url = object.icon_url().unwrap_or(FALLBACK_IMAGE_URL.into()); let avatar_url = object.icon_url().unwrap_or(FALLBACK_IMAGE_URL.into());
let username = object.preferred_username().unwrap_or_default().to_string(); let username = object.preferred_username().unwrap_or_default().to_string();
let domain = object.id().unwrap_or_default().replace("https://", "").replace("http://", "").split('/').next().unwrap_or_default().to_string(); let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string();
let display_name = object.name().unwrap_or_default().to_string(); let display_name = object.name().unwrap_or_default().to_string();
view! { view! {
<div> <div>

View file

@ -1,3 +1,5 @@
#![recursion_limit = "256"] // oh nooo leptos...
mod auth; mod auth;
mod app; mod app;
mod components; mod components;
@ -17,7 +19,7 @@ pub use auth::Auth;
pub mod prelude; pub mod prelude;
pub const URL_BASE: &str = match std::option_env!("UPUB_BASE_URL") { Some(x) => x, None => "" }; pub const URL_BASE: &str = "https://dev.upub.social";
pub const URL_PREFIX: &str = "/web"; pub const URL_PREFIX: &str = "/web";
pub const URL_SENSITIVE: &str = "https://cdn.alemi.dev/social/nsfw.png"; pub const URL_SENSITIVE: &str = "https://cdn.alemi.dev/social/nsfw.png";
pub const FALLBACK_IMAGE_URL: &str = "https://cdn.alemi.dev/social/gradient.png"; pub const FALLBACK_IMAGE_URL: &str = "https://cdn.alemi.dev/social/gradient.png";
@ -179,16 +181,16 @@ impl DashmapCache<Doc> {
} }
impl DashmapCache<String> { impl DashmapCache<String> {
pub async fn blocking_resolve(&self, user: &str, domain: &str, auth: Auth) -> Option<String> { pub async fn blocking_resolve(&self, user: &str, domain: &str) -> Option<String> {
if let Some(x) = self.resource(user, domain) { return Some(x); } if let Some(x) = self.resource(user, domain) { return Some(x); }
self.fetch(user, domain, auth).await; self.fetch(user, domain).await;
self.resource(user, domain) self.resource(user, domain)
} }
pub fn resolve(&self, user: &str, domain: &str, auth: Auth) -> Option<String> { pub fn resolve(&self, user: &str, domain: &str) -> Option<String> {
if let Some(x) = self.resource(user, domain) { return Some(x); } if let Some(x) = self.resource(user, domain) { return Some(x); }
let (_self, user, domain) = (self.clone(), user.to_string(), domain.to_string()); let (_self, user, domain) = (self.clone(), user.to_string(), domain.to_string());
leptos::task::spawn_local(async move { _self.fetch(&user, &domain, auth).await }); leptos::task::spawn_local(async move { _self.fetch(&user, &domain).await });
None None
} }
@ -197,20 +199,32 @@ impl DashmapCache<String> {
self.get(&query) self.get(&query)
} }
async fn fetch(&self, user: &str, domain: &str, auth: Auth) { async fn fetch(&self, user: &str, domain: &str) {
let query = format!("{user}@{domain}"); let query = format!("{user}@{domain}");
self.0.insert(query.to_string(), LookupStatus::Resolving); self.0.insert(query.to_string(), LookupStatus::Resolving);
match crate::Http::fetch::<jrd::JsonResourceDescriptor>(&format!("{URL_BASE}/.well-known/webfinger?resource=acct:{query}"), auth).await { match reqwest::get(format!("{URL_BASE}/.well-known/webfinger?resource=acct:{query}")).await {
Ok(doc) => { Ok(res) => match res.error_for_status() {
if let Some(uid) = doc.links.into_iter().find(|x| x.rel == "self").and_then(|x| x.href) { Ok(res) => match res.json::<jrd::JsonResourceDescriptor>().await {
self.0.insert(query, LookupStatus::Found(uid)); Ok(doc) => {
} else { if let Some(uid) = doc.links.into_iter().find(|x| x.rel == "self").and_then(|x| x.href) {
self.0.insert(query, LookupStatus::Found(uid));
} else {
self.0.insert(query, LookupStatus::NotFound);
}
},
Err(e) => {
tracing::error!("invalid webfinger response: {e:?}");
self.0.remove(&query);
},
},
Err(e) => {
tracing::error!("could not resolve webfinbger: {e:?}");
self.0.insert(query, LookupStatus::NotFound); self.0.insert(query, LookupStatus::NotFound);
} },
}, },
Err(e) => { Err(e) => {
tracing::error!("could not resolve webfinbger: {e:?}"); tracing::error!("failed accessing webfinger server: {e:?}");
self.0.insert(query, LookupStatus::NotFound); self.0.remove(&query);
}, },
} }
} }
@ -225,37 +239,14 @@ pub struct IdParam {
pub struct Http; pub struct Http;
impl Http { impl Http {
// TODO not really great.... also checked only once
pub fn location() -> &'static str {
static LOCATION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
LOCATION.get_or_init(||
web_sys::window()
.expect("could not access window element")
.location()
.origin()
.expect("could not access location origin")
).as_str()
}
pub async fn request<T: serde::ser::Serialize>( pub async fn request<T: serde::ser::Serialize>(
method: reqwest::Method, method: reqwest::Method,
url: &str, url: &str,
data: Option<&T>, data: Option<&T>,
auth: Auth, auth: Auth,
) -> reqwest::Result<reqwest::Response> { ) -> reqwest::Result<reqwest::Response> {
tracing::info!("making request to {url}");
use leptos::prelude::GetUntracked; use leptos::prelude::GetUntracked;
// TODO while in web environments it's ok (and i'd say good!) to fetch with relative urls,
// rust-url crate doesn't allow it throwing errors while constructing the url object
// itself. GET /nodeinfo/2.0.json is perfectly valid, but we have to convert it to
// something like GET http://127.0.0.1:3000/nodeinfo/2.0.json (or actual instance url for
// prod deployments). relevant issue: https://github.com/seanmonstar/reqwest/issues/1433
let mut url = url.to_string();
if !url.starts_with("http") {
url = format!("{}{url}", Self::location());
}
let mut req = reqwest::Client::new() let mut req = reqwest::Client::new()
.request(method, url); .request(method, url);
@ -304,7 +295,7 @@ impl Uri {
} }
pub fn short(url: &str) -> String { pub fn short(url: &str) -> String {
if url.starts_with(Http::location()) || url.starts_with('/') { if url.starts_with(URL_BASE) || url.starts_with('/') {
uriproxy::decompose(url) uriproxy::decompose(url)
} else if url.starts_with("https://") || url.starts_with("http://") { } else if url.starts_with("https://") || url.starts_with("http://") {
uriproxy::compact(url) uriproxy::compact(url)

View file

@ -20,11 +20,11 @@ pub fn Attachment(
let href = object.url().id().ok().unwrap_or_default(); let href = object.url().id().ok().unwrap_or_default();
let uncloaked = uncloak(href.split('/').last()).unwrap_or_default(); let uncloaked = uncloak(href.split('/').last()).unwrap_or_default();
let media_type = object.media_type() let media_type = object.media_type()
.unwrap_or("text/html".to_string()); // TODO make it an Option rather than defaulting to link everywhere .unwrap_or("link".to_string()); // TODO make it an Option rather than defaulting to link everywhere
let mut kind = media_type let mut kind = media_type
.split('/') .split('/')
.next() .next()
.unwrap_or("text") .unwrap_or("link")
.to_string(); .to_string();
// TODO in theory we should match on document_type, but mastodon and misskey send all attachments // TODO in theory we should match on document_type, but mastodon and misskey send all attachments
@ -33,7 +33,7 @@ pub fn Attachment(
// //
// those who correctly send Image type objects without a media type get shown as links here, this // those who correctly send Image type objects without a media type get shown as links here, this
// is a dirty fix to properly display as images // is a dirty fix to properly display as images
if kind == "text" && matches!(object.document_type(), Ok(apb::DocumentType::Image)) { if kind == "link" && matches!(object.document_type(), Ok(apb::DocumentType::Image)) {
kind = "image".to_string(); kind = "image".to_string();
} }

View file

@ -26,7 +26,7 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
let likes = object.likes_count().unwrap_or_default(); let likes = object.likes_count().unwrap_or_default();
let already_liked = object.liked_by_me().unwrap_or(false); let already_liked = object.liked_by_me().unwrap_or(false);
let attachments_padding = if object.attachment().flat().is_empty() { let attachments_padding = if object.attachment().is_empty() {
None None
} else { } else {
Some(view! { <div class="pb-1"></div> }) Some(view! { <div class="pb-1"></div> })
@ -56,7 +56,7 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
.ok() .ok()
.and_then(|x| { .and_then(|x| {
Some(view! { Some(view! {
<div class="quote mb-1"> <div class="quote">
<Object object=crate::cache::OBJECTS.get(&x)? controls=false /> <Object object=crate::cache::OBJECTS.get(&x)? controls=false />
</div> </div>
}) })
@ -148,7 +148,6 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
let post_inner = view! { let post_inner = view! {
<Summary summary=object.summary().ok().map(|x| x.to_string()) > <Summary summary=object.summary().ok().map(|x| x.to_string()) >
{quote_block}
<p inner_html={content}></p> <p inner_html={content}></p>
{attachments_padding} {attachments_padding}
{attachments} {attachments}
@ -159,6 +158,7 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
Ok(apb::ObjectType::Note) => view! { Ok(apb::ObjectType::Note) => view! {
<article class="tl"> <article class="tl">
{post_inner} {post_inner}
{quote_block}
</article> </article>
}.into_any(), }.into_any(),
// lemmy with Page, peertube with Video // lemmy with Page, peertube with Video
@ -170,6 +170,7 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
<b>{object.name().unwrap_or_default().to_string()}</b> <b>{object.name().unwrap_or_default().to_string()}</b>
</h4> </h4>
{post_inner} {post_inner}
{quote_block}
</div> </div>
</article> </article>
}.into_any(), }.into_any(),
@ -179,12 +180,14 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
<h3>{object.name().unwrap_or_default().to_string()}</h3> <h3>{object.name().unwrap_or_default().to_string()}</h3>
<hr /> <hr />
{post_inner} {post_inner}
{quote_block}
</article> </article>
}.into_any(), }.into_any(),
// everything else // everything else
Ok(t) => view! { Ok(t) => view! {
<h3>{t.as_ref().to_string()}</h3> <h3>{t.as_ref().to_string()}</h3>
{post_inner} {post_inner}
{quote_block}
}.into_any(), }.into_any(),
// object without type? // object without type?
Err(_) => view! { <code>missing object type</code> }.into_any(), Err(_) => view! { <code>missing object type</code> }.into_any(),
@ -227,15 +230,15 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
pub fn Summary(summary: Option<String>, children: Children) -> impl IntoView { pub fn Summary(summary: Option<String>, children: Children) -> impl IntoView {
let config = use_context::<Signal<crate::Config>>().expect("missing config context"); let config = use_context::<Signal<crate::Config>>().expect("missing config context");
match summary.filter(|x| !x.is_empty()) { match summary.filter(|x| !x.is_empty()) {
None => Either::Left(children()), None => children().into_any(),
Some(summary) => Either::Right(view! { Some(summary) => view! {
<details class="cw pa-s" prop:open=move || !config.get().collapse_content_warnings> <details class="cw pa-s" prop:open=move || !config.get().collapse_content_warnings>
<summary> <summary>
<code class="cw center color ml-s w-100 bb">{summary}</code> <code class="cw center color ml-s w-100 bb">{summary}</code>
</summary> </summary>
{children()} {children()}
</details> </details>
}), }.into_any(),
} }
} }
@ -264,7 +267,7 @@ pub fn LikeButton(
let (mut to, cc) = if private { let (mut to, cc) = if private {
(vec![], vec![]) (vec![], vec![])
} else { } else {
privacy.get().address(&auth.user_id()) privacy.get().address(&auth.username())
}; };
to.push(author.clone()); to.push(author.clone());
let payload = serde_json::Value::Object(serde_json::Map::default()) let payload = serde_json::Value::Object(serde_json::Map::default())
@ -340,7 +343,7 @@ pub fn RepostButton(n: i32, target: String, author: String) -> impl IntoView {
if !auth.present() { return; } if !auth.present() { return; }
if !clicked.get() { return; } if !clicked.get() { return; }
set_clicked.set(false); set_clicked.set(false);
let (mut to, cc) = privacy.get().address(&auth.user_id()); let (mut to, cc) = privacy.get().address(&auth.username());
to.push(author.clone()); to.push(author.clone());
let payload = serde_json::Value::Object(serde_json::Map::default()) let payload = serde_json::Value::Object(serde_json::Map::default())
.set_activity_type(Some(apb::ActivityType::Announce)) .set_activity_type(Some(apb::ActivityType::Announce))

View file

@ -187,7 +187,7 @@ pub fn ConfigPage(setter: WriteSignal<crate::Config>) -> impl IntoView {
)); ));
leptos::task::spawn_local(async move { leptos::task::spawn_local(async move {
if let Err(e) = Http::post(&auth.outbox(), &payload, auth).await { if let Err(e) = Http::post(&format!("{id}/outbox"), &payload, auth).await {
tracing::error!("could not send update activity: {e}"); tracing::error!("could not send update activity: {e}");
} }
}); });

View file

@ -28,6 +28,26 @@ pub fn SearchPage() -> impl IntoView {
} }
); );
let text_search = LocalResource::new(
move || {
let q = use_query_map().get().get("q").unwrap_or_default();
let search = format!("{URL_BASE}/search?q={q}");
async move {
let document = Http::fetch::<serde_json::Value>(&search, auth).await.ok()?;
Some(
crate::timeline::process_activities(
document,
Vec::new(),
true,
uriproxy::UriClass::Object,
auth,
).await
)
}
}
);
view! { view! {
<blockquote class="mt-3 mb-3"> <blockquote class="mt-3 mb-3">
@ -80,11 +100,21 @@ pub fn SearchPage() -> impl IntoView {
<code class="cw center color ml-s w-100">full text</code> <code class="cw center color ml-s w-100">full text</code>
</summary> </summary>
<div class="pb-1"> <div class="pb-1">
<Loadable {move || match text_search.get().map(|x| x.take()) {
base=format!("{URL_BASE}/search?q={}", query.get()) None => Some(view! { <p class="center"><small>searching...</small></p> }.into_any()),
convert=U::Object Some(None) => None,
element=|obj| view! { <Item item=obj sep=true /> } Some(Some(items)) => Some(view! {
/> // TODO ughhh too many clones
<For
each=move || items.clone()
key=|id| id.clone()
children=move |item| {
cache::OBJECTS.get(&item)
.map(|x| view! { <Item item=x always=true /> }.into_any())
}
/ >
}.into_any())
}}
</div> </div>
</details> </details>
</blockquote> </blockquote>

View file

@ -199,8 +199,9 @@ where
children=move |(id, obj)| children=move |(id, obj)|
view! { view! {
<details class="thread context depth-r" open> <details class="thread context depth-r" open>
<summary></summary> <summary>
{element(obj)} {element(obj)}
</summary>
<div class="depth-r"> <div class="depth-r">
<FeedRecursive items=items root=id element=element.clone() /> <FeedRecursive items=items root=id element=element.clone() />
</div> </div>

View file

@ -1,451 +0,0 @@
:root {
--main-col-percentage: 75%;
--transition-time: .05s;
--transition-time-long: .1s;
}
@font-face {
font-family: 'Fira Code';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("https://cdn.alemi.dev/web/font/FiraCode-Regular.woff2") format("woff2"), url("https://cdn.alemi.dev/web/font/FiraCode-Regular.woff") format("woff");
}
@font-face {
font-family: 'Fira Code';
font-style: normal;
font-weight: 800;
font-display: swap;
src: url("https://cdn.alemi.dev/web/font/FiraCode-Bold.woff2") format("woff2"), url("https://cdn.alemi.dev/web/font/FiraCode-Bold.woff") format("woff");
}
* {
font-family: 'Fira Code', Menlo, DejaVu Sans Mono, Monaco, Consolas, Ubuntu Mono, monospace;
}
html {
overflow-y: scroll;
height: 100vh;
}
body {
margin: 0;
padding-bottom: 1.2em;
font-size: 11pt;
}
textarea {
font-size: 10pt;
}
nav {
z-index: 90;
top: 0;
position: sticky;
padding-top: .05em;
background-color: var(--background);
}
footer {
width: 100%;
position: fixed;
bottom: 0;
background-color: var(--background);
text-align: center;
padding-bottom: 0;
line-height: 1rem;
}
main {
margin: 0em 1em;
}
blockquote {
margin-top: .5em;
margin-bottom: .5em;
margin-left: 1.25em;
padding-left: .3em;
overflow-wrap: break-word;
hyphens: auto;
border-left: solid 3px var(--background-secondary);
}
article {
word-break: break-word;
}
article.tl {
color: var(--text);
border-left: solid 3px var(--accent);
margin-left: 1.25em;
margin-right: 1em;
margin-top: 0;
margin-bottom: 0;
}
article.tl h1,
article.tl h2,
article.tl h3 {
margin-top: .1em;
margin-bottom: .1em;
}
article p {
margin: .5em 0 .5em .5em;
}
article.float-container {
overflow-y: auto;
}
b.displayname {
overflow-wrap: break-word;
}
table.align {
max-width: 100%;
}
table.fields,
table.fields tr,
table.fields td
{
border: 1px solid var(--background-dim);
word-wrap: break-word;
}
span.footer {
padding: .1em;
font-size: .6em;
color: var(--secondary);
}
span.nowrap {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: block;
}
hr.sep {
border: 1px solid rgba(var(--accent-rgb), 0.45);
}
div.sep-top {
border-top: 2px solid rgba(var(--accent-rgb), 0.45);
}
div.quote {
border: 3px solid var(--background-dim);
margin-top: 1em;
margin-left: 1em;
margin-bottom: 1em;
padding: 1em;
}
hr.sticky {
position: sticky;
z-index: 100;
margin-top: 0;
padding-top: 0;
margin-bottom: 0;
padding-bottom: 0;
top: 1.65rem;
}
div.sticky {
z-index: 100;
top: 2rem;
position: sticky;
background-color: var(--background);
}
span.border-button {
border: 1px solid var(--background-dim);
}
span.border-button:hover {
background-color: var(--background-dim);
}
div.border,
span.border,
ul.border,
table.border {
border: 1px dashed var(--accent);
}
div.inline {
display: inline;
}
div.notification {
background-color: var(--background-dim);
}
@media screen and (max-width: 786px) {
div.sticky {
top: 1.75rem;
padding-top: .25rem;
}
}
a.upub-title {
color: var(--accent);
text-decoration: none;
}
a.upub-title:hover {
text-decoration: underline;
}
a.hover {
text-decoration: none;
}
a.hover:hover {
text-decoration: underline;
}
a.breadcrumb {
text-decoration: none;
color: var(--secondary);
}
a.breadcrumb:hover {
font-weight: bold;
color: var(--accent);
}
b.big {
font-size: 18pt;
}
div.banner {
margin-top: .3em;
outline: .3em solid rgba(var(--accent-rgb), 0.33);
}
div.overlap {
position: relative;
bottom: 2em;
margin-bottom: -2em;
}
img {
max-width: 100%;
}
img.avatar {
display: inline;
border-radius: 50%;
}
img.avatar-border {
background-color: var(--background);
border: .3em solid var(--accent);
}
img.inline {
height: .75em;
}
img.avatar-actor {
min-height: 2em;
max-height: 2em;
min-width: 2em;
max-width: 2em;
}
img.flex-pic {
float: left;
width: 10em;
height: 10em;
object-fit: cover;
margin-right: 1em;
margin-top: .5em;
margin-bottom: .5em;
margin-left: .5em;
border: 3px solid var(--accent);
box-sizing: border-box;
}
img.flex-pic-expand {
width: unset;
height: unset;
max-width: calc(100% - 1.5em);
max-height: 90vh;
}
.box {
border: 3px solid var(--accent);
box-sizing: border-box;
}
.cursor {
cursor: pointer;
}
video.attachment {
height: 10em;
}
img.attachment {
cursor: pointer;
height: 10em;
border: 3px solid var(--accent);
padding: 5px;
object-fit: cover;
box-sizing: border-box;
}
img.expand,
video.expand {
height: unset;
max-height: 90vh;
max-width: 100%;
object-fit: contain;
}
div.tl-header {
background-color: rgba(var(--accent-rgb), 0.33);
color: var(--accent);
}
p.bio {
line-height: 1.2rem;
font-size: .8rem;
}
p.tiny-text {
line-height: .75em;
}
p.line {
margin: 0;
}
p.shadow {
text-shadow: 0px 0px 3px var(--background);
}
table.post-table {
border-collapse: collapse;
}
table p {
margin: .25em 1em;
}
tr.post-table,
td.post-table {
border: 1px dashed var(--accent);
padding: .5em;
}
td.top {
vertical-align: top;
}
td.bottom {
vertical-align: bottom;
}
details>summary::marker {
display: none;
}
details>summary {
list-style: none;
cursor: pointer;
text-align: center;
}
details.cw>summary:hover {
font-weight: bold;
}
details.thread>summary {
line-height: .75rem;
color: var(--background-secondary);
background-color: var(--background-dim);
}
details.thread[open]>summary {
background-color: var(--background);
}
details.thread>summary:hover {
background-color: var(--background-dim);
}
details.thread>summary::before {
content: ' + + + ';
}
details.thread[open]>summary::before {
content: ' - - -';
}
code.cw {
display: block;
}
input[type=button]:hover,
input[type=submit].active {
background-color: var(--accent);
border-color: var(--accent);
color: var(--background);
cursor: pointer;
}
input[type="range"] {
accent-color: var(--accent);
}
input[type="range"]:hover {
outline: none;
}
input[type="range"]:focus {
outline: none;
accent-color: var(--accent-dim);
}
.ml-1-r {
margin-left: 1em;
}
.mr-1-r {
margin-right: 1em;
}
.ml-3-r {
margin-left: 3em;
}
.mr-3-r {
margin-right: 3em;
}
.depth-r {
margin-left: .5em;
}
.only-on-mobile {
display: none;
}
@media screen and (max-width: 786px) {
.depth-r {
margin-left: .125em;
}
.ml-1-l {
margin-left: 0;
}
.mr-1-r {
margin-right: 0;
}
.ml-3-r {
margin-left: 0;
}
.mr-3-r {
margin-right: 0;
}
.only-on-mobile {
display: inherit;
}
.hidden-on-mobile {
display: none;
}
div.col-side {
padding-right: .25em;
}
main {
margin: 0;
}
}
@media screen and (max-width: 400px) {
.hidden-on-tiny {
display: none;
}
}
span.emoji {
color: transparent;
text-shadow: 0 0 0 var(--secondary);
}
span.emoji-btn:hover {
color: unset;
text-shadow: unset;
}
span.big-emoji {
font-size: 1.5em;
}
details.context {
border-left: 1px solid var(--background-dim);
padding-left: 1px;
}
span.json-key {
color: var(--accent);
}
span.json-text {
color: var(--text);
}
span.tab-active {
color: var(--accent);
font-weight: bold;
}
pre.striped {
background: repeating-linear-gradient(
135deg,
var(--background-dim),
var(--background-dim) .9em,
var(--background) .9em,
var(--background) 1em
);
}
.spinner {
animation: spin 1s linear infinite;
}
span.dots {
&:after {
animation: dots 1.5s linear infinite;
display: inline-block;
content: "\00a0\00a0\00a0";
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* \00a0 is unicode for "space", because otherwise it gets removed */
@keyframes dots {
0% { content: "\00a0\00a0\00a0"; }
25% { content: ".\00a0\00a0"; }
50% { content: "..\00a0"; }
75% { content: "..."; }
100% { content: "\00a0\00a0\00a0"; }
}

View file

@ -1,5 +0,0 @@
# upub worker
background worker for `upub`, processing activities and evolving internal server state
this handles both remote and local activities: it makes no distinction! local activities just get some pre-processing

View file

@ -1,5 +1,5 @@
use apb::{target::Addressed, Activity, ActivityMut, ActorMut, Base, BaseMut, Object, ObjectMut, Shortcuts}; use apb::{target::Addressed, Activity, ActivityMut, ActorMut, Base, BaseMut, Object, ObjectMut, Shortcuts};
use sea_orm::{prelude::Expr, ColumnTrait, DbErr, EntityTrait, QueryFilter, QuerySelect, SelectColumns, TransactionTrait}; use sea_orm::{prelude::Expr, ColumnTrait, DbErr, EntityTrait, QueryFilter, QueryOrder, QuerySelect, SelectColumns, TransactionTrait};
use upub::{model::{self, actor::Field}, traits::{process::ProcessorError, Addresser, Processor}, Context}; use upub::{model::{self, actor::Field}, traits::{process::ProcessorError, Addresser, Processor}, Context};
@ -46,54 +46,23 @@ pub async fn process(ctx: Context, job: &model::job::Model) -> crate::JobResult<
.set_published(Some(now)); .set_published(Some(now));
if matches!(t, apb::ObjectType::Activity(apb::ActivityType::Undo)) { if matches!(t, apb::ObjectType::Activity(apb::ActivityType::Undo)) {
match activity.object().id() { let mut undone = activity.object().into_inner()?;
Ok(undone) => { if undone.id().is_err() {
let activity = upub::model::activity::Entity::find_by_ap_id(&undone) let undone_target = undone.object().id()?;
.one(&tx) let undone_type = undone.activity_type().map_err(|_| crate::JobError::MissingPayload)?;
.await? let undone_model = model::activity::Entity::find()
.ok_or_else(|| DbErr::RecordNotFound(undone))?; .filter(model::activity::Column::Object.eq(&undone_target))
if activity.actor != job.actor { .filter(model::activity::Column::Actor.eq(&job.actor))
return Err(crate::JobError::Forbidden); .filter(model::activity::Column::ActivityType.eq(undone_type))
} .order_by_desc(model::activity::Column::Published)
}, .one(&tx)
Err(_) => { .await?
// frontend doesn't know the activity id, so we have to look it up .ok_or_else(|| sea_orm::DbErr::RecordNotFound(format!("actor={},type={},object={}",job.actor, undone_type, undone_target)))?;
let undone = activity.object().into_inner()?; // if even this is missing, malformed undone = undone
match undone.activity_type()? { .set_id(Some(undone_model.id))
apb::ActivityType::Follow => { .set_actor(apb::Node::link(job.actor.clone()));
let follower = undone.actor().id().unwrap_or(job.actor.clone());
let follower_internal = upub::model::actor::Entity::ap_to_internal(&follower, &tx)
.await?
.ok_or(sea_orm::DbErr::RecordNotFound(follower))?;
let following = undone.object().id()?;
let following_internal = upub::model::actor::Entity::ap_to_internal(&following, &tx)
.await?
.ok_or(sea_orm::DbErr::RecordNotFound(following))?;
let activity_id_internal = upub::model::relation::Entity::find()
.filter(upub::model::relation::Column::Follower.eq(follower_internal))
.filter(upub::model::relation::Column::Following.eq(following_internal))
.select_only()
.select_column(upub::model::relation::Column::Activity)
.into_tuple::<i64>()
.one(&tx)
.await?
.ok_or(crate::JobError::ProcessorError(ProcessorError::Incomplete))?;
let activity_id = upub::model::activity::Entity::find_by_id(activity_id_internal)
.select_only()
.select_column(upub::model::activity::Column::Id)
.into_tuple::<String>()
.one(&tx)
.await?
.ok_or(crate::JobError::ProcessorError(ProcessorError::Incomplete))?;
activity = activity.set_object(apb::Node::link(activity_id));
},
t => return Err(crate::JobError::ProcessorError(
ProcessorError::Unprocessable(format!("can't normalize Undo({t})"))
)),
}
},
} }
activity = activity.set_object(apb::Node::object(undone));
} }
macro_rules! update { macro_rules! update {