Compare commits

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

128 commits

Author SHA1 Message Date
beccf9bc64
fix(web): activity item component propagate articles 2025-02-20 17:50:40 +01:00
d85c9c64e7
fix(web): quote posts above original reply
so they get compressed, but also read first because i really hate quote
posts so im forcing you to read first the quoted person and then _maybe_
the other person quoting to steal interactions or dunk on them or being
too edgy to normal reply
2025-02-20 17:48:27 +01:00
d93034fbf1
fix: compat option to force document type 2025-02-12 13:31:25 +01:00
e6d6de4cc6
fix(cli): stream without transaction 2025-02-12 13:06:51 +01:00
cc9f3c85da
feat: proper nodeinfo stats 2025-02-12 12:58:26 +01:00
b4853e1bf6
fix: try to set attachments types properly 2025-02-12 11:47:15 +01:00
fafbf11b33
feat(cli): task to update attachments document_type 2025-02-12 11:38:10 +01:00
aa92ad6248
fix: use "text/html" media type rather than "link" 2025-02-12 11:37:53 +01:00
f05b72542c
fix(routes): copied without thinking 2025-02-11 23:31:45 +01:00
80eb1d27cb
fix(routes): more precise redirects 2025-02-10 10:10:39 +01:00
271b8eda2a
fix(web): copy audience from post being replied to 2025-02-07 19:22:10 +01:00
43a82edb5b
ci: bah run on ubuntu, i give up 2025-02-07 19:00:06 +01:00
bc6d138a03
ci: forgejo actions are so bad, omg 2025-02-07 18:58:32 +01:00
7a9b836c1c
ci: separate forgejo and github CI 2025-02-07 18:52:47 +01:00
5905879010
fix(cli): also likes have a like_count shortcut 2025-02-06 16:22:01 +01:00
3512dd826d
feat(cli): import also tries to recover post stats 2025-02-06 16:20:22 +01:00
bccde686f9
fix: akkoma always sets summary, want null not "" 2025-02-06 15:57:07 +01:00
c32785cab5
fix(cli): special handling for akkoma exports
should not break compat with just plain objects
2025-02-06 14:46:09 +01:00
06eebe3ac1
feat(cli): import handles attachments too 2025-02-06 14:26:18 +01:00
d5ba46a7be
feat(cli): import command
allows importing objects coming from previous instances, or generally
objects coming from a backup
2025-02-06 13:34:56 +01:00
310fd400db
fix: oops how did this slip 2025-02-05 23:32:05 +01:00
25f38724b9
fix: thank you gotosocial................ 2025-02-05 23:27:51 +01:00
dca326626f
fix: internal ids are used for relations 2025-02-05 20:29:49 +01:00
bf376b15ac
feat: undo Announces, also delete undone activity 2025-02-05 20:24:52 +01:00
81bb1e92e7
fix(web): use Either rather than into_any 2025-02-05 19:02:38 +01:00
c86ba0d291
feat(web): add remove follower 2025-02-05 18:55:23 +01:00
1e25ffffe1
feat: normalize undos coming from our frontend 2025-02-05 18:55:06 +01:00
9133577e4c
fix(web): functions can't access context? 2025-02-05 18:23:16 +01:00
2134380ff5
fix: allow outbound just-id undos 2025-02-03 15:01:18 +01:00
b7ff3701b0
fix: allow undoing with just activity id
will fetch undone activity from local db, rather than trusting the
embedded object
2025-02-03 14:40:34 +01:00
95bb2e60dc
fix(httpsign): recover original url in axum nested routes 2025-02-02 13:45:30 +01:00
3210a3a2d5
fix(web): actually media store alt text in name...
???????? whatever
2025-02-02 03:21:34 +01:00
89852c0870
feat(web): attachment summary, clear on post
attachment summary is alt text
2025-02-02 03:10:01 +01:00
37827588b7
chore: oops forgot to update this one 2025-02-02 02:58:07 +01:00
e5c5295242
fix(web): get_untracked 2025-02-02 02:57:17 +01:00
39ffbb137a
fix(web): allow posting empty body if attachment 2025-02-02 02:57:04 +01:00
259dc1fbf7
fix: generate document type on client
idk where it fails but not worth it to allow sending documents without
type
2025-02-02 02:53:59 +01:00
9f4a5b052a
fix: accept attachments without type
is this a mistake?
2025-02-02 02:33:55 +01:00
f69c798a77
feat(web): crude attachment input fields
allows to link media hosted elsewhere, and must specify correct media
type, but at least it's better than posting from curl
2025-02-02 02:13:00 +01:00
6f0026a818
fix: infer document type from mimetype 2025-02-02 01:25:12 +01:00
44690b3637
ci: i used download artifact?? also broken on gh 2025-01-29 17:36:47 +01:00
2ca6f8f134
ci: full url but still @ 2025-01-29 17:20:36 +01:00
6ffece2587
ci: must use forgejo fork......... 2025-01-29 17:15:24 +01:00
9893f20481
chore: better versioning
now it's <next-version>-dev rather than keeping previous version until
we make a release. i'm not sure what to do with inner crates: those
versions aren't really visible and managing ALL crates version would be
hell, but also ignoring all these feels very much arbitrary. idk
2025-01-29 16:58:08 +01:00
1144b03503
ci: does it work on one line? 2025-01-29 16:57:39 +01:00
8bd9549bec
ci: try setting PATH manually 2025-01-29 16:37:52 +01:00
8630869f53
ci: UGHH add alias for cargo
this is getting out of hand... i should probably split forgejo and
github CIs
2025-01-29 16:26:57 +01:00
339fae104a
ci: forgejo actions really troubling me 2025-01-29 16:09:16 +01:00
19e4bc0f6c
ci: im desperate 2025-01-29 16:07:15 +01:00
a658a090c1
ci: working-directory doesn't work on forgejo? 2025-01-29 16:05:19 +01:00
0d299c4bef
ci: forgot to remove cargo install trunk 2025-01-29 15:56:20 +01:00
6d56e581aa
ci: download trunk rather than building it
should DRAMATICALLY speed up CIs! building trunk took 10~15 minutes
every time
2025-01-29 15:53:36 +01:00
9ecb095f46
fix: rand moved things around 2025-01-29 15:44:08 +01:00
4df61c3bfc
fix: id for search collections 2025-01-29 15:40:11 +01:00
39d58f6dd0
chore: bump dep 2025-01-29 15:39:59 +01:00
899b292699
fix: also work with ids having queries 2025-01-29 15:38:59 +01:00
95da6d419c
chore(web): unused import 2025-01-29 15:26:45 +01:00
ae4459f5bb
fix(web): allow fetching more search pages 2025-01-29 15:25:08 +01:00
08fdc93d35
feat: optionally fetch and verify relayed activity
relays usually Announce(Create), so the Create is not from them but the
announce is, and it gets processed properly. Lemmy does the correct
thing: it sends Announce(...activity...), so the "topmost" activity
effectively comes from the sending server and can be verified. however
aode relay sends activities as-is, without wrapping. so if we receive
activities from someone else, it won't match the http signature and
we thus can't be sure this wasn't falsified. added an option to directly
fetch such cases. it's probably not great, so defaults to OFF
2025-01-28 13:55:20 +01:00
d77197a325
ci: dont copy, ure absolute paths
github is unhappy with me copying stuff around,
uuuugggggghhhhhhhhhhhhhhhhhhhhhhhhhh
2025-01-24 02:04:31 +01:00
f6637ac0cc
ci: do checkout later
maybe docker layers can be reused since they're always the same?
2025-01-24 02:00:47 +01:00
c37c0ac58c
chore(web): default online trunk build 2025-01-24 02:00:35 +01:00
d13fd39d81
ci: just build 2025-01-24 01:47:44 +01:00
221fcca082
ci: copy rust stuff to /usr/bin, ughhhhhhhhhh 2025-01-24 01:42:30 +01:00
e24ff2a749
ci: ughh interactive rustup 2025-01-24 01:37:25 +01:00
002da752c3
docs: small readmes for other crates 2025-01-24 01:27:11 +01:00
c08fed6636
ci: UGH rustup install is interactive
lets try with this one......
2025-01-24 01:22:53 +01:00
b7a290dc22
fix(web): better check to avoid attachment padding 2025-01-24 01:16:59 +01:00
e71fe89e12
fix(web): use Either 2025-01-24 01:16:38 +01:00
5e98ff6bf7
fix(web): undo <p> styling, looks too weird 2025-01-24 01:15:26 +01:00
afec0dcce6
fix: don't try building web by default 2025-01-24 01:05:11 +01:00
0c12b2b815
ci: also in test 2025-01-24 01:03:15 +01:00
0bb7bf3094
ci: there is no common action, do manual rustup 2025-01-24 01:01:55 +01:00
db879daf06
ci: actual ci to test and build 2025-01-24 00:53:35 +01:00
75d8267c44
ci: does this run on ubuntu? 2025-01-24 00:48:22 +01:00
1d5f200db2
ci: ughh is it literal 'docker' isnt it 2025-01-24 00:38:42 +01:00
1b393c4ec0
ci: do i need to use the runner name itself? 2025-01-24 00:38:03 +01:00
1b5849420e
ci: simple test CI
will this run on both forgejo and github?
2025-01-24 00:23:43 +01:00
a8808c3a95
fix: include a link to hashtag collection
i don't think this is necessary but mastodon seems to not recognize my
hashtags if i don't do this?
2025-01-23 14:14:43 +01:00
1762764862
docs(web): fix command for running dev 2025-01-23 13:40:45 +01:00
9cf2f3314f
fix(web): restyled paragraphs a bit 2025-01-23 13:40:31 +01:00
c49a8928d4
fix(web): hashtags feed 2025-01-23 12:10:23 +01:00
614eabf357
docs: small wording 2025-01-23 04:05:55 +01:00
cf9d36ac78
docs: small wording, config example 2025-01-23 04:04:35 +01:00
e737f2b7c6
chore: wow this is so OG im crying removing it
but it makes sense for nobody else, put the "slogan" there instead
2025-01-23 03:58:08 +01:00
c28459024b
docs: tell more about moderation and polylith 2025-01-23 03:56:36 +01:00
89a574d50b
docs: style 2025-01-23 03:41:07 +01:00
36c93e24d8
docs: clarify 2025-01-23 03:40:26 +01:00
4a932546af
docs: oops forgot to finish this sentence 2025-01-23 03:39:03 +01:00
edd23be385
chore: remove unused file 2025-01-23 03:37:32 +01:00
ae45077a87
docs: add download links 2025-01-23 03:37:19 +01:00
3c7d535bfd
docs(web): add frontend screenshot 2025-01-23 03:23:37 +01:00
87ad62fbd7
fix(routes): remove debug log 2025-01-23 03:16:07 +01:00
03f2e754da
feat(routes): track user agent in logs 2025-01-23 03:15:00 +01:00
42f7ddd2f8
fix(routes): don't log redirects 2025-01-23 03:08:26 +01:00
d94aa3aab3
fix(routes): centralize check, use correct header
not CONTENT_TYPE, ACCEPT!!
2025-01-23 03:08:04 +01:00
d44f3e441a
fix(routes): catch /web/ 2025-01-23 03:07:30 +01:00
79ded2737f
fix(routes): apparently can't serve with "" need "/" 2025-01-23 02:11:57 +01:00
5589a12312
feat(routes): redirect from ap to web and vice versa 2025-01-23 01:43:43 +01:00
008ce2a95c
feat(routes): timeout middleware 2025-01-23 01:43:31 +01:00
de9b635a30
chore(routes): refactor with router merge and tower 2025-01-23 01:41:35 +01:00
d8c416d1d9
feat(apb): introduce content-type constants 2025-01-23 01:38:14 +01:00
908662628b
docs(web): document a bit how to deal with frontend 2025-01-23 00:14:01 +01:00
5e6f5cfdf0
fix(cli): also change command invocation 2025-01-22 22:09:53 +01:00
430165c300
feat(cli): option to re-cloak already cloaked stuff 2025-01-22 21:28:22 +01:00
3ad0376940
feat(web): better thread compression
probably will be hard to use on mobile, but better than what we had
before which would toggle stuff when trying to scroll
2025-01-22 21:19:01 +01:00
55c50a92cc
chore: bump versions 2025-01-22 04:20:02 +01:00
aee7718485
docs: smaller image
html tricks, will these work everywhere?
2025-01-22 04:16:46 +01:00
bd8952b79d
docs: update README
some basic deploy instructions
2025-01-22 04:07:40 +01:00
5bb5780e75
fix: don't double cloak, for real 2025-01-22 03:54:32 +01:00
873ad98347
fix: don't double cloak, just re-cloak 2025-01-22 03:48:38 +01:00
a1a8901786
ci: set URL_BASE for prod frontend
can't hurt
2025-01-22 02:08:33 +01:00
1f36091ce3
fix(routes): frontend url now is not optional 2025-01-22 02:08:13 +01:00
b67d278bd8
fix: default port 3000 not 300 2025-01-22 02:07:53 +01:00
eba399abe5
fix(web): rely on user_id from server
this way we don't have to construct it ourselves every time with
URL_BASE. i think it's a bit weaker this way tho
2025-01-22 02:07:16 +01:00
d290a5e083
ci: update tci script 2025-01-22 01:21:00 +01:00
883523e99a
fix(web): fetch url without base
basically get the base from js window stuff (once!) and add it before
our base-less url, so that it works. this is ugly but ehh getting rid of
reqwest is a hassle...
2025-01-22 01:20:11 +01:00
0adf0d85d5
fix(web): fetch notifications only if logged in 2025-01-22 01:19:34 +01:00
ad65f86360
chore(web): trunk index file
oops this should have been part of commit fbbb635
2025-01-22 01:19:08 +01:00
2e41df9bf3
feat: run migrations while starting with monolith
just more user friendly
2025-01-22 01:18:40 +01:00
410dca1eef
fix: add like addressing only if config says so 2025-01-22 01:18:21 +01:00
59890d62f3
chore: remove leftovers 2025-01-22 01:18:10 +01:00
8daac4c662
build: smaller builds, profile for even smaller
just build with --profile=release-tiny instead of --release to make it
even smaller!
2025-01-22 01:17:46 +01:00
368cf1a480
chore: remove auto-build build.rs
too magic, very weird, just naaahh
2025-01-22 01:17:09 +01:00
555e1bfa21
chore: don't build web by default
requires extra step
2025-01-22 01:16:52 +01:00
40721e40ed
feat: config tweaks, better defaults
should just work ™️ for local development
2025-01-22 01:16:22 +01:00
03dfb36b17
fix(web): use Http util for requests 2025-01-22 01:16:09 +01:00
fbbb6350b8
feat(web): build CSR with trunk and bundle
used some build.rs magic to find my assets, trunk build must be run
manually
2025-01-22 01:14:14 +01:00
67 changed files with 2170 additions and 1055 deletions

View file

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

16
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,16 @@
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,7 +1,11 @@
#!/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"
cargo build --release --all-features -j 4
cargo build --release --features=web -j 4
echo "stopping service"
systemctl --user stop upub
echo "installing new binary"
@ -10,10 +14,4 @@ echo "migrating database"
/opt/bin/upub -c /etc/upub/config.toml migrate
echo "restarting service"
systemctl --user start upub
echo "rebuilding frontend"
cd web
CARGO_BUILD_JOBS=4 /opt/bin/trunk build --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"

140
Cargo.lock generated
View file

@ -23,7 +23,7 @@ version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom",
"getrandom 0.2.15",
"once_cell",
"version_check",
]
@ -1281,10 +1281,22 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
"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]]
name = "gimli"
version = "0.31.1"
@ -1708,7 +1720,7 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "httpsign"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"axum 0.8.1",
"base64",
@ -2077,7 +2089,7 @@ dependencies = [
"cfg-if",
"either_of",
"futures",
"getrandom",
"getrandom 0.2.15",
"hydration_context",
"leptos_config",
"leptos_dom",
@ -2087,7 +2099,7 @@ dependencies = [
"oco_ref",
"or_poisoned",
"paste",
"rand",
"rand 0.8.5",
"reactive_graph",
"rustc-hash",
"send_wrapper",
@ -2521,7 +2533,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0",
]
@ -2623,7 +2635,7 @@ dependencies = [
"num-integer",
"num-iter",
"num-traits",
"rand",
"rand 0.8.5",
"smallvec",
"zeroize",
]
@ -2892,7 +2904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
dependencies = [
"phf_shared 0.10.0",
"rand",
"rand 0.8.5",
]
[[package]]
@ -2902,7 +2914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand",
"rand 0.8.5",
]
[[package]]
@ -3007,7 +3019,7 @@ version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
dependencies = [
"zerocopy",
"zerocopy 0.7.35",
]
[[package]]
@ -3186,8 +3198,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"rand_chacha 0.3.1",
"rand_core 0.6.4",
]
[[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]]
@ -3197,7 +3220,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
"rand_core 0.6.4",
]
[[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]]
@ -3206,7 +3239,17 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"getrandom 0.2.15",
]
[[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]]
@ -3373,7 +3416,7 @@ checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"getrandom 0.2.15",
"libc",
"spin",
"untrusted",
@ -3422,7 +3465,7 @@ dependencies = [
"num-traits",
"pkcs1",
"pkcs8",
"rand_core",
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
@ -3454,7 +3497,7 @@ dependencies = [
"borsh",
"bytes",
"num-traits",
"rand",
"rand 0.8.5",
"rkyv",
"serde",
"serde_json",
@ -4029,7 +4072,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"rand_core",
"rand_core 0.6.4",
]
[[package]]
@ -4240,7 +4283,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rand",
"rand 0.8.5",
"rsa",
"rust_decimal",
"serde",
@ -4284,7 +4327,7 @@ dependencies = [
"memchr",
"num-bigint",
"once_cell",
"rand",
"rand 0.8.5",
"rust_decimal",
"serde",
"serde_json",
@ -4615,7 +4658,7 @@ checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
dependencies = [
"cfg-if",
"fastrand",
"getrandom",
"getrandom 0.2.15",
"once_cell",
"rustix",
"windows-sys 0.59.0",
@ -5119,7 +5162,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "upub"
version = "0.4.3"
version = "0.5.1-dev"
dependencies = [
"apb",
"async-recursion",
@ -5149,7 +5192,7 @@ dependencies = [
[[package]]
name = "upub-bin"
version = "0.4.3"
version = "0.5.1-dev"
dependencies = [
"clap",
"futures",
@ -5169,7 +5212,7 @@ dependencies = [
[[package]]
name = "upub-cli"
version = "0.3.0"
version = "0.3.1"
dependencies = [
"apb",
"chrono",
@ -5194,7 +5237,7 @@ dependencies = [
[[package]]
name = "upub-routes"
version = "0.3.0"
version = "0.4.1"
dependencies = [
"apb",
"axum 0.8.1",
@ -5207,7 +5250,7 @@ dependencies = [
"leptos_router",
"mastodon-async-entities",
"nodeinfo",
"rand",
"rand 0.9.0",
"reqwest",
"sea-orm",
"serde",
@ -5216,6 +5259,7 @@ dependencies = [
"thiserror 2.0.11",
"time",
"tokio",
"tower",
"tower-http",
"tracing",
"upub",
@ -5223,7 +5267,7 @@ dependencies = [
[[package]]
name = "upub-web"
version = "0.4.3"
version = "0.5.1-dev"
dependencies = [
"apb",
"base64",
@ -5324,7 +5368,7 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "744018581f9a3454a9e15beb8a33b017183f1e7c0cd170232a2d1453b23a51c4"
dependencies = [
"getrandom",
"getrandom 0.2.15",
"serde",
]
@ -5407,6 +5451,15 @@ version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "wasite"
version = "0.1.0"
@ -5753,6 +5806,15 @@ dependencies = [
"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]]
name = "write16"
version = "1.0.0"
@ -5832,7 +5894,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"byteorder",
"zerocopy-derive",
"zerocopy-derive 0.7.35",
]
[[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]]
@ -5846,6 +5917,17 @@ dependencies = [
"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]]
name = "zerofrom"
version = "0.1.5"

View file

@ -14,7 +14,7 @@ members = [
[package]
name = "upub-bin"
version = "0.4.3"
version = "0.5.1-dev"
edition = "2021"
authors = [ "alemi <me@alemi.dev>" ]
description = "Traits and types to handle ActivityPub objects"
@ -45,15 +45,28 @@ upub-routes = { path = "routes", optional = true }
upub-worker = { path = "worker", optional = true }
[features]
default = ["serve", "migrate", "cli", "worker", "web"]
default = ["serve", "migrate", "cli", "worker"]
serve = ["dep:upub-routes"]
migrate = ["dep:upub-migrations"]
cli = ["dep:upub-cli"]
worker = ["dep:upub-worker"]
web = []
web = ["upub-routes?/web"]
web-build-fe = []
[profile.wasm-release]
# upub: ~38M
# upub-web: ~9M
# [profile.release] # without any tweak
# upub: ~22M
# 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"
opt-level = 'z'
lto = true

222
README.md
View file

@ -1,42 +1,172 @@
<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
> [micro social network, federated](https://join.upub.social)
![screenshot of upub simple frontend](https://cdn.alemi.dev/proj/upub/fe/20240704.png)
> ## [micro social network, federated](https://join.upub.social)
>
> - [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
* follow development [in the dedicated matrix room](https://matrix.to/#/#upub:moonlit.technology)
μpub is modeled around timelines but tries to be unopinionated in its implementation, allowing representing multiple different fediverse "modalities" together
μ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
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**!
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**!
> [!NOTE]
> a test instance is available at [dev.upub.social](https://dev.upub.social)
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
## features
μ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"
a test instance is available at [dev.upub.social](https://dev.upub.social)
## about security
## 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
μpub correctly and rigorously implements and enforces access control on each object based on its addressing
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
> [!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
μpub may be considered to have "authorized fetch" permanently on, except it depends on each post:
* all posts marked public (meaning, addressed to "https://www.w3.org/ns/activitystreams#Public"), will be fetchable without any authorization
* 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 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
* 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)
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
> [!TIP]
> 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**
## 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.
## 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.
while this just works for small instances, larger servers should set up aggressive caching on `/proxy/...` path
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)
for example, on `nginx`:
# deploy
μ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
proxy_cache_path /tmp/upub/cache levels=1:2 keys_zone=upub_cache:100m max_size=50g inactive=168h use_temp_path=off;
@ -58,44 +188,34 @@ 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
μpub can always use more dev time!
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
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!
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)
reading a bit of the [ActivityPub](https://www.w3.org/TR/activitypub/) specification can be useful but not really required
don't hesitate to get in touch, i'd be thrilled to showcase the project to you!
hanging out in the relevant matrix room will probably be useful, as you can ask questions while familiarizing with the codebase
## progress
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!
- [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,5 +1,21 @@
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 {
fn ld_context(self) -> Self;
}

View file

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

View file

@ -1,12 +0,0 @@
fn main() {
#[cfg(all(feature = "web", feature = "web-build-fe"))]
{
println!("cargo:warning=running sub-process to build frontend");
let status = std::process::Command::new("cargo")
.current_dir("web")
.args(["build", "--profile=wasm-release", "--target=wasm32-unknown-unknown"])
.status()
.unwrap();
assert!(status.success(), "failed building wasm bundle");
}
}

View file

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

View file

@ -1 +1,55 @@
# 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
```

35
cli/src/attachments.rs Normal file
View file

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

119
cli/src/import.rs Normal file
View file

@ -0,0 +1,119 @@
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,6 +28,14 @@ pub use thread::*;
mod 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)]
pub enum CliCommand {
/// generate fake user, note and activity
@ -138,6 +146,10 @@ pub enum CliCommand {
/// also replace urls inside post contents
#[arg(long, default_value_t = false)]
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
@ -150,6 +162,29 @@ pub enum CliCommand {
#[arg(long, default_value_t = false)]
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>> {
@ -171,9 +206,13 @@ pub async fn run(ctx: upub::Context, command: CliCommand) -> Result<(), Box<dyn
Ok(nuke(ctx, for_real, delete_objects).await?),
CliCommand::Thread { } =>
Ok(thread(ctx).await?),
CliCommand::Cloak { objects, actors, contents } =>
Ok(cloak(ctx, contents, objects, actors).await?),
CliCommand::Cloak { objects, actors, contents, re_cloak } =>
Ok(cloak(ctx, contents, objects, actors, re_cloak).await?),
CliCommand::FixActivities { likes, announces } =>
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]
name = "upub"
version = "0.4.3"
version = "0.5.1-dev"
edition = "2021"
authors = [ "alemi <me@alemi.dev>" ]
description = "core inner workings of upub"

View file

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

View file

@ -1,5 +1,3 @@
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct Config {
@ -35,23 +33,23 @@ pub struct InstanceConfig {
/// description, shown in nodeinfo and instance actor
pub description: String,
#[serde_inline_default("upub.social".into())]
/// domain of current instance
#[serde_inline_default("http://127.0.0.1:3000".into())]
/// domain of current instance, must change this for prod
pub domain: String,
#[serde(default)]
/// contact information for an administrator, currently unused
pub contact: Option<String>,
pub contact: String,
#[serde(default)]
/// base url for frontend, will be used to compose pretty urls
pub frontend: Option<String>,
pub frontend: String,
}
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct DatasourceConfig {
#[serde_inline_default("sqlite://./upub.db".into())]
#[serde_inline_default("sqlite://./upub.db?mode=rwc".into())]
pub connection_string: String,
#[serde_inline_default(32)]
@ -94,7 +92,11 @@ pub struct SecurityConfig {
/// allow anonymous users to perform full-text searches
pub allow_public_search: bool,
#[serde_inline_default("changeme".to_string())]
#[serde_inline_default(30)]
/// 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
pub proxy_secret: String,
@ -126,17 +128,25 @@ pub struct SecurityConfig {
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, serde_default::DefaultFromSerde)]
pub struct CompatibilityConfig {
#[serde(default)]
/// compatibility with almost everything: set image attachments as images
pub fix_attachment_images_media_type: bool,
#[serde_inline_default(true)]
/// compatibility with almost everything: set document type as image/video/audio according to
/// mediaType, because almost all software sends us `Document` attachments
pub fix_attachment_media_type: bool,
#[serde(default)]
/// compatibility with lemmy and mastodon: notify like receiver
#[serde_inline_default(true)]
/// compatibility with mastodon and misskey (and somewhat lemmy?): notify like receiver
pub add_explicit_target_to_likes_if_local: bool,
#[serde(default)]
#[serde_inline_default(true)]
/// compatibility with lemmy: avoid showing images twice
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]
@ -194,7 +204,13 @@ impl Config {
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> {
Some(format!("{}{}", self.instance.frontend.as_deref()?, url))
if !self.instance.frontend.is_empty() {
Some(format!("{}{url}", self.instance.frontend))
} else {
None
}
}
}

View file

@ -66,9 +66,38 @@ impl Entity {
pub async fn nodeinfo(domain: &str) -> reqwest::Result<NodeInfoOwned> {
match reqwest::get(format!("https://{domain}/nodeinfo/2.0.json")).await {
Ok(res) => res.json().await,
Ok(res) => {
// 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
Err(_) => reqwest::get(format!("https://{domain}/nodeinfo/2.0.json"))
Err(_) => reqwest::get(format!("https://{domain}/nodeinfo/2.0"))
.await?
.json()
.await,

View file

@ -24,11 +24,12 @@ pub struct 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;
apb::new()
.set_name(Some(format!("#{}", self.hash.name)))
.set_link_type(Some(apb::LinkType::Hashtag))
.set_href(Some(crate::url!(ctx, "/tags/{}", self.hash.name)))
}
}

View file

@ -38,6 +38,25 @@ impl Cloaker for crate::Context {
}
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);
crate::url!(self, "/proxy/{sig}/{url}")
}
@ -47,15 +66,6 @@ impl Cloaker for crate::Context {
impl crate::Context {
pub fn sanitize(&self, text: &str) -> String {
let _ctx = self.clone();
mdhtml::Sanitizer::new(
Box::new(move |txt| {
if _ctx.is_local(txt) {
txt.to_string()
} else {
_ctx.cloaked(txt)
}
})
)
.html(text)
mdhtml::Sanitizer::new(Box::new(move |txt| _ctx.cloaked(txt))).html(text)
}
}

View file

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

View file

@ -1,4 +1,4 @@
use apb::{Document, Endpoints, Node, Object, PublicKey, Shortcuts};
use apb::{Endpoints, Node, Object, PublicKey, Shortcuts};
use sea_orm::{sea_query::Expr, ActiveModelTrait, ActiveValue::{Unchanged, NotSet, Set}, ColumnTrait, ConnectionTrait, DbErr, EntityTrait, IntoActiveModel, QueryFilter};
use super::{Cloaker, Fetcher};
@ -88,20 +88,43 @@ impl Normalizer for crate::Context {
if u == obj_image { continue };
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
},
Node::Link(l) => {
let url = l.href().unwrap_or_default();
if url == obj_image { continue };
let mut media_type = l.media_type().unwrap_or("link".to_string());
let mut document_type = apb::DocumentType::Page;
let mut media_type = l.media_type().unwrap_or("text/html".to_string());
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;
if [".jpg", ".jpeg", ".png", ".webp", ".bmp"] // TODO more image types???
.iter()
.any(|x| url.ends_with(x))
{
is_image = true;
if self.cfg().compat.fix_attachment_images_media_type {
if self.cfg().compat.fix_attachment_media_type {
document_type = apb::DocumentType::Image;
media_type = format!("image/{}", url.split('.').last().unwrap_or_default());
}
@ -268,17 +291,27 @@ impl AP {
pub fn attachment(document: &impl apb::Document, parent: i64) -> Result<crate::model::attachment::Model, NormalizerError> {
let t = document.base_type()?;
if !matches!(t, apb::BaseType::Object(apb::ObjectType::Document(_))) {
return Err(NormalizerError::WrongType(apb::BaseType::Object(apb::ObjectType::Document(apb::DocumentType::Document)), t));
let base_type = document.base_type()?;
if !matches!(base_type, apb::BaseType::Object(apb::ObjectType::Document(_))) {
return Err(NormalizerError::WrongType(apb::BaseType::Object(apb::ObjectType::Document(apb::DocumentType::Document)), base_type));
}
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 {
internal: 0,
url: document.url().id().unwrap_or_default(),
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(),
media_type: document.media_type().unwrap_or("link".to_string()),
media_type, document_type,
})
}

View file

@ -1,4 +1,4 @@
use apb::{target::Addressed, Activity, Actor, Base, Object};
use apb::{target::Addressed, Actor, Base, Object};
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}};
@ -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
// 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...
if likes_local_object {
if ctx.cfg().compat.add_explicit_target_to_likes_if_local && likes_local_object {
activity_model.to.0.push(obj.attributed_to.clone().unwrap_or_default());
}
ctx.address(Some(&activity_model), None, tx).await?;
@ -434,21 +434,26 @@ 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> {
// TODO in theory we could work with just object_id but right now only accept embedded
let undone_activity = activity.object().into_inner()?;
let undone_activity_id = activity.object().id()?;
let uid = activity.actor().id()?.to_string();
let internal_uid = crate::model::actor::Entity::ap_to_internal(&uid, tx)
.await?
.ok_or(ProcessorError::Incomplete)?;
if uid != undone_activity.as_activity()?.actor().id()? {
let undone_activity = crate::model::activity::Entity::find_by_ap_id(&undone_activity_id)
.one(tx)
.await?
.ok_or(ProcessorError::Incomplete)?;
if uid != undone_activity.actor {
return Err(ProcessorError::Unauthorized);
}
match undone_activity.as_activity()?.activity_type()? {
match undone_activity.activity_type {
apb::ActivityType::Like => {
let internal_oid = crate::model::object::Entity::ap_to_internal(
&undone_activity.as_activity()?.object().id()?,
&undone_activity.object.ok_or(apb::FieldErr("object"))?,
tx
)
.await?
@ -461,15 +466,45 @@ pub async fn process_undo(ctx: &crate::Context, activity: impl apb::Activity, tx
)
.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::Likes, Expr::col(crate::model::object::Column::Likes).sub(1))
.exec(tx)
.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 => {
let internal_uid_following = crate::model::actor::Entity::ap_to_internal(
&undone_activity.as_activity()?.object().id()?,
&undone_activity.object.ok_or(apb::FieldErr("object"))?,
tx,
)
.await?
@ -513,12 +548,10 @@ pub async fn process_undo(ctx: &crate::Context, activity: impl apb::Activity, tx
ctx.address(Some(&activity_model), None, tx).await?;
}
if let Some(internal) = crate::model::activity::Entity::ap_to_internal(&undone_activity.id()?, tx).await? {
crate::model::notification::Entity::delete_many()
.filter(crate::model::notification::Column::Activity.eq(internal))
.exec(tx)
.await?;
}
crate::model::notification::Entity::delete_many()
.filter(crate::model::notification::Column::Activity.eq(undone_activity.internal))
.exec(tx)
.await?;
Ok(())
}

12
main.rs
View file

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

View file

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

View file

@ -1,6 +1,6 @@
[package]
name = "upub-routes"
version = "0.3.0"
version = "0.4.1"
edition = "2021"
authors = [ "alemi <me@alemi.dev>" ]
description = "api route definitions for upub"
@ -12,7 +12,7 @@ readme = "README.md"
[dependencies]
thiserror = "2.0"
rand = "0.8"
rand = "0.9"
sha256 = "1.5" # TODO ughhh
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
@ -23,7 +23,8 @@ tracing = "0.1"
tokio = "1.43"
reqwest = { version = "0.12", features = ["json"] }
axum = { version = "0.8", features = ["multipart"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "timeout"] }
httpsign = { path = "../utils/httpsign/", features = ["axum"] }
apb = { path = "../apb", features = ["unstructured", "orm", "activitypub-fe", "activitypub-counters", "litepub", "ostatus", "toot", "jsonld"] }
sea-orm = "1.1"
@ -39,7 +40,7 @@ leptos_axum = { version = "0.7", optional = true }
leptos_meta = { version = "0.7", optional = true }
[features]
default = ["activitypub", "web"]
default = ["activitypub"]
activitypub = []
mastodon = ["dep:mastodon-async-entities"]
web = [
@ -48,3 +49,5 @@ web = [
"dep:leptos_axum",
"dep:leptos_meta"
]
web-redirect = []
activitypub-redirect = []

View file

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

89
routes/build.rs Normal file
View file

@ -0,0 +1,89 @@
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
if let Some(ref fe) = ctx.cfg().instance.frontend {
user = user.set_url(Node::link(format!("{fe}/actors/{id}")));
if !ctx.cfg().instance.frontend.is_empty() {
user = user.set_url(Node::link(format!("{}/actors/{id}", ctx.cfg().instance.frontend)));
}
Ok(JsonLD(user.ld_context()))

View file

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

View file

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

View file

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

View file

@ -12,77 +12,100 @@ pub mod well_known;
use axum::{http::StatusCode, response::IntoResponse, routing::{get, patch, post, put}, Router};
impl super::ActivityPubRouter for Router<upub::Context> {
fn ap_routes(self) -> Self {
use crate::activitypub as ap; // TODO use self ?
self
// core server inbox/outbox, maybe for feeds? TODO do we need these?
.route("/", get(ap::application::view))
// fetch route, to debug and retreive remote objects
.route("/search", get(ap::application::search))
.route("/fetch", get(ap::application::ap_fetch))
.route("/proxy/{hmac}/{uri}", get(ap::application::cloak_proxy))
.route("/inbox", post(ap::inbox::post))
.route("/inbox", get(ap::inbox::get))
.route("/inbox/page", get(ap::inbox::page))
.route("/outbox", post(ap::outbox::post))
.route("/outbox", get(ap::outbox::get))
.route("/outbox/page", get(ap::outbox::page))
// AUTH routes
.route("/auth", put(ap::auth::register))
.route("/auth", post(ap::auth::login))
.route("/auth", patch(ap::auth::refresh))
// .well-known and discovery
.route("/manifest.json", get(ap::well_known::manifest))
.route("/.well-known/webfinger", get(ap::well_known::webfinger))
.route("/.well-known/host-meta", get(ap::well_known::host_meta))
.route("/.well-known/nodeinfo", get(ap::well_known::nodeinfo_discovery))
.route("/.well-known/oauth-authorization-server", get(ap::well_known::oauth_authorization_server))
.route("/nodeinfo/{version}", get(ap::well_known::nodeinfo))
// actor routes
.route("/actors/{id}", get(ap::actor::view))
.route("/actors/{id}/inbox", post(ap::actor::inbox::post))
.route("/actors/{id}/inbox", get(ap::actor::inbox::get))
.route("/actors/{id}/inbox/page", get(ap::actor::inbox::page))
.route("/actors/{id}/outbox", post(ap::actor::outbox::post))
.route("/actors/{id}/outbox", get(ap::actor::outbox::get))
.route("/actors/{id}/outbox/page", get(ap::actor::outbox::page))
.route("/actors/{id}/notifications", get(ap::actor::notifications::get))
.route("/actors/{id}/notifications/page", get(ap::actor::notifications::page))
.route("/actors/{id}/followers", get(ap::actor::following::get::<false>))
.route("/actors/{id}/followers/page", get(ap::actor::following::page::<false>))
.route("/actors/{id}/following", get(ap::actor::following::get::<true>))
.route("/actors/{id}/following/page", get(ap::actor::following::page::<true>))
.route("/actors/{id}/likes", get(ap::actor::likes::get))
.route("/actors/{id}/likes/page", get(ap::actor::likes::page))
.route("/groups", get(ap::groups::get))
.route("/groups/page", get(ap::groups::page))
// .route("/actors/{id}/audience", get(ap::actor::audience::get))
// .route("/actors/{id}/audience/page", get(ap::actor::audience::page))
// activities
.route("/activities/{id}", get(ap::activity::view))
// hashtags
.route("/tags/{id}", get(ap::tags::get))
.route("/tags/{id}/page", get(ap::tags::page))
// specific object routes
.route("/objects/{id}", get(ap::object::view))
.route("/objects/{id}/replies", get(ap::object::replies::get))
.route("/objects/{id}/replies/page", get(ap::object::replies::page))
.route("/objects/{id}/context", get(ap::object::context::get))
.route("/objects/{id}/context/page", get(ap::object::context::page))
.route("/objects/{id}/likes", get(ap::object::likes::get))
.route("/objects/{id}/likes/page", get(ap::object::likes::page))
.route("/objects/{id}/shares", get(ap::object::shares::get))
.route("/objects/{id}/shares/page", get(ap::object::shares::page))
// file routes
.route("/file", post(ap::file::upload))
.route("/file/{id}", get(ap::file::download))
//.route("/objects/{id}/likes", get(ap::object::likes::get))
//.route("/objects/{id}/likes/page", get(ap::object::likes::page))
//.route("/objects/{id}/shares", get(ap::object::announces::get))
//.route("/objects/{id}/shares/page", get(ap::object::announces::page))
pub fn ap_routes(ctx: upub::Context) -> Router {
use crate::activitypub as ap; // TODO use self ?
Router::new()
.route("/", get(ap::application::view))
.route("/search", get(ap::application::search))
.route("/fetch", get(ap::application::ap_fetch))
.route("/proxy/{hmac}/{uri}", get(ap::application::cloak_proxy))
.route("/inbox", post(ap::inbox::post))
.route("/inbox", get(ap::inbox::get))
.route("/inbox/page", get(ap::inbox::page))
.route("/outbox", post(ap::outbox::post))
.route("/outbox", get(ap::outbox::get))
.route("/outbox/page", get(ap::outbox::page))
.route("/auth", put(ap::auth::register))
.route("/auth", post(ap::auth::login))
.route("/auth", patch(ap::auth::refresh))
.nest("/.well-known", Router::new()
.route("/webfinger", get(ap::well_known::webfinger))
.route("/host-meta", get(ap::well_known::host_meta))
.route("/nodeinfo", get(ap::well_known::nodeinfo_discovery))
.route("/oauth-authorization-server", get(ap::well_known::oauth_authorization_server))
)
.route("/manifest.json", get(ap::well_known::manifest))
.route("/nodeinfo/{version}", get(ap::well_known::nodeinfo))
.route("/groups", get(ap::groups::get))
.route("/groups/page", get(ap::groups::page))
.nest("/actors/{id}", Router::new()
.route("/", get(ap::actor::view))
.route("/inbox", post(ap::actor::inbox::post))
.route("/inbox", get(ap::actor::inbox::get))
.route("/inbox/page", get(ap::actor::inbox::page))
.route("/outbox", post(ap::actor::outbox::post))
.route("/outbox", get(ap::actor::outbox::get))
.route("/outbox/page", get(ap::actor::outbox::page))
.route("/notifications", get(ap::actor::notifications::get))
.route("/notifications/page", get(ap::actor::notifications::page))
.route("/followers", get(ap::actor::following::get::<false>))
.route("/followers/page", get(ap::actor::following::page::<false>))
.route("/following", get(ap::actor::following::get::<true>))
.route("/following/page", get(ap::actor::following::page::<true>))
// .route("/audience", get(ap::actor::audience::get))
// .route("/audience/page", get(ap::actor::audience::page))
.route("/likes", get(ap::actor::likes::get))
.route("/likes/page", get(ap::actor::likes::page))
)
.route("/activities/{id}", get(ap::activity::view))
.nest("/objects/{id}", Router::new()
.route("/", get(ap::object::view))
.route("/replies", get(ap::object::replies::get))
.route("/replies/page", get(ap::object::replies::page))
.route("/context", get(ap::object::context::get))
.route("/context/page", get(ap::object::context::page))
.route("/likes", get(ap::object::likes::get))
.route("/likes/page", get(ap::object::likes::page))
.route("/shares", get(ap::object::shares::get))
.route("/shares/page", get(ap::object::shares::page))
)
.route("/tags/{id}", get(ap::tags::get))
.route("/tags/{id}/page", get(ap::tags::page))
.route("/file", post(ap::file::upload))
.route("/file/{id}", get(ap::file::download))
.route_layer(axum::middleware::from_fn(redirect_to_web))
.with_state(ctx)
}
async fn redirect_to_web(
request: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
#[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)]

View file

@ -1,10 +1,10 @@
use std::sync::atomic::AtomicI64;
use axum::{extract::{Path, Query, State}, http::StatusCode, response::{IntoResponse, Response}, Json};
use jrd::{JsonResourceDescriptor, JsonResourceDescriptorLink};
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter};
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, SelectColumns};
use upub::{model, Context};
use crate::ApiError;
#[derive(serde::Serialize)]
pub struct NodeInfoDiscovery {
pub links: Vec<NodeInfoDiscoveryRel>,
@ -33,12 +33,81 @@ 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"
// even if None! technically leads to invalid nodeinfo 2.0
pub async fn nodeinfo(State(ctx): State<Context>, Path(version): Path<String>) -> Result<Json<nodeinfo::NodeInfoOwned>, StatusCode> {
// TODO it's unsustainable to count these every time, especially comments since it's a complex
// filter! keep these numbers caches somewhere, maybe db, so that we can just look them up
let total_users = model::actor::Entity::find().count(ctx.db()).await.ok();
let total_posts = None;
let total_comments = None;
pub async fn nodeinfo(State(ctx): State<Context>, Path(version): Path<String>) -> crate::ApiResult<Json<nodeinfo::NodeInfoOwned>> {
// keep these as statics so they get calculated once and then stay cached
// TODO this will cache them just once per runtime, maybe re-calculate them after some time?
static TOTAL_USERS: AtomicI64 = AtomicI64::new(i64::MIN);
static TOTAL_POSTS: AtomicI64 = AtomicI64::new(i64::MIN);
static TOTAL_COMMENTS: AtomicI64 = AtomicI64::new(i64::MIN);
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() {
"2.0.json" | "2.0" => (
nodeinfo::types::Software {
@ -53,30 +122,30 @@ pub async fn nodeinfo(State(ctx): State<Context>, Path(version): Path<String>) -
nodeinfo::types::Software {
name: "μpub".to_string(),
version: Some(upub::VERSION.into()),
repository: Some("https://git.alemi.dev/upub.git/".into()),
homepage: None,
repository: Some("https://moonlit.technology/alemi/upub".into()),
homepage: Some("https://join.upub.social".into()),
},
"2.1".to_string()
),
_ => return Err(StatusCode::NOT_IMPLEMENTED),
_ => return Err(crate::ApiError::Status(StatusCode::NOT_IMPLEMENTED)),
};
Ok(Json(
nodeinfo::NodeInfoOwned {
version,
software,
open_registrations: false,
open_registrations: ctx.cfg().security.allow_registration,
protocols: vec!["activitypub".into()],
services: nodeinfo::types::Services {
inbound: vec![],
outbound: vec![],
},
usage: nodeinfo::types::Usage {
local_posts: total_posts,
local_comments: total_comments,
local_posts: Some(total_posts),
local_comments: Some(total_comments),
users: Some(nodeinfo::types::Users {
active_month: None,
active_halfyear: None,
total: total_users.map(|x| x as i64),
active_month: Some(total_active_users_month),
active_halfyear: Some(total_active_users_halfyear),
total: Some(total_users),
}),
},
metadata: serde_json::Map::default(),
@ -124,7 +193,7 @@ pub async fn webfinger(
.await?
{
Some(usr) => usr,
None => return Err(ApiError::not_found()),
None => return Err(crate::ApiError::not_found()),
}
} else {
return Err(StatusCode::UNPROCESSABLE_ENTITY.into());
@ -145,7 +214,7 @@ pub async fn webfinger(
links: vec![
JsonResourceDescriptorLink {
rel: "self".to_string(),
link_type: Some("application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"".to_string()),
link_type: Some(apb::jsonld::CONTENT_TYPE_LD_JSON_ACTIVITYPUB.to_string()),
href: Some(user.id),
properties: jrd::Map::default(),
titles: jrd::Map::default(),

View file

@ -7,6 +7,8 @@ pub fn collection_page(id: &str, page: Pagination, items: apb::Node<serde_json::
let (limit, offset) = page.pagination();
let next = if items.len() < limit as usize {
apb::Node::Empty
} else if id.contains('?') {
apb::Node::link(format!("{id}&offset={}", offset+limit))
} else {
apb::Node::link(format!("{id}?offset={}", offset+limit))
};
@ -39,8 +41,27 @@ pub struct JsonLD<T>(pub T);
impl<T: serde::Serialize> IntoResponse for JsonLD<T> {
fn into_response(self) -> Response {
(
[("Content-Type", "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")],
[("Content-Type", apb::jsonld::CONTENT_TYPE_LD_JSON_ACTIVITYPUB)],
axum::Json(self.0)
).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,5 +1,3 @@
use tower_http::classify::{SharedClassifier, StatusInRangeAsFailures};
pub mod auth;
pub use auth::{AuthIdentity, Identity};
@ -9,63 +7,52 @@ pub use error::{ApiError, ApiResult};
pub mod builders;
pub trait ActivityPubRouter {
fn ap_routes(self) -> Self where Self: Sized { self }
}
#[cfg(feature = "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")]
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) -> Self where Self: Sized { self }
}
#[cfg(feature = "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> {
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use tower_http::{
cors::CorsLayer, trace::TraceLayer, timeout::TimeoutLayer,
classify::{SharedClassifier, StatusInRangeAsFailures}
};
let router = axum::Router::new()
let mut 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(
// 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
TraceLayer::new(SharedClassifier::new(StatusInRangeAsFailures::new(300..=999)))
.make_span_with(|req: &axum::http::Request<_>| {
tracing::span!(
tracing::Level::INFO,
"request",
uri = %req.uri(),
status_code = tracing::field::Empty,
)
})
)
.ap_routes()
.mastodon_routes() // no-op if mastodon feature is disabled
.web_routes() // no-op if web feature is disabled
.layer(CorsLayer::permissive())
.with_state(ctx);
tower::ServiceBuilder::new()
// 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
.layer(
TraceLayer::new(SharedClassifier::new(StatusInRangeAsFailures::new(400..=999)))
.make_span_with(|req: &axum::http::Request<_>| {
tracing::span!(
tracing::Level::INFO,
"request",
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,
)
})
)
.layer(CorsLayer::permissive())
.layer(TimeoutLayer::new(std::time::Duration::from_secs(ctx.cfg().security.request_timeout)))
);
tracing::info!("serving api routes on {bind}");

View file

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

View file

@ -1,27 +0,0 @@
use axum::{response::IntoResponse, routing::get, Router};
impl super::WebRouter for Router<upub::Context> {
fn web_routes(self) -> Self {
self
.route("/web/assets/upub-web.wasm", get(upub_web_wasm))
.route("/web/assets/style.css", get(upub_style_css))
.route("/web", get(upub_web_index))
.route("/web/", get(upub_web_index))
.route("/web/{*any}", get(upub_web_index))
}
}
async fn upub_web_wasm() -> impl IntoResponse {
include_bytes!("../../target/wasm32-unknown-unknown/wasm-release/upub-web.wasm")
}
async fn upub_style_css() -> impl IntoResponse {
include_str!("../../web/assets/style.css")
}
async fn upub_web_index() -> impl IntoResponse {
include_str!("../../web/index.html")
}

93
routes/src/web/mod.rs Normal file
View file

@ -0,0 +1,93 @@
use axum::{response::IntoResponse, routing, Router, http};
pub fn web_routes(ctx: upub::Context) -> Router {
Router::new()
.route("/web/", routing::get(|| async { axum::response::Redirect::permanent("/web") }))
.nest("/web", Router::new()
.nest("/assets", Router::new()
.route("/upub-web.js", routing::get(upub_web_js))
.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]
name = "httpsign"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
authors = [ "alemi <me@alemi.dev>" ]
description = "fediverse-friendly implementation of http signaures in rust"

View file

@ -85,16 +85,26 @@ impl HttpSignature {
#[cfg(feature = "axum")]
pub fn build_from_parts(&mut self, parts: &axum::http::request::Parts) -> &mut Self {
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() {
match header.as_str() {
"(request-target)" => out.push(
format!(
"(request-target): {} {}",
parts.method.to_string().to_lowercase(),
parts.uri.path_and_query().map(|x| x.as_str()).unwrap_or("/")
)
),
// TODO other pseudo-headers,
// pseudo-headers
"(request-target)" => out.push(format!("(request-target): {method} {uri}")),
// TODO handle other pseudo-headers,
// normal headers
_ => out.push(format!("{}: {}",
header.to_lowercase(),
parts.headers.get(header).map(|x| x.to_str().unwrap_or("")).unwrap_or("")

7
utils/mdhtml/README.md Normal file
View file

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

7
utils/uriproxy/README.md Normal file
View file

@ -0,0 +1,7 @@
# 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]
name = "upub-web"
version = "0.4.3"
version = "0.5.1-dev"
edition = "2021"
authors = [ "alemi <me@alemi.dev>" ]
description = "web frontend for upub"
@ -38,3 +38,8 @@ jrd = "0.1"
tld = "2.36"
web-sys = { version = "0.3", features = ["Screen"] }
regex = "1.11"
[package.metadata.trunk.build]
public_url = "/web/assets/"
filehash = false
#offline = true # if you're looking in here, you may want to uncomment this and download wasm-bindgen-cli yourself

View file

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

View file

BIN
web/favicon.ico Normal file

Binary file not shown.

After

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

BIN
web/icon.png Normal file

Binary file not shown.

After

(image error) Size: 43 KiB

View file

@ -12,455 +12,20 @@
<meta property="og:url" content="https://upub.alemi.dev/web" />
<meta property="og:site_name" content="upub" />
<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="preload" href="https://cdn.alemi.dev/web/font/FiraCode-Bold.woff2" as="font" type="font/woff2" crossorigin="anonymous">
<link rel="icon" data-trunk href="favicon.ico" type="image/x-icon" />
<link rel="manifest" href="/web/assets/manifest.json" />
<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">
<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>
<link rel="css" data-trunk href="style.css" />
</head>
</head>
<body>
</body>
</html>

15
web/manifest.json Normal file
View file

@ -0,0 +1,15 @@
{
"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) {
// special case for placeholder activities
apb::ObjectType::Note | apb::ObjectType::Document(_) =>
apb::ObjectType::Note | apb::ObjectType::Document(_) | apb::ObjectType::Article =>
Some(view! { <Object object=item.clone() />{sep.clone()} }.into_any()),
// everything else
apb::ObjectType::Activity(t) => {

View file

@ -57,6 +57,7 @@ pub fn ActorHeader() -> impl IntoView {
// TODO what the fuck...
let _uid = uid.clone();
let __uid = uid.clone();
let ___uid = uid.clone();
view! {
<div class="ml-3 mr-3">
<div
@ -85,8 +86,8 @@ pub fn ActorHeader() -> impl IntoView {
<div class="mr-1 ml-1" class:hidden=move || !auth.present() || auth.user_id() == uid>
{if following_me {
Some(view! {
<a class="clean dim" href="#remove" on:click=move |_| tracing::error!("not yet implemented")>
<span class="border-button ml-s" title="remove follower (not yet implemented)">
<a class="clean dim" href="#remove" on:click=move |_| remove_follower(___uid.clone(), auth)>
<span class="border-button ml-s" title="remove follower">
<code class="color mr-s">"!"</code>
<small class="mr-s">follows you</small>
</span>
@ -96,23 +97,23 @@ pub fn ActorHeader() -> impl IntoView {
None
}}
{if followed_by_me {
view! {
<a class="clean dim" href="#unfollow" on:click=move |_| unfollow(_uid.clone())>
Either::Left(view! {
<a class="clean dim" href="#unfollow" on:click=move |_| unfollow(_uid.clone(), auth)>
<span class="border-button ml-s" title="undo follow">
<code class="color mr-s">x</code>
<small class="mr-s">following</small>
</span>
</a>
}.into_any()
})
} else {
view! {
<a class="clean dim" href="#follow" on:click=move |_| send_follow_request(_uid.clone())>
Either::Right(view! {
<a class="clean dim" href="#follow" on:click=move |_| send_follow_request(_uid.clone(), auth)>
<span class="border-button ml-s" title="send follow request">
<code class="color mr-s">+</code>
<small class="mr-s">follow</small>
</span>
</a>
}.into_any()
})
}}
</div>
</div>
@ -177,8 +178,7 @@ async fn send_follow_response(kind: apb::ActivityType, target: String, to: Strin
}
}
fn send_follow_request(target: String) {
let auth = use_context::<Auth>().expect("missing auth context");
fn send_follow_request(target: String, auth: Auth) {
leptos::task::spawn_local(async move {
let payload = apb::new()
.set_activity_type(Some(apb::ActivityType::Follow))
@ -190,8 +190,7 @@ fn send_follow_request(target: String) {
})
}
fn unfollow(target: String) {
let auth = use_context::<Auth>().expect("missing auth context");
fn unfollow(target: String, auth: Auth) {
leptos::task::spawn_local(async move {
let payload = apb::new()
.set_activity_type(Some(apb::ActivityType::Undo))
@ -202,7 +201,24 @@ fn unfollow(target: String) {
.set_object(apb::Node::link(target))
));
if let Err(e) = Http::post(&auth.outbox(), &payload, auth).await {
tracing::error!("failed sending follow request: {e}");
tracing::error!("failed sending unfollow: {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,21 +62,22 @@ pub fn App() -> impl IntoView {
let title_target = move || if auth.present() { "/web/home" } else { "/web/global" };
// refresh token immediately and every hour
let refresh_token = move || leptos::task::spawn_local(async move { Auth::refresh(auth.token, set_token, set_userid).await; });
let refresh_token = move || leptos::task::spawn_local(async move { Auth::refresh(auth, set_token, set_userid).await; });
refresh_token();
set_interval(refresh_token, std::time::Duration::from_secs(3600));
// refresh notifications
let (notifications, set_notifications) = signal(0);
let fetch_notifications = move || leptos::task::spawn_local(async move {
let actor_id = userid.get_untracked().unwrap_or_default();
let notif_url = format!("{actor_id}/notifications");
match Http::fetch::<serde_json::Value>(&notif_url, auth).await {
Err(e) => tracing::error!("failed fetching notifications: {e}"),
Ok(doc) => if let Ok(count) = doc.total_items() {
set_notifications.set(count);
},
}
if let Some(actor_id) = userid.get_untracked() {
let notif_url = format!("{actor_id}/notifications");
match Http::fetch::<serde_json::Value>(&notif_url, auth).await {
Err(e) => tracing::error!("failed fetching notifications: {e}"),
Ok(doc) => if let Ok(count) = doc.total_items() {
set_notifications.set(count);
},
}
}
});
fetch_notifications();
set_interval(fetch_notifications, std::time::Duration::from_secs(60));
@ -129,7 +130,7 @@ pub fn App() -> impl IntoView {
<Route path=path!("home") view=move || if auth.present() {
Either::Left(view! {
<Loadable
base=format!("{URL_BASE}/actors/{}/inbox/page", auth.username())
base=format!("{}/inbox/page", auth.user_id())
element=move |obj| view! { <Item item=obj sep=true /> }
/>
})
@ -154,7 +155,7 @@ pub fn App() -> impl IntoView {
<Route path=path!("notifications") view=move || if auth.present() {
Either::Left(view! {
<Loadable
base=format!("{URL_BASE}/actors/{}/notifications/page", auth.username())
base=format!("{}/notifications/page", auth.user_id())
element=move |obj| view! { <Item item=obj sep=true always=true /> }
/>
})
@ -167,7 +168,7 @@ pub fn App() -> impl IntoView {
let tag = params.get().ok().and_then(|x| x.id).unwrap_or_default();
view! {
<Loadable
base=format!("{URL_BASE}/tag/{tag}/page", )
base=format!("{URL_BASE}/tags/{tag}/page", )
element=move |obj| view! { <Item item=obj sep=true always=true /> }
/>
}

View file

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

View file

@ -27,16 +27,31 @@ pub fn LoginBox(
let email = username_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 {
let Ok(res) = reqwest::Client::new()
.post(format!("{URL_BASE}/auth"))
.json(&LoginForm { email, password })
.send()
.await
else { if let Some(rf) = password_ref.get() { rf.set_value("") }; return };
let Ok(auth_response) = res
.json::<AuthResponse>()
.await
else { if let Some(rf) = password_ref.get() { rf.set_value("") }; return };
let res = match crate::Http::request::<LoginForm>(
reqwest::Method::POST,
&format!("{URL_BASE}/auth"),
Some(&LoginForm { email, password }),
auth,
).await {
Ok(res) => res,
Err(e) => {
tracing::warn!("could not login: {e}");
if let Some(rf) = password_ref.get() {
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);
// update our username and token cookies
userid_tx.set(Some(auth_response.user));

View file

@ -1,4 +1,4 @@
use apb::{ActivityMut, Base, BaseMut, Object, ObjectMut};
use apb::{ActivityMut, Base, BaseMut, DocumentMut, Object, ObjectMut};
use leptos::prelude::*;
use crate::prelude::*;
@ -89,19 +89,20 @@ impl Privacy {
}
}
pub fn address(&self, user: &str) -> (Vec<String>, Vec<String>) {
// TODO this is weird... should probably come from core or apb
pub fn address(&self, user_id: &str) -> (Vec<String>, Vec<String>) {
match self {
Self::Broadcast => (
vec![apb::target::PUBLIC.to_string()],
vec![format!("{URL_BASE}/actors/{user}/followers")],
vec![format!("{user_id}/followers")],
),
Self::Public => (
vec![],
vec![apb::target::PUBLIC.to_string(), format!("{URL_BASE}/actors/{user}/followers")],
vec![apb::target::PUBLIC.to_string(), format!("{user_id}/followers")],
),
Self::Private => (
vec![],
vec![format!("{URL_BASE}/actors/{user}/followers")],
vec![format!("{user_id}/followers")],
),
Self::Direct => (
vec![],
@ -133,7 +134,7 @@ pub fn PrivacySelector(setter: WriteSignal<Privacy>) -> impl IntoView {
<td>
{move || {
let p = privacy.get();
let (to, cc) = p.address(&auth.username());
let (to, cc) = p.address(&auth.user_id());
view! {
<PrivacyMarker privacy=p to=to cc=cc big=true />
}
@ -144,6 +145,19 @@ 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]
pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
let auth = use_context::<Auth>().expect("missing auth context");
@ -153,6 +167,7 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
let (error, set_error) = signal(None);
let (content, set_content) = signal("".to_string());
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 debounce this!
@ -166,7 +181,7 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
if let Some((name, domain)) = stripped.split_once('@') {
if let Some(tld) = domain.split('.').last() {
if tld::exist(tld) {
if let Some(uid) = cache::WEBFINGER.blocking_resolve(name, domain).await {
if let Some(uid) = cache::WEBFINGER.blocking_resolve(name, domain, auth).await {
out.push(TextMatch::Mention { name: name.to_string(), domain: domain.to_string(), href: uid });
}
}
@ -217,6 +232,16 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
}
<table class="align w-100">
<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 class="w-100"><input class="w-100" type="text" node_ref=summary_ref title="summary" /></td>
</tr>
@ -227,17 +252,35 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
on:input=move |ev| set_content.set(event_target_value(&ev))
></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 |_| {
let content = content.get();
if content.is_empty() {
set_error.set(Some("missing post body".to_string()));
let content = content.get_untracked();
let attachments_vec = attachments.get_untracked();
if content.is_empty() && attachments_vec.is_empty() {
set_error.set(Some("missing post body or attachments".to_string()));
return;
}
set_posting.set(true);
leptos::task::spawn_local(async move {
let summary = get_if_some(summary_ref);
let (mut to_vec, cc_vec) = privacy.get().address(&auth.username());
let mut mention_tags : Vec<serde_json::Value> = mentions.get()
let (mut to_vec, cc_vec) = privacy.get_untracked().address(&auth.user_id());
let mut mention_tags : Vec<serde_json::Value> = mentions.get_untracked()
.map(|x| x.take())
.unwrap_or_default()
.into_iter()
@ -258,7 +301,7 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
})
.collect();
if let Some(r) = reply.reply_to.get() {
if let Some(r) = reply.reply_to.get_untracked() {
if let Some(au) = post_author(&r) {
if let Ok(uid) = au.id() {
to_vec.push(uid.to_string());
@ -274,13 +317,43 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
}
}
}
for mention in mentions.get().map(|x| x.take()).as_deref().unwrap_or(&[]) {
for mention in mentions.get_untracked().map(|x| x.take()).as_deref().unwrap_or(&[]) {
if let TextMatch::Mention { href, .. } = mention {
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()
.set_object_type(Some(apb::ObjectType::Note))
.set_attachment(attachments_node)
.set_summary(summary)
.set_content(Some(content))
.set_context(apb::Node::maybe_link(reply.context.get()))
@ -294,6 +367,7 @@ pub fn PostBox(advanced: WriteSignal<bool>) -> impl IntoView {
set_error.set(None);
if let Some(x) = summary_ref.get() { x.set_value("") }
set_content.set("".to_string());
set_attachments.set(vec![]);
},
}
set_posting.set(false);
@ -328,6 +402,11 @@ pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
<table class="align w-100">
<tr>
<td>
<input type="checkbox" title="embedded object" on:input=move |ev| {
set_embedded.set(event_target_checked(&ev))
}/>
</td>
<td>
<input type="checkbox" title="advanced" checked on:input=move |ev| {
advanced.set(event_target_checked(&ev))
@ -346,11 +425,6 @@ pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
<SelectOption value is="Update" />
</select>
</td>
<td>
<input type="checkbox" title="embedded object" on:input=move |ev| {
set_embedded.set(event_target_checked(&ev))
}/>
</td>
</tr>
</table>
@ -380,7 +454,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>
</tr>
<tr>
<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=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=bcc_ref title="bcc" placeholder="bcc" /></td>
</tr>
</table>
@ -398,6 +472,10 @@ pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
let bto = get_vec_if_some(bto_ref);
let cc = get_vec_if_some(cc_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())
.set_activity_type(Some(value.get().as_str().try_into().unwrap_or(apb::ActivityType::Create)))
.set_to(apb::Node::links(to.clone()))
@ -414,6 +492,7 @@ pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
.set_summary(summary)
.set_content(content)
.set_in_reply_to(apb::Node::maybe_link(reply))
.set_audience(apb::Node::maybe_link(audience))
.set_context(apb::Node::maybe_link(context))
.set_to(apb::Node::links(to))
.set_bto(apb::Node::links(bto))
@ -424,7 +503,7 @@ pub fn AdvancedPostBox(advanced: WriteSignal<bool>) -> impl IntoView {
apb::Node::maybe_link(object_id)
}
);
let target_url = format!("{URL_BASE}/actors/{}/outbox", auth.username());
let target_url = auth.outbox();
match Http::post(&target_url, &payload, auth).await {
Err(e) => set_error.set(Some(e.to_string())),
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 avatar_url = object.icon_url().unwrap_or(FALLBACK_IMAGE_URL.into());
let username = object.preferred_username().unwrap_or_default().to_string();
let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string();
let domain = object.id().unwrap_or_default().replace("https://", "").replace("http://", "").split('/').next().unwrap_or_default().to_string();
let display_name = object.name().unwrap_or_default().to_string();
view! {
<div>

View file

@ -17,7 +17,7 @@ pub use auth::Auth;
pub mod prelude;
pub const URL_BASE: &str = "https://dev.upub.social";
pub const URL_BASE: &str = match std::option_env!("UPUB_BASE_URL") { Some(x) => x, None => "" };
pub const URL_PREFIX: &str = "/web";
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";
@ -179,16 +179,16 @@ impl DashmapCache<Doc> {
}
impl DashmapCache<String> {
pub async fn blocking_resolve(&self, user: &str, domain: &str) -> Option<String> {
pub async fn blocking_resolve(&self, user: &str, domain: &str, auth: Auth) -> Option<String> {
if let Some(x) = self.resource(user, domain) { return Some(x); }
self.fetch(user, domain).await;
self.fetch(user, domain, auth).await;
self.resource(user, domain)
}
pub fn resolve(&self, user: &str, domain: &str) -> Option<String> {
pub fn resolve(&self, user: &str, domain: &str, auth: Auth) -> Option<String> {
if let Some(x) = self.resource(user, domain) { return Some(x); }
let (_self, user, domain) = (self.clone(), user.to_string(), domain.to_string());
leptos::task::spawn_local(async move { _self.fetch(&user, &domain).await });
leptos::task::spawn_local(async move { _self.fetch(&user, &domain, auth).await });
None
}
@ -197,32 +197,20 @@ impl DashmapCache<String> {
self.get(&query)
}
async fn fetch(&self, user: &str, domain: &str) {
async fn fetch(&self, user: &str, domain: &str, auth: Auth) {
let query = format!("{user}@{domain}");
self.0.insert(query.to_string(), LookupStatus::Resolving);
match reqwest::get(format!("{URL_BASE}/.well-known/webfinger?resource=acct:{query}")).await {
Ok(res) => match res.error_for_status() {
Ok(res) => match res.json::<jrd::JsonResourceDescriptor>().await {
Ok(doc) => {
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:?}");
match crate::Http::fetch::<jrd::JsonResourceDescriptor>(&format!("{URL_BASE}/.well-known/webfinger?resource=acct:{query}"), auth).await {
Ok(doc) => {
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!("failed accessing webfinger server: {e:?}");
self.0.remove(&query);
tracing::error!("could not resolve webfinbger: {e:?}");
self.0.insert(query, LookupStatus::NotFound);
},
}
}
@ -237,14 +225,37 @@ pub struct IdParam {
pub struct 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>(
method: reqwest::Method,
url: &str,
data: Option<&T>,
auth: Auth,
) -> reqwest::Result<reqwest::Response> {
tracing::info!("making request to {url}");
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()
.request(method, url);
@ -293,7 +304,7 @@ impl Uri {
}
pub fn short(url: &str) -> String {
if url.starts_with(URL_BASE) || url.starts_with('/') {
if url.starts_with(Http::location()) || url.starts_with('/') {
uriproxy::decompose(url)
} else if url.starts_with("https://") || url.starts_with("http://") {
uriproxy::compact(url)

View file

@ -20,11 +20,11 @@ pub fn Attachment(
let href = object.url().id().ok().unwrap_or_default();
let uncloaked = uncloak(href.split('/').last()).unwrap_or_default();
let media_type = object.media_type()
.unwrap_or("link".to_string()); // TODO make it an Option rather than defaulting to link everywhere
.unwrap_or("text/html".to_string()); // TODO make it an Option rather than defaulting to link everywhere
let mut kind = media_type
.split('/')
.next()
.unwrap_or("link")
.unwrap_or("text")
.to_string();
// 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
// is a dirty fix to properly display as images
if kind == "link" && matches!(object.document_type(), Ok(apb::DocumentType::Image)) {
if kind == "text" && matches!(object.document_type(), Ok(apb::DocumentType::Image)) {
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 already_liked = object.liked_by_me().unwrap_or(false);
let attachments_padding = if object.attachment().is_empty() {
let attachments_padding = if object.attachment().flat().is_empty() {
None
} else {
Some(view! { <div class="pb-1"></div> })
@ -56,7 +56,7 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
.ok()
.and_then(|x| {
Some(view! {
<div class="quote">
<div class="quote mb-1">
<Object object=crate::cache::OBJECTS.get(&x)? controls=false />
</div>
})
@ -148,6 +148,7 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
let post_inner = view! {
<Summary summary=object.summary().ok().map(|x| x.to_string()) >
{quote_block}
<p inner_html={content}></p>
{attachments_padding}
{attachments}
@ -158,7 +159,6 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
Ok(apb::ObjectType::Note) => view! {
<article class="tl">
{post_inner}
{quote_block}
</article>
}.into_any(),
// lemmy with Page, peertube with Video
@ -170,7 +170,6 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
<b>{object.name().unwrap_or_default().to_string()}</b>
</h4>
{post_inner}
{quote_block}
</div>
</article>
}.into_any(),
@ -180,14 +179,12 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
<h3>{object.name().unwrap_or_default().to_string()}</h3>
<hr />
{post_inner}
{quote_block}
</article>
}.into_any(),
// everything else
Ok(t) => view! {
<h3>{t.as_ref().to_string()}</h3>
{post_inner}
{quote_block}
}.into_any(),
// object without type?
Err(_) => view! { <code>missing object type</code> }.into_any(),
@ -230,15 +227,15 @@ pub fn Object(object: crate::Doc, #[prop(default = true)] controls: bool) -> imp
pub fn Summary(summary: Option<String>, children: Children) -> impl IntoView {
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
match summary.filter(|x| !x.is_empty()) {
None => children().into_any(),
Some(summary) => view! {
None => Either::Left(children()),
Some(summary) => Either::Right(view! {
<details class="cw pa-s" prop:open=move || !config.get().collapse_content_warnings>
<summary>
<code class="cw center color ml-s w-100 bb">{summary}</code>
</summary>
{children()}
</details>
}.into_any(),
}),
}
}
@ -267,7 +264,7 @@ pub fn LikeButton(
let (mut to, cc) = if private {
(vec![], vec![])
} else {
privacy.get().address(&auth.username())
privacy.get().address(&auth.user_id())
};
to.push(author.clone());
let payload = serde_json::Value::Object(serde_json::Map::default())
@ -343,7 +340,7 @@ pub fn RepostButton(n: i32, target: String, author: String) -> impl IntoView {
if !auth.present() { return; }
if !clicked.get() { return; }
set_clicked.set(false);
let (mut to, cc) = privacy.get().address(&auth.username());
let (mut to, cc) = privacy.get().address(&auth.user_id());
to.push(author.clone());
let payload = serde_json::Value::Object(serde_json::Map::default())
.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 {
if let Err(e) = Http::post(&format!("{id}/outbox"), &payload, auth).await {
if let Err(e) = Http::post(&auth.outbox(), &payload, auth).await {
tracing::error!("could not send update activity: {e}");
}
});

View file

@ -28,26 +28,6 @@ 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! {
<blockquote class="mt-3 mb-3">
@ -100,21 +80,11 @@ pub fn SearchPage() -> impl IntoView {
<code class="cw center color ml-s w-100">full text</code>
</summary>
<div class="pb-1">
{move || match text_search.get().map(|x| x.take()) {
None => Some(view! { <p class="center"><small>searching...</small></p> }.into_any()),
Some(None) => None,
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())
}}
<Loadable
base=format!("{URL_BASE}/search?q={}", query.get())
convert=U::Object
element=|obj| view! { <Item item=obj sep=true /> }
/>
</div>
</details>
</blockquote>

View file

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

451
web/style.css Normal file
View file

@ -0,0 +1,451 @@
: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"; }
}

5
worker/README.md Normal file
View file

@ -0,0 +1,5 @@
# 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 sea_orm::{prelude::Expr, ColumnTrait, DbErr, EntityTrait, QueryFilter, QueryOrder, QuerySelect, SelectColumns, TransactionTrait};
use sea_orm::{prelude::Expr, ColumnTrait, DbErr, EntityTrait, QueryFilter, QuerySelect, SelectColumns, TransactionTrait};
use upub::{model::{self, actor::Field}, traits::{process::ProcessorError, Addresser, Processor}, Context};
@ -46,23 +46,54 @@ pub async fn process(ctx: Context, job: &model::job::Model) -> crate::JobResult<
.set_published(Some(now));
if matches!(t, apb::ObjectType::Activity(apb::ActivityType::Undo)) {
let mut undone = activity.object().into_inner()?;
if undone.id().is_err() {
let undone_target = undone.object().id()?;
let undone_type = undone.activity_type().map_err(|_| crate::JobError::MissingPayload)?;
let undone_model = model::activity::Entity::find()
.filter(model::activity::Column::Object.eq(&undone_target))
.filter(model::activity::Column::Actor.eq(&job.actor))
.filter(model::activity::Column::ActivityType.eq(undone_type))
.order_by_desc(model::activity::Column::Published)
.one(&tx)
.await?
.ok_or_else(|| sea_orm::DbErr::RecordNotFound(format!("actor={},type={},object={}",job.actor, undone_type, undone_target)))?;
undone = undone
.set_id(Some(undone_model.id))
.set_actor(apb::Node::link(job.actor.clone()));
match activity.object().id() {
Ok(undone) => {
let activity = upub::model::activity::Entity::find_by_ap_id(&undone)
.one(&tx)
.await?
.ok_or_else(|| DbErr::RecordNotFound(undone))?;
if activity.actor != job.actor {
return Err(crate::JobError::Forbidden);
}
},
Err(_) => {
// frontend doesn't know the activity id, so we have to look it up
let undone = activity.object().into_inner()?; // if even this is missing, malformed
match undone.activity_type()? {
apb::ActivityType::Follow => {
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 {